diff --git a/.config/ast-grep/rule-tests/__snapshots__/is-err-then-unwrap-err-snapshot.yml b/.config/ast-grep/rule-tests/__snapshots__/is-err-then-unwrap-err-snapshot.yml index dff7614..7c8084b 100644 --- a/.config/ast-grep/rule-tests/__snapshots__/is-err-then-unwrap-err-snapshot.yml +++ b/.config/ast-grep/rule-tests/__snapshots__/is-err-then-unwrap-err-snapshot.yml @@ -12,10 +12,6 @@ snapshots: style: primary start: 0 end: 55 - - source: result.is_err() - style: secondary - start: 3 - end: 18 - source: result.unwrap_err() style: secondary start: 32 diff --git a/.config/ast-grep/rule-tests/__snapshots__/is-ok-then-unwrap-snapshot.yml b/.config/ast-grep/rule-tests/__snapshots__/is-ok-then-unwrap-snapshot.yml index 5c65957..703f5fc 100644 --- a/.config/ast-grep/rule-tests/__snapshots__/is-ok-then-unwrap-snapshot.yml +++ b/.config/ast-grep/rule-tests/__snapshots__/is-ok-then-unwrap-snapshot.yml @@ -12,10 +12,6 @@ snapshots: style: primary start: 0 end: 51 - - source: result.is_ok() - style: secondary - start: 3 - end: 17 - source: result.unwrap() style: secondary start: 32 diff --git a/.config/ast-grep/rule-tests/__snapshots__/is-some-then-unwrap-snapshot.yml b/.config/ast-grep/rule-tests/__snapshots__/is-some-then-unwrap-snapshot.yml index 168d872..adb82b5 100644 --- a/.config/ast-grep/rule-tests/__snapshots__/is-some-then-unwrap-snapshot.yml +++ b/.config/ast-grep/rule-tests/__snapshots__/is-some-then-unwrap-snapshot.yml @@ -12,10 +12,6 @@ snapshots: style: primary start: 0 end: 51 - - source: value.is_some() - style: secondary - start: 3 - end: 18 - source: value.unwrap() style: secondary start: 33 diff --git a/.config/ast-grep/rule-tests/__snapshots__/unwrap-or-default-snapshot.yml b/.config/ast-grep/rule-tests/__snapshots__/unwrap-or-default-snapshot.yml index 82fb7b0..5d962d4 100644 --- a/.config/ast-grep/rule-tests/__snapshots__/unwrap-or-default-snapshot.yml +++ b/.config/ast-grep/rule-tests/__snapshots__/unwrap-or-default-snapshot.yml @@ -12,3 +12,9 @@ snapshots: style: primary start: 0 end: 34 + value.unwrap_or_default(): + labels: + - source: value.unwrap_or_default() + style: primary + start: 0 + end: 25 diff --git a/.config/ast-grep/rule-tests/unwrap-or-default-test.yml b/.config/ast-grep/rule-tests/unwrap-or-default-test.yml index 8d62342..626f77b 100644 --- a/.config/ast-grep/rule-tests/unwrap-or-default-test.yml +++ b/.config/ast-grep/rule-tests/unwrap-or-default-test.yml @@ -1,7 +1,7 @@ id: unwrap-or-default valid: - - value.unwrap_or_default() - value.unwrap_or(fallback) -invalid: - value.unwrap_or(Default::default()) - value.unwrap_or(String::default()) +invalid: + - value.unwrap_or_default() diff --git a/.config/ast-grep/rules/continue-filter-loop.yml b/.config/ast-grep/rules/continue-filter-loop.yml new file mode 100644 index 0000000..c12d1b3 --- /dev/null +++ b/.config/ast-grep/rules/continue-filter-loop.yml @@ -0,0 +1,47 @@ +id: continue-filter-loop +language: rust +severity: warning +rule: + any: + - pattern: | + for $X in $ITER { + if !$COND { + continue; + } + $$$REST + } + - pattern: | + for $X in $ITER { + if $COND { + continue; + } + $$$REST + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Guard-and-`continue` loop — pull the predicate into `.filter()` upstream. +note: | + Replace: + for x in iter { + if !pred(&x) { + continue; + } + out.push(f(x)); + } + with: + let out: Vec<_> = iter.filter(|x| pred(x)).map(|x| f(x)).collect(); + The early-continue is the imperative form of `filter`. Once the guard is + gone, the remaining body often collapses to `map`, `for_each`, or another + combinator. Inverse guard (`if cond { continue; }`) flips to + `.filter(|x| !cond)`. diff --git a/.config/ast-grep/rules/filter-in-loop.yml b/.config/ast-grep/rules/filter-in-loop.yml new file mode 100644 index 0000000..8785259 --- /dev/null +++ b/.config/ast-grep/rules/filter-in-loop.yml @@ -0,0 +1,30 @@ +id: filter-in-loop +language: rust +severity: warning +rule: + pattern: | + for $X in $ITER { + if $COND { + $VEC.push($EXPR); + } + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Filter-then-push loop — consider using iterator methods. +note: | + Replace: + for x in iter { if cond { vec.push(expr); } } + with: + let vec: Vec<_> = iter.filter(|x| cond).map(|x| expr).collect(); + or use .filter_map() if condition and transformation are combined. diff --git a/.config/ast-grep/rules/for-range-len.yml b/.config/ast-grep/rules/for-range-len.yml new file mode 100644 index 0000000..d15ff36 --- /dev/null +++ b/.config/ast-grep/rules/for-range-len.yml @@ -0,0 +1,57 @@ +id: for-range-len +language: rust +severity: warning +rule: + any: + - pattern: | + for $I in 0..$V.len() { + $$$ + } + - pattern: | + for $I in 0..=$V.len() { + $$$ + } + - pattern: | + for $I in $START..$V.len() { + $$$ + } + - pattern: | + for $I in $START..=$V.len() { + $$$ + } + - pattern: | + for $I in 0..$V.len() - $N { + $$$ + } + - pattern: | + for $I in 0..=$V.len() - $N { + $$$ + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Index-based iteration — use an iterator instead of a `..v.len()` range. +note: | + Replace: + for i in 0..v.len() { ... v[i] ... } + with one of: + for x in &v { ... } — when you don't need the index + for (i, x) in v.iter().enumerate() { ... } + for x in v.iter_mut() { ... } — when you need to mutate + for w in v.windows(2) { ... w[0], w[1] ... } — for `0..v.len() - 1` + for w in v.chunks(n) { ... } — for chunked passes + When the range starts at a non-zero offset: + for x in &v[start..] { ... } — slice + iterate + for (i, x) in v.iter().enumerate().skip(start) { ... } + Index iteration sidesteps bounds-check elision and prevents the borrow + checker from helping you. diff --git a/.config/ast-grep/rules/hashmap-entry-grouping-loop.yml b/.config/ast-grep/rules/hashmap-entry-grouping-loop.yml new file mode 100644 index 0000000..e9dafd8 --- /dev/null +++ b/.config/ast-grep/rules/hashmap-entry-grouping-loop.yml @@ -0,0 +1,42 @@ +id: hashmap-entry-grouping-loop +language: rust +severity: hint +rule: + any: + - pattern: | + for $X in $ITER { + $MAP.entry($KEY).or_default().push($VAL); + } + - pattern: | + for $X in $ITER { + $MAP.entry($KEY).or_insert_with(Vec::new).push($VAL); + } + - pattern: | + for $X in $ITER { + $MAP.entry($KEY).or_insert_with(|| Vec::new()).push($VAL); + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Manual grouping loop — consider `fold` or `itertools::into_group_map_by`. +note: | + Pure functional rewrite (no extra deps): + let by_key = iter.fold(HashMap::new(), |mut acc, item| { + acc.entry(key_of(&item)).or_default().push(item); + acc + }); + Or, if you already pull in `itertools`: + use itertools::Itertools; + let by_key = iter.into_group_map_by(|item| key_of(item)); + Hint-only — the imperative form is often the most readable shape in + Rust. Switch when the surrounding code is already iterator-chained. diff --git a/.config/ast-grep/rules/if-let-ok-push-loop.yml b/.config/ast-grep/rules/if-let-ok-push-loop.yml new file mode 100644 index 0000000..d8189f9 --- /dev/null +++ b/.config/ast-grep/rules/if-let-ok-push-loop.yml @@ -0,0 +1,35 @@ +id: if-let-ok-push-loop +language: rust +severity: warning +rule: + pattern: | + for $X in $ITER { + if let Ok($Y) = $EXPR { + $VEC.push($PUSH); + } + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Conditional-push loop on Result — use `.filter_map(|x| f(x).ok())`. +note: | + Replace: + for x in iter { + if let Ok(y) = f(x) { + out.push(y); + } + } + with: + let out: Vec<_> = iter.filter_map(|x| f(x).ok()).collect(); + Errors are silently dropped — if that matters, log them first: + .filter_map(|x| f(x).inspect_err(|e| warn!(%e)).ok()) diff --git a/.config/ast-grep/rules/if-let-some-else.yml b/.config/ast-grep/rules/if-let-some-else.yml new file mode 100644 index 0000000..44c0a21 --- /dev/null +++ b/.config/ast-grep/rules/if-let-some-else.yml @@ -0,0 +1,34 @@ +id: if-let-some-else +language: rust +severity: warning +rule: + all: + - pattern: | + if let Some($X) = $Y { + $$$ + } else { + $$$ + } + - not: + has: + pattern: return $$$ + stopBy: end +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + if let Some/else can often be replaced with Option combinators. +note: | + Alternatives: + opt.map_or(default, |x| ...) — both branches return values + opt.map_or_else(|| default, |x| ...) — lazy default computation + opt.map(|x| ...).unwrap_or(default) — transform then default diff --git a/.config/ast-grep/rules/if-let-some-push-loop.yml b/.config/ast-grep/rules/if-let-some-push-loop.yml new file mode 100644 index 0000000..04e7aa8 --- /dev/null +++ b/.config/ast-grep/rules/if-let-some-push-loop.yml @@ -0,0 +1,36 @@ +id: if-let-some-push-loop +language: rust +severity: warning +rule: + pattern: | + for $X in $ITER { + if let Some($Y) = $EXPR { + $VEC.push($PUSH); + } + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Conditional-push loop on Option — use `.filter_map()` instead. +note: | + Replace: + for x in iter { + if let Some(y) = f(x) { + out.push(y); + } + } + with: + let out: Vec<_> = iter.filter_map(|x| f(x)).collect(); + or, when `out` already exists: + out.extend(iter.filter_map(|x| f(x))); + If you need both Some and None branches handled, keep the explicit loop. diff --git a/.config/ast-grep/rules/is-err-then-unwrap-err.yml b/.config/ast-grep/rules/is-err-then-unwrap-err.yml index f7a0f4b..a0c3a8a 100644 --- a/.config/ast-grep/rules/is-err-then-unwrap-err.yml +++ b/.config/ast-grep/rules/is-err-then-unwrap-err.yml @@ -1,14 +1,36 @@ id: is-err-then-unwrap-err -language: Rust +language: rust severity: warning -message: Replace an is_err() check followed by unwrap_err() with if let Err(...). rule: - kind: if_expression all: + - pattern: | + if $RES.is_err() { + $$$ + } - has: - field: condition - pattern: $VALUE.is_err() - - has: - field: consequence + pattern: $RES.unwrap_err() stopBy: end - pattern: $VALUE.unwrap_err() +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + `if r.is_err()` + `r.unwrap_err()` — use `if let Err(e) = r` to bind once. +note: | + Replace: + if res.is_err() { + ... res.unwrap_err() ... + } + with: + if let Err(e) = res { + ... e ... + } + This is the Err sibling of [[is-ok-then-unwrap]] / [[is-some-then-unwrap]]. diff --git a/.config/ast-grep/rules/is-ok-then-unwrap.yml b/.config/ast-grep/rules/is-ok-then-unwrap.yml index 7bc579f..914fe26 100644 --- a/.config/ast-grep/rules/is-ok-then-unwrap.yml +++ b/.config/ast-grep/rules/is-ok-then-unwrap.yml @@ -1,14 +1,37 @@ id: is-ok-then-unwrap -language: Rust +language: rust severity: warning -message: Replace an is_ok() check followed by unwrap() with if let Ok(...). rule: - kind: if_expression all: + - pattern: | + if $RES.is_ok() { + $$$ + } - has: - field: condition - pattern: $VALUE.is_ok() - - has: - field: consequence + pattern: $RES.unwrap() stopBy: end - pattern: $VALUE.unwrap() +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + `if r.is_ok()` + `r.unwrap()` — use `if let Ok(v) = r` to bind once. +note: | + Replace: + if res.is_ok() { + ... res.unwrap() ... + } + with: + if let Ok(v) = res { + ... v ... + } + Binding once removes a panic site and lets the borrow checker reason + about the inner value. The Option twin is [[is-some-then-unwrap]]. diff --git a/.config/ast-grep/rules/is-some-then-unwrap.yml b/.config/ast-grep/rules/is-some-then-unwrap.yml index 70ff4fd..aa823ae 100644 --- a/.config/ast-grep/rules/is-some-then-unwrap.yml +++ b/.config/ast-grep/rules/is-some-then-unwrap.yml @@ -1,14 +1,38 @@ id: is-some-then-unwrap -language: Rust +language: rust severity: warning -message: Replace an is_some() check followed by unwrap() with if let Some(...). rule: - kind: if_expression all: + - pattern: | + if $OPT.is_some() { + $$$ + } - has: - field: condition - pattern: $VALUE.is_some() - - has: - field: consequence + pattern: $OPT.unwrap() stopBy: end - pattern: $VALUE.unwrap() +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + `if x.is_some()` + `x.unwrap()` — use `if let Some(v) = x` to bind once. +note: | + Replace: + if opt.is_some() { + ... opt.unwrap() ... + } + with: + if let Some(v) = opt { + ... v ... + } + Same shape for `Result`: prefer `if let Ok(v) = res` over `res.is_ok()` + followed by `res.unwrap()`. Binding once removes the panic site and lets + the borrow checker reason about the inner value. diff --git a/.config/ast-grep/rules/let-underscore-call.yml b/.config/ast-grep/rules/let-underscore-call.yml new file mode 100644 index 0000000..8a77ce7 --- /dev/null +++ b/.config/ast-grep/rules/let-underscore-call.yml @@ -0,0 +1,34 @@ +id: let-underscore-call +language: rust +severity: warning +rule: + all: + - pattern: let _ = $EXPR; + - not: + inside: + stopBy: end + kind: mod_item + follows: + kind: attribute_item + regex: "cfg\\(test\\)" +constraints: + EXPR: + kind: call_expression +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "**/test_utils.rs" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Discarded call result — `let _ = expr()` suppresses the unused-Result + warning, silently dropping any error. If the call can fail meaningfully, + handle or log the error. If failure is truly ignorable, add a comment + explaining why. diff --git a/.config/ast-grep/rules/manual-all-loop.yml b/.config/ast-grep/rules/manual-all-loop.yml new file mode 100644 index 0000000..6ad2e4a --- /dev/null +++ b/.config/ast-grep/rules/manual-all-loop.yml @@ -0,0 +1,56 @@ +id: manual-all-loop +language: rust +severity: warning +rule: + any: + - all: + - kind: expression_statement + - has: + pattern: | + for $X in $ITER { + if !$COND { + $FLAG = false; + break; + } + } + - follows: + pattern: let mut $FLAG = true; + - all: + - kind: expression_statement + - has: + pattern: | + for $X in $ITER { + if $COND { + $FLAG = false; + break; + } + } + - follows: + pattern: let mut $FLAG = true; +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Manual `all` loop — use `iter.all(...)` instead. +note: | + Replace: + let mut ok = true; + for x in iter { + if !pred(&x) { + ok = false; + break; + } + } + with: + let ok = iter.all(|x| pred(x)); + The inverse shape (`if cond { ok = false; break; }`) is also caught; in + that case flip the predicate: `iter.all(|x| !cond)`. diff --git a/.config/ast-grep/rules/manual-any-loop.yml b/.config/ast-grep/rules/manual-any-loop.yml new file mode 100644 index 0000000..f664209 --- /dev/null +++ b/.config/ast-grep/rules/manual-any-loop.yml @@ -0,0 +1,42 @@ +id: manual-any-loop +language: rust +severity: warning +rule: + all: + - kind: expression_statement + - has: + pattern: | + for $X in $ITER { + if $COND { + $FLAG = true; + break; + } + } + - follows: + pattern: let mut $FLAG = false; +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Manual `any` loop — use `iter.any(...)` instead. +note: | + Replace: + let mut ok = false; + for x in iter { + if pred(&x) { + ok = true; + break; + } + } + with: + let ok = iter.any(|x| pred(x)); + `.any()` short-circuits on the first match, just like the break does. diff --git a/.config/ast-grep/rules/manual-find-loop.yml b/.config/ast-grep/rules/manual-find-loop.yml new file mode 100644 index 0000000..1d87d13 --- /dev/null +++ b/.config/ast-grep/rules/manual-find-loop.yml @@ -0,0 +1,46 @@ +id: manual-find-loop +language: rust +severity: warning +rule: + all: + - kind: expression_statement + - has: + pattern: | + for $X in $ITER { + if $COND { + $FOUND = Some($VAL); + break; + } + } + - follows: + pattern: let mut $FOUND = None; +constraints: + X: + kind: identifier +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Manual `find` loop — use `iter.find(...)` instead. +note: | + Replace: + let mut found = None; + for x in iter { + if pred(&x) { + found = Some(x); + break; + } + } + with: + let found = iter.find(|x| pred(x)); + If you only need a yes/no answer, `.any(...)` reads more directly than + binding the value just to ignore it. diff --git a/.config/ast-grep/rules/manual-position-loop.yml b/.config/ast-grep/rules/manual-position-loop.yml new file mode 100644 index 0000000..e2d64bb --- /dev/null +++ b/.config/ast-grep/rules/manual-position-loop.yml @@ -0,0 +1,43 @@ +id: manual-position-loop +language: rust +severity: warning +rule: + all: + - kind: expression_statement + - has: + pattern: | + for ($I, $X) in $ITER { + if $COND { + $FOUND = Some($I); + break; + } + } + - follows: + pattern: let mut $FOUND = None; +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Manual `position` loop — use `iter.position(...)` instead. +note: | + Replace: + let mut idx = None; + for (i, x) in iter.enumerate() { + if pred(&x) { + idx = Some(i); + break; + } + } + with: + let idx = iter.position(|x| pred(x)); + `.position()` short-circuits like the break does and returns the index of + the first match. Use `.rposition()` if you walk back-to-front. diff --git a/.config/ast-grep/rules/map-collect-loop-with-let.yml b/.config/ast-grep/rules/map-collect-loop-with-let.yml new file mode 100644 index 0000000..9431166 --- /dev/null +++ b/.config/ast-grep/rules/map-collect-loop-with-let.yml @@ -0,0 +1,45 @@ +id: map-collect-loop-with-let +language: rust +severity: hint +rule: + all: + - pattern: | + for $X in $ITER { + let $Y = $LET_EXPR; + $VEC.push($PUSH); + } + - not: + inside: + kind: if_expression + stopBy: end +constraints: + VEC: + not: + kind: call_expression +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Push-only loop with a single `let` binding — likely a `.map(...).collect()`. +note: | + This is the [[map-collect-loop]] shape with one intermediate binding: + for x in iter { + let y = transform(x); + out.push(use_y(y)); + } + Usually rewrites as: + let out: Vec<_> = iter.map(|x| { + let y = transform(x); + use_y(y) + }).collect(); + Lower confidence than [[map-collect-loop]] because the body might rely + on side effects between `let` and `push`. Skim before accepting. diff --git a/.config/ast-grep/rules/map-collect-loop.yml b/.config/ast-grep/rules/map-collect-loop.yml new file mode 100644 index 0000000..854b41c --- /dev/null +++ b/.config/ast-grep/rules/map-collect-loop.yml @@ -0,0 +1,42 @@ +id: map-collect-loop +language: rust +severity: warning +rule: + all: + - pattern: | + for $X in $ITER { + $VEC.push($EXPR); + } + - not: + inside: + kind: if_expression + stopBy: end +constraints: + VEC: + not: + kind: call_expression +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Push-only loop — consider iter.map(...).collect() instead. +note: | + Replace: + for x in iter { vec.push(expr); } + with: + let vec: Vec<_> = iter.map(|x| expr).collect(); + or, if `vec` already exists and should be extended: + vec.extend(iter.map(|x| expr)); + The filter-then-push variant (`if cond { vec.push(...) }`) is covered by + filter-in-loop; this rule targets the unconditional shape. Chained-method + receivers (`HashMap::entry(...).or_default().push(...)`) are excluded + because they bucket into a HashMap, not append to a single Vec. diff --git a/.config/ast-grep/rules/match-ok-push-loop.yml b/.config/ast-grep/rules/match-ok-push-loop.yml new file mode 100644 index 0000000..f0e7942 --- /dev/null +++ b/.config/ast-grep/rules/match-ok-push-loop.yml @@ -0,0 +1,52 @@ +id: match-ok-push-loop +language: rust +severity: warning +rule: + any: + - pattern: | + for $X in $ITER { + match $EXPR { + Ok($Y) => $VEC.push($PUSH), + Err(_) => {} + } + } + - pattern: | + for $X in $ITER { + match $EXPR { + Err(_) => {}, + Ok($Y) => $VEC.push($PUSH), + } + } + - pattern: | + for $X in $ITER { + match $EXPR { + Ok($Y) => $VEC.push($PUSH), + _ => {} + } + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Match-Ok-push loop — collapse into `.filter_map(|x| f(x).ok())`. +note: | + Replace: + for x in iter { + match f(x) { + Ok(y) => out.push(y), + Err(_) => {} + } + } + with: + let out: Vec<_> = iter.filter_map(|x| f(x).ok()).collect(); + Errors disappear silently — if that matters, log them first: + .filter_map(|x| f(x).inspect_err(|e| warn!(%e)).ok()) diff --git a/.config/ast-grep/rules/match-option-verbose.yml b/.config/ast-grep/rules/match-option-verbose.yml new file mode 100644 index 0000000..54343cb --- /dev/null +++ b/.config/ast-grep/rules/match-option-verbose.yml @@ -0,0 +1,54 @@ +id: match-option-verbose +language: rust +severity: warning +rule: + all: + - any: + - pattern: | + match $OPT { + Some($X) => $SOME_BODY, + None => $NONE_BODY, + } + - pattern: | + match $OPT { + None => $NONE_BODY, + Some($X) => $SOME_BODY, + } + - pattern: | + match $OPT { + Some($X) => $SOME_BODY, + _ => $NONE_BODY, + } + - pattern: | + match $OPT { + Some(ref $X) => $SOME_BODY, + None => $NONE_BODY, + } + - not: + has: + pattern: return $$$ + stopBy: end +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Verbose match on Option — consider using Option combinators. +note: | + Alternatives: + opt.map_or(default, |x| ...) — both arms are expressions + opt.map_or_else(|| default, |x| ...) — lazy default + opt.unwrap_or(default) — extract value with fallback + opt.unwrap_or_else(|| ...) — lazy fallback + This catches all four arm orderings: `Some/None`, `None/Some`, + `Some/_`, and `Some(ref x)/None`. For `match opt.as_ref()`, write the + combinator on the original Option instead — `opt.as_ref().map(...)` + composes the same way. diff --git a/.config/ast-grep/rules/match-result-verbose.yml b/.config/ast-grep/rules/match-result-verbose.yml new file mode 100644 index 0000000..c9030f4 --- /dev/null +++ b/.config/ast-grep/rules/match-result-verbose.yml @@ -0,0 +1,53 @@ +id: match-result-verbose +language: rust +severity: warning +rule: + all: + - any: + - pattern: | + match $RES { + Ok($V) => $OK_BODY, + Err($E) => $ERR_BODY, + } + - pattern: | + match $RES { + Err($E) => $ERR_BODY, + Ok($V) => $OK_BODY, + } + - pattern: | + match $RES { + Ok($V) => $OK_BODY, + Err(_) => $ERR_BODY, + } + - pattern: | + match $RES { + Err(_) => $ERR_BODY, + Ok($V) => $OK_BODY, + } + - not: + has: + pattern: return $$$ + stopBy: end +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Verbose match on Result — consider Result combinators. +note: | + Alternatives: + res.map_or(default, |v| ...) — both arms are pure expressions + res.map_or_else(|e| ..., |v| ...) — both arms use their binding + res.unwrap_or(default) — extract Ok value with fallback + res.unwrap_or_else(|e| ...) — lazy fallback using the error + res.map(|v| ...).unwrap_or(default) — transform then default + Keep the explicit `match` when either arm has side effects, uses `?`, or + returns from the enclosing function. diff --git a/.config/ast-grep/rules/match-some-push-loop.yml b/.config/ast-grep/rules/match-some-push-loop.yml new file mode 100644 index 0000000..18ebc1b --- /dev/null +++ b/.config/ast-grep/rules/match-some-push-loop.yml @@ -0,0 +1,51 @@ +id: match-some-push-loop +language: rust +severity: warning +rule: + any: + - pattern: | + for $X in $ITER { + match $EXPR { + Some($Y) => $VEC.push($PUSH), + None => {} + } + } + - pattern: | + for $X in $ITER { + match $EXPR { + None => {}, + Some($Y) => $VEC.push($PUSH), + } + } + - pattern: | + for $X in $ITER { + match $EXPR { + Some($Y) => $VEC.push($PUSH), + _ => {} + } + } +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Match-Some-push loop — collapse into `.filter_map()`. +note: | + Replace: + for x in iter { + match f(x) { + Some(y) => out.push(y), + None => {} + } + } + with: + let out: Vec<_> = iter.filter_map(|x| f(x)).collect(); + This is the same shape as [[if-let-some-push-loop]] written with `match`. diff --git a/.config/ast-grep/rules/mut-fold-accumulator.yml b/.config/ast-grep/rules/mut-fold-accumulator.yml new file mode 100644 index 0000000..e242121 --- /dev/null +++ b/.config/ast-grep/rules/mut-fold-accumulator.yml @@ -0,0 +1,56 @@ +id: mut-fold-accumulator +language: rust +severity: warning +rule: + any: + - all: + - kind: expression_statement + - has: + pattern: | + for $X in $ITER { + $ACC += $RHS; + } + - follows: + pattern: let mut $ACC = $INIT; + - all: + - kind: expression_statement + - has: + pattern: | + for $X in $ITER { + $ACC = $ACC + $RHS; + } + - follows: + pattern: let mut $ACC = $INIT; + - all: + - kind: expression_statement + - has: + pattern: | + for $X in $ITER { + $ACC = $ACC.$OP($$$); + } + - follows: + pattern: let mut $ACC = $INIT; +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Mutable accumulator loop — consider `.fold(init, |acc, x| ...)`. +note: | + Replace: + let mut acc = init; + for x in iter { acc = acc.op(x); } // or acc += x; + with: + let acc = iter.fold(init, |acc, x| acc.op(x)); + Two common specializations: + iter.sum() — when acc starts at 0 and you `+= x` + iter.product() — when acc starts at 1 and you `*= x` + Prefer those when they fit; fold otherwise. diff --git a/.config/ast-grep/rules/push-str-format.yml b/.config/ast-grep/rules/push-str-format.yml new file mode 100644 index 0000000..a29b61f --- /dev/null +++ b/.config/ast-grep/rules/push-str-format.yml @@ -0,0 +1,25 @@ +id: push-str-format +language: rust +severity: error +rule: + pattern: $VAR.push_str(&format!($$$)) +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Avoid push_str(&format!(...)) — it allocates an intermediate String. + Use write!(&mut $VAR, ...) instead, which writes directly into the buffer. +note: | + Add `use std::fmt::Write;` then replace: + $VAR.push_str(&format!("...", args)) + with: + write!(&mut $VAR, "...", args).unwrap() diff --git a/.config/ast-grep/rules/silent-filter-map-ok.yml b/.config/ast-grep/rules/silent-filter-map-ok.yml new file mode 100644 index 0000000..08b03bd --- /dev/null +++ b/.config/ast-grep/rules/silent-filter-map-ok.yml @@ -0,0 +1,32 @@ +id: silent-filter-map-ok +language: rust +severity: warning +rule: + any: + - pattern: $EXPR.filter_map(Result::ok) + - pattern: $EXPR.filter_map(|$X| $X.ok()) + not: + inside: + stopBy: end + kind: mod_item + follows: + kind: attribute_item + regex: "cfg\\(test\\)" +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "**/test_utils.rs" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Silent error filtering — .filter_map(Result::ok) silently drops all Err + values from the iterator. If any row/item fails to parse or deserialize, + it vanishes with no trace. Prefer logging failures: + .filter_map(|r| r.inspect_err(|e| warn!(%e, "skipped")).ok()) diff --git a/.config/ast-grep/rules/silent-map-err.yml b/.config/ast-grep/rules/silent-map-err.yml new file mode 100644 index 0000000..148097e --- /dev/null +++ b/.config/ast-grep/rules/silent-map-err.yml @@ -0,0 +1,32 @@ +id: silent-map-err +language: rust +severity: hint +rule: + all: + - pattern: $EXPR.map_err(|_| $$$BODY) + - not: + inside: + stopBy: end + kind: mod_item + follows: + kind: attribute_item + regex: "cfg\\(test\\)" +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "**/test_utils.rs" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Original error discarded — .map_err(|_| ...) replaces the error without + preserving context, making failures harder to diagnose. Prefer: + .map_err(|e| MyError::Foo(e)) or .map_err(MyError::from) + For parse errors where the original is unhelpful, add .inspect_err() or + include the input value in the replacement error message. diff --git a/.config/ast-grep/rules/silent-ok-discard.yml b/.config/ast-grep/rules/silent-ok-discard.yml new file mode 100644 index 0000000..a56f6ea --- /dev/null +++ b/.config/ast-grep/rules/silent-ok-discard.yml @@ -0,0 +1,34 @@ +id: silent-ok-discard +language: rust +severity: warning +rule: + all: + - pattern: $EXPR.ok() + - inside: + kind: expression_statement + stopBy: neighbor + - not: + inside: + stopBy: end + kind: mod_item + follows: + kind: attribute_item + regex: "cfg\\(test\\)" +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "**/test_utils.rs" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Silent .ok() discard — calling .ok() on a Result as a statement converts + the error to None and drops it. If the operation can fail meaningfully, + log the error or propagate with ?. If failure is truly ignorable, use + `let _ = expr;` with a comment explaining why. diff --git a/.config/ast-grep/rules/silent-unwrap-or-else.yml b/.config/ast-grep/rules/silent-unwrap-or-else.yml new file mode 100644 index 0000000..8e72f1c --- /dev/null +++ b/.config/ast-grep/rules/silent-unwrap-or-else.yml @@ -0,0 +1,31 @@ +id: silent-unwrap-or-else +language: rust +severity: warning +rule: + all: + - pattern: $EXPR.unwrap_or_else(|_| $$$BODY) + - not: + inside: + stopBy: end + kind: mod_item + follows: + kind: attribute_item + regex: "cfg\\(test\\)" +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "**/test_utils.rs" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Silent error swallow — .unwrap_or_else(|_| ...) discards the error and + returns a default value. If this operation fails, the bug will be invisible. + Log the error first with .inspect_err(|e| warn!(%e, "context")), or + propagate with ?. diff --git a/.config/ast-grep/rules/stringly-typed-dispatch.yml b/.config/ast-grep/rules/stringly-typed-dispatch.yml new file mode 100644 index 0000000..b13687d --- /dev/null +++ b/.config/ast-grep/rules/stringly-typed-dispatch.yml @@ -0,0 +1,42 @@ +id: stringly-typed-dispatch +language: rust +severity: warning +rule: + kind: if_expression + all: + - has: + field: condition + pattern: $EXPR.contains($LIT) + - has: + kind: else_clause + has: + kind: if_expression + has: + field: condition + pattern: $EXPR2.contains($LIT2) + - not: + inside: + stopBy: end + kind: function_item + regex: "fn\\s+(from_str|parse)" +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Stringly-typed dispatch — chained .contains() checks simulate enum + matching with strings. Define an enum with the variants and match on + it instead. +note: | + Define an enum and implement FromStr: + enum Model { Haiku, Opus, Sonnet } + let model = Model::from_str(name).unwrap_or(Model::Sonnet); + match model { Model::Haiku => ..., Model::Opus => ... } diff --git a/.config/ast-grep/rules/stringly-typed-match.yml b/.config/ast-grep/rules/stringly-typed-match.yml new file mode 100644 index 0000000..2b1689e --- /dev/null +++ b/.config/ast-grep/rules/stringly-typed-match.yml @@ -0,0 +1,49 @@ +id: stringly-typed-match +language: rust +severity: warning +rule: + all: + - kind: match_expression + - has: + field: value + pattern: $EXPR.as_str() + - not: + inside: + stopBy: end + kind: impl_item + regex: "impl\\s+FromStr" + - not: + inside: + stopBy: end + kind: impl_item + regex: "impl\\s+TryFrom<&str>" + - not: + inside: + stopBy: end + kind: impl_item + regex: "impl\\s+TryFrom" + - not: + inside: + stopBy: end + kind: function_item + regex: "fn\\s+(from_str|parse)" +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Stringly-typed match — matching on .as_str() with string literal arms + loses exhaustiveness checking and type safety. Use the enum's existing + parse/from_str method, or define one if it doesn't exist. +note: | + If the enum has a from_str_loose/parse method, call it instead: + let value = MyEnum::from_str_loose(input).unwrap_or_default(); + match value { MyEnum::Foo => ..., MyEnum::Bar => ... } diff --git a/.config/ast-grep/rules/unwrap-in-prod.yml b/.config/ast-grep/rules/unwrap-in-prod.yml new file mode 100644 index 0000000..72487ac --- /dev/null +++ b/.config/ast-grep/rules/unwrap-in-prod.yml @@ -0,0 +1,31 @@ +id: unwrap-in-prod +language: rust +severity: error +rule: + all: + - pattern: $EXPR.unwrap() + - not: + inside: + stopBy: end + kind: mod_item + follows: + kind: attribute_item + regex: "cfg\\(.*\\btest\\b" +ignores: + - "**/benches/**" + - "**/examples/**" + - "**/tests/**" + - "**/tests.rs" + - "**/*-tests.rs" + - "**/*_test.rs" + - "**/*_tests.rs" + - "**/test_*.rs" + - "**/test_utils.rs" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Avoid .unwrap() in production code — prefer .expect("reason"), ?, or + combinators (.unwrap_or, .map_or, .ok_or_else) for better diagnostics + and graceful error handling. diff --git a/.config/ast-grep/rules/unwrap-or-default.yml b/.config/ast-grep/rules/unwrap-or-default.yml index ab0f57e..0b77577 100644 --- a/.config/ast-grep/rules/unwrap-or-default.yml +++ b/.config/ast-grep/rules/unwrap-or-default.yml @@ -1,8 +1,31 @@ id: unwrap-or-default -language: Rust -severity: warning -message: Use unwrap_or_default() instead of constructing a default value eagerly. +language: rust +severity: hint rule: - any: - - pattern: $VALUE.unwrap_or(Default::default()) - - pattern: $VALUE.unwrap_or($TYPE::default()) + all: + - pattern: $EXPR.unwrap_or_default() + - not: + inside: + stopBy: end + kind: mod_item + follows: + kind: attribute_item + regex: "cfg\\(test\\)" +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "**/test_utils.rs" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + Silent fallback — .unwrap_or_default() returns the Default value (empty + string, zero, empty vec, etc.) if the Result or Option is empty/failed. + On Results, this silently hides errors. Prefer .expect("reason"), + .inspect_err(|e| warn!(%e, "context")).unwrap_or_default(), or ?. diff --git a/.config/ast-grep/rules/with-capacity-push-loop.yml b/.config/ast-grep/rules/with-capacity-push-loop.yml new file mode 100644 index 0000000..b461272 --- /dev/null +++ b/.config/ast-grep/rules/with-capacity-push-loop.yml @@ -0,0 +1,36 @@ +id: with-capacity-push-loop +language: rust +severity: warning +rule: + kind: expression_statement + has: + pattern: | + for $X in $ITER { + $V.push($EXPR); + } + follows: + pattern: let mut $V = Vec::with_capacity($CAP); +ignores: + - "crates/**/tests/**" + - "crates/**/benches/**" + - "benches/**" + - "tests/**" + - "examples/**" + - "tracehealth-api/tests/**" + - "tracehealth-api/benches/**" + - "vendor/**" + - ".claude/worktrees/**" + - "target/**" + - "tracehealth-ios/**" +message: | + `Vec::with_capacity` + push-loop — `iter.map(...).collect()` is shorter + and the size hint is propagated automatically when `ITER` is an + `ExactSizeIterator`. +note: | + Replace: + let mut v = Vec::with_capacity(iter.len()); + for x in iter { v.push(expr); } + with: + let v: Vec<_> = iter.map(|x| expr).collect(); + `collect()` calls `size_hint()` and pre-allocates, so the capacity hint + is preserved without you wiring it through by hand. diff --git a/.config/selfci/ci.sh b/.config/selfci/ci.sh index 5afcf23..e3f0881 100755 --- a/.config/selfci/ci.sh +++ b/.config/selfci/ci.sh @@ -6,6 +6,11 @@ function job_lint() { if ! treefmt --ci ; then selfci step fail fi + + selfci step start "ast-grep" + if ! ast-grep scan --config sgconfig.yml ; then + selfci step fail + fi } function job_checks() { diff --git a/checks/ast-grep-rules.nix b/checks/ast-grep-rules.nix index 554e988..5843f18 100644 --- a/checks/ast-grep-rules.nix +++ b/checks/ast-grep-rules.nix @@ -21,25 +21,25 @@ pkgs.runCommand "ast-grep-rules" ast-grep test mkdir -p \ - ast-grep-fixtures/benches \ - ast-grep-fixtures/examples \ - ast-grep-fixtures/tests \ - ast-grep-fixtures/src + benches \ + examples \ + tests \ + src for path in \ - ast-grep-fixtures/benches/case.rs \ - ast-grep-fixtures/examples/case.rs \ - ast-grep-fixtures/tests/case.rs \ - ast-grep-fixtures/src/check-tests.rs \ - ast-grep-fixtures/src/check_test.rs \ - ast-grep-fixtures/src/check_tests.rs \ - ast-grep-fixtures/src/test_check.rs \ - ast-grep-fixtures/src/tests.rs + benches/case.rs \ + examples/case.rs \ + tests/case.rs \ + src/check-tests.rs \ + src/check_test.rs \ + src/check_tests.rs \ + src/test_check.rs \ + src/tests.rs do printf 'fn fixture() { value.unwrap(); }\n' > "$path" ast-grep scan --error "$path" done - production_fixture=ast-grep-fixtures/src/check.rs + production_fixture=src/check.rs printf 'fn fixture() { value.unwrap(); }\n' > "$production_fixture" if ast-grep scan --error "$production_fixture" > violation.log 2>&1; then echo "production unwrap fixture unexpectedly passed" >&2 diff --git a/flakebox-bin/src/main.rs b/flakebox-bin/src/main.rs index 05c0fdd..13a7906 100644 --- a/flakebox-bin/src/main.rs +++ b/flakebox-bin/src/main.rs @@ -37,22 +37,25 @@ fn main() -> AppResult<()> { match opts.command { Commands::Init => init(&opts)?, Commands::Install => install(&opts)?, - Commands::Docs { docs_dir } => { - if let Some(docs_dir) = docs_dir { - let docs_index = docs_dir.join("index.html"); - eprintln!("Opening docs available at {}", docs_index.display()); - cmd!("xdg-open", docs_index) - .run() - .change_context(AppError::General)?; - } else { + Commands::Docs { docs_dir } => docs_dir.map_or_else( + || { cmd!("nix", "build", "github:rustshop/flakebox#docs") .run() .change_context(AppError::General)?; cmd!("xdg-open", "result/index.html") .run() .change_context(AppError::General)?; - } - } + Ok::<(), error_stack::Report>(()) + }, + |docs_dir| { + let docs_index = docs_dir.join("index.html"); + eprintln!("Opening docs available at {}", docs_index.display()); + cmd!("xdg-open", docs_index) + .run() + .change_context(AppError::General)?; + Ok::<(), error_stack::Report>(()) + }, + )?, Commands::Lint { fix, silent } => match lint(&opts, fix, silent) { Err(e) if e.current_context() == &AppError::Lint => std::process::exit(1), other => other, @@ -172,11 +175,7 @@ fn item_as_table_mut<'item>( ) -> &'item mut toml_edit::Table { if !item.is_table() { let owned = std::mem::take(item); - *item = toml_edit::Item::Table( - owned - .into_table() - .unwrap_or_else(|_| panic!("{invalid_message}")), - ); + *item = toml_edit::Item::Table(owned.into_table().expect(invalid_message)); } item.as_table_mut() @@ -186,17 +185,14 @@ fn item_as_table_mut<'item>( fn lint_cargo_toml(opts: &Opts, problems: &mut Vec) -> AppResult<()> { let (path, cargo_toml) = load_root_cargo_toml(opts)?; - if let Some(toml_edit::Item::Table(workspace)) = cargo_toml.get("workspace") { - match workspace.get("resolver") { - Some(toml_edit::Item::Value(toml_edit::Value::String(v))) if v.value() == "2" => {} - _ => { - problems.push(LintItem { - path: path.clone(), - msg: "`workspace.resolver` missing or not set to 'v2'".to_string(), - fix: Some(lint_cargo_toml_fix_resolver_v2), - }); - } - } + if let Some(toml_edit::Item::Table(workspace)) = cargo_toml.get("workspace") + && !workspace_resolver_is_supported(workspace) + { + problems.push(LintItem { + path: path.clone(), + msg: "`workspace.resolver` missing or not set to '2' or '3'".to_string(), + fix: Some(lint_cargo_toml_fix_resolver_v2), + }); } if cargo_toml .get("profile") @@ -212,6 +208,14 @@ fn lint_cargo_toml(opts: &Opts, problems: &mut Vec) -> AppResult<()> { Ok(()) } +fn workspace_resolver_is_supported(workspace: &toml_edit::Table) -> bool { + matches!( + workspace.get("resolver"), + Some(toml_edit::Item::Value(toml_edit::Value::String(resolver))) + if resolver.value() == "2" || resolver.value() == "3" + ) +} + #[derive(Deserialize)] struct CargoMetadataOutput { workspace_root: PathBuf, @@ -304,7 +308,9 @@ fn install_files(src: &Path, dst: &Path) -> AppResult<()> { } else { remove_file_or_symlink(&dst_path).change_context_lazy(|| AppError::IO)?; fs::copy(source_path, &dst_path).change_context_lazy(|| AppError::IO)?; - let _ = cmd!("git", "add", &dst_path).run(); + if let Err(error) = cmd!("git", "add", &dst_path).run() { + tracing::debug!(%error, path = %dst_path.display(), "could not stage installed file"); + } chmod_non_writable(&dst_path)?; } @@ -364,7 +370,9 @@ fn init_logging() { let subscriber = tracing_subscriber::fmt() .with_writer(std::io::stderr) // Print to stderr .with_env_filter( - EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")), + EnvFilter::builder() + .with_default_directive("info".parse().expect("info is a valid filter directive")) + .from_env_lossy(), ) .finish(); @@ -375,6 +383,38 @@ fn init_logging() { mod tests { use super::*; + #[test] + fn cargo_workspace_resolver_accepts_current_versions() { + for resolver in ["2", "3"] { + let cargo_toml = format!("[workspace]\nresolver = \"{resolver}\"\n") + .parse::() + .expect("valid test manifest"); + let workspace = cargo_toml["workspace"] + .as_table() + .expect("workspace is a table"); + + assert!(workspace_resolver_is_supported(workspace)); + } + } + + #[test] + fn cargo_workspace_resolver_rejects_stale_or_invalid_values() { + for manifest in [ + "[workspace]\n", + "[workspace]\nresolver = \"1\"\n", + "[workspace]\nresolver = 3\n", + ] { + let cargo_toml = manifest + .parse::() + .expect("valid test manifest"); + let workspace = cargo_toml["workspace"] + .as_table() + .expect("workspace is a table"); + + assert!(!workspace_resolver_is_supported(workspace)); + } + } + #[test] fn cargo_profile_defaults_disable_debug_info_and_preserve_inline_dev_settings() { let mut cargo_toml = "profile = { dev = { package = { \"*\" = { opt-level = 1 } } } }\n" diff --git a/lib/pkgs/git-hook-check-timer-tests.rs b/lib/pkgs/git-hook-check-timer-tests.rs index 54ca90c..d85ed46 100644 --- a/lib/pkgs/git-hook-check-timer-tests.rs +++ b/lib/pkgs/git-hook-check-timer-tests.rs @@ -45,7 +45,7 @@ fn warning_formatter_compacts_only_redundant_newlines() { .concat(); let mut output = Vec::new(); - compact_warning_spacing(&input[..], &mut output).unwrap(); + compact_warning_spacing(&input[..], &mut output).expect("warning formatter succeeds"); assert_eq!( output, @@ -64,7 +64,7 @@ fn warning_formatter_preserves_unterminated_and_binary_output() { .concat(); let mut output = Vec::new(); - compact_warning_spacing(&input[..], &mut output).unwrap(); + compact_warning_spacing(&input[..], &mut output).expect("warning formatter succeeds"); assert_eq!( output, @@ -77,7 +77,7 @@ fn warning_formatter_preserves_ordinary_output_byte_exactly() { let input = b"\0ordinary\n\noutput without newline"; let mut output = Vec::new(); - compact_warning_spacing(&input[..], &mut output).unwrap(); + compact_warning_spacing(&input[..], &mut output).expect("warning formatter succeeds"); assert_eq!(output, input); } @@ -91,7 +91,7 @@ fn warning_formatter_does_not_prefix_first_warning() { .concat(); let mut output = Vec::new(); - compact_warning_spacing(&input[..], &mut output).unwrap(); + compact_warning_spacing(&input[..], &mut output).expect("warning formatter succeeds"); assert_eq!(output, b"flakebox: warning: check_slow took 1.200s (>1s)\n"); } diff --git a/lib/pkgs/git-hook-check-timer.rs b/lib/pkgs/git-hook-check-timer.rs index 24babad..b9e31ab 100644 --- a/lib/pkgs/git-hook-check-timer.rs +++ b/lib/pkgs/git-hook-check-timer.rs @@ -174,14 +174,21 @@ fn main() { "\n" }; let warning_record = format!("{separator}{warning}\n"); - let _ = io::stderr().lock().write_all(warning_record.as_bytes()); + let stderr_write_result = io::stderr().lock().write_all(warning_record.as_bytes()); + if stderr_write_result.is_err() { + // Stderr is unavailable, so the diagnostic cannot be reported + // elsewhere. + } } } - match check_result { - Ok(check_result) => exit_like_check(check_result.status), - Err(error) => timer_error(&format!("could not run check {check_name:?}: {error}")), - } + exit_like_check( + check_result + .unwrap_or_else(|error| { + timer_error(&format!("could not run check {check_name:?}: {error}")) + }) + .status, + ); } /// Streams hook stderr while replacing timer markers with a needed separator.