use crate::domain::model::decision_record::DecisionRecord;
use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::model::issue::Issue;
use crate::domain::model::load_defect::LoadDefect;
use crate::domain::usecases::decision_record::DecisionRecordRepository;
use crate::domain::usecases::entry_defect_scanner::EntryDefectScanner;
use crate::domain::usecases::fmt::diff::{fmt as compute_diff, FmtReport, FormatEntry};
use crate::domain::usecases::fmt::ports::{RawTextReader, RawTextWriter};
use crate::domain::usecases::issue::IssueRepository;
fn locator_to_path(loc: &EntryLocator) -> std::path::PathBuf {
let s = loc.as_str();
let bare = s.strip_prefix("file://").unwrap_or(s);
std::path::PathBuf::from(bare)
}
fn render_defect(defect: &LoadDefect) -> String {
match defect {
LoadDefect::SourceUnreadable { reason } => reason.clone(),
LoadDefect::InvalidFrontmatter { reason } => reason.clone(),
LoadDefect::MissingId => "missing 'id'".to_string(),
LoadDefect::IdPrefixMismatch { expected, found } => {
format!("id '{found}' does not start with prefix '{expected}'")
}
LoadDefect::InvalidStatus { value } => format!("invalid status '{value}'"),
LoadDefect::MissingEventsLog => "missing 'events.jsonl' sibling".to_string(),
LoadDefect::StatusJournalMismatch {
frontmatter,
terminal,
} => format!(
"frontmatter status '{frontmatter}' diverges from event-log terminal '{terminal}'"
),
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum FmtMode {
Apply,
Check,
DryRun,
}
pub struct DrSource<'a> {
pub repo: &'a dyn DecisionRecordRepository,
pub defect_scanner: &'a dyn EntryDefectScanner,
pub canonical: &'a dyn Fn(&DecisionRecord) -> anyhow::Result<String>,
}
pub struct FmtOutcome {
pub report: FmtReport,
pub errors: Vec<String>,
}
impl FmtOutcome {
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
}
pub fn run_fmt(
issue_repo: &dyn IssueRepository,
issue_defects: &dyn EntryDefectScanner,
issue_canonical: &dyn Fn(&Issue) -> anyhow::Result<String>,
dr_sources: &[DrSource<'_>],
reader: &dyn RawTextReader,
writer: &dyn RawTextWriter,
mode: FmtMode,
) -> FmtOutcome {
let mut entries: Vec<FormatEntry> = Vec::new();
let mut errors: Vec<String> = Vec::new();
for malformed in issue_defects.scan().unwrap_or_default() {
if matches!(malformed.defect, LoadDefect::MissingEventsLog) {
continue;
}
errors.push(format!(
"{}: {}",
malformed.location,
render_defect(&malformed.defect)
));
}
for issue in issue_repo.list().unwrap_or_default() {
let path = locator_to_path(&issue.location);
match resolve_entry(&path, &issue, issue_canonical, reader) {
Ok(fe) => entries.push(fe),
Err(e) => errors.push(format!("{}: {e}", issue.location)),
}
}
for src in dr_sources {
for malformed in src.defect_scanner.scan().unwrap_or_default() {
if matches!(malformed.defect, LoadDefect::MissingEventsLog) {
continue;
}
errors.push(format!(
"{}: {}",
malformed.location,
render_defect(&malformed.defect)
));
}
for record in src.repo.list().unwrap_or_default() {
let path = locator_to_path(&record.location);
match resolve_entry(&path, &record, src.canonical, reader) {
Ok(fe) => entries.push(fe),
Err(e) => errors.push(format!("{}: {e}", record.location)),
}
}
}
let report = compute_diff(entries);
if matches!(mode, FmtMode::Apply) {
for change in &report.changes {
if let Err(e) = writer.write(&change.path, &change.canonical) {
errors.push(format!("error writing {}: {e}", change.path.display()));
}
}
}
FmtOutcome { report, errors }
}
fn resolve_entry<T>(
path: &std::path::Path,
model: &T,
canonical: &dyn Fn(&T) -> anyhow::Result<String>,
reader: &dyn RawTextReader,
) -> anyhow::Result<FormatEntry> {
let current = reader.read(path)?;
let canonical = canonical(model)?;
Ok(FormatEntry {
path: path.to_path_buf(),
current,
canonical,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::domain::model::decision_record::DecisionRecord;
use crate::domain::model::entry_origin::EntryOrigin;
use crate::domain::model::issue::test_fixtures::{feature, ir};
use crate::domain::model::issue::Issue;
use crate::domain::model::malformed_entry::MalformedEntry;
use crate::domain::model::record_ref::DecisionRecordRef;
use crate::domain::usecases::decision_record::test_support::{
adr, dr as dr_ref, FakeDecisionRecordRepository,
};
use crate::domain::usecases::issue::test_support::FakeIssueRepository;
use std::cell::RefCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Default)]
struct StubDefects {
defects: Vec<MalformedEntry>,
}
impl StubDefects {
fn with_invalid(mut self, path: &str, reason: &str) -> Self {
self.defects.push(MalformedEntry {
location: EntryLocator::new(path.to_string()),
origin: EntryOrigin::Local,
defect: LoadDefect::InvalidFrontmatter {
reason: reason.to_string(),
},
});
self
}
}
impl EntryDefectScanner for StubDefects {
fn scan(&self) -> anyhow::Result<Vec<MalformedEntry>> {
Ok(self.defects.clone())
}
}
#[derive(Default)]
struct StubReader {
bytes: HashMap<PathBuf, Result<String, String>>,
}
impl StubReader {
fn with(mut self, p: &str, content: &str) -> Self {
self.bytes.insert(PathBuf::from(p), Ok(content.to_string()));
self
}
fn with_error(mut self, p: &str, err: &str) -> Self {
self.bytes.insert(PathBuf::from(p), Err(err.to_string()));
self
}
}
impl RawTextReader for StubReader {
fn read(&self, path: &Path) -> anyhow::Result<String> {
match self.bytes.get(path) {
Some(Ok(s)) => Ok(s.clone()),
Some(Err(e)) => Err(anyhow::anyhow!("{e}")),
None => Err(anyhow::anyhow!("not found")),
}
}
}
#[derive(Default)]
struct StubWriter {
writes: RefCell<HashMap<PathBuf, String>>,
fail_paths: Vec<PathBuf>,
}
impl StubWriter {
fn fail_on(mut self, p: &str) -> Self {
self.fail_paths.push(PathBuf::from(p));
self
}
fn captured(&self, p: &str) -> Option<String> {
self.writes.borrow().get(&PathBuf::from(p)).cloned()
}
fn write_count(&self) -> usize {
self.writes.borrow().len()
}
}
impl RawTextWriter for StubWriter {
fn write(&self, path: &Path, content: &str) -> anyhow::Result<()> {
if self.fail_paths.iter().any(|p| p == path) {
return Err(anyhow::anyhow!("write rejected"));
}
self.writes
.borrow_mut()
.insert(path.to_path_buf(), content.to_string());
Ok(())
}
}
fn an_issue(n: u64) -> Issue {
feature("any title")
.with_id(&ir(n).to_string())
.build(ir(n))
}
fn a_dr(n: u32) -> DecisionRecord {
adr("any title")
.with_id(&dr_ref(n).to_string())
.build(dr_ref(n))
}
fn issue_canon_const(s: &'static str) -> impl Fn(&Issue) -> anyhow::Result<String> {
move |_i: &Issue| Ok(s.to_string())
}
fn issue_canon_err() -> impl Fn(&Issue) -> anyhow::Result<String> {
|_i: &Issue| Err(anyhow::anyhow!("canonical failed"))
}
fn dr_canon_const(s: &'static str) -> impl Fn(&DecisionRecord) -> anyhow::Result<String> {
move |_r: &DecisionRecord| Ok(s.to_string())
}
fn issue_repo_at(path: &str, issue: Issue) -> FakeIssueRepository {
FakeIssueRepository::with_pairs(vec![(PathBuf::from(path), issue)])
}
fn dr_repo_at(path: &str, record: DecisionRecord) -> FakeDecisionRecordRepository {
FakeDecisionRecordRepository::with_pairs(vec![(PathBuf::from(path), record)])
}
#[test]
fn empty_workspace_yields_no_changes_no_errors() {
let issue_repo = FakeIssueRepository::new();
let issue_defects = StubDefects::default();
let canon = issue_canon_const("");
let reader = StubReader::default();
let writer = StubWriter::default();
let out = run_fmt(
&issue_repo,
&issue_defects,
&canon,
&[],
&reader,
&writer,
FmtMode::Apply,
);
assert!(out.report.changes.is_empty());
assert_eq!(out.report.unchanged, 0);
assert!(out.errors.is_empty());
assert_eq!(writer.write_count(), 0);
}
#[test]
fn issue_already_canonical_is_unchanged() {
let issue_repo = issue_repo_at("docs/issues/a/index.md", an_issue(1));
let issue_defects = StubDefects::default();
let canon = issue_canon_const("CANON\n");
let reader = StubReader::default().with("docs/issues/a/index.md", "CANON\n");
let writer = StubWriter::default();
let out = run_fmt(
&issue_repo,
&issue_defects,
&canon,
&[],
&reader,
&writer,
FmtMode::Apply,
);
assert_eq!(out.report.unchanged, 1);
assert!(out.report.changes.is_empty());
assert!(out.errors.is_empty());
assert_eq!(writer.write_count(), 0);
}
#[test]
fn apply_writes_canonical_back_for_changed_entries() {
let issue_repo = issue_repo_at("docs/issues/a/index.md", an_issue(1));
let issue_defects = StubDefects::default();
let canon = issue_canon_const("---\nid: A\n---\n\nbody\n");
let reader = StubReader::default().with(
"docs/issues/a/index.md",
"---\nid: A\nstale: yes\n---\n\nbody\n",
);
let writer = StubWriter::default();
let out = run_fmt(
&issue_repo,
&issue_defects,
&canon,
&[],
&reader,
&writer,
FmtMode::Apply,
);
assert_eq!(out.report.changes.len(), 1);
assert!(out.errors.is_empty());
assert_eq!(
writer.captured("docs/issues/a/index.md").as_deref(),
Some("---\nid: A\n---\n\nbody\n")
);
}
#[test]
fn check_mode_reports_changes_without_writing() {
let issue_repo = issue_repo_at("p", an_issue(1));
let issue_defects = StubDefects::default();
let canon = issue_canon_const("CANON");
let reader = StubReader::default().with("p", "STALE");
let writer = StubWriter::default();
let out = run_fmt(
&issue_repo,
&issue_defects,
&canon,
&[],
&reader,
&writer,
FmtMode::Check,
);
assert_eq!(out.report.changes.len(), 1);
assert_eq!(writer.write_count(), 0);
}
#[test]
fn dry_run_mode_does_not_write() {
let issue_repo = issue_repo_at("p", an_issue(1));
let issue_defects = StubDefects::default();
let canon = issue_canon_const("CANON");
let reader = StubReader::default().with("p", "STALE");
let writer = StubWriter::default();
let out = run_fmt(
&issue_repo,
&issue_defects,
&canon,
&[],
&reader,
&writer,
FmtMode::DryRun,
);
assert_eq!(out.report.changes.len(), 1);
assert_eq!(writer.write_count(), 0);
}
#[test]
fn scan_parse_error_is_collected_and_does_not_abort() {
let issue_repo = issue_repo_at("good", an_issue(1));
let issue_defects = StubDefects::default().with_invalid("bad", "yaml broken");
let canon = issue_canon_const("CANON");
let reader = StubReader::default().with("good", "CANON");
let writer = StubWriter::default();
let out = run_fmt(
&issue_repo,
&issue_defects,
&canon,
&[],
&reader,
&writer,
FmtMode::Apply,
);
assert_eq!(out.errors.len(), 1);
assert!(out.errors[0].contains("bad"));
assert!(out.errors[0].contains("yaml broken"));
assert_eq!(out.report.unchanged, 1);
}
#[test]
fn read_error_is_collected_and_skips_entry() {
let issue_repo = issue_repo_at("p", an_issue(1));
let issue_defects = StubDefects::default();
let canon = issue_canon_const("CANON");
let reader = StubReader::default().with_error("p", "io kaput");
let writer = StubWriter::default();
let out = run_fmt(
&issue_repo,
&issue_defects,
&canon,
&[],
&reader,
&writer,
FmtMode::Apply,
);
assert_eq!(out.errors.len(), 1);
assert!(out.errors[0].contains("p"));
assert!(out.errors[0].contains("io kaput"));
assert!(out.report.changes.is_empty());
}
#[test]
fn canonical_error_is_collected_and_skips_entry() {
let issue_repo = issue_repo_at("p", an_issue(1));
let issue_defects = StubDefects::default();
let canon = issue_canon_err();
let reader = StubReader::default().with("p", "anything");
let writer = StubWriter::default();
let out = run_fmt(
&issue_repo,
&issue_defects,
&canon,
&[],
&reader,
&writer,
FmtMode::Apply,
);
assert_eq!(out.errors.len(), 1);
assert!(out.errors[0].contains("canonical failed"));
assert!(out.report.changes.is_empty());
}
#[test]
fn write_error_in_apply_is_collected() {
let issue_repo = issue_repo_at("p", an_issue(1));
let issue_defects = StubDefects::default();
let canon = issue_canon_const("CANON");
let reader = StubReader::default().with("p", "STALE");
let writer = StubWriter::default().fail_on("p");
let out = run_fmt(
&issue_repo,
&issue_defects,
&canon,
&[],
&reader,
&writer,
FmtMode::Apply,
);
assert_eq!(out.report.changes.len(), 1);
assert_eq!(out.errors.len(), 1);
assert!(out.errors[0].contains("write rejected"));
}
#[test]
fn decision_record_sources_are_walked_too() {
let issue_repo = FakeIssueRepository::new();
let issue_defects = StubDefects::default();
let i_canon = issue_canon_const("");
let dr_repo = dr_repo_at("docs/adr/x/index.md", a_dr(7));
let dr_defects = StubDefects::default();
let dr_canon = dr_canon_const("---\nid: ADR-7\n---\n\nbody\n");
let dr_sources = [DrSource {
repo: &dr_repo,
defect_scanner: &dr_defects,
canonical: &dr_canon,
}];
let reader = StubReader::default().with(
"docs/adr/x/index.md",
"---\nid: ADR-7\nstale: 1\n---\n\nbody\n",
);
let writer = StubWriter::default();
let out = run_fmt(
&issue_repo,
&issue_defects,
&i_canon,
&dr_sources,
&reader,
&writer,
FmtMode::Apply,
);
assert_eq!(out.report.changes.len(), 1);
assert_eq!(
writer.captured("docs/adr/x/index.md").as_deref(),
Some("---\nid: ADR-7\n---\n\nbody\n")
);
let _ = DecisionRecordRef::new("ADR-0001").ok(); }
}