radr/
actions.rs

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    // Resolve supersedes display: link to existing ADR filename when possible
25    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    // Locate ADR by listing metadata to be robust even if dir missing
82    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    // refresh index
118    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    // Try by number, else by title (case-insensitive exact match)
132    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        // insert after header line
162        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    // refresh index and return updated meta
175    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    // Build map from number -> filename for linking
240    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        // Ensure the old ADR's status contains a link to the new ADR file
339        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        // No ADR 0001 exists, should error
378        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        // Pre-create a higher numbered ADR to create a gap
419        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        // Old ADR without status/superseded-by lines
466        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        // Create new ADR to get number 2
476        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}