1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3use std::process::Command as ShellCommand;
4
5use anyhow::{anyhow, Result};
6use chrono::{Duration, Utc};
7
8use crate::discovery::{find_archived_unit, find_unit_file};
9use crate::index::Index;
10use crate::ops::create::{create, CreateParams};
11use crate::unit::{Status, Unit};
12
13const DEFAULT_TTL_DAYS: i64 = 30;
15
16pub struct FactParams {
18 pub title: String,
19 pub verify: String,
20 pub description: Option<String>,
21 pub paths: Option<String>,
22 pub ttl_days: Option<i64>,
23 pub pass_ok: bool,
24}
25
26#[derive(Debug)]
28pub struct FactResult {
29 pub unit_id: String,
30 pub unit: Unit,
31}
32
33#[derive(Debug)]
35pub struct FactVerifyEntry {
36 pub id: String,
37 pub title: String,
38 pub stale: bool,
39 pub verify_passed: Option<bool>,
40 pub error: Option<String>,
41}
42
43pub struct VerifyFactsResult {
45 pub total_facts: usize,
46 pub verified_count: usize,
47 pub stale_count: usize,
48 pub failing_count: usize,
49 pub suspect_count: usize,
50 pub entries: Vec<FactVerifyEntry>,
51 pub suspect_entries: Vec<(String, String)>,
52}
53
54pub fn create_fact(mana_dir: &Path, params: FactParams) -> Result<FactResult> {
59 if params.verify.trim().is_empty() {
60 return Err(anyhow!(
61 "Facts require a verify command. If you can't write one, \
62 this belongs in agents.md, not mana fact."
63 ));
64 }
65
66 let create_result = create(
67 mana_dir,
68 CreateParams {
69 title: params.title,
70 description: params.description,
71 acceptance: None,
72 notes: None,
73 design: None,
74 verify: Some(params.verify),
75 priority: Some(3),
76 labels: vec!["fact".to_string()],
77 assignee: None,
78 dependencies: vec![],
79 parent: None,
80 produces: vec![],
81 requires: vec![],
82 paths: vec![],
83 on_fail: None,
84 fail_first: false,
85 feature: false,
86 verify_timeout: None,
87 decisions: vec![],
88 force: false,
89 },
90 )?;
91
92 let unit_id = create_result.unit.id.clone();
93 let unit_path = create_result.path;
94 let mut unit = create_result.unit;
95
96 unit.unit_type = "fact".to_string();
97
98 let ttl = params.ttl_days.unwrap_or(DEFAULT_TTL_DAYS);
99 unit.stale_after = Some(Utc::now() + Duration::days(ttl));
100
101 if let Some(paths_str) = params.paths {
102 unit.paths = paths_str
103 .split(',')
104 .map(|s| s.trim().to_string())
105 .filter(|s| !s.is_empty())
106 .collect();
107 }
108
109 unit.to_file(&unit_path)?;
110
111 let index = Index::build(mana_dir)?;
112 index.save(mana_dir)?;
113
114 Ok(FactResult { unit_id, unit })
115}
116
117pub fn verify_facts(mana_dir: &Path) -> Result<VerifyFactsResult> {
126 let project_root = mana_dir
127 .parent()
128 .ok_or_else(|| anyhow!("Cannot determine project root from units dir"))?;
129
130 let index = Index::load_or_rebuild(mana_dir)?;
131 let archived = Index::collect_archived(mana_dir).unwrap_or_default();
132
133 let now = Utc::now();
134 let mut stale_count = 0;
135 let mut failing_count = 0;
136 let mut verified_count = 0;
137 let mut total_facts = 0;
138
139 let mut invalid_artifacts: HashSet<String> = HashSet::new();
140 let mut fact_requires: HashMap<String, Vec<String>> = HashMap::new();
141 let mut fact_titles: HashMap<String, String> = HashMap::new();
142 let mut entries: Vec<FactVerifyEntry> = Vec::new();
143
144 for entry in index.units.iter().chain(archived.iter()) {
145 let unit_path = if entry.status == Status::Closed {
146 find_archived_unit(mana_dir, &entry.id).ok()
147 } else {
148 find_unit_file(mana_dir, &entry.id).ok()
149 };
150
151 let unit_path = match unit_path {
152 Some(p) => p,
153 None => continue,
154 };
155
156 let mut unit = match Unit::from_file(&unit_path) {
157 Ok(b) => b,
158 Err(_) => continue,
159 };
160
161 if unit.unit_type != "fact" {
162 continue;
163 }
164
165 total_facts += 1;
166 fact_titles.insert(unit.id.clone(), unit.title.clone());
167 if !unit.requires.is_empty() {
168 fact_requires.insert(unit.id.clone(), unit.requires.clone());
169 }
170
171 let is_stale = unit.stale_after.map(|sa| now > sa).unwrap_or(false);
172
173 if is_stale {
174 stale_count += 1;
175 for prod in &unit.produces {
176 invalid_artifacts.insert(prod.clone());
177 }
178 }
179
180 let (verify_passed, error) = if let Some(ref verify_cmd) = unit.verify {
182 let output = ShellCommand::new("sh")
183 .args(["-c", verify_cmd])
184 .current_dir(project_root)
185 .output();
186
187 match output {
188 Ok(o) if o.status.success() => {
189 verified_count += 1;
190 unit.last_verified = Some(now);
191 if unit.stale_after.is_some() {
192 unit.stale_after = Some(now + Duration::days(DEFAULT_TTL_DAYS));
193 }
194 unit.to_file(&unit_path)?;
195 (Some(true), None)
196 }
197 Ok(_) => {
198 failing_count += 1;
199 for prod in &unit.produces {
200 invalid_artifacts.insert(prod.clone());
201 }
202 (Some(false), None)
203 }
204 Err(e) => {
205 failing_count += 1;
206 for prod in &unit.produces {
207 invalid_artifacts.insert(prod.clone());
208 }
209 (Some(false), Some(e.to_string()))
210 }
211 }
212 } else {
213 (None, None)
214 };
215
216 entries.push(FactVerifyEntry {
217 id: unit.id.clone(),
218 title: unit.title.clone(),
219 stale: is_stale,
220 verify_passed,
221 error,
222 });
223 }
224
225 let mut suspect_entries: Vec<(String, String)> = Vec::new();
227 let mut suspect_count = 0;
228
229 if !invalid_artifacts.is_empty() {
230 let mut suspect_ids: HashSet<String> = HashSet::new();
231 let mut current_invalid = invalid_artifacts.clone();
232
233 for _depth in 0..3 {
234 let mut newly_invalid: HashSet<String> = HashSet::new();
235
236 for (fact_id, requires) in &fact_requires {
237 if suspect_ids.contains(fact_id) {
238 continue;
239 }
240 for req in requires {
241 if current_invalid.contains(req) {
242 suspect_ids.insert(fact_id.clone());
243 if let Some(entry) = index
244 .units
245 .iter()
246 .chain(archived.iter())
247 .find(|e| e.id == *fact_id)
248 {
249 let bp = if entry.status == Status::Closed {
250 find_archived_unit(mana_dir, &entry.id).ok()
251 } else {
252 find_unit_file(mana_dir, &entry.id).ok()
253 };
254 if let Some(bp) = bp {
255 if let Ok(b) = Unit::from_file(&bp) {
256 for prod in &b.produces {
257 newly_invalid.insert(prod.clone());
258 }
259 }
260 }
261 }
262 break;
263 }
264 }
265 }
266
267 if newly_invalid.is_empty() {
268 break;
269 }
270 current_invalid = newly_invalid;
271 }
272
273 for suspect_id in &suspect_ids {
274 suspect_count += 1;
275 let title = fact_titles
276 .get(suspect_id)
277 .map(|s| s.as_str())
278 .unwrap_or("?")
279 .to_string();
280 suspect_entries.push((suspect_id.clone(), title));
281 }
282 }
283
284 Ok(VerifyFactsResult {
285 total_facts,
286 verified_count,
287 stale_count,
288 failing_count,
289 suspect_count,
290 entries,
291 suspect_entries,
292 })
293}
294
295#[cfg(test)]
296mod tests {
297 use super::*;
298 use crate::config::Config;
299 use std::fs;
300 use tempfile::TempDir;
301
302 fn setup_mana_dir() -> (TempDir, std::path::PathBuf) {
303 let dir = TempDir::new().unwrap();
304 let mana_dir = dir.path().join(".mana");
305 fs::create_dir(&mana_dir).unwrap();
306
307 Config {
308 project: "test".to_string(),
309 next_id: 1,
310 auto_close_parent: true,
311 run: None,
312 plan: None,
313 max_loops: 10,
314 max_concurrent: 4,
315 poll_interval: 30,
316 extends: vec![],
317 rules_file: None,
318 file_locking: false,
319 worktree: false,
320 on_close: None,
321 on_fail: None,
322 post_plan: None,
323 verify_timeout: None,
324 review: None,
325 user: None,
326 user_email: None,
327 auto_commit: false,
328 commit_template: None,
329 research: None,
330 run_model: None,
331 plan_model: None,
332 review_model: None,
333 research_model: None,
334 batch_verify: false,
335 memory_reserve_mb: 0,
336 notify: None,
337 }
338 .save(&mana_dir)
339 .unwrap();
340
341 (dir, mana_dir)
342 }
343
344 #[test]
345 fn create_fact_sets_unit_type() {
346 let (_dir, mana_dir) = setup_mana_dir();
347
348 let result = create_fact(
349 &mana_dir,
350 FactParams {
351 title: "Auth uses RS256".to_string(),
352 verify: "grep -q RS256 src/auth.rs".to_string(),
353 description: None,
354 paths: None,
355 ttl_days: None,
356 pass_ok: true,
357 },
358 )
359 .unwrap();
360
361 assert_eq!(result.unit.unit_type, "fact");
362 assert!(result.unit.labels.contains(&"fact".to_string()));
363 assert!(result.unit.stale_after.is_some());
364 assert!(result.unit.verify.is_some());
365 }
366
367 #[test]
368 fn create_fact_with_paths() {
369 let (_dir, mana_dir) = setup_mana_dir();
370
371 let result = create_fact(
372 &mana_dir,
373 FactParams {
374 title: "Config file format".to_string(),
375 verify: "grep -q 'project: test' .mana/config.yaml".to_string(),
376 description: None,
377 paths: Some("src/config.rs, src/main.rs".to_string()),
378 ttl_days: None,
379 pass_ok: true,
380 },
381 )
382 .unwrap();
383
384 assert_eq!(result.unit.paths, vec!["src/config.rs", "src/main.rs"]);
385 }
386
387 #[test]
388 fn create_fact_with_custom_ttl() {
389 let (_dir, mana_dir) = setup_mana_dir();
390
391 let result = create_fact(
392 &mana_dir,
393 FactParams {
394 title: "Short-lived fact".to_string(),
395 verify: "grep -q 'project: test' .mana/config.yaml".to_string(),
396 description: None,
397 paths: None,
398 ttl_days: Some(7),
399 pass_ok: true,
400 },
401 )
402 .unwrap();
403
404 let stale = result.unit.stale_after.unwrap();
405 let diff = stale - Utc::now();
406 assert!(diff.num_days() >= 6 && diff.num_days() <= 7);
407 }
408
409 #[test]
410 fn create_fact_requires_verify() {
411 let (_dir, mana_dir) = setup_mana_dir();
412
413 let result = create_fact(
414 &mana_dir,
415 FactParams {
416 title: "No verify fact".to_string(),
417 verify: " ".to_string(),
418 description: None,
419 paths: None,
420 ttl_days: None,
421 pass_ok: true,
422 },
423 );
424
425 assert!(result.is_err());
426 assert!(result.unwrap_err().to_string().contains("verify command"));
427 }
428}