Skip to main content

alint_core/
rule.rs

1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::path::Path;
4use std::sync::Arc;
5
6use crate::error::Result;
7use crate::facts::FactValues;
8use crate::level::Level;
9use crate::registry::RuleRegistry;
10use crate::walker::FileIndex;
11
12/// A single linting violation produced by a rule.
13///
14/// `path` holds an [`Arc<Path>`]; rules clone the [`Arc`] from
15/// [`FileEntry::path`](crate::walker::FileEntry::path) (a cheap
16/// atomic refcount bump) rather than copying the path bytes. At
17/// 100k violations this saves 100k path-byte allocations.
18///
19/// `message` is a [`Cow<'static, str>`]; per-match templated
20/// messages live as `Cow::Owned(String)` (no change in cost),
21/// while fixed messages can live as `Cow::Borrowed("…")` if a
22/// rule chooses to construct them that way. Public API on the
23/// struct is unchanged at the byte level — `Display` and serde
24/// `Serialize` impls go through the inner `&str` / `&Path`.
25#[derive(Debug, Clone)]
26pub struct Violation {
27    pub path: Option<Arc<Path>>,
28    pub message: Cow<'static, str>,
29    pub line: Option<usize>,
30    pub column: Option<usize>,
31}
32
33impl Violation {
34    pub fn new(message: impl Into<Cow<'static, str>>) -> Self {
35        Self {
36            path: None,
37            message: message.into(),
38            line: None,
39            column: None,
40        }
41    }
42
43    /// Attach a path to the violation. Accepts anything convertible
44    /// into `Arc<Path>` — the canonical caller is
45    /// `.with_path(entry.path.clone())` where `entry.path` is the
46    /// `Arc<Path>` already owned by the [`FileIndex`]; this clones
47    /// the [`Arc`] (atomic refcount bump) rather than the bytes.
48    /// `PathBuf`, `&Path`, and `Box<Path>` are also accepted via
49    /// std's `From` impls; for an ad-hoc `&str` use
50    /// `Path::new("a.rs")` to convert first.
51    #[must_use]
52    pub fn with_path(mut self, path: impl Into<Arc<Path>>) -> Self {
53        self.path = Some(path.into());
54        self
55    }
56
57    #[must_use]
58    pub fn with_location(mut self, line: usize, column: usize) -> Self {
59        self.line = Some(line);
60        self.column = Some(column);
61        self
62    }
63}
64
65/// The collected outcome of evaluating a single rule.
66///
67/// `rule_id` holds an [`Arc<str>`]: the engine builds it once
68/// per rule run and shares it across every violation that rule
69/// produces, saving N-1 allocations per rule. `policy_url`
70/// follows the same shape via [`Arc<str>`] — set once per rule,
71/// shared across violations.
72#[derive(Debug, Clone)]
73pub struct RuleResult {
74    pub rule_id: Arc<str>,
75    pub level: Level,
76    pub policy_url: Option<Arc<str>>,
77    pub violations: Vec<Violation>,
78    /// Whether the rule declares a [`Fixer`] — surfaced here so
79    /// the human formatter can tag violations as `fixable`
80    /// without threading the rule registry into the renderer.
81    pub is_fixable: bool,
82}
83
84impl RuleResult {
85    pub fn passed(&self) -> bool {
86        self.violations.is_empty()
87    }
88}
89
90/// Execution context handed to each rule during evaluation.
91///
92/// - `registry` — available for rules that need to build and evaluate nested
93///   rules at runtime (e.g. `for_each_dir`). Tests that don't exercise
94///   nested evaluation can set this to `None`.
95/// - `facts` — resolved fact values, computed once per `Engine::run`.
96/// - `vars` — user-supplied string variables from the config's `vars:` section.
97/// - `git_tracked` — set of repo paths reported by `git ls-files`,
98///   computed once per run when at least one rule has
99///   `git_tracked_only: true`. `None` outside a git repo or when
100///   no rule asked for it. Rules that opt in consult it via
101///   [`Context::is_git_tracked`].
102/// - `git_blame` — per-file `git blame` cache, computed lazily
103///   when at least one rule reports `wants_git_blame()`. `None`
104///   when no rule asked for it. Rules consult it via
105///   [`crate::git::BlameCache::get`]; both "outside a git repo"
106///   and "blame failed for this file" surface as a `None`
107///   lookup, which the rule treats as "silent no-op."
108#[derive(Debug)]
109pub struct Context<'a> {
110    pub root: &'a Path,
111    pub index: &'a FileIndex,
112    pub registry: Option<&'a RuleRegistry>,
113    pub facts: Option<&'a FactValues>,
114    pub vars: Option<&'a HashMap<String, String>>,
115    pub git_tracked: Option<&'a std::collections::HashSet<std::path::PathBuf>>,
116    pub git_blame: Option<&'a crate::git::BlameCache>,
117}
118
119impl Context<'_> {
120    /// True if `rel_path` is in git's index. Returns `false` when
121    /// no tracked-set was computed (no git repo, or no rule asked
122    /// for it). Rules that opt into `git_tracked_only` therefore
123    /// silently skip every entry outside a git repo, which is the
124    /// right behaviour for the canonical "don't let X be
125    /// committed" use case.
126    pub fn is_git_tracked(&self, rel_path: &Path) -> bool {
127        match self.git_tracked {
128            Some(set) => set.contains(rel_path),
129            None => false,
130        }
131    }
132
133    /// True if the directory at `rel_path` contains at least one
134    /// git-tracked file. Used by `dir_*` rules opting into
135    /// `git_tracked_only`. Same `None`-means-untracked semantics
136    /// as [`Context::is_git_tracked`].
137    pub fn dir_has_tracked_files(&self, rel_path: &Path) -> bool {
138        match self.git_tracked {
139            Some(set) => crate::git::dir_has_tracked_files(rel_path, set),
140            None => false,
141        }
142    }
143}
144
145/// How a rule narrows its iteration to git-tracked entries.
146/// Returned by [`Rule::git_tracked_mode`]; the engine reads
147/// this at construction time to pick the right pre-filtered
148/// `FileIndex` (file-only or dir-aware) for each opted-in
149/// rule.
150///
151/// The mode is per-rule (a config might opt in some rules and
152/// not others). The engine builds at most two filtered indexes
153/// per run regardless of how many rules opt in, so the cost
154/// amortises across the whole rule set.
155///
156/// See `docs/design/v0.9/git-tracked-filtered-index.md` for
157/// the v0.9.11 structural fix this enum is the entry point of.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159pub enum GitTrackedMode {
160    /// Rule does not consult the git-tracked set. Engine
161    /// routes the rule's evaluation against the unfiltered
162    /// `FileIndex`. The default; do not override unless the
163    /// rule opts into `git_tracked_only:`.
164    Off,
165    /// Rule iterates files (`ctx.index.files()`) and the
166    /// engine narrows that to entries where
167    /// `git_tracked.contains(path)` before the rule sees them.
168    /// File-mode existence rules (`file_exists`, `file_absent`)
169    /// pick this mode when the spec's `git_tracked_only: true`.
170    FileOnly,
171    /// Rule iterates dirs (`ctx.index.dirs()`) and the engine
172    /// narrows that to dirs where
173    /// `dir_has_tracked_files(path, &git_tracked)`. Dir-mode
174    /// existence rules (`dir_exists`, `dir_absent`) pick this
175    /// mode when the spec's `git_tracked_only: true`. The
176    /// filtered index also includes the tracked files
177    /// themselves so a `dir_*` rule's nested per-file checks
178    /// (e.g. `paths:` glob) still match.
179    DirAware,
180}
181
182/// Stamp out the three boilerplate `Rule` impl methods every rule
183/// kind ships: `id`, `level`, `policy_url`. Expects the impl'ing
184/// struct to have fields named `id: String`, `level: Level`, and
185/// `policy_url: Option<String>` (the universal shape every rule
186/// builder hands back).
187///
188/// Usage inside an `impl Rule for SomeRule` block:
189///
190/// ```ignore
191/// impl Rule for FileExistsRule {
192///     alint_core::rule_common_impl!();
193///
194///     fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>> { ... }
195///     // ... other trait methods specific to this rule
196/// }
197/// ```
198///
199/// The macro only covers the three universal methods. `fixer()` /
200/// `as_per_file()` / `evaluate()` / `requires_full_index()` etc.
201/// stay explicit per-rule because they encode the rule's actual
202/// behaviour, not boilerplate.
203#[macro_export]
204macro_rules! rule_common_impl {
205    () => {
206        fn id(&self) -> &str {
207            &self.id
208        }
209        fn level(&self) -> $crate::Level {
210            self.level
211        }
212        fn policy_url(&self) -> Option<&str> {
213            self.policy_url.as_deref()
214        }
215    };
216}
217
218/// Trait every built-in and plugin rule implements.
219pub trait Rule: Send + Sync + std::fmt::Debug {
220    fn id(&self) -> &str;
221    fn level(&self) -> Level;
222    fn policy_url(&self) -> Option<&str> {
223        None
224    }
225    /// Whether (and how) this rule narrows its iteration to
226    /// git-tracked entries. Default [`GitTrackedMode::Off`].
227    /// Rule kinds that support `git_tracked_only:` override to
228    /// return [`GitTrackedMode::FileOnly`] (file-mode rules:
229    /// check `set.contains(path)`) or [`GitTrackedMode::DirAware`]
230    /// (dir-mode rules: check `dir_has_tracked_files(path, set)`)
231    /// when the user opts in.
232    ///
233    /// The engine collects the tracked-paths set (via
234    /// `git ls-files`) once per run when ANY rule returns a
235    /// non-`Off` mode, then builds a pre-filtered `FileIndex`
236    /// for each mode and routes opted-in rules to the right
237    /// `Context`. Rules iterate `ctx.index.files()` /
238    /// `ctx.index.dirs()` exactly as before — the index is
239    /// already narrowed, so no per-rule `if self.git_tracked_only
240    /// && !ctx.is_git_tracked(...)` runtime check is needed.
241    /// Closes the same recurrence-risk shape as v0.9.10's
242    /// `Scope`-owns-`scope_filter` fix:
243    /// `docs/design/v0.9/git-tracked-filtered-index.md`.
244    fn git_tracked_mode(&self) -> GitTrackedMode {
245        GitTrackedMode::Off
246    }
247
248    /// Whether this rule needs `git blame` output on
249    /// [`Context`]. Default `false`; the `git_blame_age` rule
250    /// kind overrides to return `true`. The engine builds the
251    /// shared [`crate::git::BlameCache`] once per run when any
252    /// rule opts in, so multiple blame-aware rules over
253    /// overlapping `paths:` re-use the parsed result.
254    fn wants_git_blame(&self) -> bool {
255        false
256    }
257
258    /// In `--changed` mode, return `true` to evaluate this rule
259    /// against the **full** [`FileIndex`] rather than the
260    /// changed-only filtered subset. Default `false` (per-file
261    /// semantics — the rule sees only changed files in scope).
262    ///
263    /// Cross-file rules (`pair`, `for_each_dir`,
264    /// `every_matching_has`, `unique_by`, `dir_contains`,
265    /// `dir_only_contains`) override to `true` because their
266    /// inputs span the whole tree by definition — a verdict on
267    /// the changed file depends on what's still in the rest of
268    /// the tree. Existence rules (`file_exists`, `file_absent`,
269    /// `dir_exists`, `dir_absent`) likewise consult the whole
270    /// tree to answer "is X present?" correctly.
271    fn requires_full_index(&self) -> bool {
272        false
273    }
274
275    /// In `--changed` mode, return the [`Scope`](crate::Scope)
276    /// this rule is scoped to (typically the rule's `paths:`
277    /// field). The engine intersects the scope with the
278    /// changed-set; rules whose scope doesn't intersect are
279    /// skipped, which is the optimisation `--changed` exists
280    /// for.
281    ///
282    /// Default `None` ("no scope information") means the rule is
283    /// always evaluated. Cross-file rules deliberately leave this
284    /// as `None` (they always evaluate per the roadmap contract).
285    /// Per-file rules with a single `Scope` field should override
286    /// to return `Some(&self.scope)`.
287    fn path_scope(&self) -> Option<&crate::scope::Scope> {
288        None
289    }
290
291    fn evaluate(&self, ctx: &Context<'_>) -> Result<Vec<Violation>>;
292
293    /// Optional automatic-fix strategy. Rules whose violations can be
294    /// mechanically corrected (e.g. creating a missing file, removing a
295    /// forbidden one, renaming to the correct case) return a
296    /// [`Fixer`] here; the default implementation reports the rule as
297    /// unfixable.
298    fn fixer(&self) -> Option<&dyn Fixer> {
299        None
300    }
301
302    /// Opt into the file-major dispatch path. Per-file rules that
303    /// can evaluate one file at a time given a pre-loaded byte
304    /// slice override this to return `Some(self)`; cross-file
305    /// rules and any rule with `requires_full_index() == true`
306    /// leave it as `None` and keep evaluating under the rule-
307    /// major loop.
308    ///
309    /// When the engine has multiple per-file rules sharing one
310    /// scope, the file-major loop reads each matched file once
311    /// and dispatches to every applicable per-file rule against
312    /// the same byte buffer — coalescing N reads of one file
313    /// into 1.
314    fn as_per_file(&self) -> Option<&dyn PerFileRule> {
315        None
316    }
317}
318
319/// File-major dispatch entry-point for a per-file rule.
320///
321/// Rules that can evaluate one file at a time given a pre-loaded
322/// byte slice implement this trait alongside [`Rule`] and opt
323/// into the file-major path via [`Rule::as_per_file`]. The
324/// engine reads each file once per evaluation pass and calls
325/// `evaluate_file` on every per-file rule whose
326/// [`path_scope`](PerFileRule::path_scope) matches that file —
327/// avoiding the per-rule `std::fs::read` the rule-major loop
328/// would otherwise duplicate.
329///
330/// Implementations MUST NOT call `std::fs::read` themselves; the
331/// `bytes` argument is the engine's already-read content. The
332/// rule's existing [`Rule::evaluate`] implementation (which does
333/// read the file) stays in place as the rule-major fallback —
334/// it's still the path used by `alint fix` (sequential
335/// filesystem mutation rules out coalesced reads there) and by
336/// fallback test harnesses.
337pub trait PerFileRule: Send + Sync + std::fmt::Debug {
338    /// The rule's scope. The engine checks
339    /// `path_scope().matches(path)` before calling
340    /// `evaluate_file`; a rule that returns
341    /// [`Scope::match_all`](crate::scope::Scope::match_all) is
342    /// in scope for every file.
343    fn path_scope(&self) -> &crate::scope::Scope;
344
345    /// Evaluate one file given the engine's already-read byte
346    /// content. The `path` is the relative path from the lint
347    /// root; the rule should `with_path(path.into())` (or clone
348    /// the matched [`FileEntry::path`](crate::walker::FileEntry::path)
349    /// if it has one in hand) on emitted violations.
350    fn evaluate_file(&self, ctx: &Context<'_>, path: &Path, bytes: &[u8])
351    -> Result<Vec<Violation>>;
352
353    /// Optional lower bound on the bytes the rule needs to
354    /// evaluate. Default `None` means "I need the whole file."
355    /// Used as a hint; the engine in v0.9.3 reads the whole
356    /// file regardless and hands it to every applicable rule —
357    /// the hint is reserved for a future engine-side bounded-
358    /// read optimisation.
359    fn max_bytes_needed(&self) -> Option<usize> {
360        None
361    }
362}
363
364/// Rule-major fallback for [`PerFileRule`] implementors.
365///
366/// Every per-file rule needs a [`Rule::evaluate`] body — the engine's
367/// file-major fast path uses [`PerFileRule::evaluate_file`] directly,
368/// but `alint fix` (sequential filesystem mutation) and a handful of
369/// test harnesses still drive rules through [`Rule::evaluate`]. The
370/// loop is mechanical:
371///
372/// ```text
373/// for entry in ctx.index.files() {
374///     if scope doesn't match { continue }
375///     let bytes = std::fs::read(full)?  // continue on read failure
376///     violations.extend(self.evaluate_file(ctx, path, &bytes)?)
377/// }
378/// ```
379///
380/// Twenty-five rules ship the same loop verbatim. Calling
381/// `eval_per_file(self, ctx)` from `Rule::evaluate` collapses each
382/// of them to a one-liner. The helper takes `&R: PerFileRule` so
383/// it inlines for static dispatch.
384///
385/// Read failures (file deleted mid-walk, permission flake) skip the
386/// file silently to match the engine's file-major behaviour at
387/// `crate::engine` line ~506.
388pub fn eval_per_file<R: PerFileRule + ?Sized>(
389    rule: &R,
390    ctx: &Context<'_>,
391) -> Result<Vec<Violation>> {
392    let mut violations = Vec::new();
393    for entry in ctx.index.files() {
394        if !rule.path_scope().matches(&entry.path, ctx.index) {
395            continue;
396        }
397        let full = ctx.root.join(&entry.path);
398        let Ok(bytes) = std::fs::read(&full) else {
399            continue;
400        };
401        violations.extend(rule.evaluate_file(ctx, &entry.path, &bytes)?);
402    }
403    Ok(violations)
404}
405
406/// Runtime context for applying a fix.
407#[derive(Debug)]
408pub struct FixContext<'a> {
409    pub root: &'a Path,
410    /// When true, fixers must describe what they would do without
411    /// touching the filesystem.
412    pub dry_run: bool,
413    /// Max bytes a content-editing fix will read + rewrite.
414    /// `None` means no cap. Honored by the `read_for_fix` helper
415    /// (and any custom fixer that opts in).
416    pub fix_size_limit: Option<u64>,
417}
418
419/// The result of applying (or simulating) one fix against one violation.
420#[derive(Debug, Clone)]
421pub enum FixOutcome {
422    /// The fix was applied (or would be, under `dry_run`). The string
423    /// is a human-readable one-liner — e.g. `"created LICENSE"`,
424    /// `"would remove target/debug.log"`.
425    Applied(String),
426    /// The fixer intentionally did nothing; the string explains why
427    /// (e.g. `"already exists"`, `"no path on violation"`). This is
428    /// distinct from a hard error returned via `Result::Err`.
429    Skipped(String),
430}
431
432/// A mechanical corrector for a specific rule's violations.
433pub trait Fixer: Send + Sync + std::fmt::Debug {
434    /// Short human-readable summary of what this fixer does,
435    /// independent of any specific violation.
436    fn describe(&self) -> String;
437
438    /// Apply the fix against a single violation.
439    fn apply(&self, violation: &Violation, ctx: &FixContext<'_>) -> Result<FixOutcome>;
440}
441
442/// Result of [`read_for_fix`] — either the bytes of the file,
443/// or a [`FixOutcome::Skipped`] the caller should return.
444///
445/// Content-editing fixers (`file_prepend`, `file_append`,
446/// `file_trim_trailing_whitespace`, …) funnel their initial read
447/// through this helper so the `fix_size_limit` guard is enforced
448/// uniformly: over-limit files are reported as `Skipped` with a
449/// clear reason, and a one-line warning is printed to stderr so
450/// scripted runs notice.
451#[derive(Debug)]
452pub enum ReadForFix {
453    Bytes(Vec<u8>),
454    Skipped(FixOutcome),
455}
456
457/// Check whether `abs` is within the `fix_size_limit` on `ctx`.
458/// Returns `Some(outcome)` when the file is over-limit (the
459/// caller returns this directly); returns `None` when the fix
460/// can proceed. Emits a one-line stderr warning on over-limit.
461///
462/// Use this in fixers that modify the file without reading the
463/// full body (e.g. streaming append). For read-modify-write
464/// flows, prefer [`read_for_fix`] which folds the check in.
465pub fn check_fix_size(
466    abs: &Path,
467    display_path: &std::path::Path,
468    ctx: &FixContext<'_>,
469) -> Result<Option<FixOutcome>> {
470    let Some(limit) = ctx.fix_size_limit else {
471        return Ok(None);
472    };
473    let metadata = std::fs::metadata(abs).map_err(|source| crate::error::Error::Io {
474        path: abs.to_path_buf(),
475        source,
476    })?;
477    if metadata.len() > limit {
478        let reason = format!(
479            "{} is {} bytes; exceeds fix_size_limit ({}). Raise \
480             `fix_size_limit` in .alint.yml (or set it to `null` to disable) \
481             to fix files this large.",
482            display_path.display(),
483            metadata.len(),
484            limit,
485        );
486        eprintln!("alint: warning: {reason}");
487        return Ok(Some(FixOutcome::Skipped(reason)));
488    }
489    Ok(None)
490}
491
492/// Read `abs` subject to the size limit on `ctx`. Over-limit
493/// files return `ReadForFix::Skipped(Outcome::Skipped(_))` and
494/// emit a one-line stderr warning; in-limit files return
495/// `ReadForFix::Bytes(...)`. Pass-through I/O errors propagate.
496pub fn read_for_fix(
497    abs: &Path,
498    display_path: &std::path::Path,
499    ctx: &FixContext<'_>,
500) -> Result<ReadForFix> {
501    if let Some(outcome) = check_fix_size(abs, display_path, ctx)? {
502        return Ok(ReadForFix::Skipped(outcome));
503    }
504    let bytes = std::fs::read(abs).map_err(|source| crate::error::Error::Io {
505        path: abs.to_path_buf(),
506        source,
507    })?;
508    Ok(ReadForFix::Bytes(bytes))
509}
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    fn empty_index() -> FileIndex {
516        FileIndex::default()
517    }
518
519    #[test]
520    fn violation_builder_sets_fields_via_chain() {
521        let v = Violation::new("trailing whitespace")
522            .with_path(Path::new("src/main.rs"))
523            .with_location(12, 4);
524        assert_eq!(v.message, "trailing whitespace");
525        assert_eq!(v.path.as_deref(), Some(Path::new("src/main.rs")));
526        assert_eq!(v.line, Some(12));
527        assert_eq!(v.column, Some(4));
528    }
529
530    #[test]
531    fn violation_new_starts_with_no_path_or_location() {
532        let v = Violation::new("global note");
533        assert!(v.path.is_none());
534        assert!(v.line.is_none());
535        assert!(v.column.is_none());
536    }
537
538    #[test]
539    fn rule_result_passed_iff_violations_empty() {
540        let mut r = RuleResult {
541            rule_id: "x".into(),
542            level: Level::Error,
543            policy_url: None,
544            violations: Vec::new(),
545            is_fixable: false,
546        };
547        assert!(r.passed());
548        r.violations.push(Violation::new("oops"));
549        assert!(!r.passed());
550    }
551
552    #[test]
553    fn context_is_git_tracked_returns_false_outside_repo() {
554        let idx = empty_index();
555        let ctx = Context {
556            root: Path::new("/tmp"),
557            index: &idx,
558            registry: None,
559            facts: None,
560            vars: None,
561            git_tracked: None, // outside-a-repo / no rule opted in
562            git_blame: None,
563        };
564        assert!(!ctx.is_git_tracked(Path::new("anything.rs")));
565        assert!(!ctx.dir_has_tracked_files(Path::new("src")));
566    }
567
568    #[test]
569    fn context_is_git_tracked_consults_set_when_present() {
570        let mut tracked: std::collections::HashSet<std::path::PathBuf> =
571            std::collections::HashSet::new();
572        tracked.insert(std::path::PathBuf::from("src/main.rs"));
573        let idx = empty_index();
574        let ctx = Context {
575            root: Path::new("/tmp"),
576            index: &idx,
577            registry: None,
578            facts: None,
579            vars: None,
580            git_tracked: Some(&tracked),
581            git_blame: None,
582        };
583        assert!(ctx.is_git_tracked(Path::new("src/main.rs")));
584        assert!(!ctx.is_git_tracked(Path::new("README.md")));
585    }
586
587    /// Stand-in `Rule` impl that returns the trait defaults.
588    /// Lets us assert the documented defaults without dragging
589    /// in a real registered rule.
590    #[derive(Debug)]
591    struct DefaultRule;
592
593    impl Rule for DefaultRule {
594        fn id(&self) -> &'static str {
595            "default"
596        }
597        fn level(&self) -> Level {
598            Level::Warning
599        }
600        fn evaluate(&self, _ctx: &Context<'_>) -> Result<Vec<Violation>> {
601            Ok(Vec::new())
602        }
603    }
604
605    #[test]
606    fn rule_trait_defaults_are_safe_no_ops() {
607        let r = DefaultRule;
608        assert_eq!(r.policy_url(), None);
609        assert_eq!(r.git_tracked_mode(), GitTrackedMode::Off);
610        assert!(!r.wants_git_blame());
611        assert!(!r.requires_full_index());
612        assert!(r.path_scope().is_none());
613        assert!(r.fixer().is_none());
614    }
615
616    #[test]
617    fn check_fix_size_returns_none_when_limit_disabled() {
618        let dir = tempfile::tempdir().unwrap();
619        let f = dir.path().join("a.txt");
620        std::fs::write(&f, b"hello").unwrap();
621        let ctx = FixContext {
622            root: dir.path(),
623            dry_run: false,
624            fix_size_limit: None,
625        };
626        let outcome = check_fix_size(&f, Path::new("a.txt"), &ctx).unwrap();
627        assert!(outcome.is_none());
628    }
629
630    #[test]
631    fn check_fix_size_skips_over_limit_files() {
632        let dir = tempfile::tempdir().unwrap();
633        let f = dir.path().join("big.txt");
634        std::fs::write(&f, vec![b'x'; 1024]).unwrap();
635        let ctx = FixContext {
636            root: dir.path(),
637            dry_run: false,
638            fix_size_limit: Some(64),
639        };
640        let outcome = check_fix_size(&f, Path::new("big.txt"), &ctx).unwrap();
641        match outcome {
642            Some(FixOutcome::Skipped(reason)) => {
643                assert!(reason.contains("exceeds fix_size_limit"));
644                assert!(reason.contains("big.txt"));
645            }
646            other => panic!("expected Skipped, got {other:?}"),
647        }
648    }
649
650    #[test]
651    fn read_for_fix_returns_bytes_when_in_limit() {
652        let dir = tempfile::tempdir().unwrap();
653        let f = dir.path().join("a.txt");
654        std::fs::write(&f, b"hello").unwrap();
655        let ctx = FixContext {
656            root: dir.path(),
657            dry_run: false,
658            fix_size_limit: Some(1 << 20),
659        };
660        match read_for_fix(&f, Path::new("a.txt"), &ctx).unwrap() {
661            ReadForFix::Bytes(b) => assert_eq!(b, b"hello"),
662            ReadForFix::Skipped(_) => panic!("expected Bytes, got Skipped"),
663        }
664    }
665
666    #[test]
667    fn read_for_fix_returns_skipped_when_over_limit() {
668        let dir = tempfile::tempdir().unwrap();
669        let f = dir.path().join("big.txt");
670        std::fs::write(&f, vec![b'x'; 1024]).unwrap();
671        let ctx = FixContext {
672            root: dir.path(),
673            dry_run: false,
674            fix_size_limit: Some(64),
675        };
676        match read_for_fix(&f, Path::new("big.txt"), &ctx).unwrap() {
677            ReadForFix::Skipped(FixOutcome::Skipped(_)) => {}
678            ReadForFix::Skipped(FixOutcome::Applied(_)) => {
679                panic!("expected Skipped, got Skipped(Applied)")
680            }
681            ReadForFix::Bytes(_) => panic!("expected Skipped, got Bytes"),
682        }
683    }
684
685    #[test]
686    fn fix_outcome_variants_are_constructible() {
687        // Sanity: documented variant shapes haven't drifted.
688        let _applied = FixOutcome::Applied("created LICENSE".into());
689        let _skipped = FixOutcome::Skipped("already exists".into());
690    }
691}