cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Filesystem adapter for [`EntryDefectScanner`] over an issue corpus.
//!
//! Walks the writable home and any union sources, translates every
//! load failure (frontmatter unparseable, missing id, …) into a
//! [`LoadDefect`] variant, and additionally classifies a parsed entry
//! whose `id:` does not match the corpus's `id_prefix` as
//! [`LoadDefect::IdPrefixMismatch`]. Well-formed entries are not in
//! the output — they reach the domain through `IssueRepository`.

use std::path::{Path, PathBuf};

use crate::domain::model::entry_origin::EntryOrigin;
use crate::domain::model::issue::Issue;
use crate::domain::model::load_defect::LoadDefect;
use crate::domain::model::malformed_entry::MalformedEntry;
use crate::domain::model::status::StatusesConfig;
use crate::domain::usecases::entry_defect_scanner::EntryDefectScanner;
use crate::infra::driven::fs::repository_pipeline;

pub struct FsIssueDefectScanner {
    pub dir: PathBuf,
    pub union: Vec<PathBuf>,
    pub id_prefix: Option<String>,
    pub statuses: StatusesConfig,
    pub schema_version: u32,
}

impl FsIssueDefectScanner {
    fn collect(&self, root: &Path, origin: EntryOrigin) -> anyhow::Result<Vec<MalformedEntry>> {
        let entries =
            repository_pipeline::scan_all::<Issue>(root, self.schema_version, &self.statuses, &())?;
        let mut out = Vec::new();
        for entry in entries {
            match entry.result {
                Err(reason) => out.push(MalformedEntry {
                    location: crate::domain::model::entry_locator::EntryLocator::new(
                        entry.path.display().to_string(),
                    ),
                    origin: origin.clone(),
                    defect: LoadDefect::InvalidFrontmatter { reason },
                }),
                Ok(issue) => {
                    if let Some(prefix) = self.id_prefix.as_deref() {
                        let id = issue.id.to_string();
                        if !id.starts_with(prefix) {
                            out.push(MalformedEntry {
                                location: crate::domain::model::entry_locator::EntryLocator::new(
                                    entry.path.display().to_string(),
                                ),
                                origin: origin.clone(),
                                defect: LoadDefect::IdPrefixMismatch {
                                    expected: prefix.to_string(),
                                    found: id,
                                },
                            });
                        }
                    }
                    let sibling = entry
                        .path
                        .parent()
                        .expect("index.md has a parent dir")
                        .join("events.jsonl");
                    if !sibling.exists() {
                        out.push(MalformedEntry {
                            location: crate::domain::model::entry_locator::EntryLocator::new(
                                entry.path.display().to_string(),
                            ),
                            origin: origin.clone(),
                            defect: LoadDefect::MissingEventsLog,
                        });
                    }
                    for warning in entry.warnings {
                        match warning {
                            super::enrich::EnrichWarning::StatusJournalMismatch {
                                frontmatter,
                                terminal,
                            } => out.push(MalformedEntry {
                                location: crate::domain::model::entry_locator::EntryLocator::new(
                                    entry.path.display().to_string(),
                                ),
                                origin: origin.clone(),
                                defect: LoadDefect::StatusJournalMismatch {
                                    frontmatter,
                                    terminal,
                                },
                            }),
                        }
                    }
                }
            }
        }
        Ok(out)
    }
}

impl EntryDefectScanner for FsIssueDefectScanner {
    fn scan(&self) -> anyhow::Result<Vec<MalformedEntry>> {
        let mut out = self.collect(&self.dir, EntryOrigin::Local)?;
        for source in &self.union {
            let label = source.display().to_string();
            out.extend(self.collect(source, EntryOrigin::Union { name: label })?);
        }
        Ok(out)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;
    use tempfile::TempDir;

    fn write_issue(dir: &std::path::Path, slug: &str, id: &str, title: &str, status: &str) {
        let issue_dir = dir.join(slug);
        fs::create_dir_all(&issue_dir).unwrap();
        let content = format!(
            "---\nid: {id}\ntitle: {title}\nstatus: {status}\ndate: 2026-01-01\ntags:\n  - flow:feature\n---\n\nBody.\n"
        );
        fs::write(issue_dir.join("index.md"), content).unwrap();
        fs::write(issue_dir.join("events.jsonl"), "").unwrap();
    }

    fn scanner(dir: PathBuf, union: Vec<PathBuf>, id_prefix: Option<&str>) -> FsIssueDefectScanner {
        FsIssueDefectScanner {
            dir,
            union,
            id_prefix: id_prefix.map(str::to_string),
            statuses: StatusesConfig::default_issue(),
            schema_version: 3,
        }
    }

    #[test]
    fn empty_corpus_yields_no_defects() {
        let tmp = TempDir::new().unwrap();
        let s = scanner(tmp.path().to_path_buf(), vec![], None);
        assert!(s.scan().unwrap().is_empty());
    }

    #[test]
    fn well_formed_entries_are_not_reported() {
        let tmp = TempDir::new().unwrap();
        write_issue(tmp.path(), "0001-ok", "ISSUE-0001", "Ok", "open");
        let s = scanner(tmp.path().to_path_buf(), vec![], Some("ISSUE-"));
        assert!(s.scan().unwrap().is_empty());
    }

    #[test]
    fn missing_events_jsonl_sibling_surfaces_a_defect() {
        let tmp = TempDir::new().unwrap();
        // Hand-written entry without the events.jsonl companion.
        let entry = tmp.path().join("0001-no-jsonl");
        fs::create_dir_all(&entry).unwrap();
        fs::write(
            entry.join("index.md"),
            "---\nid: ISSUE-0001\ntitle: T\nstatus: open\ndate: 2026-01-01\n---\n\nBody.\n",
        )
        .unwrap();
        let s = scanner(tmp.path().to_path_buf(), vec![], None);
        let defects = s.scan().unwrap();
        assert!(
            defects
                .iter()
                .any(|d| matches!(d.defect, LoadDefect::MissingEventsLog)),
            "expected MissingEventsLog defect, got: {defects:?}"
        );
    }

    #[test]
    fn status_journal_mismatch_surfaces_a_defect() {
        let tmp = TempDir::new().unwrap();
        let entry = tmp.path().join("0001-stale");
        fs::create_dir_all(&entry).unwrap();
        // Frontmatter says open; journal's terminal says closed.
        fs::write(
            entry.join("index.md"),
            "---\nid: ISSUE-0001\ntitle: T\nstatus: open\ndate: 2026-01-01\n---\n\nBody.\n",
        )
        .unwrap();
        fs::write(
            entry.join("events.jsonl"),
            "{\"timestamp\":\"2026-01-01T00:00:00Z\",\"action\":{\"name\":\"created\",\"status\":\"open\"}}\n\
             {\"timestamp\":\"2026-01-02T00:00:00Z\",\"action\":{\"name\":\"status_changed\",\"from\":\"open\",\"to\":\"closed\"}}\n",
        )
        .unwrap();
        let s = scanner(tmp.path().to_path_buf(), vec![], None);
        let defects = s.scan().unwrap();
        assert!(
            defects.iter().any(|d| matches!(
                &d.defect,
                LoadDefect::StatusJournalMismatch { frontmatter, terminal }
                    if frontmatter == "open" && terminal == "closed"
            )),
            "expected StatusJournalMismatch open→closed, got: {defects:?}"
        );
    }

    #[test]
    fn broken_frontmatter_surfaces_as_invalid_frontmatter() {
        let tmp = TempDir::new().unwrap();
        let broken = tmp.path().join("0001-broken");
        fs::create_dir_all(&broken).unwrap();
        fs::write(broken.join("index.md"), "no frontmatter at all\n").unwrap();
        let s = scanner(tmp.path().to_path_buf(), vec![], None);
        let defects = s.scan().unwrap();
        assert_eq!(defects.len(), 1);
        assert!(matches!(
            defects[0].defect,
            LoadDefect::InvalidFrontmatter { .. }
        ));
        assert_eq!(defects[0].origin, EntryOrigin::Local);
    }

    #[test]
    fn id_prefix_mismatch_on_local_is_reported() {
        let tmp = TempDir::new().unwrap();
        write_issue(tmp.path(), "0001-foreign", "TASK-0001", "Foreign", "open");
        let s = scanner(tmp.path().to_path_buf(), vec![], Some("ISSUE-"));
        let defects = s.scan().unwrap();
        assert_eq!(defects.len(), 1);
        match &defects[0].defect {
            LoadDefect::IdPrefixMismatch { expected, found } => {
                assert_eq!(expected, "ISSUE-");
                assert_eq!(found, "TASK-0001");
            }
            other => panic!("expected IdPrefixMismatch, got {other:?}"),
        }
        assert_eq!(defects[0].origin, EntryOrigin::Local);
    }

    #[test]
    fn id_prefix_mismatch_on_union_carries_union_origin() {
        let tmp = TempDir::new().unwrap();
        let local = tmp.path().join("docs/issues");
        let shared = tmp.path().join("shared/issues");
        write_issue(&local, "0001-ok", "ISSUE-0001", "Ok", "open");
        write_issue(&shared, "0002-foreign", "TASK-0002", "Foreign", "open");
        let s = scanner(local, vec![shared.clone()], Some("ISSUE-"));
        let defects = s.scan().unwrap();
        assert_eq!(defects.len(), 1);
        match &defects[0].origin {
            EntryOrigin::Union { name } => assert_eq!(name, &shared.display().to_string()),
            other => panic!("expected Union origin, got {other:?}"),
        }
    }

    #[test]
    fn defects_from_both_sides_are_collected() {
        let tmp = TempDir::new().unwrap();
        let local = tmp.path().join("docs/issues");
        let shared = tmp.path().join("shared/issues");
        let local_broken = local.join("0001-broken");
        fs::create_dir_all(&local_broken).unwrap();
        fs::write(local_broken.join("index.md"), "garbage\n").unwrap();
        write_issue(&shared, "0002-foreign", "TASK-0002", "Foreign", "open");
        let s = scanner(local, vec![shared], Some("ISSUE-"));
        let defects = s.scan().unwrap();
        assert_eq!(defects.len(), 2);
    }
}