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();
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();
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);
}
}