Skip to main content

pristine/
rules.rs

1//! The tier-one ruleset: what a reclaimable directory looks like, as data.
2//!
3//! The rules themselves live in [`rules.toml`](../src/rules.toml), which is compiled into the
4//! binary and can be extended or replaced by a user file. Nothing in this module encodes a
5//! single ecosystem, so adding one is a config edit rather than a release.
6
7use std::fmt;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12use serde::Deserialize;
13
14use crate::detect::Detector;
15
16/// The ruleset shipped with the binary.
17const BUILTIN: &str = include_str!("rules.toml");
18
19/// Where the markers for a rule are looked for, relative to the directory being judged.
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum Anchor {
23    /// The directory the target path hangs off. For a single-segment target such as
24    /// `node_modules` that is simply the matched directory's parent.
25    #[default]
26    Parent,
27    /// The matched directory itself. This is how an ambiguous name is made safe: a CMake
28    /// `build/` is only output if it contains the `CMakeCache.txt` CMake wrote there.
29    #[serde(rename = "self")]
30    SelfDir,
31    /// The nearest ancestor carrying a marker, searched upward as far as the scan root. For
32    /// artefacts scattered through a project rather than parked at its root, like
33    /// `__pycache__`.
34    Ancestor,
35}
36
37/// What a reclaimable thing *is*, from a closed vocabulary **ordered by what it costs to
38/// lose**.
39///
40/// The vocabulary is closed on purpose, and it is the half of a label that a machine can act
41/// on: a kind sorts, groups and filters, so "show me every cache" is a question the front end
42/// can answer. A free-text sentence never could.
43///
44/// It also carries the cost, which is the one thing the regeneration command it replaced was
45/// genuinely for. A cache is free, an output is a compile, dependencies are a network fetch —
46/// and unlike a command string, that reading holds without knowing anything about the machine
47/// it would be paid on.
48///
49/// # The ordering is the point, and it is [`Kind::ALL`]
50///
51/// The three middle members were already ordered — what was fetched, what was compiled, what
52/// will come back on its own — and [`Kind::ALL`] is now that ordering made explicit, with a
53/// member at each extreme:
54///
55/// | | cost to lose |
56/// |---|---|
57/// | [`Unrecoverable`](Self::Unrecoverable) | **nothing brings it back** |
58/// | [`Dependencies`](Self::Dependencies) | a network fetch |
59/// | [`Build`](Self::Build) | a compile |
60/// | [`Cache`](Self::Cache) | it returns on its own |
61/// | [`Noise`](Self::Noise) | nothing will miss it |
62///
63/// Everything else in the crate reads the order off `ALL` rather than restating it, so the
64/// confirmation groups the expensive end first and the front end derives a key per member.
65///
66/// # A kind NAMES, it does not gate
67///
68/// [`Unrecoverable`](Self::Unrecoverable) inverts the premise of the rest of the vocabulary —
69/// nothing brings it back — and it is tempting to make the code treat it apart: skip it in a
70/// bulk mark, put a second flag in front of deleting one. **That was built and then removed,
71/// and the reason is worth keeping.** A mark is a statement about a subtree, and the fractional
72/// glyph on an ancestor is a true reading of how much of it is spoken for; a mark that silently
73/// skipped some descendants would make that glyph describe a set no reader can see, and the
74/// exception would live nowhere a reader could find it.
75///
76/// So the safety is carried entirely by **what a view shows**, which is a lever the front end
77/// already had: no lens shows gitignored files until `i` says so, no sweep claims one until
78/// `--ignored-files` says so, and a mark carries the lens it was made through. Seeing a `.env`
79/// at all is the deliberate act. After that it is a row like any other — same keys, same rules,
80/// one confirmation that lists the whole batch with the expensive end first.
81///
82/// What the kind still does is *name* the thing, which is what a label has been for since the
83/// regeneration command was deleted. See [`Kind::of_ignored_file`] and [`super::tui::lens`].
84#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
85#[serde(rename_all = "lowercase")]
86pub enum Kind {
87    /// Something nothing regenerates: an `.env`, a private key, a `credentials` file.
88    ///
89    /// A rule may declare it — a `secrets/` directory somebody's own ruleset names is exactly
90    /// as unrecoverable as a `.env` — but nothing in the shipped ruleset does, because the
91    /// evidence a marker-anchored rule offers is "this project is of that type" rather than
92    /// "this file is the only copy".
93    Unrecoverable,
94    /// Installed third-party code: `node_modules`, `.venv`, `vendor`.
95    Dependencies,
96    /// Compiled output: `target`, `bin`, `obj`, `dist`.
97    Build,
98    /// Regenerated automatically: `__pycache__`, `.nx/cache`, `.gradle`, `.ipynb_checkpoints`.
99    Cache,
100    /// Written by something nobody asked, and read by nothing: `*.log`, `.DS_Store`,
101    /// `Thumbs.db`. The cheapest thing here to lose.
102    Noise,
103}
104
105/// What `*.env*` reduces to against a single path component.
106///
107/// Shared with [`crate::repo`], which asks the same question of a `git clean` entry. A run where
108/// the two disagreed would report holding an env file back through one door and delete it
109/// through the other.
110pub(crate) const ENV_MARK: &str = ".env";
111
112/// Names that mean "nothing brings this back", matched whole against a lowercased file name.
113const UNRECOVERABLE_NAMES: [&str; 5] =
114    [".npmrc", "credentials", "id_rsa", "id_ecdsa", "id_ed25519"];
115
116/// Suffixes that mean the same. `.pem` is a private key far more often than it is anything
117/// else, and the times it is a certificate it is still not something a rebuild produces.
118const UNRECOVERABLE_SUFFIXES: [&str; 1] = [".pem"];
119
120/// Names nothing will miss, matched whole.
121const NOISE_NAMES: [&str; 2] = [".ds_store", "thumbs.db"];
122
123/// Suffixes nothing will miss.
124const NOISE_SUFFIXES: [&str; 1] = [".log"];
125
126impl Kind {
127    /// The whole vocabulary, **in order of what it costs to lose**: what nothing brings back,
128    /// what was fetched, what was compiled, what will come back on its own, what nothing will
129    /// miss.
130    ///
131    /// Being able to enumerate it is half of what "closed" buys — the front end derives a key
132    /// and a help sentence per kind from this rather than listing them again, and the
133    /// confirmation's grouping is this order, so a sixth kind would arrive already filterable
134    /// and already sorted.
135    pub const ALL: [Self; 5] = [
136        Self::Unrecoverable,
137        Self::Dependencies,
138        Self::Build,
139        Self::Cache,
140        Self::Noise,
141    ];
142
143    /// Where this kind sits on the cost axis, counting from the expensive end.
144    ///
145    /// Read off [`Kind::ALL`] rather than written out a second time, because a listing that
146    /// grouped in one order while the help page named them in another would be two claims
147    /// about one vocabulary.
148    #[must_use]
149    pub fn cost(self) -> usize {
150        Self::ALL
151            .iter()
152            .position(|&kind| kind == self)
153            .unwrap_or(Self::ALL.len())
154    }
155
156    /// What the name of a gitignored **file** says it is, or `None` when the name says
157    /// nothing.
158    ///
159    /// This is a claim about a name and not about contents, which is why the two ends of the
160    /// vocabulary are the only ones it can reach: `.env` and `id_rsa` are the only copy of
161    /// something, `*.log` and `.DS_Store` are the copy of nothing. A name that says neither
162    /// gets `None` and reads as the tier-two directory claim already does — git knows the file
163    /// is disposable and nothing knows what it is.
164    ///
165    /// Matched against the lowercased name, because `.DS_Store` and `Thumbs.db` are written
166    /// both ways by the systems that create them and the case is not information.
167    #[must_use]
168    pub fn of_ignored_file(name: &str) -> Option<Self> {
169        let name = name.to_ascii_lowercase();
170        // The expensive end is asked first, and that ordering is the safety property rather
171        // than a tidiness one: a name that could be read either way — `.env.log` — is the one
172        // where being wrong in the cheap direction is the failure that cannot be undone.
173        if name.contains(ENV_MARK)
174            || UNRECOVERABLE_NAMES.contains(&name.as_str())
175            || UNRECOVERABLE_SUFFIXES
176                .iter()
177                .any(|suffix| name.ends_with(suffix))
178        {
179            return Some(Self::Unrecoverable);
180        }
181        if NOISE_NAMES.contains(&name.as_str())
182            || NOISE_SUFFIXES.iter().any(|suffix| name.ends_with(suffix))
183        {
184            return Some(Self::Noise);
185        }
186        None
187    }
188
189    /// One word, for somewhere with no room for the whole label.
190    #[must_use]
191    pub fn short(self) -> &'static str {
192        match self {
193            Self::Unrecoverable => "unrecoverable",
194            Self::Dependencies => "dependencies",
195            Self::Build => "build",
196            Self::Cache => "cache",
197            Self::Noise => "noise",
198        }
199    }
200
201    /// What losing one of these costs, said out loud.
202    ///
203    /// The vocabulary's whole content in one sentence per member, which is what the help page
204    /// and the confirmation print. `Unrecoverable` is not decoration and a reader has to be
205    /// able to find out what it means without reading this file.
206    #[must_use]
207    pub fn cost_said(self) -> &'static str {
208        match self {
209            Self::Unrecoverable => "nothing brings this back",
210            Self::Dependencies => "a network fetch brings this back",
211            Self::Build => "a compile brings this back",
212            Self::Cache => "this comes back on its own",
213            Self::Noise => "nothing will miss this",
214        }
215    }
216}
217
218impl fmt::Display for Kind {
219    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
220        f.write_str(match self {
221            Self::Unrecoverable => "Unrecoverable",
222            Self::Dependencies => "Dependencies",
223            Self::Build => "Build Artifacts",
224            Self::Cache => "Cache",
225            Self::Noise => "Noise",
226        })
227    }
228}
229
230/// Whether one marker is enough, or all of them are needed.
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
232#[serde(rename_all = "lowercase")]
233pub enum MarkersRequired {
234    /// Any one marker proves the project type. The common case.
235    #[default]
236    Any,
237    /// Every marker must be present. Unity is a `ProjectSettings/` **and** an `Assets/`.
238    All,
239}
240
241/// One marker-anchored rule: the evidence that a project is of some kind, and the directories
242/// that kind of project generates.
243#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
244#[serde(deny_unknown_fields)]
245pub struct Rule {
246    /// Stable identifier. A user rule with the same id replaces the built-in one.
247    pub id: String,
248    /// Human-readable name of the ecosystem: the first half of the label.
249    pub ecosystem: String,
250    /// What the directories this rule claims are: the second half of the label.
251    ///
252    /// A rule whose targets do not all share one kind is two rules, which is why `python` and
253    /// `python-caches` are separate and why `gradle`'s `build` and `.gradle` are. One rule
254    /// covering both could only label one of them honestly.
255    pub kind: Kind,
256    /// File or directory names proving the anchor is a project of this kind. A name
257    /// containing `*` or `?` is a glob and costs a directory listing to check.
258    pub markers: Vec<String>,
259    /// Whether one marker is enough.
260    #[serde(default)]
261    pub markers_required: MarkersRequired,
262    /// Reclaimable paths relative to the anchor. Multi-segment paths (`vendor/bundle`) and a
263    /// globbed final segment (`bazel-*`) are both allowed.
264    pub targets: Vec<String>,
265    /// Where the markers are looked for.
266    #[serde(default)]
267    pub anchor: Anchor,
268    /// An optional caveat to surface next to the hit.
269    #[serde(default)]
270    pub note: Option<String>,
271}
272
273impl Rule {
274    /// What the directories this rule claims are, named: the ecosystem and the kind.
275    ///
276    /// Composed rather than written out per rule, because thirty hand-written strings are
277    /// thirty chances to drift apart — and because the half that a reader groups by has to be
278    /// the same word every time it appears.
279    #[must_use]
280    pub fn label(&self) -> String {
281        format!("{} {}", self.ecosystem, self.kind)
282    }
283}
284
285/// A parsed, validated and compiled set of rules.
286#[derive(Debug)]
287pub struct Ruleset {
288    rules: Vec<Arc<Rule>>,
289    detector: Detector,
290    excludes: Vec<String>,
291}
292
293/// The wire shape of a rules file. Only ever seen by serde.
294#[derive(Debug, Deserialize)]
295#[serde(deny_unknown_fields)]
296struct RulesFile {
297    #[serde(default)]
298    rules: Vec<Rule>,
299    /// Paths the reader never wants walked, in gitignore syntax. Not a rule and deliberately
300    /// not spelled as one: a rule says what a directory *is* so that it can be reclaimed, and
301    /// this says where not to look at all.
302    #[serde(default)]
303    exclude: Vec<String>,
304}
305
306impl Ruleset {
307    /// The ruleset compiled into the binary.
308    ///
309    /// # Errors
310    ///
311    /// Only if the compiled-in TOML is malformed, which a unit test in this module rules out.
312    pub fn builtin() -> Result<Self, RuleError> {
313        Self::from_rules(Self::parse_rules(BUILTIN)?)
314    }
315
316    /// Parses a rules file on its own, replacing the built-in set entirely.
317    ///
318    /// # Errors
319    ///
320    /// If the TOML is malformed, a rule is incomplete, or a glob fails to compile.
321    pub fn parse(toml: &str) -> Result<Self, RuleError> {
322        Self::from_rules(Self::parse_rules(toml)?)
323    }
324
325    /// Parses a user rules file and layers it over the built-in set: a rule whose `id`
326    /// already exists replaces the built-in one in place, and a new `id` is appended.
327    ///
328    /// Replacing in place rather than appending keeps the first-match-wins ordering
329    /// predictable — overriding `cargo` does not quietly move it behind `maven`.
330    ///
331    /// # Errors
332    ///
333    /// If either file is malformed, a rule is incomplete, or a glob fails to compile.
334    pub fn with_overrides(user_toml: &str) -> Result<Self, RuleError> {
335        let user = Self::parse_file(user_toml)?;
336        let mut rules = Self::parse_rules(BUILTIN)?;
337        for rule in user.rules {
338            match rules.iter().position(|existing| existing.id == rule.id) {
339                Some(at) => rules[at] = rule,
340                None => rules.push(rule),
341            }
342        }
343        let mut ruleset = Self::from_rules(rules)?;
344        // Only the user's file can carry these. The built-in set names what artefacts *are*,
345        // which is knowledge about ecosystems; where not to look is knowledge about one disk.
346        ruleset.excludes = user.exclude;
347        Ok(ruleset)
348    }
349
350    /// Loads the ruleset, layering the user's file over the built-in set when it exists.
351    ///
352    /// Passing `None` looks in the default location, [`Ruleset::user_config_path`]. A missing
353    /// file is not an error; an unreadable or malformed one is, because silently falling back
354    /// to the built-in set would hide the user's edits from them.
355    ///
356    /// # Errors
357    ///
358    /// If the user file exists but cannot be read or parsed.
359    pub fn load(user_path: Option<&Path>) -> Result<Self, RuleError> {
360        let Some(path) = user_path.map(PathBuf::from).or_else(Self::user_config_path) else {
361            return Self::builtin();
362        };
363        match fs::read_to_string(&path) {
364            Ok(toml) => Self::with_overrides(&toml),
365            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Self::builtin(),
366            Err(err) => Err(RuleError::Read(path, err)),
367        }
368    }
369
370    /// The default location of the user's rules file, `$XDG_CONFIG_HOME/pristine/rules.toml`
371    /// falling back to `~/.config/pristine/rules.toml`.
372    #[must_use]
373    pub fn user_config_path() -> Option<PathBuf> {
374        let base = std::env::var_os("XDG_CONFIG_HOME")
375            .map(PathBuf::from)
376            .or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
377        Some(base.join("pristine").join("rules.toml"))
378    }
379
380    /// The rules, in evaluation order.
381    #[must_use]
382    pub fn rules(&self) -> &[Arc<Rule>] {
383        &self.rules
384    }
385
386    pub(crate) fn detector(&self) -> &Detector {
387        &self.detector
388    }
389
390    fn parse_rules(toml: &str) -> Result<Vec<Rule>, RuleError> {
391        Ok(Self::parse_file(toml)?.rules)
392    }
393
394    fn parse_file(toml: &str) -> Result<RulesFile, RuleError> {
395        toml::from_str(toml).map_err(|err| RuleError::Parse(err.to_string()))
396    }
397
398    /// The paths the user's file says never to walk, in gitignore syntax.
399    ///
400    /// Empty unless they asked for it. What a cleaner does not look at is a decision only its
401    /// reader can make, and a shipped list of "system directories" would be a guess about an
402    /// operating system that changes underneath it.
403    #[must_use]
404    pub fn excludes(&self) -> &[String] {
405        &self.excludes
406    }
407
408    fn from_rules(rules: Vec<Rule>) -> Result<Self, RuleError> {
409        for rule in &rules {
410            if rule.markers.is_empty() {
411                return Err(RuleError::NoMarkers(rule.id.clone()));
412            }
413            if rule.targets.is_empty() {
414                return Err(RuleError::NoTargets(rule.id.clone()));
415            }
416            if let Some(other) = rules.iter().filter(|r| r.id == rule.id).nth(1) {
417                return Err(RuleError::DuplicateId(other.id.clone()));
418            }
419        }
420        let rules: Vec<Arc<Rule>> = rules.into_iter().map(Arc::new).collect();
421        let detector = Detector::new(&rules)?;
422        Ok(Self {
423            rules,
424            detector,
425            excludes: Vec::new(),
426        })
427    }
428}
429
430/// Why a ruleset could not be loaded.
431#[derive(Debug)]
432#[non_exhaustive]
433pub enum RuleError {
434    /// The user's rules file could not be read.
435    Read(PathBuf, std::io::Error),
436    /// The TOML was malformed or a rule was missing a mandatory field.
437    Parse(String),
438    /// A rule declared no markers, which would make it a bare-name match.
439    NoMarkers(String),
440    /// A rule declared nothing to reclaim.
441    NoTargets(String),
442    /// Two rules share an id, so an override would be ambiguous.
443    DuplicateId(String),
444    /// A marker or target pattern is not a valid glob.
445    Glob(String, String),
446}
447
448impl fmt::Display for RuleError {
449    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
450        match self {
451            Self::Read(path, err) => write!(f, "reading rules from {}: {err}", path.display()),
452            Self::Parse(err) => write!(f, "parsing rules: {err}"),
453            Self::NoMarkers(id) => write!(
454                f,
455                "rule `{id}` declares no markers; a rule without one is a bare-name match, \
456                 which is how a cleaner deletes somebody's source"
457            ),
458            Self::NoTargets(id) => write!(f, "rule `{id}` declares nothing to reclaim"),
459            Self::DuplicateId(id) => write!(f, "two rules share the id `{id}`"),
460            Self::Glob(pattern, err) => write!(f, "`{pattern}` is not a valid glob: {err}"),
461        }
462    }
463}
464
465impl std::error::Error for RuleError {
466    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
467        match self {
468            Self::Read(_, err) => Some(err),
469            _ => None,
470        }
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::{Anchor, Kind, MarkersRequired, RuleError, Ruleset};
477
478    #[test]
479    fn the_builtin_ruleset_parses() {
480        let ruleset = Ruleset::builtin().unwrap();
481        assert!(
482            ruleset.rules().len() >= 20,
483            "kondo covers 20+ project types and pristine must not ship narrower"
484        );
485    }
486
487    #[test]
488    fn every_builtin_rule_is_marker_anchored_and_names_what_it_claims() {
489        for rule in Ruleset::builtin().unwrap().rules() {
490            assert!(!rule.markers.is_empty(), "{} has no marker", rule.id);
491            assert!(!rule.targets.is_empty(), "{} reclaims nothing", rule.id);
492            assert!(
493                !rule.ecosystem.is_empty(),
494                "{} has no ecosystem name",
495                rule.id
496            );
497        }
498    }
499
500    #[test]
501    fn a_label_is_the_ecosystem_and_the_kind() {
502        let ruleset = Ruleset::builtin().unwrap();
503        let labelled = |id: &str| {
504            ruleset
505                .rules()
506                .iter()
507                .find(|rule| rule.id == id)
508                .unwrap_or_else(|| panic!("no rule for {id}"))
509                .label()
510        };
511        assert_eq!(labelled("node"), "Node Dependencies");
512        assert_eq!(labelled("dotnet"), ".NET Build Artifacts");
513        assert_eq!(labelled("nx-caches"), "Nx Cache");
514        // One ecosystem, two kinds, two rules — the split the vocabulary makes explicit.
515        assert_eq!(labelled("python"), "Python Dependencies");
516        assert_eq!(labelled("python-caches"), "Python Cache");
517    }
518
519    #[test]
520    fn the_vocabulary_is_ordered_by_what_it_costs_to_lose() {
521        // The ordering is the thing worth more than the individual patterns: everything else
522        // reads the cost axis off `ALL`, so this is where it is pinned. Unrecoverable is the
523        // expensive end and noise is the cheap one, with the three regenerable kinds between
524        // them in the order the design has always named them.
525        assert_eq!(
526            Kind::ALL.map(Kind::short),
527            ["unrecoverable", "dependencies", "build", "cache", "noise"]
528        );
529        assert!(Kind::Unrecoverable.cost() < Kind::Dependencies.cost());
530        assert!(Kind::Dependencies.cost() < Kind::Build.cost());
531        assert!(Kind::Build.cost() < Kind::Cache.cost());
532        assert!(Kind::Cache.cost() < Kind::Noise.cost());
533    }
534
535    #[test]
536    fn a_name_that_is_the_only_copy_of_something_is_unrecoverable() {
537        for name in [
538            ".env",
539            ".env.local",
540            "prod.env",
541            ".env.production.local",
542            "server.pem",
543            "id_rsa",
544            "id_ed25519",
545            ".npmrc",
546            "credentials",
547        ] {
548            assert_eq!(
549                Kind::of_ignored_file(name),
550                Some(Kind::Unrecoverable),
551                "{name}"
552            );
553        }
554    }
555
556    #[test]
557    fn a_name_nothing_will_miss_is_noise_whichever_way_the_system_spelled_it() {
558        // Both systems that write these write them inconsistently, and the case is not
559        // information — a `.DS_STORE` that read as "kind unknown" would be the same file
560        // sorted somewhere else for no reason a reader could see.
561        for name in [
562            "build.log",
563            ".DS_Store",
564            ".ds_store",
565            "Thumbs.db",
566            "thumbs.db",
567        ] {
568            assert_eq!(Kind::of_ignored_file(name), Some(Kind::Noise), "{name}");
569        }
570    }
571
572    #[test]
573    fn a_name_that_could_be_read_either_way_is_read_as_the_expensive_one() {
574        // `.env.log` matches both tables. Being wrong toward "noise" is the one mistake here
575        // that cannot be undone by waiting for a rebuild, so the expensive end is asked first.
576        assert_eq!(Kind::of_ignored_file(".env.log"), Some(Kind::Unrecoverable));
577    }
578
579    #[test]
580    fn a_name_that_says_nothing_gets_no_kind() {
581        // The tier-two claim's own content, in a file's clothes: git knows it is disposable
582        // and nothing knows what it is. Note `environment` and `logic`, which the substring
583        // and suffix tests must not reach.
584        for name in ["dump.sql", "environment", "logic", "scratch", "envoy.yaml"] {
585            assert_eq!(Kind::of_ignored_file(name), None, "{name}");
586        }
587    }
588
589    #[test]
590    fn every_kind_says_what_losing_it_costs() {
591        for kind in Kind::ALL {
592            assert!(!kind.cost_said().is_empty(), "{kind}");
593        }
594    }
595
596    #[test]
597    fn a_rule_that_does_not_say_what_it_claims_is_rejected() {
598        // Not defaulted: a kind that can be omitted is a kind that is silently wrong, and it
599        // is the field a reader groups and filters by.
600        let err = Ruleset::parse(
601            r#"
602            [[rules]]
603            id = "nameless"
604            ecosystem = "Nameless"
605            markers = ["m"]
606            targets = ["out"]
607            "#,
608        )
609        .unwrap_err();
610        assert!(matches!(err, RuleError::Parse(_)), "got {err:?}");
611    }
612
613    #[test]
614    fn a_kind_outside_the_vocabulary_is_rejected() {
615        let err = Ruleset::parse(
616            r#"
617            [[rules]]
618            id = "inventive"
619            ecosystem = "Inventive"
620            markers = ["m"]
621            targets = ["out"]
622            kind = "sediment"
623            "#,
624        )
625        .unwrap_err();
626        assert!(matches!(err, RuleError::Parse(_)), "got {err:?}");
627    }
628
629    #[test]
630    fn the_builtin_ruleset_covers_the_ecosystems_kondo_does() {
631        let ruleset = Ruleset::builtin().unwrap();
632        let ids: Vec<&str> = ruleset.rules().iter().map(|r| r.id.as_str()).collect();
633        for expected in [
634            "node",
635            "cargo",
636            "go",
637            "python",
638            "dotnet",
639            "gradle",
640            "maven",
641            "composer",
642            "bundler",
643            "elixir",
644            "swift",
645            "dart",
646            "zig",
647            "unity",
648            "nx",
649            "bazel",
650            "cmake",
651            "godot",
652            "unreal",
653            "terraform",
654            "react-native",
655            "sbt",
656            "stack",
657            "cabal",
658            "pixi",
659            "jupyter",
660            "turborepo",
661        ] {
662            assert!(ids.contains(&expected), "no rule for {expected}");
663        }
664    }
665
666    #[test]
667    fn anchors_marker_modes_and_kinds_round_trip_from_toml() {
668        let ruleset = Ruleset::parse(
669            r#"
670            [[rules]]
671            id = "a"
672            ecosystem = "A"
673            anchor = "self"
674            markers_required = "all"
675            markers = ["m1", "m2"]
676            targets = ["out"]
677            kind = "build"
678            "#,
679        )
680        .unwrap();
681        let rule = &ruleset.rules()[0];
682        assert_eq!(rule.anchor, Anchor::SelfDir);
683        assert_eq!(rule.markers_required, MarkersRequired::All);
684        assert_eq!(rule.kind, Kind::Build);
685        assert_eq!(rule.label(), "A Build Artifacts");
686    }
687
688    #[test]
689    fn the_user_file_can_say_where_never_to_look_and_the_builtin_set_cannot() {
690        // Two different kinds of knowledge in one file. A rule says what an artefact *is*,
691        // which is true of an ecosystem everywhere; an exclude says where not to look, which is
692        // true of one disk. Only the second can come from the user, and shipping a default set
693        // of "system paths" would be a guess about an operating system that moves.
694        let ruleset = Ruleset::with_overrides(
695            r#"
696            exclude = ["Library/Application Support/CloudDocs", "!keep/me"]
697            "#,
698        )
699        .unwrap();
700        assert_eq!(
701            ruleset.excludes(),
702            ["Library/Application Support/CloudDocs", "!keep/me"]
703        );
704        // The rules still layer as they always did.
705        assert!(ruleset.rules().iter().any(|rule| rule.id == "node"));
706
707        // And a file that says nothing about it excludes nothing, which is the default a
708        // cleaner has to have: what it does not look at is the reader's decision.
709        assert!(Ruleset::builtin().unwrap().excludes().is_empty());
710        assert!(Ruleset::with_overrides("").unwrap().excludes().is_empty());
711    }
712
713    #[test]
714    fn a_user_rule_replaces_a_builtin_one_in_place() {
715        let builtin = Ruleset::builtin().unwrap();
716        let cargo_at = builtin
717            .rules()
718            .iter()
719            .position(|r| r.id == "cargo")
720            .unwrap();
721
722        let ruleset = Ruleset::with_overrides(
723            r#"
724            [[rules]]
725            id = "cargo"
726            ecosystem = "Rust"
727            markers = ["Cargo.toml"]
728            targets = ["target", "coverage"]
729            kind = "build"
730            "#,
731        )
732        .unwrap();
733
734        assert_eq!(ruleset.rules().len(), builtin.rules().len());
735        let cargo = &ruleset.rules()[cargo_at];
736        assert_eq!(cargo.targets, ["target", "coverage"]);
737    }
738
739    #[test]
740    fn a_rule_without_markers_is_rejected() {
741        let err = Ruleset::parse(
742            r#"
743            [[rules]]
744            id = "reckless"
745            ecosystem = "Reckless"
746            markers = []
747            targets = ["build"]
748            kind = "build"
749            "#,
750        )
751        .unwrap_err();
752        assert!(matches!(err, RuleError::NoMarkers(id) if id == "reckless"));
753    }
754
755    #[test]
756    fn an_unknown_field_is_rejected_rather_than_silently_ignored() {
757        let err = Ruleset::parse(
758            r#"
759            [[rules]]
760            id = "typo"
761            ecosystem = "Typo"
762            markers = ["m"]
763            target = ["build"]
764            targets = ["build"]
765            kind = "build"
766            "#,
767        )
768        .unwrap_err();
769        assert!(matches!(err, RuleError::Parse(_)), "got {err:?}");
770    }
771
772    #[test]
773    fn duplicate_ids_are_rejected() {
774        let err = Ruleset::parse(
775            r#"
776            [[rules]]
777            id = "dup"
778            ecosystem = "A"
779            markers = ["a"]
780            targets = ["out"]
781            kind = "build"
782
783            [[rules]]
784            id = "dup"
785            ecosystem = "B"
786            markers = ["b"]
787            targets = ["out"]
788            kind = "build"
789            "#,
790        )
791        .unwrap_err();
792        assert!(matches!(err, RuleError::DuplicateId(id) if id == "dup"));
793    }
794}