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, §ion_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}