1use 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}