use std::fmt;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::Deserialize;
use crate::detect::Detector;
const BUILTIN: &str = include_str!("rules.toml");
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Anchor {
#[default]
Parent,
#[serde(rename = "self")]
SelfDir,
Ancestor,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Kind {
Unrecoverable,
Dependencies,
Build,
Cache,
Noise,
}
pub(crate) const ENV_MARK: &str = ".env";
const UNRECOVERABLE_NAMES: [&str; 5] =
[".npmrc", "credentials", "id_rsa", "id_ecdsa", "id_ed25519"];
const UNRECOVERABLE_SUFFIXES: [&str; 1] = [".pem"];
const NOISE_NAMES: [&str; 2] = [".ds_store", "thumbs.db"];
const NOISE_SUFFIXES: [&str; 1] = [".log"];
impl Kind {
pub const ALL: [Self; 5] = [
Self::Unrecoverable,
Self::Dependencies,
Self::Build,
Self::Cache,
Self::Noise,
];
#[must_use]
pub fn cost(self) -> usize {
Self::ALL
.iter()
.position(|&kind| kind == self)
.unwrap_or(Self::ALL.len())
}
#[must_use]
pub fn of_ignored_file(name: &str) -> Option<Self> {
let name = name.to_ascii_lowercase();
if name.contains(ENV_MARK)
|| UNRECOVERABLE_NAMES.contains(&name.as_str())
|| UNRECOVERABLE_SUFFIXES
.iter()
.any(|suffix| name.ends_with(suffix))
{
return Some(Self::Unrecoverable);
}
if NOISE_NAMES.contains(&name.as_str())
|| NOISE_SUFFIXES.iter().any(|suffix| name.ends_with(suffix))
{
return Some(Self::Noise);
}
None
}
#[must_use]
pub fn short(self) -> &'static str {
match self {
Self::Unrecoverable => "unrecoverable",
Self::Dependencies => "dependencies",
Self::Build => "build",
Self::Cache => "cache",
Self::Noise => "noise",
}
}
#[must_use]
pub fn cost_said(self) -> &'static str {
match self {
Self::Unrecoverable => "nothing brings this back",
Self::Dependencies => "a network fetch brings this back",
Self::Build => "a compile brings this back",
Self::Cache => "this comes back on its own",
Self::Noise => "nothing will miss this",
}
}
}
impl fmt::Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Unrecoverable => "Unrecoverable",
Self::Dependencies => "Dependencies",
Self::Build => "Build Artifacts",
Self::Cache => "Cache",
Self::Noise => "Noise",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum MarkersRequired {
#[default]
Any,
All,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Rule {
pub id: String,
pub ecosystem: String,
pub kind: Kind,
pub markers: Vec<String>,
#[serde(default)]
pub markers_required: MarkersRequired,
pub targets: Vec<String>,
#[serde(default)]
pub anchor: Anchor,
#[serde(default)]
pub note: Option<String>,
}
impl Rule {
#[must_use]
pub fn label(&self) -> String {
format!("{} {}", self.ecosystem, self.kind)
}
}
#[derive(Debug)]
pub struct Ruleset {
rules: Vec<Arc<Rule>>,
detector: Detector,
excludes: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RulesFile {
#[serde(default)]
rules: Vec<Rule>,
#[serde(default)]
exclude: Vec<String>,
}
impl Ruleset {
pub fn builtin() -> Result<Self, RuleError> {
Self::from_rules(Self::parse_rules(BUILTIN)?)
}
pub fn parse(toml: &str) -> Result<Self, RuleError> {
Self::from_rules(Self::parse_rules(toml)?)
}
pub fn with_overrides(user_toml: &str) -> Result<Self, RuleError> {
let user = Self::parse_file(user_toml)?;
let mut rules = Self::parse_rules(BUILTIN)?;
for rule in user.rules {
match rules.iter().position(|existing| existing.id == rule.id) {
Some(at) => rules[at] = rule,
None => rules.push(rule),
}
}
let mut ruleset = Self::from_rules(rules)?;
ruleset.excludes = user.exclude;
Ok(ruleset)
}
pub fn load(user_path: Option<&Path>) -> Result<Self, RuleError> {
let Some(path) = user_path.map(PathBuf::from).or_else(Self::user_config_path) else {
return Self::builtin();
};
match fs::read_to_string(&path) {
Ok(toml) => Self::with_overrides(&toml),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Self::builtin(),
Err(err) => Err(RuleError::Read(path, err)),
}
}
#[must_use]
pub fn user_config_path() -> Option<PathBuf> {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))?;
Some(base.join("pristine").join("rules.toml"))
}
#[must_use]
pub fn rules(&self) -> &[Arc<Rule>] {
&self.rules
}
pub(crate) fn detector(&self) -> &Detector {
&self.detector
}
fn parse_rules(toml: &str) -> Result<Vec<Rule>, RuleError> {
Ok(Self::parse_file(toml)?.rules)
}
fn parse_file(toml: &str) -> Result<RulesFile, RuleError> {
toml::from_str(toml).map_err(|err| RuleError::Parse(err.to_string()))
}
#[must_use]
pub fn excludes(&self) -> &[String] {
&self.excludes
}
fn from_rules(rules: Vec<Rule>) -> Result<Self, RuleError> {
for rule in &rules {
if rule.markers.is_empty() {
return Err(RuleError::NoMarkers(rule.id.clone()));
}
if rule.targets.is_empty() {
return Err(RuleError::NoTargets(rule.id.clone()));
}
if let Some(other) = rules.iter().filter(|r| r.id == rule.id).nth(1) {
return Err(RuleError::DuplicateId(other.id.clone()));
}
}
let rules: Vec<Arc<Rule>> = rules.into_iter().map(Arc::new).collect();
let detector = Detector::new(&rules)?;
Ok(Self {
rules,
detector,
excludes: Vec::new(),
})
}
}
#[derive(Debug)]
#[non_exhaustive]
pub enum RuleError {
Read(PathBuf, std::io::Error),
Parse(String),
NoMarkers(String),
NoTargets(String),
DuplicateId(String),
Glob(String, String),
}
impl fmt::Display for RuleError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Read(path, err) => write!(f, "reading rules from {}: {err}", path.display()),
Self::Parse(err) => write!(f, "parsing rules: {err}"),
Self::NoMarkers(id) => write!(
f,
"rule `{id}` declares no markers; a rule without one is a bare-name match, \
which is how a cleaner deletes somebody's source"
),
Self::NoTargets(id) => write!(f, "rule `{id}` declares nothing to reclaim"),
Self::DuplicateId(id) => write!(f, "two rules share the id `{id}`"),
Self::Glob(pattern, err) => write!(f, "`{pattern}` is not a valid glob: {err}"),
}
}
}
impl std::error::Error for RuleError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Read(_, err) => Some(err),
_ => None,
}
}
}
#[cfg(test)]
mod tests {
use super::{Anchor, Kind, MarkersRequired, RuleError, Ruleset};
#[test]
fn the_builtin_ruleset_parses() {
let ruleset = Ruleset::builtin().unwrap();
assert!(
ruleset.rules().len() >= 20,
"kondo covers 20+ project types and pristine must not ship narrower"
);
}
#[test]
fn every_builtin_rule_is_marker_anchored_and_names_what_it_claims() {
for rule in Ruleset::builtin().unwrap().rules() {
assert!(!rule.markers.is_empty(), "{} has no marker", rule.id);
assert!(!rule.targets.is_empty(), "{} reclaims nothing", rule.id);
assert!(
!rule.ecosystem.is_empty(),
"{} has no ecosystem name",
rule.id
);
}
}
#[test]
fn a_label_is_the_ecosystem_and_the_kind() {
let ruleset = Ruleset::builtin().unwrap();
let labelled = |id: &str| {
ruleset
.rules()
.iter()
.find(|rule| rule.id == id)
.unwrap_or_else(|| panic!("no rule for {id}"))
.label()
};
assert_eq!(labelled("node"), "Node Dependencies");
assert_eq!(labelled("dotnet"), ".NET Build Artifacts");
assert_eq!(labelled("nx-caches"), "Nx Cache");
assert_eq!(labelled("python"), "Python Dependencies");
assert_eq!(labelled("python-caches"), "Python Cache");
}
#[test]
fn the_vocabulary_is_ordered_by_what_it_costs_to_lose() {
assert_eq!(
Kind::ALL.map(Kind::short),
["unrecoverable", "dependencies", "build", "cache", "noise"]
);
assert!(Kind::Unrecoverable.cost() < Kind::Dependencies.cost());
assert!(Kind::Dependencies.cost() < Kind::Build.cost());
assert!(Kind::Build.cost() < Kind::Cache.cost());
assert!(Kind::Cache.cost() < Kind::Noise.cost());
}
#[test]
fn a_name_that_is_the_only_copy_of_something_is_unrecoverable() {
for name in [
".env",
".env.local",
"prod.env",
".env.production.local",
"server.pem",
"id_rsa",
"id_ed25519",
".npmrc",
"credentials",
] {
assert_eq!(
Kind::of_ignored_file(name),
Some(Kind::Unrecoverable),
"{name}"
);
}
}
#[test]
fn a_name_nothing_will_miss_is_noise_whichever_way_the_system_spelled_it() {
for name in [
"build.log",
".DS_Store",
".ds_store",
"Thumbs.db",
"thumbs.db",
] {
assert_eq!(Kind::of_ignored_file(name), Some(Kind::Noise), "{name}");
}
}
#[test]
fn a_name_that_could_be_read_either_way_is_read_as_the_expensive_one() {
assert_eq!(Kind::of_ignored_file(".env.log"), Some(Kind::Unrecoverable));
}
#[test]
fn a_name_that_says_nothing_gets_no_kind() {
for name in ["dump.sql", "environment", "logic", "scratch", "envoy.yaml"] {
assert_eq!(Kind::of_ignored_file(name), None, "{name}");
}
}
#[test]
fn every_kind_says_what_losing_it_costs() {
for kind in Kind::ALL {
assert!(!kind.cost_said().is_empty(), "{kind}");
}
}
#[test]
fn a_rule_that_does_not_say_what_it_claims_is_rejected() {
let err = Ruleset::parse(
r#"
[[rules]]
id = "nameless"
ecosystem = "Nameless"
markers = ["m"]
targets = ["out"]
"#,
)
.unwrap_err();
assert!(matches!(err, RuleError::Parse(_)), "got {err:?}");
}
#[test]
fn a_kind_outside_the_vocabulary_is_rejected() {
let err = Ruleset::parse(
r#"
[[rules]]
id = "inventive"
ecosystem = "Inventive"
markers = ["m"]
targets = ["out"]
kind = "sediment"
"#,
)
.unwrap_err();
assert!(matches!(err, RuleError::Parse(_)), "got {err:?}");
}
#[test]
fn the_builtin_ruleset_covers_the_ecosystems_kondo_does() {
let ruleset = Ruleset::builtin().unwrap();
let ids: Vec<&str> = ruleset.rules().iter().map(|r| r.id.as_str()).collect();
for expected in [
"node",
"cargo",
"go",
"python",
"dotnet",
"gradle",
"maven",
"composer",
"bundler",
"elixir",
"swift",
"dart",
"zig",
"unity",
"nx",
"bazel",
"cmake",
"godot",
"unreal",
"terraform",
"react-native",
"sbt",
"stack",
"cabal",
"pixi",
"jupyter",
"turborepo",
] {
assert!(ids.contains(&expected), "no rule for {expected}");
}
}
#[test]
fn anchors_marker_modes_and_kinds_round_trip_from_toml() {
let ruleset = Ruleset::parse(
r#"
[[rules]]
id = "a"
ecosystem = "A"
anchor = "self"
markers_required = "all"
markers = ["m1", "m2"]
targets = ["out"]
kind = "build"
"#,
)
.unwrap();
let rule = &ruleset.rules()[0];
assert_eq!(rule.anchor, Anchor::SelfDir);
assert_eq!(rule.markers_required, MarkersRequired::All);
assert_eq!(rule.kind, Kind::Build);
assert_eq!(rule.label(), "A Build Artifacts");
}
#[test]
fn the_user_file_can_say_where_never_to_look_and_the_builtin_set_cannot() {
let ruleset = Ruleset::with_overrides(
r#"
exclude = ["Library/Application Support/CloudDocs", "!keep/me"]
"#,
)
.unwrap();
assert_eq!(
ruleset.excludes(),
["Library/Application Support/CloudDocs", "!keep/me"]
);
assert!(ruleset.rules().iter().any(|rule| rule.id == "node"));
assert!(Ruleset::builtin().unwrap().excludes().is_empty());
assert!(Ruleset::with_overrides("").unwrap().excludes().is_empty());
}
#[test]
fn a_user_rule_replaces_a_builtin_one_in_place() {
let builtin = Ruleset::builtin().unwrap();
let cargo_at = builtin
.rules()
.iter()
.position(|r| r.id == "cargo")
.unwrap();
let ruleset = Ruleset::with_overrides(
r#"
[[rules]]
id = "cargo"
ecosystem = "Rust"
markers = ["Cargo.toml"]
targets = ["target", "coverage"]
kind = "build"
"#,
)
.unwrap();
assert_eq!(ruleset.rules().len(), builtin.rules().len());
let cargo = &ruleset.rules()[cargo_at];
assert_eq!(cargo.targets, ["target", "coverage"]);
}
#[test]
fn a_rule_without_markers_is_rejected() {
let err = Ruleset::parse(
r#"
[[rules]]
id = "reckless"
ecosystem = "Reckless"
markers = []
targets = ["build"]
kind = "build"
"#,
)
.unwrap_err();
assert!(matches!(err, RuleError::NoMarkers(id) if id == "reckless"));
}
#[test]
fn an_unknown_field_is_rejected_rather_than_silently_ignored() {
let err = Ruleset::parse(
r#"
[[rules]]
id = "typo"
ecosystem = "Typo"
markers = ["m"]
target = ["build"]
targets = ["build"]
kind = "build"
"#,
)
.unwrap_err();
assert!(matches!(err, RuleError::Parse(_)), "got {err:?}");
}
#[test]
fn duplicate_ids_are_rejected() {
let err = Ruleset::parse(
r#"
[[rules]]
id = "dup"
ecosystem = "A"
markers = ["a"]
targets = ["out"]
kind = "build"
[[rules]]
id = "dup"
ecosystem = "B"
markers = ["b"]
targets = ["out"]
kind = "build"
"#,
)
.unwrap_err();
assert!(matches!(err, RuleError::DuplicateId(id) if id == "dup"));
}
}