asdecided-core 0.26.2

Deterministic, offline engine for validating and enforcing engineering decisions
Documentation
//! Native deterministic renderer for Herald's governing-decisions PR comment.

use std::collections::{BTreeMap, BTreeSet};

use crate::retrieve::decisions_for_path;

pub const MARKER: &str = "<!-- lore-decisions-on-pr -->";
const PATHS_SHOWN: usize = 3;

#[derive(Debug)]
pub struct HeraldDecision {
    pub id: String,
    pub title: String,
    pub status: String,
    pub path: String,
    pub matching_entries: BTreeSet<String>,
    pub changed_paths: BTreeSet<String>,
}

#[derive(Debug)]
pub struct HeraldReport {
    pub decisions: Vec<HeraldDecision>,
}

impl HeraldReport {
    pub fn has_decisions(&self) -> bool {
        !self.decisions.is_empty()
    }
}

pub fn collect(corpus: &str, paths: &[String], recursive: bool) -> HeraldReport {
    let mut merged: BTreeMap<String, HeraldDecision> = BTreeMap::new();
    let changed: BTreeSet<&String> = paths
        .iter()
        .filter(|path| !path.trim().is_empty())
        .collect();
    for path in changed {
        for decision in decisions_for_path(corpus, path, recursive).decisions {
            let entry = merged
                .entry(decision.id.clone())
                .or_insert_with(|| HeraldDecision {
                    id: decision.id,
                    title: decision.title,
                    status: decision.status,
                    path: decision.path,
                    matching_entries: BTreeSet::new(),
                    changed_paths: BTreeSet::new(),
                });
            entry.matching_entries.insert(decision.matching_entry);
            entry.changed_paths.insert(path.clone());
        }
    }
    HeraldReport {
        decisions: merged.into_values().collect(),
    }
}

fn bullet(decision: &HeraldDecision, link_base: &str) -> String {
    let scopes = decision
        .matching_entries
        .iter()
        .map(|scope| format!("`{scope}`"))
        .collect::<Vec<_>>()
        .join(", ");
    let paths: Vec<&String> = decision.changed_paths.iter().collect();
    let mut changed = paths
        .iter()
        .take(PATHS_SHOWN)
        .map(|path| format!("`{path}`"))
        .collect::<Vec<_>>()
        .join(", ");
    let more = paths.len().saturating_sub(PATHS_SHOWN);
    if more > 0 {
        changed.push_str(&format!(" +{more} more"));
    }
    let link = if link_base.is_empty() {
        decision.path.clone()
    } else {
        format!("{}/{}", link_base.trim_end_matches('/'), decision.path)
    };
    format!(
        "- **[{} — {}]({})** ({}) — applies to {} — changed: {}",
        decision.id, decision.title, link, decision.status, scopes, changed
    )
}

pub fn render(report: &HeraldReport, link_base: &str, max_inline: i64) -> String {
    if report.decisions.is_empty() {
        return format!(
            "{MARKER}\n### Decisions governing this change\n\n\
             No recorded decisions govern the paths changed by this pull request.\n"
        );
    }
    let count = report.decisions.len();
    let plural = if count == 1 { "" } else { "s" };
    let mut lines = vec![
        MARKER.to_string(),
        "### Decisions governing this change".to_string(),
        String::new(),
        format!(
            "This pull request touches paths governed by {count} recorded decision{plural} — review recommended."
        ),
        String::new(),
    ];
    let inline_count = if max_inline > 0 {
        (max_inline as usize).min(count)
    } else {
        count
    };
    lines.extend(
        report.decisions[..inline_count]
            .iter()
            .map(|decision| bullet(decision, link_base)),
    );
    let rest = &report.decisions[inline_count..];
    if !rest.is_empty() {
        let rest_plural = if rest.len() == 1 { "" } else { "s" };
        lines.push(String::new());
        lines.push(format!(
            "<details><summary>{} more governing decision{rest_plural}</summary>",
            rest.len()
        ));
        lines.push(String::new());
        lines.extend(rest.iter().map(|decision| bullet(decision, link_base)));
        lines.push(String::new());
        lines.push("</details>".to_string());
    }
    lines.push(String::new());
    lines.join("\n")
}

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

    fn decision(id: &str) -> HeraldDecision {
        HeraldDecision {
            id: id.to_string(),
            title: format!("{id} title"),
            status: "Accepted".to_string(),
            path: format!("decisions/{id}.md"),
            matching_entries: BTreeSet::from(["src/**".to_string()]),
            changed_paths: BTreeSet::from([
                "src/a.rs".to_string(),
                "src/b.rs".to_string(),
                "src/c.rs".to_string(),
                "src/d.rs".to_string(),
            ]),
        }
    }

    #[test]
    fn empty_state_is_stable() {
        let body = render(&HeraldReport { decisions: vec![] }, "", 5);
        assert!(body.starts_with(MARKER));
        assert!(body.contains("No recorded decisions govern"));
        assert!(body.ends_with('\n'));
    }

    #[test]
    fn overflow_and_path_collapse_match_contract() {
        let body = render(
            &HeraldReport {
                decisions: vec![decision("ADR-001"), decision("ADR-002")],
            },
            "https://example.com/blob/HEAD/",
            1,
        );
        assert!(body.contains("<details><summary>1 more governing decision</summary>"));
        assert!(body.contains("`src/a.rs`, `src/b.rs`, `src/c.rs` +1 more"));
        assert!(body.contains("https://example.com/blob/HEAD/decisions/ADR-001.md"));
    }

    #[test]
    fn collection_is_sorted_and_deduplicated_across_paths() {
        let root = std::env::temp_dir().join(format!("decided-herald-{}", std::process::id()));
        let corpus = root.join("decisions");
        let _ = fs::remove_dir_all(&root);
        fs::create_dir_all(root.join(".decided")).unwrap();
        fs::create_dir_all(&corpus).unwrap();
        fs::write(root.join(".decided/config.yaml"), "repository_key: TEST\n").unwrap();
        fs::write(
            corpus.join("adr-001.md"),
            "# Alpha rule\n\n## Status\n\nAccepted\n\n## Context\n\nContext.\n\n## Decision\n\nRule.\n\n## Consequences\n\nConsequence.\n\n## Applies To\n\n- src/**/*.rs\n",
        )
        .unwrap();
        let report = collect(
            corpus.to_str().unwrap(),
            &[
                "src/a.rs".to_string(),
                "src/b.rs".to_string(),
                "src/a.rs".to_string(),
            ],
            true,
        );
        assert_eq!(report.decisions.len(), 1);
        assert_eq!(report.decisions[0].changed_paths.len(), 2);
        assert_eq!(
            report.decisions[0].matching_entries,
            BTreeSet::from(["src/**/*.rs".to_string()])
        );
        fs::remove_dir_all(root).unwrap();
    }
}