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}