Skip to content

Commit 0d23eb9

Browse files
jdxclaude
andcommitted
fix(cli): preserve parse-fall-through for --config.<key> bool/int values
Restore the original loop semantics in `bool_from_cli` / `u64_from_cli`: when a key matches but its value won't parse, keep scanning earlier entries rather than returning the first match unconditionally. This matters now that raw user strings flow through the global slot — a typo'd `--config.strictDepBuilds=garbage` no longer masks an earlier valid duplicate. Mirrors how `bool_from_npmrc` / `u64_from_npmrc` behave on the same input. Also tighten the `to_kebab_case` doc to describe what the function actually does (consecutive uppercase runs collapse to a single token, matching `aube-settings/build.rs`'s alias generator), since no current pnpm setting contains an internal acronym to expose the imperfection. Addresses greptile P2 review feedback on PR #447. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c274492 commit 0d23eb9

1 file changed

Lines changed: 54 additions & 19 deletions

File tree

crates/aube-settings/src/values.rs

Lines changed: 54 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -421,11 +421,18 @@ fn cli_key_matches(key: &str, meta: &meta::SettingMeta) -> bool {
421421
}
422422

423423
/// Lower-case kebab form of a setting / flag identifier. Splits on
424-
/// `-`, `_`, camelCase boundaries, and dotted-path segments so callers
425-
/// can compare `strict-dep-builds`, `strictDepBuilds`, `STRICT_DEP_BUILDS`,
426-
/// and `strict_dep_builds` interchangeably. Dots are preserved so
427-
/// nested settings like `peerDependencyRules.ignoreMissing` keep their
428-
/// path structure.
424+
/// `-`, `_`, dotted-path segments, and lowercase→UPPER transitions so
425+
/// callers can compare `strict-dep-builds`, `strictDepBuilds`,
426+
/// `STRICT_DEP_BUILDS`, and `strict_dep_builds` interchangeably. Dots
427+
/// are preserved so nested settings like
428+
/// `peerDependencyRules.ignoreMissing` keep their path structure.
429+
///
430+
/// Consecutive uppercase runs (e.g. `XMLConfig`) are collapsed to a
431+
/// single lowercase token (`xmlconfig`), matching the auto-alias
432+
/// generator in `aube-settings/build.rs`. No pnpm setting today contains
433+
/// an internal acronym, so the imperfection is invisible in practice; if
434+
/// one is ever added, the synthesized npmrc alias and this matcher have
435+
/// to evolve together.
429436
fn to_kebab_case(s: &str) -> String {
430437
let mut out = String::with_capacity(s.len() + 4);
431438
let mut prev_lower = false;
@@ -452,20 +459,25 @@ fn to_kebab_case(s: &str) -> String {
452459
out
453460
}
454461

455-
/// Walk the per-callsite `cli` slice (newest entry first) and then the
456-
/// process-global `--config.<key>` overrides. First match wins, mirroring
457-
/// the existing reverse-scan semantics for `cli`. Returning the borrowed
458-
/// raw value lets each caller pick its own parsing path; the global
459-
/// overrides have `'static` storage so the merged lifetime is whichever
460-
/// `cli` borrow the caller passed in.
461-
fn cli_raw_for<'a>(meta: &meta::SettingMeta, cli: &'a [(String, String)]) -> Option<&'a str> {
462+
/// Walk the per-callsite `cli` slice (newest entry first), then the
463+
/// process-global `--config.<key>` overrides. The `accept` predicate
464+
/// lets typed callers (`bool`, `int`) keep scanning past an unparseable
465+
/// value so a later valid duplicate still wins — matching the original
466+
/// per-source loops. String / string-list callers pass `|_| true`; the
467+
/// global overrides have `'static` storage so the merged lifetime is
468+
/// whichever `cli` borrow the caller passed in.
469+
fn cli_raw_for<'a>(
470+
meta: &meta::SettingMeta,
471+
cli: &'a [(String, String)],
472+
accept: impl Fn(&str) -> bool,
473+
) -> Option<&'a str> {
462474
for (key, raw) in cli.iter().rev() {
463-
if cli_key_matches(key, meta) {
475+
if cli_key_matches(key, meta) && accept(raw.as_str()) {
464476
return Some(raw.as_str());
465477
}
466478
}
467479
for (key, raw) in global_cli_overrides().iter().rev() {
468-
if cli_key_matches(key, meta) {
480+
if cli_key_matches(key, meta) && accept(raw.as_str()) {
469481
return Some(raw.as_str());
470482
}
471483
}
@@ -477,13 +489,16 @@ fn cli_raw_for<'a>(meta: &meta::SettingMeta, cli: &'a [(String, String)]) -> Opt
477489
/// before building the `ResolveCtx`. Keys may be either an alias
478490
/// declared in `sources.cli` or the canonical setting name (in any
479491
/// reasonable case form), so generic `--config.<key>` overrides reach
480-
/// every setting without per-flag wiring.
492+
/// every setting without per-flag wiring. An unparseable value (e.g.
493+
/// `--config.strictDepBuilds=notabool`) is skipped rather than masking
494+
/// an earlier valid entry — caller still gets `None` if every match is
495+
/// invalid, matching how `bool_from_npmrc` handles the same case.
481496
pub(crate) fn bool_from_cli(setting: &str, cli: &[(String, String)]) -> Option<bool> {
482497
let meta = meta::find(setting)?;
483498
if meta.type_ != "bool" {
484499
return None;
485500
}
486-
cli_raw_for(meta, cli).and_then(parse_bool)
501+
cli_raw_for(meta, cli, |raw| parse_bool(raw).is_some()).and_then(parse_bool)
487502
}
488503

489504
/// Resolve a `string` setting from a parsed CLI flag bag.
@@ -492,7 +507,7 @@ pub fn string_from_cli(setting: &str, cli: &[(String, String)]) -> Option<String
492507
if !is_stringish(meta.type_) {
493508
return None;
494509
}
495-
cli_raw_for(meta, cli).map(ToOwned::to_owned)
510+
cli_raw_for(meta, cli, |_| true).map(ToOwned::to_owned)
496511
}
497512

498513
/// Resolve an `int` setting from a parsed CLI flag bag.
@@ -501,7 +516,8 @@ pub(crate) fn u64_from_cli(setting: &str, cli: &[(String, String)]) -> Option<u6
501516
if meta.type_ != "int" {
502517
return None;
503518
}
504-
cli_raw_for(meta, cli).and_then(|raw| raw.trim().parse::<u64>().ok())
519+
cli_raw_for(meta, cli, |raw| raw.trim().parse::<u64>().is_ok())
520+
.and_then(|raw| raw.trim().parse::<u64>().ok())
505521
}
506522

507523
/// Resolve a `list<string>` setting from a parsed CLI flag bag.
@@ -510,7 +526,7 @@ pub(crate) fn string_list_from_cli(setting: &str, cli: &[(String, String)]) -> O
510526
if meta.type_ != "list<string>" {
511527
return None;
512528
}
513-
cli_raw_for(meta, cli).map(parse_string_list)
529+
cli_raw_for(meta, cli, |_| true).map(parse_string_list)
514530
}
515531

516532
/// Parse a pnpm/npm-style stringified list. Accepts a JSON-ish array
@@ -833,6 +849,25 @@ mod tests {
833849
assert_eq!(bool_from_cli("verifyStoreIntegrity", &cli), Some(true));
834850
}
835851

852+
#[test]
853+
fn cli_bag_falls_through_unparseable_values_to_earlier_valid_entry() {
854+
// Regression: an unparseable `--config.<key>=garbage` must not
855+
// mask an earlier valid entry for the same setting. Iteration
856+
// is reverse, so the later (garbage) entry is visited first;
857+
// the helper has to keep scanning rather than commit to it.
858+
let cli = vec![
859+
("strictDepBuilds".to_string(), "true".to_string()),
860+
("strictDepBuilds".to_string(), "notabool".to_string()),
861+
];
862+
assert_eq!(bool_from_cli("strictDepBuilds", &cli), Some(true));
863+
864+
let cli = vec![
865+
("network-concurrency".to_string(), "8".to_string()),
866+
("network-concurrency".to_string(), "garbage".to_string()),
867+
];
868+
assert_eq!(u64_from_cli("networkConcurrency", &cli), Some(8));
869+
}
870+
836871
#[test]
837872
fn cli_beats_env_beats_npmrc_beats_workspace_yaml() {
838873
// Precedence order is cli > env > npmrc > workspaceYaml. This

0 commit comments

Comments
 (0)