Skip to main content

mana_core/ops/
fact_sheet.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use anyhow::Result;
6use serde::{Deserialize, Serialize};
7
8use crate::discovery::{find_archived_unit, find_unit_file};
9use crate::index::Index;
10use crate::unit::{Status, Unit};
11
12pub const FACTS_FILE: &str = "facts.mana";
13
14#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum FactSheetStatus {
17    Draft,
18    Spec,
19    InProgress,
20    Verified,
21    Stale,
22    Rejected,
23}
24
25impl FactSheetStatus {
26    pub fn parse(token: &str) -> Option<Self> {
27        match token.strip_prefix('@').unwrap_or(token) {
28            "draft" => Some(Self::Draft),
29            "spec" => Some(Self::Spec),
30            "in_progress" => Some(Self::InProgress),
31            "verified" => Some(Self::Verified),
32            "stale" => Some(Self::Stale),
33            "rejected" => Some(Self::Rejected),
34            _ => None,
35        }
36    }
37
38    pub fn as_str(self) -> &'static str {
39        match self {
40            Self::Draft => "draft",
41            Self::Spec => "spec",
42            Self::InProgress => "in_progress",
43            Self::Verified => "verified",
44            Self::Stale => "stale",
45            Self::Rejected => "rejected",
46        }
47    }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
51pub struct FactSheetFact {
52    pub text: String,
53    pub status: FactSheetStatus,
54    pub unit_ref: Option<String>,
55    pub anchor: Option<String>,
56    pub section: Vec<String>,
57    pub line: usize,
58}
59
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub enum FactSheetDiagnosticSeverity {
62    Error,
63    Warning,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67pub struct FactSheetDiagnostic {
68    pub line: Option<usize>,
69    pub severity: FactSheetDiagnosticSeverity,
70    pub message: String,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct FactSheetParseResult {
75    pub facts: Vec<FactSheetFact>,
76    pub diagnostics: Vec<FactSheetDiagnostic>,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct FactSheetCheckEntry {
81    pub fact: FactSheetFact,
82    pub passed: bool,
83    pub message: Option<String>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct FactSheetCheckResult {
88    pub path: PathBuf,
89    pub facts: Vec<FactSheetFact>,
90    pub diagnostics: Vec<FactSheetDiagnostic>,
91    pub entries: Vec<FactSheetCheckEntry>,
92}
93
94impl FactSheetCheckResult {
95    pub fn has_errors(&self) -> bool {
96        self.diagnostics
97            .iter()
98            .any(|d| d.severity == FactSheetDiagnosticSeverity::Error)
99            || self.entries.iter().any(|e| !e.passed)
100    }
101}
102
103pub fn facts_path_from_mana_dir(mana_dir: &Path) -> Result<PathBuf> {
104    let project_root = mana_dir
105        .parent()
106        .ok_or_else(|| anyhow::anyhow!("cannot determine project root from mana dir"))?;
107    Ok(project_root.join(FACTS_FILE))
108}
109
110pub fn parse_facts_sheet(content: &str) -> FactSheetParseResult {
111    let mut facts = Vec::new();
112    let mut diagnostics = Vec::new();
113    let mut section_stack: Vec<(usize, String)> = Vec::new();
114    let mut anchors: HashMap<String, usize> = HashMap::new();
115
116    for (idx, raw_line) in content.lines().enumerate() {
117        let line_no = idx + 1;
118        let line = raw_line.trim();
119
120        if line.is_empty() || line.starts_with("//") {
121            continue;
122        }
123
124        if let Some((depth, title)) = parse_heading(line) {
125            while section_stack.last().is_some_and(|(d, _)| *d >= depth) {
126                section_stack.pop();
127            }
128            section_stack.push((depth, title));
129            continue;
130        }
131
132        if !line.starts_with("- ") {
133            diagnostics.push(error(
134                Some(line_no),
135                "expected a fact line starting with '- ' or a Markdown heading",
136            ));
137            continue;
138        }
139
140        let Some(fact) = parse_fact_line(&line[2..], line_no, &section_stack, &mut diagnostics)
141        else {
142            continue;
143        };
144
145        if let Some(anchor) = &fact.anchor {
146            if let Some(first_line) = anchors.insert(anchor.clone(), line_no) {
147                diagnostics.push(error(
148                    Some(line_no),
149                    format!("duplicate fact anchor '{{{anchor}}}' first used on line {first_line}"),
150                ));
151            }
152        }
153
154        facts.push(fact);
155    }
156
157    FactSheetParseResult { facts, diagnostics }
158}
159
160pub fn check_facts_sheet(mana_dir: &Path) -> Result<FactSheetCheckResult> {
161    let path = facts_path_from_mana_dir(mana_dir)?;
162    if !path.exists() {
163        return Ok(FactSheetCheckResult {
164            path,
165            facts: Vec::new(),
166            diagnostics: Vec::new(),
167            entries: Vec::new(),
168        });
169    }
170
171    let content = fs::read_to_string(&path)?;
172    let parsed = parse_facts_sheet(&content);
173    let index = Index::load_or_rebuild(mana_dir)?;
174
175    let mut entries = Vec::new();
176    let mut diagnostics = parsed.diagnostics.clone();
177
178    for fact in &parsed.facts {
179        if let Some(unit_ref) = &fact.unit_ref {
180            match load_backing_unit(mana_dir, &index, unit_ref) {
181                Ok(Some(unit)) => {
182                    if fact.status == FactSheetStatus::Verified && unit.status != Status::Closed {
183                        entries.push(FactSheetCheckEntry {
184                            fact: fact.clone(),
185                            passed: false,
186                            message: Some(format!(
187                                "@verified fact references unit {unit_ref}, but that unit is {}",
188                                unit.status
189                            )),
190                        });
191                    } else {
192                        entries.push(FactSheetCheckEntry {
193                            fact: fact.clone(),
194                            passed: true,
195                            message: None,
196                        });
197                    }
198                }
199                Ok(None) => {
200                    entries.push(FactSheetCheckEntry {
201                        fact: fact.clone(),
202                        passed: false,
203                        message: Some(format!("referenced Mana unit {unit_ref} was not found")),
204                    });
205                }
206                Err(err) => diagnostics.push(error(
207                    Some(fact.line),
208                    format!("failed to load referenced Mana unit {unit_ref}: {err}"),
209                )),
210            }
211        } else {
212            entries.push(FactSheetCheckEntry {
213                fact: fact.clone(),
214                passed: true,
215                message: None,
216            });
217        }
218    }
219
220    Ok(FactSheetCheckResult {
221        path,
222        facts: parsed.facts,
223        diagnostics,
224        entries,
225    })
226}
227
228fn parse_fact_line(
229    content: &str,
230    line: usize,
231    section_stack: &[(usize, String)],
232    diagnostics: &mut Vec<FactSheetDiagnostic>,
233) -> Option<FactSheetFact> {
234    let mut words: Vec<&str> = content.split_whitespace().collect();
235    if words.is_empty() {
236        diagnostics.push(error(Some(line), "fact line is empty"));
237        return None;
238    }
239
240    let mut anchor = None;
241    if let Some(last) = words.last().copied() {
242        if last.starts_with('{') || last.ends_with('}') {
243            if is_valid_anchor_token(last) {
244                anchor = Some(last[1..last.len() - 1].to_string());
245                words.pop();
246            } else {
247                diagnostics.push(error(Some(line), format!("malformed fact anchor '{last}'")));
248                return None;
249            }
250        }
251    }
252
253    let mut unit_ref = None;
254    if let Some(last) = words.last().copied() {
255        if looks_like_unit_ref(last) {
256            unit_ref = Some(last.to_string());
257            words.pop();
258        }
259    }
260
261    let status_positions: Vec<usize> = words
262        .iter()
263        .enumerate()
264        .filter_map(|(idx, word)| FactSheetStatus::parse(word).map(|_| idx))
265        .collect();
266
267    if status_positions.is_empty() {
268        diagnostics.push(error(
269            Some(line),
270            "fact line must contain one status: @draft, @spec, @in_progress, @verified, @stale, or @rejected",
271        ));
272        return None;
273    }
274
275    if status_positions.len() > 1 {
276        diagnostics.push(error(
277            Some(line),
278            "fact line must contain exactly one status",
279        ));
280        return None;
281    }
282
283    let status_idx = status_positions[0];
284    let status = FactSheetStatus::parse(words[status_idx]).expect("status checked above");
285    words.remove(status_idx);
286
287    if words.iter().any(|word| word.starts_with('@')) {
288        diagnostics.push(error(
289            Some(line),
290            "unknown @status or extra @tag in fact line",
291        ));
292        return None;
293    }
294
295    let text = words.join(" ").trim().to_string();
296    if text.is_empty() {
297        diagnostics.push(error(Some(line), "fact text is empty"));
298        return None;
299    }
300
301    Some(FactSheetFact {
302        text,
303        status,
304        unit_ref,
305        anchor,
306        section: section_stack
307            .iter()
308            .map(|(_, title)| title.clone())
309            .collect(),
310        line,
311    })
312}
313
314fn parse_heading(line: &str) -> Option<(usize, String)> {
315    if !line.starts_with('#') {
316        return None;
317    }
318    let depth = line.chars().take_while(|c| *c == '#').count();
319    let title = line[depth..].trim();
320    if title.is_empty() {
321        return None;
322    }
323    Some((depth, title.to_string()))
324}
325
326fn is_valid_anchor_token(token: &str) -> bool {
327    let Some(inner) = token.strip_prefix('{').and_then(|s| s.strip_suffix('}')) else {
328        return false;
329    };
330    !inner.is_empty()
331        && inner
332            .chars()
333            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
334}
335
336fn looks_like_unit_ref(token: &str) -> bool {
337    let parts: Vec<&str> = token.split('.').collect();
338    !parts.is_empty()
339        && parts
340            .iter()
341            .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
342}
343
344fn load_backing_unit(mana_dir: &Path, index: &Index, unit_ref: &str) -> Result<Option<Unit>> {
345    let Some(entry) = index.units.iter().find(|entry| entry.id == unit_ref) else {
346        return Ok(None);
347    };
348
349    let path = if entry.status == Status::Closed {
350        find_archived_unit(mana_dir, unit_ref).ok()
351    } else {
352        find_unit_file(mana_dir, unit_ref).ok()
353    };
354
355    path.map(Unit::from_file).transpose()
356}
357
358fn error(line: Option<usize>, message: impl Into<String>) -> FactSheetDiagnostic {
359    FactSheetDiagnostic {
360        line,
361        severity: FactSheetDiagnosticSeverity::Error,
362        message: message.into(),
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369    use crate::config::Config;
370    use crate::ops::create::{create, CreateParams};
371    use std::fs;
372    use tempfile::TempDir;
373
374    fn setup_mana_dir() -> (TempDir, PathBuf) {
375        let dir = TempDir::new().unwrap();
376        let mana_dir = dir.path().join(".mana");
377        fs::create_dir(&mana_dir).unwrap();
378        Config {
379            project: "test".to_string(),
380            next_id: 1,
381            auto_close_parent: true,
382            run: None,
383            plan: None,
384            max_loops: 10,
385            max_concurrent: 4,
386            poll_interval: 30,
387            extends: vec![],
388            rules_file: None,
389            file_locking: false,
390            worktree: false,
391            on_close: None,
392            on_fail: None,
393            verify_timeout: None,
394            review: None,
395            user: None,
396            user_email: None,
397            auto_commit: false,
398            commit_template: None,
399            research: None,
400            run_model: None,
401            plan_model: None,
402            review_model: None,
403            research_model: None,
404            batch_verify: false,
405            memory_reserve_mb: 0,
406            notify: None,
407        }
408        .save(&mana_dir)
409        .unwrap();
410        (dir, mana_dir)
411    }
412
413    #[test]
414    fn parse_single_line_facts_with_sections_refs_and_anchors() {
415        let content = "# architecture\n\n- SQLite mirrors Mana files for fast agent reads @verified 247.1.2.7 {sqlite-mirror}\n## context\n- Imp reads relevant facts from Mana APIs @spec\n";
416        let parsed = parse_facts_sheet(content);
417        assert!(parsed.diagnostics.is_empty(), "{:?}", parsed.diagnostics);
418        assert_eq!(parsed.facts.len(), 2);
419        assert_eq!(
420            parsed.facts[0].text,
421            "SQLite mirrors Mana files for fast agent reads"
422        );
423        assert_eq!(parsed.facts[0].status, FactSheetStatus::Verified);
424        assert_eq!(parsed.facts[0].unit_ref.as_deref(), Some("247.1.2.7"));
425        assert_eq!(parsed.facts[0].anchor.as_deref(), Some("sqlite-mirror"));
426        assert_eq!(parsed.facts[1].section, vec!["architecture", "context"]);
427    }
428
429    #[test]
430    fn parse_rejects_unknown_status() {
431        let parsed = parse_facts_sheet("- Mana is great @done\n");
432        assert_eq!(parsed.facts.len(), 0);
433        assert!(parsed.diagnostics[0].message.contains("one status"));
434    }
435
436    #[test]
437    fn parse_rejects_duplicate_anchors() {
438        let parsed = parse_facts_sheet("- One fact @spec {same}\n- Another fact @draft {same}\n");
439        assert_eq!(parsed.facts.len(), 2);
440        assert!(parsed
441            .diagnostics
442            .iter()
443            .any(|diag| diag.message.contains("duplicate fact anchor")));
444    }
445
446    #[test]
447    fn missing_facts_file_checks_cleanly() {
448        let (_dir, mana_dir) = setup_mana_dir();
449        let checked = check_facts_sheet(&mana_dir).unwrap();
450        assert!(checked.facts.is_empty());
451        assert!(!checked.has_errors());
452    }
453
454    #[test]
455    fn check_verified_fact_fails_for_open_backing_unit() {
456        let (dir, mana_dir) = setup_mana_dir();
457        let created = create(
458            &mana_dir,
459            CreateParams {
460                title: "Open backing unit".to_string(),
461                handle: None,
462                description: None,
463                acceptance: None,
464                notes: None,
465                design: None,
466                verify: Some("test -f .mana/config.yaml".to_string()),
467                priority: Some(2),
468                labels: vec![],
469                assignee: None,
470                dependencies: vec![],
471                parent: None,
472                produces: vec![],
473                requires: vec![],
474                paths: vec![],
475                on_fail: None,
476                fail_first: false,
477                feature: false,
478                kind: None,
479                verify_timeout: None,
480                decisions: vec![],
481                force: false,
482            },
483        )
484        .unwrap();
485
486        fs::write(
487            dir.path().join(FACTS_FILE),
488            format!(
489                "- This fact is backed by open work @verified {}\n",
490                created.unit.id
491            ),
492        )
493        .unwrap();
494
495        let checked = check_facts_sheet(&mana_dir).unwrap();
496        assert!(checked.has_errors());
497        assert_eq!(checked.entries.len(), 1);
498        assert!(!checked.entries[0].passed);
499    }
500}