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 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
15/// Default TTL for facts: 30 days.
16const DEFAULT_TTL_DAYS: i64 = 30;
17
18/// Parameters for creating a fact.
19pub 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/// Result of creating a fact.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct FactResult {
31    pub unit_id: String,
32    pub unit: Unit,
33}
34
35/// Result of a single fact verification.
36#[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/// Aggregated result of verifying all facts.
46#[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
57/// Create a verified fact (unit with unit_type=fact).
58///
59/// Facts require a verify command — that's the point. If you can't write a
60/// verify command, the knowledge belongs in agents.md, not in a fact.
61pub 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
122/// Verify all facts and return structured results.
123///
124/// Re-runs verify commands for all units with unit_type=fact.
125/// Reports which facts are stale (past their stale_after date)
126/// and which have failing verify commands.
127///
128/// Suspect propagation: facts that require artifacts from failing/stale facts
129/// are marked as suspect (up to depth 3).
130pub 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        // Re-run verify command
186        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    // Suspect propagation
231    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}