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