1use anyhow::{anyhow, Context, Result};
2use chrono::Local;
3use std::ffi::OsStr;
4use std::path::PathBuf;
5
6use crate::config::Config;
7use crate::domain::{parse_number, slugify, AdrMeta};
8use crate::repository::{idx_path, AdrRepository};
9use std::collections::HashMap;
10
11pub fn create_new_adr<R: AdrRepository>(
12 repo: &R,
13 cfg: &Config,
14 title: &str,
15 supersedes: Option<u32>,
16) -> Result<AdrMeta> {
17 let mut adrs = repo.list()?;
18 let next = adrs.iter().map(|a| a.number).max().unwrap_or(0) + 1;
19 let slug = slugify(title);
20 let filename = format!("{:04}-{}.md", next, slug);
21 let path = repo.adr_dir().join(filename);
22 let date = Local::now().format("%Y-%m-%d").to_string();
23
24 let supersedes_display = supersedes.and_then(|n| {
26 adrs.iter()
27 .find(|a| a.number == n)
28 .and_then(|a| a.path.file_name().and_then(OsStr::to_str))
29 .map(|fname| format!("[{:04}]({})", n, fname))
30 .or_else(|| Some(format!("{:04}", n)))
31 });
32
33 let content = if let Some(tpl_path) = &cfg.template {
34 let tpl = std::fs::read_to_string(tpl_path)
35 .with_context(|| format!("Reading template at {}", tpl_path.display()))?;
36 tpl.replace("{{NUMBER}}", &format!("{:04}", next))
37 .replace("{{TITLE}}", title)
38 .replace("{{DATE}}", &date)
39 .replace("{{STATUS}}", "Proposed")
40 .replace(
41 "{{SUPERSEDES}}",
42 supersedes_display.as_deref().unwrap_or_default(),
43 )
44 } else {
45 let mut header = format!(
46 "# ADR {:04}: {}\n\nDate: {}\nStatus: Proposed\n",
47 next, title, date
48 );
49 if let Some(sup) = &supersedes_display {
50 header.push_str(&format!("Supersedes: {}\n", sup));
51 }
52 header.push_str(
53 "\n## Context\n\nDescribe the context and forces at play.\n\n## Decision\n\nState the decision that was made and why.\n\n## Consequences\n\nList the trade-offs and follow-ups.\n",
54 );
55 header
56 };
57
58 repo.write_string(&path, &content)?;
59
60 let meta = AdrMeta {
61 number: next,
62 title: title.to_string(),
63 status: "Proposed".to_string(),
64 date: date.clone(),
65 supersedes,
66 superseded_by: None,
67 path: path.clone(),
68 };
69 adrs.push(meta.clone());
70 adrs.sort_by_key(|a| a.number);
71 write_index(repo, cfg, &adrs)?;
72 Ok(meta)
73}
74
75pub fn mark_superseded<R: AdrRepository>(
76 repo: &R,
77 cfg: &Config,
78 old_number: u32,
79 new_number: u32,
80) -> Result<()> {
81 let adrs = repo.list()?;
83 let path: PathBuf = adrs
84 .into_iter()
85 .find(|a| a.number == old_number)
86 .map(|a| a.path)
87 .ok_or_else(|| anyhow!("Could not find ADR {:04} to supersede", old_number))?;
88
89 let contents = repo.read_string(&path)?;
90 let mut lines: Vec<String> = contents.lines().map(|s| s.to_string()).collect();
91 let mut found_status = false;
92 let mut found_superseded_by = false;
93 for l in &mut lines {
94 if l.starts_with("Status:") {
95 *l = format!("Status: Superseded by {:04}", new_number);
96 found_status = true;
97 }
98 if l.starts_with("Superseded-by:") {
99 *l = format!("Superseded-by: {:04}", new_number);
100 found_superseded_by = true;
101 }
102 }
103 if !found_status {
104 lines.insert(1, format!("Status: Superseded by {:04}", new_number));
105 }
106 if !found_superseded_by {
107 let insert_at = lines
108 .iter()
109 .position(|l| l.trim().is_empty())
110 .unwrap_or(lines.len());
111 lines.insert(insert_at, format!("Superseded-by: {:04}", new_number));
112 }
113 let mut content = lines.join("\n");
114 content.push('\n');
115 repo.write_string(&path, &content)?;
116
117 let adrs = repo.list()?;
119 write_index(repo, cfg, &adrs)?;
120 Ok(())
121}
122
123pub fn list_and_index<R: AdrRepository>(repo: &R, cfg: &Config) -> Result<Vec<AdrMeta>> {
124 let adrs = repo.list()?;
125 write_index(repo, cfg, &adrs)?;
126 Ok(adrs)
127}
128
129pub fn accept<R: AdrRepository>(repo: &R, cfg: &Config, id_or_title: &str) -> Result<AdrMeta> {
130 let adrs = repo.list()?;
131 let target = match parse_number(id_or_title) {
133 Ok(n) if adrs.iter().any(|a| a.number == n) => adrs
134 .into_iter()
135 .find(|a| a.number == n)
136 .ok_or_else(|| anyhow!("ADR not found by id: {}", n))?,
137 _ => {
138 let lower = id_or_title.trim().to_ascii_lowercase();
139 adrs.into_iter()
140 .find(|a| a.title.to_ascii_lowercase() == lower)
141 .ok_or_else(|| anyhow!("ADR not found by id or title: {}", id_or_title))?
142 }
143 };
144
145 let mut content = repo.read_string(&target.path)?;
146 let today = Local::now().format("%Y-%m-%d").to_string();
147 let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
148 let mut found_status = false;
149 let mut found_date = false;
150 for l in &mut lines {
151 if l.starts_with("Status:") {
152 *l = "Status: Accepted".to_string();
153 found_status = true;
154 }
155 if l.starts_with("Date:") {
156 *l = format!("Date: {}", today);
157 found_date = true;
158 }
159 }
160 if !found_status {
161 let insert_at = if !lines.is_empty() { 1 } else { 0 };
163 lines.insert(insert_at, "Status: Accepted".to_string());
164 }
165 if !found_date {
166 lines.insert(1, format!("Date: {}", today));
167 }
168 content = lines.join("\n");
169 if !content.ends_with('\n') {
170 content.push('\n');
171 }
172 repo.write_string(&target.path, &content)?;
173
174 let adrs2 = repo.list()?;
176 write_index(repo, cfg, &adrs2)?;
177 let updated = adrs2
178 .into_iter()
179 .find(|a| a.number == target.number)
180 .ok_or_else(|| anyhow!("Updated ADR not found"))?;
181 Ok(updated)
182}
183
184pub fn reject<R: AdrRepository>(repo: &R, cfg: &Config, id_or_title: &str) -> Result<AdrMeta> {
185 let adrs = repo.list()?;
186 let target = match parse_number(id_or_title) {
187 Ok(n) if adrs.iter().any(|a| a.number == n) => adrs
188 .into_iter()
189 .find(|a| a.number == n)
190 .ok_or_else(|| anyhow!("ADR not found by id: {}", n))?,
191 _ => {
192 let lower = id_or_title.trim().to_ascii_lowercase();
193 adrs.into_iter()
194 .find(|a| a.title.to_ascii_lowercase() == lower)
195 .ok_or_else(|| anyhow!("ADR not found by id or title: {}", id_or_title))?
196 }
197 };
198
199 let mut content = repo.read_string(&target.path)?;
200 let today = Local::now().format("%Y-%m-%d").to_string();
201 let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
202 let mut found_status = false;
203 let mut found_date = false;
204 for l in &mut lines {
205 if l.starts_with("Status:") {
206 *l = "Status: Rejected".to_string();
207 found_status = true;
208 }
209 if l.starts_with("Date:") {
210 *l = format!("Date: {}", today);
211 found_date = true;
212 }
213 }
214 if !found_status {
215 let insert_at = if !lines.is_empty() { 1 } else { 0 };
216 lines.insert(insert_at, "Status: Rejected".to_string());
217 }
218 if !found_date {
219 lines.insert(1, format!("Date: {}", today));
220 }
221 content = lines.join("\n");
222 if !content.ends_with('\n') {
223 content.push('\n');
224 }
225 repo.write_string(&target.path, &content)?;
226
227 let adrs2 = repo.list()?;
228 write_index(repo, cfg, &adrs2)?;
229 let updated = adrs2
230 .into_iter()
231 .find(|a| a.number == target.number)
232 .ok_or_else(|| anyhow!("Updated ADR not found"))?;
233 Ok(updated)
234}
235
236fn write_index<R: AdrRepository>(repo: &R, cfg: &Config, adrs: &[AdrMeta]) -> Result<()> {
237 let mut content = String::new();
238 content.push_str("# Architecture Decision Records\n\n");
239 let mut by_number: HashMap<u32, String> = HashMap::new();
241 for a in adrs {
242 if let Some(fname) = a.path.file_name().and_then(OsStr::to_str) {
243 by_number.insert(a.number, fname.to_string());
244 }
245 }
246 for a in adrs {
247 let fname = a.path.file_name().and_then(OsStr::to_str).unwrap_or("");
248 let status_display = if let Some(n) = a.superseded_by {
249 if let Some(target) = by_number.get(&n) {
250 format!("Superseded by [{:04}]({})", n, target)
251 } else {
252 format!("Superseded by {:04}", n)
253 }
254 } else {
255 a.status.clone()
256 };
257 content.push_str(&format!(
258 "- [{:04}: {}]({}) — Status: {} — Date: {}\n",
259 a.number, a.title, fname, status_display, a.date
260 ));
261 }
262 content.push('\n');
263 let idx = idx_path(&cfg.adr_dir, &cfg.index_name);
264 repo.write_string(&idx, &content)
265}
266
267#[cfg(test)]
268mod tests {
269 use super::*;
270 use crate::repository::fs::FsAdrRepository;
271 use tempfile::tempdir;
272
273 #[test]
274 fn test_create_and_index() {
275 let dir = tempdir().unwrap();
276 let adr_dir = dir.path().join("adrs");
277 let repo = FsAdrRepository::new(&adr_dir);
278 let cfg = Config {
279 adr_dir: adr_dir.clone(),
280 index_name: "index.md".to_string(),
281 template: None,
282 };
283
284 let meta = create_new_adr(&repo, &cfg, "First Decision", None).unwrap();
285 assert_eq!(meta.number, 1);
286 assert!(meta.path.exists());
287 assert_eq!(meta.status, "Proposed");
288 let idx = cfg.adr_dir.join("index.md");
289 assert!(idx.exists());
290 let adrs = repo.list().unwrap();
291 assert_eq!(adrs.len(), 1);
292 assert_eq!(adrs[0].title, "First Decision");
293 assert_eq!(adrs[0].status, "Proposed");
294 }
295
296 #[test]
297 fn test_supersede_updates_old_adr() {
298 let dir = tempdir().unwrap();
299 let adr_dir = dir.path().join("adrs");
300 let repo = FsAdrRepository::new(&adr_dir);
301 let cfg = Config {
302 adr_dir: adr_dir.clone(),
303 index_name: "index.md".to_string(),
304 template: None,
305 };
306
307 let old = create_new_adr(&repo, &cfg, "Choose X", None).unwrap();
308 let new_meta = create_new_adr(&repo, &cfg, "Choose Y", Some(old.number)).unwrap();
309 mark_superseded(&repo, &cfg, old.number, new_meta.number).unwrap();
310
311 let old_path = cfg.adr_dir.join(format!(
312 "{:04}-{}.md",
313 old.number,
314 crate::domain::slugify("Choose X")
315 ));
316 let contents = repo.read_string(&old_path).unwrap();
317 assert!(contents.contains("Status: Superseded by 0002"));
318 assert!(contents.contains("Superseded-by: 0002"));
319 }
320
321 #[test]
322 fn test_index_links_to_superseding_adr() {
323 let dir = tempdir().unwrap();
324 let adr_dir = dir.path().join("adrs");
325 let repo = FsAdrRepository::new(&adr_dir);
326 let cfg = Config {
327 adr_dir: adr_dir.clone(),
328 index_name: "index.md".to_string(),
329 template: None,
330 };
331
332 let old = create_new_adr(&repo, &cfg, "Choose X", None).unwrap();
333 let new_meta = create_new_adr(&repo, &cfg, "Choose Y", Some(old.number)).unwrap();
334 mark_superseded(&repo, &cfg, old.number, new_meta.number).unwrap();
335
336 let index = cfg.adr_dir.join("index.md");
337 let idx = repo.read_string(&index).unwrap();
338 assert!(idx.contains("Status: Superseded by [0002](0002-choose-y.md)"));
340 }
341
342 #[test]
343 fn test_accept_by_id_and_title() {
344 let dir = tempdir().unwrap();
345 let adr_dir = dir.path().join("adrs");
346 let repo = FsAdrRepository::new(&adr_dir);
347 let cfg = Config {
348 adr_dir: adr_dir.clone(),
349 index_name: "index.md".to_string(),
350 template: None,
351 };
352
353 let m1 = create_new_adr(&repo, &cfg, "Adopt Z", None).unwrap();
354 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
355
356 let updated1 = accept(&repo, &cfg, &format!("{}", m1.number)).unwrap();
357 assert_eq!(updated1.status, "Accepted");
358 let c1 = repo.read_string(&updated1.path).unwrap();
359 assert!(c1.contains("Status: Accepted"));
360 assert!(c1.contains(&format!("Date: {}", today)));
361
362 let _m2 = create_new_adr(&repo, &cfg, "Pick W", None).unwrap();
363 let updated2 = accept(&repo, &cfg, "Pick W").unwrap();
364 assert_eq!(updated2.status, "Accepted");
365 }
366
367 #[test]
368 fn test_mark_superseded_not_found_errors() {
369 let dir = tempdir().unwrap();
370 let adr_dir = dir.path().join("adrs");
371 let repo = FsAdrRepository::new(&adr_dir);
372 let cfg = Config {
373 adr_dir: adr_dir.clone(),
374 index_name: "index.md".to_string(),
375 template: None,
376 };
377 let err = mark_superseded(&repo, &cfg, 1, 2).unwrap_err();
379 let msg = format!("{}", err);
380 assert!(msg.contains("Could not find ADR 0001"));
381 }
382
383 #[test]
384 fn test_accept_not_found_errors() {
385 let dir = tempdir().unwrap();
386 let adr_dir = dir.path().join("adrs");
387 let repo = FsAdrRepository::new(&adr_dir);
388 let cfg = Config {
389 adr_dir: adr_dir.clone(),
390 index_name: "index.md".to_string(),
391 template: None,
392 };
393 let err = accept(&repo, &cfg, "999").unwrap_err();
394 let msg = format!("{}", err);
395 assert!(msg.contains("ADR not found"));
396 }
397
398 #[test]
399 fn test_create_with_missing_template_errors() {
400 let dir = tempdir().unwrap();
401 let adr_dir = dir.path().join("adrs");
402 let repo = FsAdrRepository::new(&adr_dir);
403 let cfg = Config {
404 adr_dir: adr_dir.clone(),
405 index_name: "index.md".into(),
406 template: Some(dir.path().join("missing.tpl")),
407 };
408 let err = create_new_adr(&repo, &cfg, "X", None).unwrap_err();
409 let msg = format!("{}", err);
410 assert!(msg.contains("Reading template"));
411 }
412
413 #[test]
414 fn test_next_number_after_gap() {
415 let dir = tempdir().unwrap();
416 let adr_dir = dir.path().join("adrs");
417 std::fs::create_dir_all(&adr_dir).unwrap();
418 let pre = adr_dir.join("0005-existing.md");
420 std::fs::write(&pre, "# ADR 0005: Existing\n\nBody\n").unwrap();
421
422 let repo = FsAdrRepository::new(&adr_dir);
423 let cfg = Config {
424 adr_dir: adr_dir.clone(),
425 index_name: "index.md".into(),
426 template: None,
427 };
428
429 let meta = create_new_adr(&repo, &cfg, "Next After Gap", None).unwrap();
430 assert_eq!(meta.number, 6);
431 assert!(meta.path.ends_with("0006-next-after-gap.md"));
432 }
433
434 #[test]
435 fn test_template_substitution_with_supersedes() {
436 let dir = tempdir().unwrap();
437 let adr_dir = dir.path().join("adrs");
438 let tpl_path = dir.path().join("tpl.md");
439 std::fs::write(
440 &tpl_path,
441 "# ADR {{NUMBER}}: {{TITLE}}\n\nDate: {{DATE}}\nStatus: {{STATUS}}\nSupersedes: {{SUPERSEDES}}\n\nBody\n",
442 )
443 .unwrap();
444
445 let repo = FsAdrRepository::new(&adr_dir);
446 let cfg = Config {
447 adr_dir: adr_dir.clone(),
448 index_name: "index.md".into(),
449 template: Some(tpl_path.clone()),
450 };
451 let meta = create_new_adr(&repo, &cfg, "Use Template", Some(3)).unwrap();
452 let content = repo.read_string(&meta.path).unwrap();
453 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
454 assert!(content.contains("# ADR 0001: Use Template"));
455 assert!(content.contains(&format!("Date: {}", today)));
456 assert!(content.contains("Status: Proposed"));
457 assert!(content.contains("Supersedes: 0003"));
458 }
459
460 #[test]
461 fn test_mark_superseded_inserts_when_missing() {
462 let dir = tempdir().unwrap();
463 let adr_dir = dir.path().join("adrs");
464 std::fs::create_dir_all(&adr_dir).unwrap();
465 let old_path = adr_dir.join("0001-old.md");
467 std::fs::write(&old_path, "# ADR 0001: Old\n\nContext\n").unwrap();
468 let repo = FsAdrRepository::new(&adr_dir);
469 let cfg = Config {
470 adr_dir: adr_dir.clone(),
471 index_name: "index.md".into(),
472 template: None,
473 };
474
475 let new_meta = create_new_adr(&repo, &cfg, "New", None).unwrap();
477 mark_superseded(&repo, &cfg, 1, new_meta.number).unwrap();
478 let updated = repo.read_string(&old_path).unwrap();
479 assert!(updated.contains("Status: Superseded by 0002"));
480 assert!(updated.contains("Superseded-by: 0002"));
481 }
482
483 #[test]
484 fn test_accept_zero_padded_and_case_insensitive_title() {
485 let dir = tempdir().unwrap();
486 let adr_dir = dir.path().join("adrs");
487 let repo = FsAdrRepository::new(&adr_dir);
488 let cfg = Config {
489 adr_dir: adr_dir.clone(),
490 index_name: "index.md".into(),
491 template: None,
492 };
493
494 let m1 = create_new_adr(&repo, &cfg, "Choose DB", None).unwrap();
495 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
496
497 let _ = accept(&repo, &cfg, "0001").unwrap();
498 let c1 = repo.read_string(&m1.path).unwrap();
499 assert!(c1.contains("Status: Accepted"));
500 assert!(c1.contains(&format!("Date: {}", today)));
501
502 let _m2 = create_new_adr(&repo, &cfg, "Use Queue", None).unwrap();
503 let updated2 = accept(&repo, &cfg, "use queue").unwrap();
504 assert_eq!(updated2.status, "Accepted");
505 }
506
507 #[test]
508 fn test_reject_by_id_and_title() {
509 let dir = tempdir().unwrap();
510 let adr_dir = dir.path().join("adrs");
511 let repo = FsAdrRepository::new(&adr_dir);
512 let cfg = Config {
513 adr_dir: adr_dir.clone(),
514 index_name: "index.md".into(),
515 template: None,
516 };
517
518 let m1 = create_new_adr(&repo, &cfg, "Reject Me", None).unwrap();
519 let today = chrono::Local::now().format("%Y-%m-%d").to_string();
520
521 let updated1 = reject(&repo, &cfg, &format!("{}", m1.number)).unwrap();
522 assert_eq!(updated1.status, "Rejected");
523 let c1 = repo.read_string(&updated1.path).unwrap();
524 assert!(c1.contains("Status: Rejected"));
525 assert!(c1.contains(&format!("Date: {}", today)));
526
527 let _m2 = create_new_adr(&repo, &cfg, "Another One", None).unwrap();
528 let updated2 = reject(&repo, &cfg, "another one").unwrap();
529 assert_eq!(updated2.status, "Rejected");
530 }
531}