1use 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#[derive(Debug, Clone, PartialEq)]
32pub enum FactValue {
33 Bool(bool),
34 Int(i64),
35 String(String),
36}
37
38impl FactValue {
39 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#[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#[derive(Debug, Clone, Deserialize)]
70pub struct FactSpec {
71 pub id: String,
72 #[serde(flatten)]
73 pub kind: FactKind,
74}
75
76#[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#[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
116pub 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}