Skip to main content

mana_core/ops/
fact.rs

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
13/// Default TTL for facts: 30 days.
14const DEFAULT_TTL_DAYS: i64 = 30;
15
16/// Parameters for creating a fact.
17pub 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/// Result of creating a fact.
27#[derive(Debug)]
28pub struct FactResult {
29    pub unit_id: String,
30    pub unit: Unit,
31}
32
33/// Result of a single fact verification.
34#[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
43/// Aggregated result of verifying all facts.
44pub 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
54/// Create a verified fact (unit with unit_type=fact).
55///
56/// Facts require a verify command — that's the point. If you can't write a
57/// verify command, the knowledge belongs in agents.md, not in a fact.
58pub 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
117/// Verify all facts and return structured results.
118///
119/// Re-runs verify commands for all units with unit_type=fact.
120/// Reports which facts are stale (past their stale_after date)
121/// and which have failing verify commands.
122///
123/// Suspect propagation: facts that require artifacts from failing/stale facts
124/// are marked as suspect (up to depth 3).
125pub 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        // Re-run verify command
181        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    // Suspect propagation
226    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}