cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Drive the fixable-rule pass.
//!
//! [`run_fix`] walks every rule that emits a `fix` edit, collects the
//! edits, and (in [`FixMode::Apply`]) commits them through the per-scope
//! edit engines. [`FixMode::DryRun`] returns the same report without
//! committing — callers see what *would* be applied.

use std::path::PathBuf;

use crate::domain::model::entity_ref::KnownRefs;
use crate::domain::model::entry_locator::EntryLocator;
use crate::domain::model::status::StatusesConfig;
use crate::domain::model::tag_descriptor::TagDescriptors;
use crate::domain::usecases::check::{dr_rules, issue_rules, DrCheckCtx, IssueCheckCtx};
use crate::domain::usecases::decision_record::DecisionRecordRepository;
use crate::domain::usecases::edit::dr::commit_dr_edits;
use crate::domain::usecases::edit::issue::commit_issue_edits;
use crate::domain::usecases::issue::IssueRepository;

fn locator_to_path(loc: &EntryLocator) -> PathBuf {
    let s = loc.as_str();
    let bare = s.strip_prefix("file://").unwrap_or(s);
    PathBuf::from(bare)
}

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum FixMode {
    Apply,
    DryRun,
}

#[derive(Debug)]
pub struct FixItem {
    pub rule_id: &'static str,
    pub description: String,
    pub applied: bool,
}

#[derive(Debug, Default)]
pub struct FixReport {
    pub items: Vec<FixItem>,
}

pub struct FixDrSource<'a> {
    pub kind: &'a str,
    pub repo: &'a dyn DecisionRecordRepository,
    pub tag_descriptors: &'a TagDescriptors,
}

pub struct FixIssueSource<'a> {
    pub repo: &'a dyn IssueRepository,
    pub statuses: &'a StatusesConfig,
    pub tag_descriptors: &'a TagDescriptors,
}

pub fn run_fix(
    issue: FixIssueSource<'_>,
    dr_sources: &[FixDrSource<'_>],
    known_refs: &KnownRefs,
    mode: FixMode,
) -> anyhow::Result<FixReport> {
    let apply = matches!(mode, FixMode::Apply);
    let mut report = FixReport::default();

    for src in dr_sources {
        let records: Vec<(PathBuf, _)> = src
            .repo
            .list()?
            .into_iter()
            .map(|r| (locator_to_path(&r.location), r))
            .collect();
        let ctx = DrCheckCtx {
            repo: src.repo,
            records: &records,
            known_refs,
            tag_descriptors: src.tag_descriptors,
        };
        let mut edits = Vec::new();
        for rule in dr_rules() {
            for finding in rule.find(&ctx)? {
                if let Some(edit) = finding.fix {
                    report.items.push(FixItem {
                        rule_id: rule.id(),
                        description: edit.describe(),
                        applied: apply,
                    });
                    edits.push(edit);
                }
            }
        }
        if apply && !edits.is_empty() {
            commit_dr_edits(src.repo, edits)?;
        }
    }

    let issues: Vec<(PathBuf, _)> = issue
        .repo
        .list()?
        .into_iter()
        .map(|i| (locator_to_path(&i.location), i))
        .collect();
    let ctx = IssueCheckCtx {
        repo: issue.repo,
        issues: &issues,
        known_refs,
        statuses: issue.statuses,
        tag_descriptors: issue.tag_descriptors,
    };
    let mut edits = Vec::new();
    for rule in issue_rules() {
        for finding in rule.find(&ctx)? {
            if let Some(edit) = finding.fix {
                report.items.push(FixItem {
                    rule_id: rule.id(),
                    description: edit.describe(),
                    applied: apply,
                });
                edits.push(edit);
            }
        }
    }
    if apply && !edits.is_empty() {
        commit_issue_edits(issue.repo, edits)?;
    }

    Ok(report)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::domain::model::entity_ref::KnownRefs;
    use crate::domain::model::status::StatusesConfig;
    use crate::domain::model::tag_descriptor::TagDescriptors;
    use crate::domain::usecases::issue::test_support::FakeIssueRepository;

    #[test]
    fn clean_workspace_yields_an_empty_report() {
        let repo = FakeIssueRepository::new();
        let statuses = StatusesConfig::default_issue();
        let td = TagDescriptors::default();
        let report = run_fix(
            FixIssueSource {
                repo: &repo,
                statuses: &statuses,
                tag_descriptors: &td,
            },
            &[],
            &KnownRefs::new(),
            FixMode::Apply,
        )
        .unwrap();
        assert!(report.items.is_empty());
    }

    #[test]
    fn dry_run_does_not_call_commit_when_no_fixable_findings() {
        // We do not stage a fixable finding here — the issue rule set
        // is data-driven; this test just exercises the path with a
        // clean stub to confirm DryRun does not panic and yields no
        // items.
        let repo = FakeIssueRepository::new();
        let statuses = StatusesConfig::default_issue();
        let td = TagDescriptors::default();
        let report = run_fix(
            FixIssueSource {
                repo: &repo,
                statuses: &statuses,
                tag_descriptors: &td,
            },
            &[],
            &KnownRefs::new(),
            FixMode::DryRun,
        )
        .unwrap();
        assert!(report.items.is_empty());
        assert_eq!(repo.save_count(), 0);
    }
}