Skip to main content

alint_core/
facts.rs

1//! Facts — cached properties of the repository evaluated once per run and
2//! referenced by `when` clauses on rules (shipping in a later commit).
3//!
4//! Each fact has an `id` and exactly one kind-specific top-level field that
5//! names its type. Example:
6//!
7//! ```yaml
8//! facts:
9//!   - id: is_rust
10//!     any_file_exists: ["Cargo.toml"]
11//!   - id: is_monorepo
12//!     all_files_exist: ["packages", "pnpm-workspace.yaml"]
13//!   - id: n_java_files
14//!     count_files: "**/*.java"
15//! ```
16//!
17//! Evaluation is declarative and cheap — facts see the walked `FileIndex`
18//! but not arbitrary filesystem state outside the repo root.
19
20use std::collections::HashMap;
21use std::path::Path;
22
23use serde::Deserialize;
24
25use crate::error::Result;
26use crate::scope::Scope;
27use crate::walker::FileIndex;
28
29/// A value a fact evaluates to. Keeps the surface small for v0.2; richer
30/// types (list, map) arrive with the `when` expression language.
31#[derive(Debug, Clone, PartialEq)]
32pub enum FactValue {
33    Bool(bool),
34    Int(i64),
35    String(String),
36}
37
38impl FactValue {
39    /// Boolean coercion — `Bool(b)` → b; `Int(n)` → `n != 0`; `String(s)` →
40    /// `!s.is_empty()`. Used by `when` evaluation's truthiness checks.
41    pub fn truthy(&self) -> bool {
42        match self {
43            Self::Bool(b) => *b,
44            Self::Int(n) => *n != 0,
45            Self::String(s) => !s.is_empty(),
46        }
47    }
48}
49
50/// A string or a list of strings — accepted by fact kinds whose input is
51/// glob-shaped.
52#[derive(Debug, Clone, Deserialize)]
53#[serde(untagged)]
54pub enum OneOrMany {
55    One(String),
56    Many(Vec<String>),
57}
58
59impl OneOrMany {
60    pub fn to_vec(&self) -> Vec<String> {
61        match self {
62            Self::One(s) => vec![s.clone()],
63            Self::Many(v) => v.clone(),
64        }
65    }
66}
67
68/// YAML-level declaration of a single fact.
69#[derive(Debug, Clone, Deserialize)]
70pub struct FactSpec {
71    pub id: String,
72    #[serde(flatten)]
73    pub kind: FactKind,
74}
75
76/// The closed set of built-in fact kinds. Serde dispatches via `untagged`
77/// — the first variant whose required field is present in the YAML wins.
78#[derive(Debug, Clone, Deserialize)]
79#[serde(untagged)]
80pub enum FactKind {
81    AnyFileExists { any_file_exists: OneOrMany },
82    AllFilesExist { all_files_exist: OneOrMany },
83    CountFiles { count_files: String },
84}
85
86/// The resolved map from fact id to value, produced once per `Engine::run`.
87#[derive(Debug, Default, Clone)]
88pub struct FactValues(HashMap<String, FactValue>);
89
90impl FactValues {
91    pub fn new() -> Self {
92        Self::default()
93    }
94
95    pub fn insert(&mut self, id: String, v: FactValue) {
96        self.0.insert(id, v);
97    }
98
99    pub fn get(&self, id: &str) -> Option<&FactValue> {
100        self.0.get(id)
101    }
102
103    pub fn len(&self) -> usize {
104        self.0.len()
105    }
106
107    pub fn is_empty(&self) -> bool {
108        self.0.is_empty()
109    }
110
111    pub fn as_map(&self) -> &HashMap<String, FactValue> {
112        &self.0
113    }
114}
115
116/// Evaluate a whole fact list against a prebuilt `FileIndex`. Invoked by
117/// `Engine::run` before any rule evaluates.
118pub fn evaluate_facts(facts: &[FactSpec], _root: &Path, index: &FileIndex) -> Result<FactValues> {
119    let mut out = FactValues::new();
120    for spec in facts {
121        let value = evaluate_one(spec, index)?;
122        out.insert(spec.id.clone(), value);
123    }
124    Ok(out)
125}
126
127fn evaluate_one(spec: &FactSpec, index: &FileIndex) -> Result<FactValue> {
128    match &spec.kind {
129        FactKind::AnyFileExists { any_file_exists } => {
130            let globs = any_file_exists.to_vec();
131            let scope = Scope::from_patterns(&globs)?;
132            let found = index.files().any(|e| scope.matches(&e.path));
133            Ok(FactValue::Bool(found))
134        }
135        FactKind::AllFilesExist { all_files_exist } => {
136            let globs = all_files_exist.to_vec();
137            for glob in &globs {
138                let scope = Scope::from_patterns(std::slice::from_ref(glob))?;
139                if !index.files().any(|e| scope.matches(&e.path)) {
140                    return Ok(FactValue::Bool(false));
141                }
142            }
143            Ok(FactValue::Bool(true))
144        }
145        FactKind::CountFiles { count_files } => {
146            let scope = Scope::from_patterns(std::slice::from_ref(count_files))?;
147            let count = index.files().filter(|e| scope.matches(&e.path)).count();
148            Ok(FactValue::Int(i64::try_from(count).unwrap_or(i64::MAX)))
149        }
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::walker::FileEntry;
157    use std::path::PathBuf;
158
159    fn idx(paths: &[&str]) -> FileIndex {
160        FileIndex {
161            entries: paths
162                .iter()
163                .map(|p| FileEntry {
164                    path: PathBuf::from(p),
165                    is_dir: false,
166                    size: 1,
167                })
168                .collect(),
169        }
170    }
171
172    fn parse(yaml: &str) -> Vec<FactSpec> {
173        serde_yaml_ng::from_str(yaml).unwrap()
174    }
175
176    #[test]
177    fn any_file_exists_true_when_match_found() {
178        let facts = parse("- id: is_rust\n  any_file_exists: [Cargo.toml]\n");
179        let v =
180            evaluate_facts(&facts, Path::new("/"), &idx(&["Cargo.toml", "src/lib.rs"])).unwrap();
181        assert_eq!(v.get("is_rust"), Some(&FactValue::Bool(true)));
182    }
183
184    #[test]
185    fn any_file_exists_false_when_no_match() {
186        let facts = parse("- id: is_rust\n  any_file_exists: [Cargo.toml]\n");
187        let v = evaluate_facts(&facts, Path::new("/"), &idx(&["src/lib.rs"])).unwrap();
188        assert_eq!(v.get("is_rust"), Some(&FactValue::Bool(false)));
189    }
190
191    #[test]
192    fn any_file_exists_accepts_single_string() {
193        let facts = parse("- id: has_readme\n  any_file_exists: README.md\n");
194        let v = evaluate_facts(&facts, Path::new("/"), &idx(&["README.md"])).unwrap();
195        assert_eq!(v.get("has_readme"), Some(&FactValue::Bool(true)));
196    }
197
198    #[test]
199    fn all_files_exist_true_when_all_match() {
200        let facts = parse("- id: is_monorepo\n  all_files_exist: [Cargo.toml, README.md]\n");
201        let v = evaluate_facts(
202            &facts,
203            Path::new("/"),
204            &idx(&["Cargo.toml", "README.md", "src/main.rs"]),
205        )
206        .unwrap();
207        assert_eq!(v.get("is_monorepo"), Some(&FactValue::Bool(true)));
208    }
209
210    #[test]
211    fn all_files_exist_false_when_any_missing() {
212        let facts = parse("- id: is_monorepo\n  all_files_exist: [Cargo.toml, README.md]\n");
213        let v = evaluate_facts(&facts, Path::new("/"), &idx(&["Cargo.toml"])).unwrap();
214        assert_eq!(v.get("is_monorepo"), Some(&FactValue::Bool(false)));
215    }
216
217    #[test]
218    fn count_files_returns_integer() {
219        let facts = parse("- id: n_rs\n  count_files: \"**/*.rs\"\n");
220        let v = evaluate_facts(
221            &facts,
222            Path::new("/"),
223            &idx(&["a.rs", "b.rs", "src/c.rs", "README.md"]),
224        )
225        .unwrap();
226        assert_eq!(v.get("n_rs"), Some(&FactValue::Int(3)));
227    }
228
229    #[test]
230    fn multiple_facts_all_resolved() {
231        let facts = parse(
232            r#"
233- id: is_rust
234  any_file_exists: [Cargo.toml]
235- id: n_rs
236  count_files: "**/*.rs"
237- id: has_readme
238  any_file_exists: README.md
239"#,
240        );
241        let v = evaluate_facts(
242            &facts,
243            Path::new("/"),
244            &idx(&["Cargo.toml", "src/lib.rs", "README.md"]),
245        )
246        .unwrap();
247        assert_eq!(v.len(), 3);
248        assert_eq!(v.get("is_rust"), Some(&FactValue::Bool(true)));
249        assert_eq!(v.get("n_rs"), Some(&FactValue::Int(1)));
250        assert_eq!(v.get("has_readme"), Some(&FactValue::Bool(true)));
251    }
252
253    #[test]
254    fn truthy_coercion() {
255        assert!(FactValue::Bool(true).truthy());
256        assert!(!FactValue::Bool(false).truthy());
257        assert!(FactValue::Int(1).truthy());
258        assert!(!FactValue::Int(0).truthy());
259        assert!(FactValue::String("x".into()).truthy());
260        assert!(!FactValue::String(String::new()).truthy());
261    }
262}