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 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 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 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 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; 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 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 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 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 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 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 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 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 if new_path != target.path {
331 let _ = std::fs::remove_file(&target.path);
332 }
333
334 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 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 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 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 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 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 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 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 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 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 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 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}