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 crate::yaml_util::escape_yaml;
10use std::collections::HashMap;
11
12pub fn create_new_adr<R: AdrRepository>(
13    repo: &R,
14    cfg: &Config,
15    title: &str,
16    supersedes: Option<u32>,
17) -> Result<AdrMeta> {
18    let mut adrs = repo.list()?;
19    let next = adrs.iter().map(|a| a.number).max().unwrap_or(0) + 1;
20    let slug = slugify(title);
21    let ext = cfg.format.as_str();
22    let filename = format!("{:04}-{}.{}", next, slug, ext);
23    let path = repo.adr_dir().join(filename);
24    let date = Local::now().format("%Y-%m-%d").to_string();
25
26    // Resolve supersedes display: link to existing ADR filename when possible
27    let supersedes_display = supersedes.map(|n| {
28        if let Some(fname) = adrs
29            .iter()
30            .find(|a| a.number == n)
31            .and_then(|a| a.path.file_name().and_then(OsStr::to_str))
32        {
33            format!("[{:04}]({})", n, fname)
34        } else {
35            format!("{:04}", n)
36        }
37    });
38
39    let content = if let Some(tpl_path) = &cfg.template {
40        let tpl = std::fs::read_to_string(tpl_path)
41            .with_context(|| format!("Reading template at {}", tpl_path.display()))?;
42        tpl.replace("{{NUMBER}}", &format!("{:04}", next))
43            .replace("{{TITLE}}", title)
44            .replace("{{DATE}}", &date)
45            .replace("{{STATUS}}", "Proposed")
46            .replace(
47                "{{SUPERSEDES}}",
48                supersedes_display.as_deref().unwrap_or_default(),
49            )
50    } else if cfg.front_matter {
51        let mut body = String::new();
52        body.push_str("---\n");
53        body.push_str(&format!("title: {}\n", escape_yaml(title)));
54        body.push_str("---\n\n");
55        body.push_str(&format!("Date: {}\n", date));
56        body.push_str("Status: Proposed\n");
57        if let Some(sup) = &supersedes_display {
58            body.push_str(&format!("Supersedes: {}\n", sup));
59        }
60        body.push('\n');
61        body.push_str("## Context\n\nDescribe the context and forces at play.\n\n");
62        body.push_str("## Decision\n\nState the decision that was made and why.\n\n");
63        body.push_str("## Consequences\n\nList the trade-offs and follow-ups.\n");
64        body
65    } else {
66        let mut header = format!(
67            "# ADR {:04}: {}\n\nDate: {}\nStatus: Proposed\n",
68            next, title, date
69        );
70        if let Some(sup) = &supersedes_display {
71            header.push_str(&format!("Supersedes: {}\n", sup));
72        }
73        header.push_str(
74                "\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",
75            );
76        header
77    };
78
79    repo.write_string(&path, &content)?;
80
81    let meta = AdrMeta {
82        number: next,
83        title: title.to_string(),
84        status: "Proposed".to_string(),
85        date,
86        supersedes,
87        superseded_by: None,
88        path: path.clone(),
89    };
90    adrs.push(meta.clone());
91    adrs.sort_by_key(|a| a.number);
92    write_index(repo, cfg, &adrs)?;
93    Ok(meta)
94}
95
96pub fn mark_superseded<R: AdrRepository>(
97    repo: &R,
98    cfg: &Config,
99    old_number: u32,
100    new_number: u32,
101) -> Result<()> {
102    // Locate ADR by listing metadata to be robust even if dir missing
103    let adrs = repo.list()?;
104    let path: PathBuf = adrs
105        .into_iter()
106        .find(|a| a.number == old_number)
107        .map(|a| a.path)
108        .ok_or_else(|| anyhow!("Could not find ADR {:04} to supersede", old_number))?;
109
110    let contents = repo.read_string(&path)?;
111    let mut updated = String::new();
112    if let Some(stripped) = contents.strip_prefix("---\n") {
113        // Front matter present: keep it as-is, update fields in body
114        if let Some(end) = stripped.find("\n---\n") {
115            let fm_block = &stripped[..end];
116            let rest = &stripped[end + 5..];
117            let mut lines: Vec<String> = rest.lines().map(|s| s.to_string()).collect();
118            // Update status/superseded-by with ordering
119            let mut idx_status: Option<usize> = None;
120            let mut idx_superseded_by: Option<usize> = None;
121            for (i, l) in lines.iter_mut().enumerate() {
122                if l.starts_with("Status:") {
123                    *l = format!("Status: Superseded by {:04}", new_number);
124                    idx_status = Some(i);
125                }
126                if l.starts_with("Superseded-by:") {
127                    *l = format!("Superseded-by: {:04}", new_number);
128                    idx_superseded_by = Some(i);
129                }
130            }
131            if idx_status.is_none() {
132                let insert_at = 0; // top of body
133                lines.insert(
134                    insert_at,
135                    format!("Status: Superseded by {:04}", new_number),
136                );
137                idx_status = Some(insert_at);
138            }
139            match (idx_status, idx_superseded_by) {
140                (Some(s_idx), Some(sb_idx)) => {
141                    let desired = s_idx + 1;
142                    if sb_idx != desired {
143                        let _ = lines.remove(sb_idx);
144                        let insert_pos = if sb_idx < desired {
145                            desired - 1
146                        } else {
147                            desired
148                        };
149                        lines.insert(insert_pos, format!("Superseded-by: {:04}", new_number));
150                    }
151                }
152                (Some(s_idx), None) => {
153                    lines.insert(s_idx + 1, format!("Superseded-by: {:04}", new_number));
154                }
155                _ => {}
156            }
157
158            updated.push_str("---\n");
159            updated.push_str(fm_block);
160            updated.push_str("\n---\n");
161            if !rest.starts_with('\n') && (lines.first().map(|l| !l.is_empty()).unwrap_or(false)) {
162                updated.push('\n');
163            }
164            updated.push_str(&lines.join("\n"));
165            if !updated.ends_with('\n') {
166                updated.push('\n');
167            }
168        } else {
169            updated = contents;
170        }
171    } else {
172        let mut lines: Vec<String> = contents.lines().map(|s| s.to_string()).collect();
173        let mut idx_status: Option<usize> = None;
174        let mut idx_superseded_by: Option<usize> = None;
175        for (i, l) in lines.iter_mut().enumerate() {
176            if l.starts_with("Status:") {
177                *l = format!("Status: Superseded by {:04}", new_number);
178                idx_status = Some(i);
179            }
180            if l.starts_with("Superseded-by:") {
181                *l = format!("Superseded-by: {:04}", new_number);
182                idx_superseded_by = Some(i);
183            }
184        }
185        if idx_status.is_none() {
186            let insert_at = if !lines.is_empty() { 1 } else { 0 };
187            lines.insert(
188                insert_at,
189                format!("Status: Superseded by {:04}", new_number),
190            );
191            idx_status = Some(insert_at);
192        }
193        // Ensure Superseded-by appears immediately after Status
194        match (idx_status, idx_superseded_by) {
195            (Some(s_idx), Some(sb_idx)) => {
196                let desired = s_idx + 1;
197                if sb_idx != desired {
198                    // Remove current and insert at desired (adjust if removing before desired)
199                    let _ = lines.remove(sb_idx);
200                    let insert_pos = if sb_idx < desired {
201                        desired - 1
202                    } else {
203                        desired
204                    };
205                    lines.insert(insert_pos, format!("Superseded-by: {:04}", new_number));
206                }
207            }
208            (Some(s_idx), None) => {
209                lines.insert(s_idx + 1, format!("Superseded-by: {:04}", new_number));
210            }
211            _ => {}
212        }
213
214        updated = lines.join("\n");
215        if !updated.ends_with('\n') {
216            updated.push('\n');
217        }
218    }
219    repo.write_string(&path, &updated)?;
220
221    // refresh index
222    let adrs = repo.list()?;
223    write_index(repo, cfg, &adrs)?;
224    Ok(())
225}
226
227pub fn reformat<R: AdrRepository>(repo: &R, cfg: &Config, id: u32) -> Result<AdrMeta> {
228    let adrs = repo.list()?;
229    let target = adrs
230        .iter()
231        .find(|a| a.number == id)
232        .ok_or_else(|| anyhow!("ADR not found by id: {:04}", id))?;
233
234    let original = repo.read_string(&target.path)?;
235
236    // Build map for linking by number
237    let mut by_number: HashMap<u32, String> = HashMap::new();
238    for a in &adrs {
239        if let Some(fname) = a.path.file_name().and_then(OsStr::to_str) {
240            by_number.insert(a.number, fname.to_string());
241        }
242    }
243
244    // Extract body content after any header/front-matter + meta lines
245    fn body_after_meta(raw: &str) -> String {
246        let mut rest = raw;
247        if let Some(stripped) = raw.strip_prefix("---\n") {
248            if let Some(end) = stripped.find("\n---\n") {
249                rest = &stripped[end + 5..];
250            }
251        }
252        let lines: Vec<&str> = rest.lines().collect();
253        let mut i = 0usize;
254        if i < lines.len() && lines[i].starts_with("# ADR ") {
255            i += 1;
256            if i < lines.len() && lines[i].trim().is_empty() {
257                i += 1;
258            }
259        }
260        while i < lines.len() {
261            let l = lines[i];
262            let is_meta = l.starts_with("Title:")
263                || l.starts_with("Date:")
264                || l.starts_with("Status:")
265                || l.starts_with("Supersedes:")
266                || l.starts_with("Superseded-by:");
267            if is_meta || l.trim().is_empty() {
268                i += 1;
269                continue;
270            }
271            break;
272        }
273        let tail = lines[i..].join("\n");
274        if tail.is_empty() {
275            String::new()
276        } else {
277            format!("{}\n", tail)
278        }
279    }
280
281    let tail_body = body_after_meta(&original);
282
283    // Render new content according to cfg
284    let mut new_content = String::new();
285    if cfg.front_matter {
286        new_content.push_str("---\n");
287        new_content.push_str(&format!("title: {}\n", escape_yaml(&target.title)));
288        new_content.push_str("---\n\n");
289        new_content.push_str(&format!("Date: {}\n", target.date));
290        new_content.push_str(&format!("Status: {}\n", target.status));
291        if let Some(n) = target.superseded_by {
292            new_content.push_str(&format!("Superseded-by: {:04}\n", n));
293        }
294        if let Some(n) = target.supersedes {
295            if let Some(fname) = by_number.get(&n) {
296                new_content.push_str(&format!("Supersedes: [{:04}]({})\n", n, fname));
297            } else {
298                new_content.push_str(&format!("Supersedes: {:04}\n", n));
299            }
300        }
301        new_content.push('\n');
302        new_content.push_str(&tail_body);
303    } else {
304        new_content.push_str(&format!("# ADR {:04}: {}\n\n", target.number, target.title));
305        new_content.push_str(&format!("Date: {}\n", target.date));
306        new_content.push_str(&format!("Status: {}\n", target.status));
307        if let Some(n) = target.superseded_by {
308            new_content.push_str(&format!("Superseded-by: {:04}\n", n));
309        }
310        if let Some(n) = target.supersedes {
311            if let Some(fname) = by_number.get(&n) {
312                new_content.push_str(&format!("Supersedes: [{:04}]({})\n", n, fname));
313            } else {
314                new_content.push_str(&format!("Supersedes: {:04}\n", n));
315            }
316        }
317        new_content.push('\n');
318        new_content.push_str(&tail_body);
319    }
320
321    // Determine new path
322    let slug = slugify(&target.title);
323    let ext = cfg.format.as_str();
324    let new_filename = format!("{:04}-{}.{}", target.number, slug, ext);
325    let new_path = repo.adr_dir().join(new_filename);
326
327    repo.write_string(&new_path, &new_content)?;
328
329    // Remove old file if different path
330    if new_path != target.path {
331        let _ = std::fs::remove_file(&target.path);
332    }
333
334    // Update incoming links in other ADRs' Supersedes lines to point to the new filename
335    let new_filename = new_path
336        .file_name()
337        .and_then(OsStr::to_str)
338        .unwrap_or("")
339        .to_string();
340    let mut adrs_scan = repo.list()?;
341    for a in &mut adrs_scan {
342        if a.number == id {
343            continue;
344        }
345        let content = repo.read_string(&a.path)?;
346        let mut changed = false;
347        let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
348        for l in &mut lines {
349            if l.starts_with("Supersedes: [") {
350                // Try to parse number between [ and ]
351                if let Some(lb) = l.find('[') {
352                    if let Some(rb) = l[lb + 1..].find(']') {
353                        let num_str = &l[lb + 1..lb + 1 + rb];
354                        if let Ok(n) = num_str.parse::<u32>() {
355                            if n == id {
356                                *l = format!("Supersedes: [{:04}]({})", n, new_filename);
357                                changed = true;
358                            }
359                        }
360                    }
361                }
362            }
363        }
364        if changed {
365            let mut out = lines.join("\n");
366            if !out.ends_with('\n') {
367                out.push('\n');
368            }
369            repo.write_string(&a.path, &out)?;
370        }
371    }
372
373    // Refresh index and return updated meta
374    let adrs2 = repo.list()?;
375    write_index(repo, cfg, &adrs2)?;
376    let updated = adrs2
377        .into_iter()
378        .find(|a| a.number == target.number)
379        .ok_or_else(|| anyhow!("Reformatted ADR not found"))?;
380    Ok(updated)
381}
382
383pub fn reformat_all<R: AdrRepository>(repo: &R, cfg: &Config) -> Result<Vec<AdrMeta>> {
384    let adrs = repo.list()?;
385    let ids: Vec<u32> = adrs.iter().map(|a| a.number).collect();
386    let mut out = Vec::with_capacity(ids.len());
387    for id in ids {
388        let m = reformat(repo, cfg, id)?;
389        out.push(m);
390    }
391    Ok(out)
392}
393
394pub fn list_and_index<R: AdrRepository>(repo: &R, cfg: &Config) -> Result<Vec<AdrMeta>> {
395    let adrs = repo.list()?;
396    write_index(repo, cfg, &adrs)?;
397    Ok(adrs)
398}
399
400pub fn accept<R: AdrRepository>(repo: &R, cfg: &Config, id_or_title: &str) -> Result<AdrMeta> {
401    let adrs = repo.list()?;
402    // Try by number, else by title (case-insensitive exact match)
403    let target = match parse_number(id_or_title) {
404        Ok(n) if adrs.iter().any(|a| a.number == n) => adrs
405            .into_iter()
406            .find(|a| a.number == n)
407            .ok_or_else(|| anyhow!("ADR not found by id: {}", n))?,
408        _ => {
409            let lower = id_or_title.trim().to_ascii_lowercase();
410            adrs.into_iter()
411                .find(|a| a.title.to_ascii_lowercase() == lower)
412                .ok_or_else(|| anyhow!("ADR not found by id or title: {}", id_or_title))?
413        }
414    };
415
416    let mut content = repo.read_string(&target.path)?;
417    let today = Local::now().format("%Y-%m-%d").to_string();
418    if let Some(stripped) = content.strip_prefix("---\n") {
419        if let Some(end) = stripped.find("\n---\n") {
420            let fm_block = &stripped[..end];
421            let rest = &stripped[end + 5..];
422            let mut lines: Vec<String> = rest.lines().map(|s| s.to_string()).collect();
423            let mut found_status = false;
424            let mut found_date = false;
425            for l in &mut lines {
426                if l.starts_with("Status:") {
427                    *l = "Status: Accepted".to_string();
428                    found_status = true;
429                }
430                if l.starts_with("Date:") {
431                    *l = format!("Date: {}", today);
432                    found_date = true;
433                }
434            }
435            if !found_status {
436                lines.insert(0, "Status: Accepted".to_string());
437            }
438            if !found_date {
439                lines.insert(0, format!("Date: {}", today));
440            }
441            let mut out = String::new();
442            out.push_str("---\n");
443            out.push_str(fm_block);
444            out.push_str("\n---\n");
445            out.push_str(&lines.join("\n"));
446            out.push('\n');
447            content = out;
448        }
449    } else {
450        let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
451        let mut found_status = false;
452        let mut found_date = false;
453        for l in &mut lines {
454            if l.starts_with("Status:") {
455                *l = "Status: Accepted".to_string();
456                found_status = true;
457            }
458            if l.starts_with("Date:") {
459                *l = format!("Date: {}", today);
460                found_date = true;
461            }
462        }
463        if !found_status {
464            let insert_at = if !lines.is_empty() { 1 } else { 0 };
465            lines.insert(insert_at, "Status: Accepted".to_string());
466        }
467        if !found_date {
468            lines.insert(1, format!("Date: {}", today));
469        }
470        content = lines.join("\n");
471        if !content.ends_with('\n') {
472            content.push('\n');
473        }
474    }
475    repo.write_string(&target.path, &content)?;
476
477    // refresh index and return updated meta
478    let adrs2 = repo.list()?;
479    write_index(repo, cfg, &adrs2)?;
480    let updated = adrs2
481        .into_iter()
482        .find(|a| a.number == target.number)
483        .ok_or_else(|| anyhow!("Updated ADR not found"))?;
484    Ok(updated)
485}
486
487pub fn reject<R: AdrRepository>(repo: &R, cfg: &Config, id_or_title: &str) -> Result<AdrMeta> {
488    let adrs = repo.list()?;
489    let target = match parse_number(id_or_title) {
490        Ok(n) if adrs.iter().any(|a| a.number == n) => adrs
491            .into_iter()
492            .find(|a| a.number == n)
493            .ok_or_else(|| anyhow!("ADR not found by id: {}", n))?,
494        _ => {
495            let lower = id_or_title.trim().to_ascii_lowercase();
496            adrs.into_iter()
497                .find(|a| a.title.to_ascii_lowercase() == lower)
498                .ok_or_else(|| anyhow!("ADR not found by id or title: {}", id_or_title))?
499        }
500    };
501
502    let mut content = repo.read_string(&target.path)?;
503    let today = Local::now().format("%Y-%m-%d").to_string();
504    if let Some(stripped) = content.strip_prefix("---\n") {
505        if let Some(end) = stripped.find("\n---\n") {
506            let fm_block = &stripped[..end];
507            let rest = &stripped[end + 5..];
508            let mut lines: Vec<String> = rest.lines().map(|s| s.to_string()).collect();
509            let mut found_status = false;
510            let mut found_date = false;
511            for l in &mut lines {
512                if l.starts_with("Status:") {
513                    *l = "Status: Rejected".to_string();
514                    found_status = true;
515                }
516                if l.starts_with("Date:") {
517                    *l = format!("Date: {}", today);
518                    found_date = true;
519                }
520            }
521            if !found_status {
522                lines.insert(0, "Status: Rejected".to_string());
523            }
524            if !found_date {
525                lines.insert(0, format!("Date: {}", today));
526            }
527            let mut out = String::new();
528            out.push_str("---\n");
529            out.push_str(fm_block);
530            out.push_str("\n---\n");
531            out.push_str(&lines.join("\n"));
532            out.push('\n');
533            content = out;
534        }
535    } else {
536        let mut lines: Vec<String> = content.lines().map(|s| s.to_string()).collect();
537        let mut found_status = false;
538        let mut found_date = false;
539        for l in &mut lines {
540            if l.starts_with("Status:") {
541                *l = "Status: Rejected".to_string();
542                found_status = true;
543            }
544            if l.starts_with("Date:") {
545                *l = format!("Date: {}", today);
546                found_date = true;
547            }
548        }
549        if !found_status {
550            let insert_at = if !lines.is_empty() { 1 } else { 0 };
551            lines.insert(insert_at, "Status: Rejected".to_string());
552        }
553        if !found_date {
554            lines.insert(1, format!("Date: {}", today));
555        }
556        content = lines.join("\n");
557        if !content.ends_with('\n') {
558            content.push('\n');
559        }
560    }
561    repo.write_string(&target.path, &content)?;
562
563    let adrs2 = repo.list()?;
564    write_index(repo, cfg, &adrs2)?;
565    let updated = adrs2
566        .into_iter()
567        .find(|a| a.number == target.number)
568        .ok_or_else(|| anyhow!("Updated ADR not found"))?;
569    Ok(updated)
570}
571
572fn write_index<R: AdrRepository>(repo: &R, cfg: &Config, adrs: &[AdrMeta]) -> Result<()> {
573    let mut content = String::new();
574    content.push_str("# Architecture Decision Records\n\n");
575    // Build map from number -> filename for linking
576    let mut by_number: HashMap<u32, String> = HashMap::new();
577    for a in adrs {
578        if let Some(fname) = a.path.file_name().and_then(OsStr::to_str) {
579            by_number.insert(a.number, fname.to_string());
580        }
581    }
582    for a in adrs {
583        let fname = a.path.file_name().and_then(OsStr::to_str).unwrap_or("");
584        let status_display = if let Some(n) = a.superseded_by {
585            if let Some(target) = by_number.get(&n) {
586                format!("Superseded by [{:04}]({})", n, target)
587            } else {
588                format!("Superseded by {:04}", n)
589            }
590        } else {
591            a.status.clone()
592        };
593        content.push_str(&format!(
594            "- [{:04}: {}]({}) — Status: {} — Date: {}\n",
595            a.number, a.title, fname, status_display, a.date
596        ));
597    }
598    content.push('\n');
599    let idx = idx_path(&cfg.adr_dir, &cfg.index_name);
600    repo.write_string(&idx, &content)
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606    use crate::repository::fs::FsAdrRepository;
607    use tempfile::tempdir;
608
609    #[test]
610    fn test_create_and_index() {
611        let dir = tempdir().unwrap();
612        let adr_dir = dir.path().join("adrs");
613        let repo = FsAdrRepository::new(&adr_dir);
614        let cfg = Config {
615            adr_dir: adr_dir.clone(),
616            index_name: "index.md".to_string(),
617            template: None,
618            ..Config::default()
619        };
620
621        let meta = create_new_adr(&repo, &cfg, "First Decision", None).unwrap();
622        assert_eq!(meta.number, 1);
623        assert!(meta.path.exists());
624        assert_eq!(meta.status, "Proposed");
625        let idx = cfg.adr_dir.join("index.md");
626        assert!(idx.exists());
627        let adrs = repo.list().unwrap();
628        assert_eq!(adrs.len(), 1);
629        assert_eq!(adrs[0].title, "First Decision");
630        assert_eq!(adrs[0].status, "Proposed");
631    }
632
633    #[test]
634    fn test_supersede_updates_old_adr() {
635        let dir = tempdir().unwrap();
636        let adr_dir = dir.path().join("adrs");
637        let repo = FsAdrRepository::new(&adr_dir);
638        let cfg = Config {
639            adr_dir: adr_dir.clone(),
640            index_name: "index.md".to_string(),
641            template: None,
642            ..Config::default()
643        };
644
645        let old = create_new_adr(&repo, &cfg, "Choose X", None).unwrap();
646        let new_meta = create_new_adr(&repo, &cfg, "Choose Y", Some(old.number)).unwrap();
647        mark_superseded(&repo, &cfg, old.number, new_meta.number).unwrap();
648
649        let old_path = cfg.adr_dir.join(format!(
650            "{:04}-{}.md",
651            old.number,
652            crate::domain::slugify("Choose X")
653        ));
654        let contents = repo.read_string(&old_path).unwrap();
655        assert!(contents.contains("Status: Superseded by 0002"));
656        assert!(contents.contains("Superseded-by: 0002"));
657        // Ensure Superseded-by appears right after Status
658        let pos_status = contents.find("Status: Superseded by 0002").unwrap();
659        let pos_sb = contents.find("Superseded-by: 0002").unwrap();
660        assert!(pos_status < pos_sb);
661    }
662
663    #[test]
664    fn test_index_links_to_superseding_adr() {
665        let dir = tempdir().unwrap();
666        let adr_dir = dir.path().join("adrs");
667        let repo = FsAdrRepository::new(&adr_dir);
668        let cfg = Config {
669            adr_dir: adr_dir.clone(),
670            index_name: "index.md".to_string(),
671            template: None,
672            ..Config::default()
673        };
674
675        let old = create_new_adr(&repo, &cfg, "Choose X", None).unwrap();
676        let new_meta = create_new_adr(&repo, &cfg, "Choose Y", Some(old.number)).unwrap();
677        mark_superseded(&repo, &cfg, old.number, new_meta.number).unwrap();
678
679        let index = cfg.adr_dir.join("index.md");
680        let idx = repo.read_string(&index).unwrap();
681        // Ensure the old ADR's status contains a link to the new ADR file
682        assert!(idx.contains("Status: Superseded by [0002](0002-choose-y.md)"));
683    }
684
685    #[test]
686    fn test_create_new_mdx_with_front_matter() {
687        let dir = tempdir().unwrap();
688        let adr_dir = dir.path().join("adrs");
689        let repo = FsAdrRepository::new(&adr_dir);
690        let mut cfg = Config {
691            adr_dir: adr_dir.clone(),
692            index_name: "index.md".into(),
693            template: None,
694            ..Config::default()
695        };
696        cfg.format = "mdx".into();
697        cfg.front_matter = true;
698
699        let meta = create_new_adr(&repo, &cfg, "Front Matter Title", None).unwrap();
700        assert!(meta.path.ends_with("0001-front-matter-title.mdx"));
701        let c = repo.read_string(&meta.path).unwrap();
702        assert!(c.starts_with("---\n"));
703        assert!(c.contains("title:"));
704        assert!(c.contains("Status: Proposed"));
705        assert!(c.contains("Date:"));
706    }
707
708    #[test]
709    fn test_accept_by_id_and_title() {
710        let dir = tempdir().unwrap();
711        let adr_dir = dir.path().join("adrs");
712        let repo = FsAdrRepository::new(&adr_dir);
713        let cfg = Config {
714            adr_dir: adr_dir.clone(),
715            index_name: "index.md".to_string(),
716            template: None,
717            ..Config::default()
718        };
719
720        let m1 = create_new_adr(&repo, &cfg, "Adopt Z", None).unwrap();
721        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
722
723        let updated1 = accept(&repo, &cfg, &format!("{}", m1.number)).unwrap();
724        assert_eq!(updated1.status, "Accepted");
725        let c1 = repo.read_string(&updated1.path).unwrap();
726        assert!(c1.contains("Status: Accepted"));
727        assert!(c1.contains(&format!("Date: {}", today)));
728
729        let _m2 = create_new_adr(&repo, &cfg, "Pick W", None).unwrap();
730        let updated2 = accept(&repo, &cfg, "Pick W").unwrap();
731        assert_eq!(updated2.status, "Accepted");
732    }
733
734    #[test]
735    fn test_mark_superseded_not_found_errors() {
736        let dir = tempdir().unwrap();
737        let adr_dir = dir.path().join("adrs");
738        let repo = FsAdrRepository::new(&adr_dir);
739        let cfg = Config {
740            adr_dir: adr_dir.clone(),
741            index_name: "index.md".to_string(),
742            template: None,
743            ..Config::default()
744        };
745        // No ADR 0001 exists, should error
746        let err = mark_superseded(&repo, &cfg, 1, 2).unwrap_err();
747        let msg = format!("{}", err);
748        assert!(msg.contains("Could not find ADR 0001"));
749    }
750
751    #[test]
752    fn test_accept_not_found_errors() {
753        let dir = tempdir().unwrap();
754        let adr_dir = dir.path().join("adrs");
755        let repo = FsAdrRepository::new(&adr_dir);
756        let cfg = Config {
757            adr_dir: adr_dir.clone(),
758            index_name: "index.md".to_string(),
759            template: None,
760            ..Config::default()
761        };
762        let err = accept(&repo, &cfg, "999").unwrap_err();
763        let msg = format!("{}", err);
764        assert!(msg.contains("ADR not found"));
765    }
766
767    #[test]
768    fn test_create_with_missing_template_errors() {
769        let dir = tempdir().unwrap();
770        let adr_dir = dir.path().join("adrs");
771        let repo = FsAdrRepository::new(&adr_dir);
772        let cfg = Config {
773            adr_dir: adr_dir.clone(),
774            index_name: "index.md".into(),
775            template: Some(dir.path().join("missing.tpl")),
776            ..Config::default()
777        };
778        let err = create_new_adr(&repo, &cfg, "X", None).unwrap_err();
779        let msg = format!("{}", err);
780        assert!(msg.contains("Reading template"));
781    }
782
783    #[test]
784    fn test_next_number_after_gap() {
785        let dir = tempdir().unwrap();
786        let adr_dir = dir.path().join("adrs");
787        std::fs::create_dir_all(&adr_dir).unwrap();
788        // Pre-create a higher numbered ADR to create a gap
789        let pre = adr_dir.join("0005-existing.md");
790        std::fs::write(&pre, "# ADR 0005: Existing\n\nBody\n").unwrap();
791
792        let repo = FsAdrRepository::new(&adr_dir);
793        let cfg = Config {
794            adr_dir: adr_dir.clone(),
795            index_name: "index.md".into(),
796            template: None,
797            ..Config::default()
798        };
799
800        let meta = create_new_adr(&repo, &cfg, "Next After Gap", None).unwrap();
801        assert_eq!(meta.number, 6);
802        assert!(meta.path.ends_with("0006-next-after-gap.md"));
803    }
804
805    #[test]
806    fn test_template_substitution_with_supersedes() {
807        let dir = tempdir().unwrap();
808        let adr_dir = dir.path().join("adrs");
809        let tpl_path = dir.path().join("tpl.md");
810        std::fs::write(
811            &tpl_path,
812            "# ADR {{NUMBER}}: {{TITLE}}\n\nDate: {{DATE}}\nStatus: {{STATUS}}\nSupersedes: {{SUPERSEDES}}\n\nBody\n",
813        )
814        .unwrap();
815
816        let repo = FsAdrRepository::new(&adr_dir);
817        let cfg = Config {
818            adr_dir: adr_dir.clone(),
819            index_name: "index.md".into(),
820            template: Some(tpl_path.clone()),
821            ..Config::default()
822        };
823        let meta = create_new_adr(&repo, &cfg, "Use Template", Some(3)).unwrap();
824        let content = repo.read_string(&meta.path).unwrap();
825        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
826        assert!(content.contains("# ADR 0001: Use Template"));
827        assert!(content.contains(&format!("Date: {}", today)));
828        assert!(content.contains("Status: Proposed"));
829        assert!(content.contains("Supersedes: 0003"));
830    }
831
832    #[test]
833    fn test_mark_superseded_inserts_when_missing() {
834        let dir = tempdir().unwrap();
835        let adr_dir = dir.path().join("adrs");
836        std::fs::create_dir_all(&adr_dir).unwrap();
837        // Old ADR without status/superseded-by lines
838        let old_path = adr_dir.join("0001-old.md");
839        std::fs::write(&old_path, "# ADR 0001: Old\n\nContext\n").unwrap();
840        let repo = FsAdrRepository::new(&adr_dir);
841        let cfg = Config {
842            adr_dir: adr_dir.clone(),
843            index_name: "index.md".into(),
844            template: None,
845            ..Config::default()
846        };
847
848        // Create new ADR to get number 2
849        let new_meta = create_new_adr(&repo, &cfg, "New", None).unwrap();
850        mark_superseded(&repo, &cfg, 1, new_meta.number).unwrap();
851        let updated = repo.read_string(&old_path).unwrap();
852        assert!(updated.contains("Status: Superseded by 0002"));
853        assert!(updated.contains("Superseded-by: 0002"));
854    }
855
856    #[test]
857    fn test_accept_zero_padded_and_case_insensitive_title() {
858        let dir = tempdir().unwrap();
859        let adr_dir = dir.path().join("adrs");
860        let repo = FsAdrRepository::new(&adr_dir);
861        let cfg = Config {
862            adr_dir: adr_dir.clone(),
863            index_name: "index.md".into(),
864            template: None,
865            ..Config::default()
866        };
867
868        let m1 = create_new_adr(&repo, &cfg, "Choose DB", None).unwrap();
869        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
870
871        let _ = accept(&repo, &cfg, "0001").unwrap();
872        let c1 = repo.read_string(&m1.path).unwrap();
873        assert!(c1.contains("Status: Accepted"));
874        assert!(c1.contains(&format!("Date: {}", today)));
875
876        let _m2 = create_new_adr(&repo, &cfg, "Use Queue", None).unwrap();
877        let updated2 = accept(&repo, &cfg, "use queue").unwrap();
878        assert_eq!(updated2.status, "Accepted");
879    }
880
881    #[test]
882    fn test_reject_by_id_and_title() {
883        let dir = tempdir().unwrap();
884        let adr_dir = dir.path().join("adrs");
885        let repo = FsAdrRepository::new(&adr_dir);
886        let cfg = Config {
887            adr_dir: adr_dir.clone(),
888            index_name: "index.md".into(),
889            template: None,
890            ..Config::default()
891        };
892
893        let m1 = create_new_adr(&repo, &cfg, "Reject Me", None).unwrap();
894        let today = chrono::Local::now().format("%Y-%m-%d").to_string();
895
896        let updated1 = reject(&repo, &cfg, &format!("{}", m1.number)).unwrap();
897        assert_eq!(updated1.status, "Rejected");
898        let c1 = repo.read_string(&updated1.path).unwrap();
899        assert!(c1.contains("Status: Rejected"));
900        assert!(c1.contains(&format!("Date: {}", today)));
901
902        let _m2 = create_new_adr(&repo, &cfg, "Another One", None).unwrap();
903        let updated2 = reject(&repo, &cfg, "another one").unwrap();
904        assert_eq!(updated2.status, "Rejected");
905    }
906}