alint-core 0.9.21

Core types and execution engine for the alint language-agnostic repository linter.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
use std::collections::HashMap;
use std::path::PathBuf;

use serde::Deserialize;

use crate::facts::FactSpec;
use crate::level::Level;

/// Parsed form of a `.alint.yml` file.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct Config {
    pub version: u32,
    /// Other config files this one inherits from. Entries resolved
    /// left-to-right; later entries override earlier ones; the
    /// current file's own definitions override everything it extends.
    ///
    /// Each entry is either a bare string (local path, `https://`
    /// URL with SRI, or `alint://bundled/...`) or a mapping with
    /// `url:` and optional `only:` / `except:` filters.
    #[serde(default)]
    pub extends: Vec<ExtendsEntry>,
    #[serde(default)]
    pub ignore: Vec<String>,
    #[serde(default = "default_respect_gitignore")]
    pub respect_gitignore: bool,
    /// Free-form string variables referenced from rule messages and
    /// `when` expressions as `{{vars.<name>}}` and `vars.<name>`.
    #[serde(default)]
    pub vars: HashMap<String, String>,
    /// Repository properties evaluated once per run and referenced from
    /// `when` clauses as `facts.<id>`.
    #[serde(default)]
    pub facts: Vec<FactSpec>,
    #[serde(default)]
    pub rules: Vec<RuleSpec>,
    /// Maximum file size, in bytes, that content-editing fixes
    /// will read and rewrite. Files over this limit are reported
    /// as `Skipped` in the fix report and a one-line warning is
    /// printed to stderr. Defaults to 1 MiB; set explicitly to
    /// `null` to disable the cap entirely.
    ///
    /// Path-only fixes (`file_create`, `file_remove`,
    /// `file_rename`) ignore the cap — they don't read content.
    #[serde(default = "default_fix_size_limit")]
    pub fix_size_limit: Option<u64>,
    /// Opt in to discovery of `.alint.yml` / `.alint.yaml` files
    /// in subdirectories. When `true`, the loader walks the
    /// repository tree (from the root config's directory,
    /// respecting `.gitignore` and `ignore:`) and finds any
    /// nested config files; each nested rule's path-like fields
    /// (`paths`, `select`, `primary`) are prefixed with the
    /// directory that nested config lives in, so the rule
    /// auto-scopes to that subtree. Default `false`.
    ///
    /// Only the user's top-level config may set this — nested
    /// configs themselves cannot spawn further nested discovery.
    #[serde(default)]
    pub nested_configs: bool,
}

// Returning `Option<u64>` (rather than bare `u64`) keeps the
// YAML-facing type consistent with `Config.fix_size_limit`:
// users set `null` in YAML to mean "no limit". The Option is
// load-bearing at the field level, so clippy's warning on the
// default fn is noise here.
#[allow(clippy::unnecessary_wraps)]
fn default_fix_size_limit() -> Option<u64> {
    Some(1 << 20)
}

fn default_respect_gitignore() -> bool {
    true
}

impl Config {
    pub const CURRENT_VERSION: u32 = 1;
}

/// A single `extends:` entry. Accepts either a bare string (the
/// classic form — a local path, `https://` URL with SRI, or
/// `alint://bundled/<name>@<rev>`) or a mapping that adds
/// `only:` / `except:` filters on the inherited rule set.
///
/// ```yaml
/// extends:
///   - alint://bundled/oss-baseline@v1             # classic form
///   - url: alint://bundled/rust@v1                # filtered form
///     except: [rust-no-target-dir]                # drop by id
///   - url: ./team-defaults.yml
///     only: [team-copyright-header]               # keep by id
/// ```
///
/// Filters resolve against the *fully-resolved* rule set of the
/// entry (i.e. anything it transitively extends). `only:` and
/// `except:` are mutually exclusive on a single entry; listing an
/// unknown rule id is a config error so typos surface at load
/// time.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum ExtendsEntry {
    Url(String),
    Filtered {
        url: String,
        #[serde(default)]
        only: Option<Vec<String>>,
        #[serde(default)]
        except: Option<Vec<String>>,
    },
}

impl ExtendsEntry {
    /// The URL / path of the extended config. Uniform across both
    /// enum variants.
    pub fn url(&self) -> &str {
        match self {
            Self::Url(s) | Self::Filtered { url: s, .. } => s,
        }
    }

    /// Rule ids to keep (drop everything else). `None` when no
    /// `only:` filter is specified.
    pub fn only(&self) -> Option<&[String]> {
        match self {
            Self::Filtered { only: Some(v), .. } => Some(v),
            _ => None,
        }
    }

    /// Rule ids to drop. `None` when no `except:` filter is
    /// specified.
    pub fn except(&self) -> Option<&[String]> {
        match self {
            Self::Filtered {
                except: Some(v), ..
            } => Some(v),
            _ => None,
        }
    }
}

/// YAML shape for a rule's `paths:` field — a single glob, an array (with
/// optional `!pattern` negations), or an explicit `{include, exclude}` pair.
/// For the include/exclude form, each field accepts either a single string
/// or a list of strings.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum PathsSpec {
    Single(String),
    Many(Vec<String>),
    IncludeExclude {
        #[serde(default, deserialize_with = "string_or_vec")]
        include: Vec<String>,
        #[serde(default, deserialize_with = "string_or_vec")]
        exclude: Vec<String>,
    },
}

fn string_or_vec<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    #[derive(Deserialize)]
    #[serde(untagged)]
    enum OneOrMany {
        One(String),
        Many(Vec<String>),
    }
    match OneOrMany::deserialize(deserializer)? {
        OneOrMany::One(s) => Ok(vec![s]),
        OneOrMany::Many(v) => Ok(v),
    }
}

/// YAML-level description of a rule before it is instantiated into a `Box<dyn Rule>`
/// by a [`RuleBuilder`](crate::registry::RuleBuilder).
#[derive(Debug, Clone, Deserialize)]
pub struct RuleSpec {
    pub id: String,
    pub kind: String,
    pub level: Level,
    #[serde(default)]
    pub paths: Option<PathsSpec>,
    #[serde(default)]
    pub message: Option<String>,
    #[serde(default)]
    pub policy_url: Option<String>,
    #[serde(default)]
    pub when: Option<String>,
    /// Optional mechanical-fix strategy. Rules whose builders understand
    /// the chosen op attach a [`Fixer`](crate::Fixer) to the built rule;
    /// rules whose kind is incompatible with the op return a config error
    /// at build time.
    #[serde(default)]
    pub fix: Option<FixSpec>,
    /// Restrict the rule to files / directories tracked in git's index.
    /// When `true`, the rule's `paths`-matched entries are intersected
    /// with the set of git-tracked files; entries that exist in the
    /// walked tree but aren't in `git ls-files` output are skipped.
    /// Only meaningful for rule kinds that opt in (currently the
    /// existence family — `file_exists`, `file_absent`, `dir_exists`,
    /// `dir_absent`); rule kinds that don't support it surface a clean
    /// config error when this is `true` so silent mis-configuration
    /// doesn't slip through.
    ///
    /// Default `false`. Has no effect outside a git repo.
    #[serde(default)]
    pub git_tracked_only: bool,
    /// Per-rule override for the workspace-level
    /// `respect_gitignore:` setting. When `Some(false)`, this
    /// rule treats `.gitignore`-listed files as if they were
    /// untracked-but-on-disk: the rule sees them. The canonical
    /// use case is the bazel-style "tracked AND gitignored"
    /// pattern (a file like `.bazelversion` ships a default
    /// upstream and contributors override it locally without
    /// committing the override) — the workspace walker honours
    /// the gitignore, so `file_exists` reports "no match"
    /// against a file that's both on disk AND in `git ls-files`.
    /// This per-rule knob lets that single rule see the file
    /// without flipping the workspace-wide setting.
    ///
    /// Currently honoured by `file_exists` for literal-path
    /// patterns (the common case the pitfall surfaced). Other
    /// rule kinds + glob patterns fall through to the workspace
    /// setting; future versions will broaden coverage.
    ///
    /// Default `None` (inherit the workspace `respect_gitignore`).
    /// See `docs/development/CONFIG-AUTHORING.md` pitfall #18.
    #[serde(default)]
    pub respect_gitignore: Option<bool>,
    /// Per-file ancestor-manifest gate. When set, the rule
    /// only fires on files that have at least one ancestor
    /// directory (including the file's own directory)
    /// containing a file matching the configured
    /// `has_ancestor` name(s). Composes AND with `paths:`
    /// and `git_tracked_only:`.
    ///
    /// Only meaningful for per-file rules; cross-file rule
    /// builders MUST reject this field at build time
    /// (see the design doc for the cross-file alternative
    /// via `for_each_dir + when_iter:`).
    ///
    /// Default `None` (no scope filter; existing rules
    /// preserve their pre-v0.9.6 behaviour).
    #[serde(default)]
    pub scope_filter: Option<crate::ScopeFilterSpec>,
    /// The entire YAML mapping, retained so each rule builder can deserialize
    /// its kind-specific fields without every option being represented here.
    #[serde(flatten)]
    pub extra: serde_yaml_ng::Mapping,
}

/// The `fix:` block on a rule. Exactly one op key must be present —
/// alint errors at load time when the op and rule kind are incompatible.
#[derive(Debug, Clone, Deserialize)]
#[serde(untagged)]
pub enum FixSpec {
    FileCreate {
        file_create: FileCreateFixSpec,
    },
    FileRemove {
        file_remove: FileRemoveFixSpec,
    },
    FilePrepend {
        file_prepend: FilePrependFixSpec,
    },
    FileAppend {
        file_append: FileAppendFixSpec,
    },
    FileRename {
        file_rename: FileRenameFixSpec,
    },
    FileTrimTrailingWhitespace {
        file_trim_trailing_whitespace: FileTrimTrailingWhitespaceFixSpec,
    },
    FileAppendFinalNewline {
        file_append_final_newline: FileAppendFinalNewlineFixSpec,
    },
    FileNormalizeLineEndings {
        file_normalize_line_endings: FileNormalizeLineEndingsFixSpec,
    },
    FileStripBidi {
        file_strip_bidi: FileStripBidiFixSpec,
    },
    FileStripZeroWidth {
        file_strip_zero_width: FileStripZeroWidthFixSpec,
    },
    FileStripBom {
        file_strip_bom: FileStripBomFixSpec,
    },
    FileCollapseBlankLines {
        file_collapse_blank_lines: FileCollapseBlankLinesFixSpec,
    },
}

impl FixSpec {
    /// The op name as it appears in YAML — used in config-error messages.
    pub fn op_name(&self) -> &'static str {
        match self {
            Self::FileCreate { .. } => "file_create",
            Self::FileRemove { .. } => "file_remove",
            Self::FilePrepend { .. } => "file_prepend",
            Self::FileAppend { .. } => "file_append",
            Self::FileRename { .. } => "file_rename",
            Self::FileTrimTrailingWhitespace { .. } => "file_trim_trailing_whitespace",
            Self::FileAppendFinalNewline { .. } => "file_append_final_newline",
            Self::FileNormalizeLineEndings { .. } => "file_normalize_line_endings",
            Self::FileStripBidi { .. } => "file_strip_bidi",
            Self::FileStripZeroWidth { .. } => "file_strip_zero_width",
            Self::FileStripBom { .. } => "file_strip_bom",
            Self::FileCollapseBlankLines { .. } => "file_collapse_blank_lines",
        }
    }
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileCreateFixSpec {
    /// Inline content to write. Mutually exclusive with
    /// `content_from`; exactly one of the two must be set. For
    /// an empty file, pass `content: ""` explicitly.
    #[serde(default)]
    pub content: Option<String>,
    /// Path to a file (relative to the lint root) whose bytes
    /// will be the content. Mutually exclusive with `content`.
    /// Read at fix-apply time; missing source produces a
    /// `Skipped` outcome rather than a panic. Useful for
    /// LICENSE / NOTICE / CONTRIBUTING boilerplate that's too
    /// long to inline in YAML.
    #[serde(default)]
    pub content_from: Option<PathBuf>,
    /// Path to create, relative to the repo root. When omitted, the
    /// rule builder substitutes the first literal entry from the rule's
    /// `paths:` list.
    #[serde(default)]
    pub path: Option<PathBuf>,
    /// Whether to create intermediate directories. Defaults to true.
    #[serde(default = "default_create_parents")]
    pub create_parents: bool,
}

fn default_create_parents() -> bool {
    true
}

#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileRemoveFixSpec {}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FilePrependFixSpec {
    /// Inline bytes to insert at the beginning of each
    /// violating file. Mutually exclusive with `content_from`.
    /// A trailing newline is the caller's responsibility.
    #[serde(default)]
    pub content: Option<String>,
    /// Path to a file (relative to the lint root) whose bytes
    /// will be prepended. Mutually exclusive with `content`.
    #[serde(default)]
    pub content_from: Option<PathBuf>,
}

#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileAppendFixSpec {
    /// Inline bytes to append to each violating file. Mutually
    /// exclusive with `content_from`. A leading newline is the
    /// caller's responsibility.
    #[serde(default)]
    pub content: Option<String>,
    /// Path to a file (relative to the lint root) whose bytes
    /// will be appended. Mutually exclusive with `content`.
    #[serde(default)]
    pub content_from: Option<PathBuf>,
}

/// Resolution of an `(content, content_from)` pair to a single
/// content source. Used by the three fixers that take either.
/// Errors when neither or both are set.
pub fn resolve_content_source(
    rule_id: &str,
    op_name: &str,
    inline: &Option<String>,
    from: &Option<PathBuf>,
) -> crate::error::Result<ContentSourceSpec> {
    match (inline, from) {
        (Some(_), Some(_)) => Err(crate::error::Error::rule_config(
            rule_id,
            format!("fix.{op_name}: `content` and `content_from` are mutually exclusive"),
        )),
        (None, None) => Err(crate::error::Error::rule_config(
            rule_id,
            format!("fix.{op_name}: one of `content` or `content_from` is required"),
        )),
        (Some(s), None) => Ok(ContentSourceSpec::Inline(s.clone())),
        (None, Some(p)) => Ok(ContentSourceSpec::File(p.clone())),
    }
}

/// Pre-validated content source — exactly one of inline or
/// from-file. Resolved at config-parse time so fixers don't
/// need to reproduce the XOR check at apply time.
#[derive(Debug, Clone)]
pub enum ContentSourceSpec {
    /// Inline string body.
    Inline(String),
    /// Path relative to the lint root; bytes are read at fix-
    /// apply time.
    File(PathBuf),
}

impl From<String> for ContentSourceSpec {
    fn from(s: String) -> Self {
        Self::Inline(s)
    }
}

impl From<&str> for ContentSourceSpec {
    fn from(s: &str) -> Self {
        Self::Inline(s.to_string())
    }
}

/// Empty marker: `file_rename` takes no parameters. The target name
/// is derived from the parent rule (e.g. `filename_case` converts the
/// stem to its configured case; the extension is preserved).
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileRenameFixSpec {}

/// Empty marker. Behavior: read file (subject to `fix_size_limit`),
/// strip trailing space/tab on every line, write back.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileTrimTrailingWhitespaceFixSpec {}

/// Empty marker. Behavior: if the file has content and does not
/// end with `\n`, append one.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileAppendFinalNewlineFixSpec {}

/// Empty marker. Behavior: rewrite the file with every line ending
/// replaced by the parent rule's configured target (`lf` or `crlf`).
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileNormalizeLineEndingsFixSpec {}

/// Empty marker. Behavior: remove every Unicode bidi control
/// character (U+202A–202E, U+2066–2069) from the file's content.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileStripBidiFixSpec {}

/// Empty marker. Behavior: remove every zero-width character
/// (U+200B / U+200C / U+200D / U+FEFF) from the file's content,
/// *except* a leading BOM (U+FEFF at position 0) — that's the
/// responsibility of the `no_bom` rule.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileStripZeroWidthFixSpec {}

/// Empty marker. Behavior: remove a leading UTF-8/UTF-16/UTF-32
/// BOM byte sequence if present; otherwise a no-op.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileStripBomFixSpec {}

/// Empty marker. Behavior: collapse runs of blank lines longer than
/// the parent rule's `max` down to exactly `max` blank lines.
#[derive(Debug, Clone, Deserialize, Default)]
#[serde(deny_unknown_fields)]
pub struct FileCollapseBlankLinesFixSpec {}

impl RuleSpec {
    /// Deserialize the full spec (common + kind-specific fields) into a typed
    /// options struct. Common fields are reconstructed into the mapping so
    /// the target struct can `#[derive(Deserialize)]` against the whole shape
    /// when convenient.
    pub fn deserialize_options<T>(&self) -> crate::error::Result<T>
    where
        T: serde::de::DeserializeOwned,
    {
        Ok(serde_yaml_ng::from_value(serde_yaml_ng::Value::Mapping(
            self.extra.clone(),
        ))?)
    }

    /// Parse and validate this spec's optional `scope_filter:`
    /// field into a built [`ScopeFilter`](crate::ScopeFilter).
    /// Returns `Ok(None)` when the spec has no `scope_filter`
    /// set (the common case).
    ///
    /// Per-file rule builders typically don't call this directly
    /// since v0.9.10 — they use
    /// [`Scope::from_spec`](crate::Scope::from_spec) instead,
    /// which bundles `paths:` + `scope_filter:` parsing into one
    /// call. The Scope owns the parsed filter and consults it
    /// inside [`Scope::matches`](crate::Scope::matches), so the
    /// engine doesn't need a separate per-rule accessor any more.
    /// Cross-file rules MUST NOT call this — they call
    /// [`reject_scope_filter_on_cross_file`](crate::reject_scope_filter_on_cross_file)
    /// instead so a misconfigured `scope_filter:` on a cross-
    /// file rule surfaces as a clear build-time error rather
    /// than a silently-ignored field.
    pub fn parse_scope_filter(&self) -> crate::error::Result<Option<crate::ScopeFilter>> {
        match &self.scope_filter {
            Some(spec) => Ok(Some(crate::ScopeFilter::from_spec(&self.id, spec.clone())?)),
            None => Ok(None),
        }
    }
}

/// Rule specification for nested rules (e.g. the `require:` block of
/// `for_each_dir`). Unlike [`RuleSpec`], `id` and `level` are synthesized
/// from the parent rule — users just supply the `kind` plus kind-specific
/// options, optionally with a `message` / `policy_url` / `when`.
#[derive(Debug, Clone, Deserialize)]
pub struct NestedRuleSpec {
    pub kind: String,
    #[serde(default)]
    pub paths: Option<PathsSpec>,
    #[serde(default)]
    pub message: Option<String>,
    #[serde(default)]
    pub policy_url: Option<String>,
    #[serde(default)]
    pub when: Option<String>,
    /// Per-file scope filter — see [`RuleSpec::scope_filter`]
    /// for semantics. Inherited unchanged when
    /// [`NestedRuleSpec::instantiate`] synthesises a full
    /// `RuleSpec` per-iteration.
    #[serde(default)]
    pub scope_filter: Option<crate::ScopeFilterSpec>,
    #[serde(flatten)]
    pub extra: serde_yaml_ng::Mapping,
}

/// A [`NestedRuleSpec`] with its `when:` source pre-compiled
/// into a [`crate::when::WhenExpr`] at rule-build time.
///
/// Mirrors the v0.9.5-era pattern for `when_iter:` on cross-
/// file iteration rules: parse the source once at build, then
/// evaluate per iteration with a fresh `iter` context. v0.9.12
/// closed the gap: pre-v0.9.12 the nested `when:` source string
/// was re-parsed inside `evaluate_for_each` on every iteration
/// (one parse per (entry, nested-rule) pair, sometimes
/// thousands of redundant parses per cross-file rule eval). The
/// `Option<WhenExpr>` on this struct is parsed exactly once.
///
/// Build sites (`for_each_dir`, `for_each_file`,
/// `every_matching_has` in `alint-rules`) construct a
/// `Vec<CompiledNestedSpec>` from `Vec<NestedRuleSpec>` in
/// their `build` impl; `evaluate_for_each` consumes the
/// compiled form.
#[derive(Debug)]
pub struct CompiledNestedSpec {
    /// The original nested-rule spec — passed to
    /// [`NestedRuleSpec::instantiate`] per iteration to get a
    /// per-iteration full `RuleSpec` with template tokens
    /// substituted.
    pub spec: NestedRuleSpec,
    /// Pre-compiled `when:` expression. `None` when the nested
    /// spec carried no `when:` clause.
    pub when: Option<crate::when::WhenExpr>,
}

impl CompiledNestedSpec {
    /// Compile a [`NestedRuleSpec`] — parsing its `when:`
    /// source string once. Surfaces a build-time config error
    /// (`"<parent_id>: nested rule #<idx>: invalid when: ..."`)
    /// when the source fails to parse, so misconfigured
    /// nested-when clauses fail at config-load time instead of
    /// per-iteration during evaluation.
    pub fn compile(
        spec: NestedRuleSpec,
        parent_id: &str,
        idx: usize,
    ) -> crate::error::Result<Self> {
        let when = match spec.when.as_deref() {
            Some(src) => Some(crate::when::parse(src).map_err(|e| {
                crate::error::Error::rule_config(
                    parent_id,
                    format!("nested rule #{idx}: invalid when: {e}"),
                )
            })?),
            None => None,
        };
        Ok(Self { spec, when })
    }
}

impl NestedRuleSpec {
    /// Synthesize a full [`RuleSpec`] for a single iteration, applying
    /// path-template substitution (using the iterated entry's tokens) to
    /// every string field. The resulting spec has `id =
    /// "{parent_id}.require[{idx}]"` and inherits `level` from the parent.
    pub fn instantiate(
        &self,
        parent_id: &str,
        idx: usize,
        level: Level,
        tokens: &crate::template::PathTokens,
    ) -> RuleSpec {
        RuleSpec {
            id: format!("{parent_id}.require[{idx}]"),
            kind: self.kind.clone(),
            level,
            paths: self
                .paths
                .as_ref()
                .map(|p| crate::template::render_paths_spec(p, tokens)),
            message: self
                .message
                .as_deref()
                .map(|m| crate::template::render_path(m, tokens)),
            policy_url: self.policy_url.clone(),
            when: self.when.clone(),
            fix: None,
            // Nested rules don't currently expose
            // `git_tracked_only` or `respect_gitignore` from their
            // parent's spec — both options are meaningful on
            // top-level rules only for now. If/when `for_each_dir`'s
            // nested rules need either, plumb them through here.
            git_tracked_only: false,
            respect_gitignore: None,
            scope_filter: self.scope_filter.clone(),
            extra: crate::template::render_mapping(self.extra.clone(), tokens),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::template::PathTokens;
    use std::path::Path;

    #[test]
    fn config_default_respects_gitignore_and_caps_fix_size() {
        // Round-trip the documented defaults through serde to
        // catch silent default drift.
        let cfg: Config = serde_yaml_ng::from_str("version: 1\n").expect("minimal config");
        assert_eq!(cfg.version, 1);
        assert!(cfg.respect_gitignore);
        assert_eq!(cfg.fix_size_limit, Some(1 << 20));
        assert!(!cfg.nested_configs);
        assert!(cfg.extends.is_empty());
        assert!(cfg.rules.is_empty());
    }

    #[test]
    fn config_rejects_unknown_top_level_field() {
        let err = serde_yaml_ng::from_str::<Config>("version: 1\nignored_typo: true\n");
        assert!(err.is_err(), "deny_unknown_fields should reject typos");
    }

    #[test]
    fn config_explicit_null_disables_fix_size_limit() {
        let cfg: Config = serde_yaml_ng::from_str("version: 1\nfix_size_limit: null\n").unwrap();
        assert_eq!(cfg.fix_size_limit, None);
    }

    #[test]
    fn extends_entry_url_form_has_no_filters() {
        let e = ExtendsEntry::Url("alint://bundled/oss-baseline@v1".into());
        assert_eq!(e.url(), "alint://bundled/oss-baseline@v1");
        assert!(e.only().is_none());
        assert!(e.except().is_none());
    }

    #[test]
    fn extends_entry_filtered_form_exposes_only_and_except() {
        let e = ExtendsEntry::Filtered {
            url: "alint://bundled/rust@v1".into(),
            only: Some(vec!["rust-edition".into()]),
            except: None,
        };
        assert_eq!(e.url(), "alint://bundled/rust@v1");
        assert_eq!(e.only(), Some(&["rust-edition".to_string()][..]));
        assert!(e.except().is_none());
    }

    #[test]
    fn extends_entry_filtered_form_supports_except_only() {
        let e = ExtendsEntry::Filtered {
            url: "./team.yml".into(),
            only: None,
            except: Some(vec!["legacy-rule".into()]),
        };
        assert_eq!(e.except(), Some(&["legacy-rule".to_string()][..]));
        assert!(e.only().is_none());
    }

    #[test]
    fn paths_spec_accepts_three_shapes() {
        let single: PathsSpec = serde_yaml_ng::from_str("\"src/**\"").unwrap();
        assert!(matches!(single, PathsSpec::Single(s) if s == "src/**"));

        let many: PathsSpec = serde_yaml_ng::from_str("[\"src/**\", \"!src/vendor/**\"]").unwrap();
        assert!(matches!(many, PathsSpec::Many(v) if v.len() == 2));

        let inc_exc: PathsSpec =
            serde_yaml_ng::from_str("include: src/**\nexclude: src/vendor/**\n").unwrap();
        match inc_exc {
            PathsSpec::IncludeExclude { include, exclude } => {
                assert_eq!(include, vec!["src/**"]);
                assert_eq!(exclude, vec!["src/vendor/**"]);
            }
            _ => panic!("expected include/exclude shape"),
        }
    }

    #[test]
    fn paths_spec_include_accepts_string_or_vec() {
        let from_string: PathsSpec =
            serde_yaml_ng::from_str("include: a\nexclude:\n  - b\n  - c\n").unwrap();
        let PathsSpec::IncludeExclude { include, exclude } = from_string else {
            panic!("expected include/exclude shape");
        };
        assert_eq!(include, vec!["a"]);
        assert_eq!(exclude, vec!["b", "c"]);
    }

    #[test]
    fn rule_spec_deserialize_options_picks_up_kind_specific_fields() {
        #[derive(Deserialize, Debug)]
        struct PatternOnly {
            pattern: String,
        }
        let spec: RuleSpec = serde_yaml_ng::from_str(
            "id: r\nkind: file_content_matches\nlevel: error\npaths: src/**\npattern: TODO\n",
        )
        .unwrap();
        let opts: PatternOnly = spec.deserialize_options().unwrap();
        assert_eq!(opts.pattern, "TODO");
    }

    #[test]
    fn fix_spec_op_name_covers_every_variant() {
        // Round-trip every documented op name through YAML; any
        // future fix variant added without a corresponding
        // op_name arm will fall through serde and trip this test.
        let cases = [
            ("file_create:\n  content: x\n", "file_create"),
            ("file_remove: {}", "file_remove"),
            ("file_prepend:\n  content: x\n", "file_prepend"),
            ("file_append:\n  content: x\n", "file_append"),
            ("file_rename: {}", "file_rename"),
            (
                "file_trim_trailing_whitespace: {}",
                "file_trim_trailing_whitespace",
            ),
            ("file_append_final_newline: {}", "file_append_final_newline"),
            (
                "file_normalize_line_endings: {}",
                "file_normalize_line_endings",
            ),
            ("file_strip_bidi: {}", "file_strip_bidi"),
            ("file_strip_zero_width: {}", "file_strip_zero_width"),
            ("file_strip_bom: {}", "file_strip_bom"),
            ("file_collapse_blank_lines: {}", "file_collapse_blank_lines"),
        ];
        for (yaml, expected) in cases {
            let spec: FixSpec =
                serde_yaml_ng::from_str(yaml).unwrap_or_else(|e| panic!("{yaml}: {e}"));
            assert_eq!(spec.op_name(), expected);
        }
    }

    #[test]
    fn resolve_content_source_inline_only() {
        let s = Some("hello".to_string());
        let resolved = resolve_content_source("r", "file_create", &s, &None).unwrap();
        assert!(matches!(resolved, ContentSourceSpec::Inline(b) if b == "hello"));
    }

    #[test]
    fn resolve_content_source_file_only() {
        let p = Some(Path::new("LICENSE").into());
        let resolved = resolve_content_source("r", "file_create", &None, &p).unwrap();
        assert!(matches!(resolved, ContentSourceSpec::File(p) if p == Path::new("LICENSE")));
    }

    #[test]
    fn resolve_content_source_rejects_both_set() {
        let err = resolve_content_source(
            "r",
            "file_prepend",
            &Some("x".into()),
            &Some(Path::new("y").into()),
        )
        .unwrap_err();
        assert!(err.to_string().contains("mutually exclusive"));
    }

    #[test]
    fn resolve_content_source_rejects_neither_set() {
        let err = resolve_content_source("r", "file_append", &None, &None).unwrap_err();
        assert!(err.to_string().contains("required"));
    }

    #[test]
    fn content_source_spec_from_string_variants() {
        let from_owned: ContentSourceSpec = String::from("hi").into();
        assert!(matches!(from_owned, ContentSourceSpec::Inline(s) if s == "hi"));
        let from_str: ContentSourceSpec = "hi".into();
        assert!(matches!(from_str, ContentSourceSpec::Inline(s) if s == "hi"));
    }

    #[test]
    fn nested_rule_spec_instantiate_synthesizes_id_and_inherits_level() {
        let nested: NestedRuleSpec = serde_yaml_ng::from_str(
            "kind: file_exists\npaths: \"{path}/README.md\"\nmessage: missing in {path}\n",
        )
        .unwrap();
        let tokens = PathTokens::from_path(Path::new("packages/foo"));
        let spec = nested.instantiate("every-pkg-has-readme", 0, Level::Error, &tokens);

        assert_eq!(spec.id, "every-pkg-has-readme.require[0]");
        assert_eq!(spec.kind, "file_exists");
        assert_eq!(spec.level, Level::Error);
        // Path template should have been rendered for both
        // `paths:` and `message:` from the iterated tokens.
        match spec.paths {
            Some(PathsSpec::Single(p)) => assert_eq!(p, "packages/foo/README.md"),
            other => panic!("unexpected paths shape: {other:?}"),
        }
        assert_eq!(spec.message.as_deref(), Some("missing in packages/foo"));
        // Nested rules don't propagate git_tracked_only — the
        // option is meaningful on top-level rules only.
        assert!(!spec.git_tracked_only);
    }
}