Skip to main content

rac_engine/
herald.rs

1//! Native deterministic renderer for Herald's governing-decisions PR comment.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use crate::retrieve::decisions_for_path;
6
7pub const MARKER: &str = "<!-- lore-decisions-on-pr -->";
8const PATHS_SHOWN: usize = 3;
9
10#[derive(Debug)]
11pub struct HeraldDecision {
12    pub id: String,
13    pub title: String,
14    pub status: String,
15    pub path: String,
16    pub matching_entries: BTreeSet<String>,
17    pub changed_paths: BTreeSet<String>,
18}
19
20#[derive(Debug)]
21pub struct HeraldReport {
22    pub decisions: Vec<HeraldDecision>,
23}
24
25impl HeraldReport {
26    pub fn has_decisions(&self) -> bool {
27        !self.decisions.is_empty()
28    }
29}
30
31pub fn collect(corpus: &str, paths: &[String], recursive: bool) -> HeraldReport {
32    let mut merged: BTreeMap<String, HeraldDecision> = BTreeMap::new();
33    let changed: BTreeSet<&String> = paths
34        .iter()
35        .filter(|path| !path.trim().is_empty())
36        .collect();
37    for path in changed {
38        for decision in decisions_for_path(corpus, path, recursive).decisions {
39            let entry = merged
40                .entry(decision.id.clone())
41                .or_insert_with(|| HeraldDecision {
42                    id: decision.id,
43                    title: decision.title,
44                    status: decision.status,
45                    path: decision.path,
46                    matching_entries: BTreeSet::new(),
47                    changed_paths: BTreeSet::new(),
48                });
49            entry.matching_entries.insert(decision.matching_entry);
50            entry.changed_paths.insert(path.clone());
51        }
52    }
53    HeraldReport {
54        decisions: merged.into_values().collect(),
55    }
56}
57
58fn bullet(decision: &HeraldDecision, link_base: &str) -> String {
59    let scopes = decision
60        .matching_entries
61        .iter()
62        .map(|scope| format!("`{scope}`"))
63        .collect::<Vec<_>>()
64        .join(", ");
65    let paths: Vec<&String> = decision.changed_paths.iter().collect();
66    let mut changed = paths
67        .iter()
68        .take(PATHS_SHOWN)
69        .map(|path| format!("`{path}`"))
70        .collect::<Vec<_>>()
71        .join(", ");
72    let more = paths.len().saturating_sub(PATHS_SHOWN);
73    if more > 0 {
74        changed.push_str(&format!(" +{more} more"));
75    }
76    let link = if link_base.is_empty() {
77        decision.path.clone()
78    } else {
79        format!("{}/{}", link_base.trim_end_matches('/'), decision.path)
80    };
81    format!(
82        "- **[{} — {}]({})** ({}) — applies to {} — changed: {}",
83        decision.id, decision.title, link, decision.status, scopes, changed
84    )
85}
86
87pub fn render(report: &HeraldReport, link_base: &str, max_inline: i64) -> String {
88    if report.decisions.is_empty() {
89        return format!(
90            "{MARKER}\n### Decisions governing this change\n\n\
91             No recorded decisions govern the paths changed by this pull request.\n"
92        );
93    }
94    let count = report.decisions.len();
95    let plural = if count == 1 { "" } else { "s" };
96    let mut lines = vec![
97        MARKER.to_string(),
98        "### Decisions governing this change".to_string(),
99        String::new(),
100        format!(
101            "This pull request touches paths governed by {count} recorded decision{plural} — review recommended."
102        ),
103        String::new(),
104    ];
105    let inline_count = if max_inline > 0 {
106        (max_inline as usize).min(count)
107    } else {
108        count
109    };
110    lines.extend(
111        report.decisions[..inline_count]
112            .iter()
113            .map(|decision| bullet(decision, link_base)),
114    );
115    let rest = &report.decisions[inline_count..];
116    if !rest.is_empty() {
117        let rest_plural = if rest.len() == 1 { "" } else { "s" };
118        lines.push(String::new());
119        lines.push(format!(
120            "<details><summary>{} more governing decision{rest_plural}</summary>",
121            rest.len()
122        ));
123        lines.push(String::new());
124        lines.extend(rest.iter().map(|decision| bullet(decision, link_base)));
125        lines.push(String::new());
126        lines.push("</details>".to_string());
127    }
128    lines.push(String::new());
129    lines.join("\n")
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135    use std::fs;
136
137    fn decision(id: &str) -> HeraldDecision {
138        HeraldDecision {
139            id: id.to_string(),
140            title: format!("{id} title"),
141            status: "Accepted".to_string(),
142            path: format!("decisions/{id}.md"),
143            matching_entries: BTreeSet::from(["src/**".to_string()]),
144            changed_paths: BTreeSet::from([
145                "src/a.rs".to_string(),
146                "src/b.rs".to_string(),
147                "src/c.rs".to_string(),
148                "src/d.rs".to_string(),
149            ]),
150        }
151    }
152
153    #[test]
154    fn empty_state_is_stable() {
155        let body = render(&HeraldReport { decisions: vec![] }, "", 5);
156        assert!(body.starts_with(MARKER));
157        assert!(body.contains("No recorded decisions govern"));
158        assert!(body.ends_with('\n'));
159    }
160
161    #[test]
162    fn overflow_and_path_collapse_match_contract() {
163        let body = render(
164            &HeraldReport {
165                decisions: vec![decision("ADR-001"), decision("ADR-002")],
166            },
167            "https://example.com/blob/HEAD/",
168            1,
169        );
170        assert!(body.contains("<details><summary>1 more governing decision</summary>"));
171        assert!(body.contains("`src/a.rs`, `src/b.rs`, `src/c.rs` +1 more"));
172        assert!(body.contains("https://example.com/blob/HEAD/decisions/ADR-001.md"));
173    }
174
175    #[test]
176    fn collection_is_sorted_and_deduplicated_across_paths() {
177        let root = std::env::temp_dir().join(format!("decided-herald-{}", std::process::id()));
178        let corpus = root.join("decisions");
179        let _ = fs::remove_dir_all(&root);
180        fs::create_dir_all(root.join(".decided")).unwrap();
181        fs::create_dir_all(&corpus).unwrap();
182        fs::write(root.join(".decided/config.yaml"), "repository_key: TEST\n").unwrap();
183        fs::write(
184            corpus.join("adr-001.md"),
185            "# 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",
186        )
187        .unwrap();
188        let report = collect(
189            corpus.to_str().unwrap(),
190            &[
191                "src/a.rs".to_string(),
192                "src/b.rs".to_string(),
193                "src/a.rs".to_string(),
194            ],
195            true,
196        );
197        assert_eq!(report.decisions.len(), 1);
198        assert_eq!(report.decisions[0].changed_paths.len(), 2);
199        assert_eq!(
200            report.decisions[0].matching_entries,
201            BTreeSet::from(["src/**/*.rs".to_string()])
202        );
203        fs::remove_dir_all(root).unwrap();
204    }
205}