1use crate::{
4 Adr, AdrLink, AdrStatus, Config, ConfigMode, Error, LinkKind, Parser, Result, Template,
5 TemplateEngine, TemplateFormat, TemplateVariant,
6};
7use fuzzy_matcher::FuzzyMatcher;
8use fuzzy_matcher::skim::SkimMatcherV2;
9use serde_yaml_neo::{Mapping, Value};
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13use walkdir::WalkDir;
14
15#[derive(Debug, Default, Clone, PartialEq, Eq)]
29#[non_exhaustive]
30pub struct BodySectionPatch {
31 pub context: Option<String>,
33 pub decision: Option<String>,
35 pub consequences: Option<String>,
37}
38
39impl BodySectionPatch {
40 pub fn new() -> Self {
42 Self {
43 context: None,
44 decision: None,
45 consequences: None,
46 }
47 }
48
49 pub fn is_empty(&self) -> bool {
51 self.context.is_none() && self.decision.is_none() && self.consequences.is_none()
52 }
53
54 pub fn with_context(mut self, text: impl Into<String>) -> Self {
56 self.context = Some(text.into());
57 self
58 }
59
60 pub fn with_decision(mut self, text: impl Into<String>) -> Self {
62 self.decision = Some(text.into());
63 self
64 }
65
66 pub fn with_consequences(mut self, text: impl Into<String>) -> Self {
68 self.consequences = Some(text.into());
69 self
70 }
71}
72
73#[derive(Debug)]
75pub struct Repository {
76 root: PathBuf,
78
79 config: Config,
81
82 parser: Parser,
84
85 template_engine: TemplateEngine,
87}
88
89impl Repository {
90 pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
92 let root = root.into();
93 let config = Config::load(&root)?;
94 let template_engine = Self::engine_from_config(&config);
95
96 Ok(Self {
97 root,
98 config,
99 parser: Parser::new(),
100 template_engine,
101 })
102 }
103
104 pub fn open_or_default(root: impl Into<PathBuf>) -> Self {
106 let root = root.into();
107 let config = Config::load_or_default(&root);
108 let template_engine = Self::engine_from_config(&config);
109
110 Self {
111 root,
112 config,
113 parser: Parser::new(),
114 template_engine,
115 }
116 }
117
118 pub fn init(root: impl Into<PathBuf>, adr_dir: Option<PathBuf>, ng: bool) -> Result<Self> {
120 let root = root.into();
121 let adr_dir = adr_dir.unwrap_or_else(|| PathBuf::from(crate::config::DEFAULT_ADR_DIR));
122 let adr_path = root.join(&adr_dir);
123
124 let existing_adrs = if adr_path.exists() {
126 count_existing_adrs(&adr_path)
127 } else {
128 fs::create_dir_all(&adr_path)?;
130 0
131 };
132
133 let config = Config {
135 adr_dir,
136 mode: if ng {
137 ConfigMode::NextGen
138 } else {
139 ConfigMode::Compatible
140 },
141 ..Default::default()
142 };
143 config.save(&root)?;
144
145 let template_engine = Self::engine_from_config(&config);
146
147 let repo = Self {
148 root,
149 config,
150 parser: Parser::new(),
151 template_engine,
152 };
153
154 if existing_adrs == 0 {
156 let mut adr = Adr::new(1, crate::init_adr::TITLE);
157 adr.status = AdrStatus::Accepted;
158 adr.context = crate::init_adr::CONTEXT.into();
159 adr.decision = crate::init_adr::DECISION.into();
160 adr.consequences = crate::init_adr::CONSEQUENCES.into();
161 repo.create(&adr)?;
162 }
163
164 Ok(repo)
165 }
166
167 pub fn root(&self) -> &Path {
169 &self.root
170 }
171
172 pub fn config(&self) -> &Config {
174 &self.config
175 }
176
177 pub fn adr_path(&self) -> PathBuf {
179 self.config.adr_path(&self.root)
180 }
181
182 fn engine_from_config(config: &Config) -> TemplateEngine {
184 let mut engine = TemplateEngine::new();
185 if let Some(ref fmt) = config.templates.format
186 && let Ok(format) = fmt.parse::<TemplateFormat>()
187 {
188 engine = engine.with_format(format);
189 }
190 engine
191 }
192
193 pub fn with_template_format(mut self, format: TemplateFormat) -> Self {
195 self.template_engine = self.template_engine.with_format(format);
196 self
197 }
198
199 pub fn with_template_variant(mut self, variant: TemplateVariant) -> Self {
201 self.template_engine = self.template_engine.with_variant(variant);
202 self
203 }
204
205 pub fn with_mode(mut self, mode: ConfigMode) -> Self {
207 self.config.mode = mode;
208 self
209 }
210
211 pub fn with_custom_template(mut self, template: Template) -> Self {
213 self.template_engine = self.template_engine.with_custom_template(template);
214 self
215 }
216
217 pub fn list(&self) -> Result<Vec<Adr>> {
219 let adr_path = self.adr_path();
220 if !adr_path.exists() {
221 return Err(Error::AdrDirNotFound);
222 }
223
224 let mut adrs: Vec<Adr> = WalkDir::new(&adr_path)
225 .max_depth(1)
226 .into_iter()
227 .filter_map(|e| e.ok())
228 .filter(|e| {
229 e.path().extension().is_some_and(|ext| ext == "md")
230 && e.path()
231 .file_name()
232 .and_then(|n| n.to_str())
233 .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
234 })
235 .filter_map(|e| self.parser.parse_file(e.path()).ok())
236 .collect();
237
238 adrs.sort_by_key(|a| a.number);
239 Ok(adrs)
240 }
241
242 #[allow(clippy::type_complexity)]
248 pub fn list_with_errors(&self) -> Result<(Vec<Adr>, Vec<(PathBuf, crate::Error)>)> {
249 let adr_path = self.adr_path();
250 if !adr_path.exists() {
251 return Err(Error::AdrDirNotFound);
252 }
253
254 let mut adrs = Vec::new();
255 let mut errors = Vec::new();
256
257 let candidates: Vec<_> = WalkDir::new(&adr_path)
258 .max_depth(1)
259 .into_iter()
260 .filter_map(|e| e.ok())
261 .filter(|e| {
262 e.path().extension().is_some_and(|ext| ext == "md")
263 && e.path()
264 .file_name()
265 .and_then(|n| n.to_str())
266 .is_some_and(|n| n.chars().next().is_some_and(|c| c.is_ascii_digit()))
267 })
268 .collect();
269
270 for entry in candidates {
271 match self.parser.parse_file(entry.path()) {
272 Ok(adr) => adrs.push(adr),
273 Err(e) => errors.push((entry.path().to_path_buf(), e)),
274 }
275 }
276
277 adrs.sort_by_key(|a| a.number);
278 Ok((adrs, errors))
279 }
280
281 pub fn next_number(&self) -> Result<u32> {
283 let adrs = self.list()?;
284 Ok(adrs.last().map(|a| a.number + 1).unwrap_or(1))
285 }
286
287 pub fn get(&self, number: u32) -> Result<Adr> {
289 let adrs = self.list()?;
290 adrs.into_iter()
291 .find(|a| a.number == number)
292 .ok_or_else(|| Error::AdrNotFound(number.to_string()))
293 }
294
295 pub fn find(&self, query: &str) -> Result<Adr> {
297 if let Ok(number) = query.parse::<u32>() {
299 return self.get(number);
300 }
301
302 let adrs = self.list()?;
304 let matcher = SkimMatcherV2::default();
305
306 let mut matches: Vec<_> = adrs
307 .into_iter()
308 .filter_map(|adr| {
309 let score = matcher.fuzzy_match(&adr.title, query)?;
310 Some((adr, score))
311 })
312 .collect();
313
314 matches.sort_by_key(|m| std::cmp::Reverse(m.1));
315
316 match matches.len() {
317 0 => Err(Error::AdrNotFound(query.to_string())),
318 1 => Ok(matches.remove(0).0),
319 _ => {
320 if matches[0].1 > matches[1].1 * 2 {
322 Ok(matches.remove(0).0)
323 } else {
324 Err(Error::AmbiguousAdr {
325 query: query.to_string(),
326 matches: matches
327 .iter()
328 .take(5)
329 .map(|(a, _)| a.title.clone())
330 .collect(),
331 })
332 }
333 }
334 }
335 }
336
337 fn resolve_link_titles(&self, adr: &Adr) -> HashMap<u32, (String, String)> {
339 let mut map = HashMap::new();
340 for link in &adr.links {
341 if map.contains_key(&link.target) {
342 continue;
343 }
344 if let Ok(target_adr) = self.get(link.target) {
345 map.insert(
346 link.target,
347 (target_adr.title.clone(), Self::link_href(&target_adr)),
348 );
349 }
350 }
351 map
352 }
353
354 fn link_href(target_adr: &Adr) -> String {
362 target_adr
363 .path
364 .as_ref()
365 .and_then(|p| p.file_name())
366 .and_then(|f| f.to_str())
367 .map(str::to_string)
368 .unwrap_or_else(|| target_adr.filename())
369 }
370
371 pub fn create(&self, adr: &Adr) -> Result<PathBuf> {
373 let path = self.adr_path().join(adr.filename());
374
375 let link_titles = self.resolve_link_titles(adr);
376 let content = self
377 .template_engine
378 .render(adr, &self.config, &link_titles)?;
379 fs::write(&path, content)?;
380
381 Ok(path)
382 }
383
384 pub fn new_adr(&self, title: impl Into<String>) -> Result<(Adr, PathBuf)> {
386 let number = self.next_number()?;
387 let mut adr = Adr::new(number, title);
388 if let Some(default_status) = self.config.default_status.as_deref() {
389 adr.status = default_status.parse::<AdrStatus>().unwrap();
390 }
391 let path = self.create(&adr)?;
392 Ok((adr, path))
393 }
394
395 pub fn supersede(&self, title: impl Into<String>, superseded: u32) -> Result<(Adr, PathBuf)> {
397 let number = self.next_number()?;
398 let mut adr = Adr::new(number, title);
399 adr.add_link(AdrLink::new(superseded, LinkKind::Supersedes));
400
401 let path = self.create(&adr)?;
404
405 let mut old_adr = self.get(superseded)?;
408 old_adr.status = AdrStatus::Superseded;
409 old_adr.add_link(AdrLink::new(number, LinkKind::SupersededBy));
410 self.update_metadata(&old_adr)?;
411
412 Ok((adr, path))
413 }
414
415 pub fn set_status(
420 &self,
421 number: u32,
422 status: AdrStatus,
423 superseded_by: Option<u32>,
424 ) -> Result<PathBuf> {
425 if let AdrStatus::Custom(s) = &status
429 && s.trim().is_empty()
430 {
431 return Err(Error::InvalidStatus(
432 "status cannot be empty or whitespace-only".to_string(),
433 ));
434 }
435
436 let mut adr = self.get(number)?;
437 adr.status = status.clone();
438
439 if let (AdrStatus::Superseded, Some(by)) = (&status, superseded_by) {
441 let _ = self.get(by)?;
443
444 if !adr
446 .links
447 .iter()
448 .any(|l| matches!(l.kind, LinkKind::SupersededBy) && l.target == by)
449 {
450 adr.add_link(AdrLink::new(by, LinkKind::SupersededBy));
451 }
452 }
453
454 self.update_metadata(&adr)
455 }
456
457 pub fn link(
459 &self,
460 source: u32,
461 target: u32,
462 source_kind: LinkKind,
463 target_kind: LinkKind,
464 ) -> Result<()> {
465 let mut source_adr = self.get(source)?;
466 let mut target_adr = self.get(target)?;
467
468 source_adr.add_link(AdrLink::new(target, source_kind));
469 target_adr.add_link(AdrLink::new(source, target_kind));
470
471 self.update_metadata(&source_adr)?;
472 self.update_metadata(&target_adr)?;
473
474 Ok(())
475 }
476
477 pub fn update(&self, adr: &Adr, body: BodySectionPatch) -> Result<PathBuf> {
494 let path = adr
495 .path
496 .clone()
497 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
498
499 let content = fs::read_to_string(&path)?;
500
501 let content = if body.is_empty() {
502 if content.starts_with("---\n") {
503 self.update_frontmatter_metadata(adr, &content)?
504 } else {
505 self.update_legacy_metadata(adr, &content)?
506 }
507 } else {
508 content
509 };
510
511 let updated = if body.is_empty() {
512 content
513 } else {
514 self.update_body_sections(&content, &body)?
515 };
516 fs::write(&path, updated)?;
517
518 Ok(path)
519 }
520
521 pub fn read_content(&self, adr: &Adr) -> Result<String> {
523 let path = adr
524 .path
525 .as_ref()
526 .cloned()
527 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
528
529 Ok(fs::read_to_string(path)?)
530 }
531
532 pub fn write_content(&self, adr: &Adr, content: &str) -> Result<PathBuf> {
534 let path = adr
535 .path
536 .as_ref()
537 .cloned()
538 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
539
540 fs::write(&path, content)?;
541 Ok(path)
542 }
543
544 pub fn update_metadata(&self, adr: &Adr) -> Result<PathBuf> {
547 let path = adr
548 .path
549 .clone()
550 .unwrap_or_else(|| self.adr_path().join(adr.filename()));
551
552 let content = fs::read_to_string(&path)?;
553
554 let updated = if content.starts_with("---\n") {
555 self.update_frontmatter_metadata(adr, &content)?
556 } else {
557 self.update_legacy_metadata(adr, &content)?
558 };
559
560 fs::write(&path, updated)?;
561 Ok(path)
562 }
563
564 fn update_frontmatter_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
574 let Some(rest) = content.strip_prefix("---\n") else {
576 return Err(Error::InvalidFormat {
577 path: Default::default(),
578 reason: "Missing opening frontmatter delimiter".into(),
579 });
580 };
581
582 let Some(end_idx) = rest.find("\n---\n").or_else(|| {
583 if rest.ends_with("\n---") {
585 Some(rest.len() - 3)
586 } else {
587 None
588 }
589 }) else {
590 return Err(Error::InvalidFormat {
591 path: Default::default(),
592 reason: "Missing closing frontmatter delimiter".into(),
593 });
594 };
595
596 let yaml_block = &rest[..end_idx + 1]; let after_yaml = &rest[end_idx..]; let parsed: Value = serde_yaml_neo::from_str(yaml_block)?;
600 let Value::Mapping(mut map) = parsed else {
601 return Err(Error::InvalidFormat {
602 path: Default::default(),
603 reason: "Frontmatter YAML must be a mapping".into(),
604 });
605 };
606
607 let mut dirty = false;
608
609 let status_val = Value::String(adr.status.to_string().to_lowercase());
611 if map.get(Self::yaml_str_key("status")) != Some(&status_val) {
612 map.insert(Self::yaml_str_key("status"), status_val);
613 dirty = true;
614 }
615
616 if Self::set_yaml_sequence_field(&mut map, "links", &adr.links)? {
618 dirty = true;
619 }
620
621 if !Self::yaml_string_list_matches(&map, "tags", &adr.tags)
623 && Self::set_yaml_string_list_field(&mut map, "tags", &adr.tags)?
624 {
625 dirty = true;
626 }
627
628 if !Self::yaml_string_list_matches(&map, "decision-makers", &adr.decision_makers)
631 && Self::set_yaml_string_list_field(&mut map, "decision-makers", &adr.decision_makers)?
632 {
633 dirty = true;
634 }
635 if !Self::yaml_string_list_matches(&map, "consulted", &adr.consulted)
636 && Self::set_yaml_string_list_field(&mut map, "consulted", &adr.consulted)?
637 {
638 dirty = true;
639 }
640 if !Self::yaml_string_list_matches(&map, "informed", &adr.informed)
641 && Self::set_yaml_string_list_field(&mut map, "informed", &adr.informed)?
642 {
643 dirty = true;
644 }
645
646 if !dirty {
647 return Ok(content.to_string());
648 }
649
650 let new_yaml = serde_yaml_neo::to_string(&Value::Mapping(map))?;
651 let new_yaml = new_yaml.trim_end_matches('\n');
652 Ok(format!("---\n{new_yaml}{after_yaml}"))
653 }
654
655 fn update_legacy_metadata(&self, adr: &Adr, content: &str) -> Result<String> {
660 let lines: Vec<&str> = content.lines().collect();
661 let mut result = String::with_capacity(content.len());
662
663 let status_idx = lines.iter().position(|l| {
665 l.trim().eq_ignore_ascii_case("## Status") || l.trim().eq_ignore_ascii_case("## STATUS")
666 });
667
668 let Some(status_idx) = status_idx else {
669 return Ok(content.to_string());
671 };
672
673 let next_heading_idx = lines[status_idx + 1..]
675 .iter()
676 .position(|l| l.starts_with("## "))
677 .map(|i| i + status_idx + 1);
678
679 for line in &lines[..=status_idx] {
681 result.push_str(line);
682 result.push('\n');
683 }
684
685 result.push('\n');
687 result.push_str(&adr.status.to_string());
688 result.push('\n');
689
690 let link_titles = self.resolve_link_titles(adr);
692 for link in &adr.links {
693 result.push('\n');
694 if let Some((title, filename)) = link_titles.get(&link.target) {
695 result.push_str(&format!(
696 "{} [{}. {}]({})",
697 link.kind, link.target, title, filename
698 ));
699 } else {
700 result.push_str(&format!(
701 "{} [{}. ...]({:04}-....md)",
702 link.kind, link.target, link.target
703 ));
704 }
705 result.push('\n');
706 }
707
708 if let Some(next_idx) = next_heading_idx {
710 result.push('\n');
711 for (i, line) in lines[next_idx..].iter().enumerate() {
712 result.push_str(line);
713 if next_idx + i < lines.len() - 1 || content.ends_with('\n') {
715 result.push('\n');
716 }
717 }
718 } else if content.ends_with('\n') {
719 }
721
722 Ok(result)
723 }
724
725 fn update_body_sections(&self, content: &str, patch: &BodySectionPatch) -> Result<String> {
727 let lines: Vec<&str> = content.lines().collect();
728 let mut result = String::with_capacity(content.len());
729 let mut i = 0;
730 let mut found_context = patch.context.is_none();
731 let mut found_decision = patch.decision.is_none();
732 let mut found_consequences = patch.consequences.is_none();
733
734 while i < lines.len() {
735 let line = lines[i];
736 if Self::is_h2_outside_fence(&lines, i)
737 && let Some(heading_text) = line.strip_prefix("## ")
738 && let Some(field) = crate::parse::canonical_section_field(heading_text.trim())
739 {
740 result.push_str(line);
741 result.push('\n');
742 i += 1;
743 let body_end = Self::next_h2_index(&lines, i);
744
745 match field {
746 "context" => {
747 if patch.context.is_some() {
748 found_context = true;
749 }
750 if let Some(ref text) = patch.context {
751 Self::write_section_body(&mut result, text);
752 } else {
753 Self::append_lines(&mut result, &lines, i, body_end, content);
754 }
755 }
756 "decision" => {
757 let madr_decision_outcome =
758 heading_text.trim().eq_ignore_ascii_case("Decision Outcome");
759 let (decision_found, consequences_applied) = Self::patch_decision_section(
760 &mut result,
761 &lines,
762 content,
763 i,
764 body_end,
765 patch,
766 madr_decision_outcome,
767 );
768 if decision_found {
769 found_decision = true;
770 }
771 if consequences_applied {
772 found_consequences = true;
773 }
774 }
775 "consequences" => {
776 if patch.consequences.is_some() {
777 found_consequences = true;
778 }
779 if let Some(ref text) = patch.consequences {
780 Self::write_section_body(&mut result, text);
781 } else {
782 Self::append_lines(&mut result, &lines, i, body_end, content);
783 }
784 }
785 _ => {
786 Self::append_lines(&mut result, &lines, i, body_end, content);
787 }
788 }
789
790 i = body_end;
791 continue;
792 }
793
794 result.push_str(line);
795 if i < lines.len() - 1 || content.ends_with('\n') {
796 result.push('\n');
797 }
798 i += 1;
799 }
800
801 if !found_context {
802 return Err(Error::InvalidFormat {
803 path: PathBuf::new(),
804 reason: "context patch requested but no matching section heading found".into(),
805 });
806 }
807 if !found_decision {
808 return Err(Error::InvalidFormat {
809 path: PathBuf::new(),
810 reason: "decision patch requested but no matching section heading found".into(),
811 });
812 }
813 if !found_consequences {
814 return Err(Error::InvalidFormat {
815 path: PathBuf::new(),
816 reason: "consequences patch requested but no matching section heading found".into(),
817 });
818 }
819
820 if content.ends_with('\n') && !result.ends_with('\n') {
821 result.push('\n');
822 }
823
824 Ok(result)
825 }
826
827 fn fence_run(line: &str) -> Option<(char, usize)> {
830 let indent = line.chars().take_while(|c| *c == ' ').count();
831 if indent >= 4 {
832 return None;
833 }
834 let rest = &line[indent..];
835 let ch = rest.chars().next()?;
836 if ch != '`' && ch != '~' {
837 return None;
838 }
839 let run = rest.chars().take_while(|c| *c == ch).count();
840 if run < 3 {
841 return None;
842 }
843 Some((ch, run))
844 }
845
846 fn is_fence_close(line: &str, ch: char, open_len: usize) -> bool {
849 let Some((close_ch, run)) = Self::fence_run(line) else {
850 return false;
851 };
852 if close_ch != ch || run < open_len {
853 return false;
854 }
855 let indent = line.chars().take_while(|c| *c == ' ').count();
856 let after_run = &line[indent + run..];
857 after_run.chars().all(|c| c == ' ' || c == '\t')
858 }
859
860 fn in_fence_at_line(lines: &[&str], index: usize) -> bool {
861 let mut open: Option<(char, usize)> = None;
862 for line in &lines[..index] {
863 match open {
864 None => {
865 if let Some((ch, run)) = Self::fence_run(line) {
866 open = Some((ch, run));
867 }
868 }
869 Some((ch, open_len)) => {
870 if Self::is_fence_close(line, ch, open_len) {
871 open = None;
872 }
873 }
874 }
875 }
876 open.is_some()
877 }
878
879 fn is_h2_outside_fence(lines: &[&str], index: usize) -> bool {
880 lines[index].starts_with("## ") && !Self::in_fence_at_line(lines, index)
881 }
882
883 fn is_h3_outside_fence(lines: &[&str], index: usize) -> bool {
884 lines[index].starts_with("### ") && !Self::in_fence_at_line(lines, index)
885 }
886
887 fn next_h2_index(lines: &[&str], start: usize) -> usize {
888 lines[start..]
889 .iter()
890 .enumerate()
891 .find(|(offset, _)| Self::is_h2_outside_fence(lines, start + offset))
892 .map(|(offset, _)| start + offset)
893 .unwrap_or(lines.len())
894 }
895
896 fn append_lines(result: &mut String, lines: &[&str], start: usize, end: usize, content: &str) {
897 for (offset, line) in lines[start..end].iter().enumerate() {
898 result.push_str(line);
899 if start + offset < end - 1 || end < lines.len() || content.ends_with('\n') {
900 result.push('\n');
901 }
902 }
903 }
904
905 fn write_section_body(result: &mut String, text: &str) {
906 result.push('\n');
907 result.push_str(text);
908 if !text.ends_with('\n') {
909 result.push('\n');
910 }
911 }
912
913 fn is_consequences_h3(line: &str) -> bool {
914 line.strip_prefix("### ")
915 .is_some_and(|title| title.trim().eq_ignore_ascii_case("consequences"))
916 }
917
918 fn is_consequences_h2(line: &str) -> bool {
919 line.strip_prefix("## ")
920 .is_some_and(|title| title.trim().eq_ignore_ascii_case("consequences"))
921 }
922
923 fn patch_decision_section(
928 result: &mut String,
929 lines: &[&str],
930 content: &str,
931 body_start: usize,
932 body_end: usize,
933 patch: &BodySectionPatch,
934 madr_decision_outcome: bool,
935 ) -> (bool, bool) {
936 if patch.decision.is_none() && patch.consequences.is_none() {
937 Self::append_lines(result, lines, body_start, body_end, content);
938 return (false, false);
939 }
940
941 let mut consequences_applied = false;
942
943 if patch.decision.is_none()
947 && patch.consequences.is_some()
948 && Self::has_consequences_h2(lines)
949 {
950 Self::append_lines(result, lines, body_start, body_end, content);
951 return (true, false);
952 }
953
954 let body = &lines[body_start..body_end];
955 let first_h3 = body
956 .iter()
957 .enumerate()
958 .find(|(offset, _)| Self::is_h3_outside_fence(lines, body_start + offset))
959 .map(|(offset, _)| body_start + offset);
960 let intro_end = first_h3.unwrap_or(body_end);
961
962 if let Some(ref text) = patch.decision {
963 Self::write_section_body(result, text);
964 } else {
965 Self::append_lines(result, lines, body_start, intro_end, content);
966 }
967
968 if intro_end >= body_end {
969 if let Some(ref text) = patch.consequences
970 && !Self::has_consequences_h2(lines)
971 && madr_decision_outcome
972 {
973 Self::write_madr_consequences_subsection(result, text);
974 consequences_applied = true;
975 }
976 return (true, consequences_applied);
977 }
978
979 let mut j = intro_end;
980
981 while j < body_end {
982 if !Self::is_h3_outside_fence(lines, j) {
983 j += 1;
984 continue;
985 }
986
987 let sub_start = j;
988 let sub_end = lines[sub_start + 1..body_end]
989 .iter()
990 .enumerate()
991 .find(|(offset, _)| {
992 let idx = sub_start + 1 + offset;
993 Self::is_h2_outside_fence(lines, idx) || Self::is_h3_outside_fence(lines, idx)
994 })
995 .map(|(offset, _)| sub_start + 1 + offset)
996 .unwrap_or(body_end);
997
998 if Self::is_consequences_h3(lines[sub_start])
999 && let Some(ref text) = patch.consequences
1000 {
1001 consequences_applied = true;
1002 result.push_str(lines[sub_start]);
1003 result.push('\n');
1004 Self::write_section_body(result, text);
1005 j = sub_end;
1006 continue;
1007 }
1008
1009 Self::append_lines(result, lines, sub_start, sub_end, content);
1010 j = sub_end;
1011 }
1012
1013 if let Some(ref text) = patch.consequences
1014 && !consequences_applied
1015 && !Self::has_consequences_h2(lines)
1016 && madr_decision_outcome
1017 {
1018 Self::write_madr_consequences_subsection(result, text);
1019 consequences_applied = true;
1020 }
1021
1022 (true, consequences_applied)
1023 }
1024
1025 fn has_consequences_h2(lines: &[&str]) -> bool {
1027 lines.iter().enumerate().any(|(idx, line)| {
1028 Self::is_h2_outside_fence(lines, idx) && Self::is_consequences_h2(line)
1029 })
1030 }
1031
1032 fn write_madr_consequences_subsection(result: &mut String, text: &str) {
1033 if !result.is_empty() && !result.ends_with('\n') {
1036 result.push('\n');
1037 }
1038 result.push_str("### Consequences\n");
1039 Self::write_section_body(result, text);
1040 }
1041
1042 fn yaml_str_key(key: &str) -> Value {
1043 Value::String(key.to_string())
1044 }
1045
1046 fn set_yaml_sequence_field<T: serde::Serialize>(
1048 map: &mut Mapping,
1049 key: &str,
1050 values: &[T],
1051 ) -> Result<bool> {
1052 let key = Self::yaml_str_key(key);
1053 if values.is_empty() {
1054 return Ok(map.remove(&key).is_some());
1055 }
1056 let desired = serde_yaml_neo::to_value(values)?;
1057 if map.get(&key) == Some(&desired) {
1058 return Ok(false);
1059 }
1060 map.insert(key, desired);
1061 Ok(true)
1062 }
1063
1064 fn set_yaml_string_list_field(map: &mut Mapping, key: &str, values: &[String]) -> Result<bool> {
1066 Self::set_yaml_sequence_field(map, key, values)
1067 }
1068
1069 fn yaml_string_list_matches(map: &Mapping, key: &str, desired: &[String]) -> bool {
1072 match map.get(Self::yaml_str_key(key)) {
1073 None | Some(Value::Null) => desired.is_empty(),
1074 Some(Value::String(s)) => desired.len() == 1 && desired[0] == *s,
1075 Some(Value::Sequence(seq)) => {
1076 let got: Option<Vec<&str>> = seq.iter().map(|v| v.as_str()).collect();
1077 match got {
1078 Some(got) => got == desired.iter().map(String::as_str).collect::<Vec<_>>(),
1079 None => false,
1080 }
1081 }
1082 Some(_) => false,
1083 }
1084 }
1085}
1086
1087fn count_existing_adrs(path: &Path) -> usize {
1089 if !path.is_dir() {
1090 return 0;
1091 }
1092
1093 fs::read_dir(path)
1094 .map(|entries| {
1095 entries
1096 .filter_map(|e| e.ok())
1097 .filter(|e| {
1098 let path = e.path();
1099 path.is_file()
1100 && path.extension().is_some_and(|ext| ext == "md")
1101 && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
1102 n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
1104 })
1105 })
1106 .count()
1107 })
1108 .unwrap_or(0)
1109}
1110
1111#[cfg(test)]
1112mod tests {
1113 use super::*;
1114 use tempfile::TempDir;
1115
1116 #[test]
1119 fn test_init_repository() {
1120 let temp = TempDir::new().unwrap();
1121 let repo = Repository::init(temp.path(), None, false).unwrap();
1122
1123 assert!(repo.adr_path().exists());
1124 assert!(temp.path().join(".adr-dir").exists());
1125
1126 let adrs = repo.list().unwrap();
1127 assert_eq!(adrs.len(), 1);
1128 assert_eq!(adrs[0].number, 1);
1129 assert_eq!(adrs[0].title, "Record architecture decisions");
1130 }
1131
1132 #[test]
1133 fn test_init_repository_ng() {
1134 let temp = TempDir::new().unwrap();
1135 let repo = Repository::init(temp.path(), None, true).unwrap();
1136
1137 assert!(temp.path().join("adrs.toml").exists());
1138 assert!(repo.config().is_next_gen());
1139 }
1140
1141 #[test]
1142 fn test_init_repository_custom_dir() {
1143 let temp = TempDir::new().unwrap();
1144 let repo = Repository::init(temp.path(), Some("decisions".into()), false).unwrap();
1145
1146 assert!(temp.path().join("decisions").exists());
1147 assert_eq!(repo.config().adr_dir, PathBuf::from("decisions"));
1148 }
1149
1150 #[test]
1151 fn test_init_repository_nested_dir() {
1152 let temp = TempDir::new().unwrap();
1153 let _repo =
1154 Repository::init(temp.path(), Some("docs/architecture/adr".into()), false).unwrap();
1155
1156 assert!(temp.path().join("docs/architecture/adr").exists());
1157 }
1158
1159 #[test]
1160 fn test_init_repository_already_exists_skips_initial_adr() {
1161 let temp = TempDir::new().unwrap();
1162 Repository::init(temp.path(), None, false).unwrap();
1163
1164 let repo = Repository::init(temp.path(), None, false).unwrap();
1166 let adrs = repo.list().unwrap();
1167 assert_eq!(adrs.len(), 1); }
1169
1170 #[test]
1171 fn test_init_with_existing_adrs_skips_initial() {
1172 let temp = TempDir::new().unwrap();
1173 let adr_dir = temp.path().join("doc/adr");
1174 fs::create_dir_all(&adr_dir).unwrap();
1175
1176 fs::write(
1178 adr_dir.join("0001-existing-decision.md"),
1179 "# 1. Existing Decision\n\nDate: 2024-01-01\n\n## Status\n\nAccepted\n\n## Context\n\nTest\n\n## Decision\n\nTest\n\n## Consequences\n\nTest\n",
1180 )
1181 .unwrap();
1182 fs::write(
1183 adr_dir.join("0002-another-decision.md"),
1184 "# 2. Another Decision\n\nDate: 2024-01-02\n\n## Status\n\nAccepted\n\n## Context\n\nTest\n\n## Decision\n\nTest\n\n## Consequences\n\nTest\n",
1185 )
1186 .unwrap();
1187
1188 let repo = Repository::init(temp.path(), None, false).unwrap();
1190 let adrs = repo.list().unwrap();
1191 assert_eq!(adrs.len(), 2); assert_eq!(adrs[0].title, "Existing Decision");
1193 assert_eq!(adrs[1].title, "Another Decision");
1194 }
1195
1196 #[test]
1197 fn test_init_creates_first_adr() {
1198 let temp = TempDir::new().unwrap();
1199 let repo = Repository::init(temp.path(), None, false).unwrap();
1200
1201 let adr = repo.get(1).unwrap();
1202 assert_eq!(adr.title, crate::init_adr::TITLE);
1203 assert_eq!(adr.status, AdrStatus::Accepted);
1204 assert_eq!(adr.context, crate::init_adr::CONTEXT);
1205 assert_eq!(adr.decision, crate::init_adr::DECISION);
1206 assert_eq!(adr.consequences, crate::init_adr::CONSEQUENCES);
1207 }
1208
1209 #[test]
1210 fn test_init_first_adr_has_markdown_links_in_both_modes() {
1211 for ng in [false, true] {
1212 let temp = TempDir::new().unwrap();
1213 let repo = Repository::init(temp.path(), None, ng).unwrap();
1214 let path = repo
1215 .adr_path()
1216 .join("0001-record-architecture-decisions.md");
1217 let content = fs::read_to_string(path).unwrap();
1218
1219 assert!(
1220 content.contains(
1221 "[Documenting Architecture Decisions](https://www.cognitect.com/blog/2011/11/15/documenting-architecture-decisions)"
1222 ),
1223 "expected Nygard article link (ng={ng})"
1224 );
1225 assert!(
1226 content.contains("[adrs](https://github.com/joshrotenberg/adrs)"),
1227 "expected adrs link (ng={ng})"
1228 );
1229 assert!(
1230 content.contains("[adr-tools](https://github.com/npryce/adr-tools)"),
1231 "expected adr-tools link (ng={ng})"
1232 );
1233 assert!(
1234 content.ends_with('\n'),
1235 "init ADR should end with a newline (ng={ng})"
1236 );
1237 }
1238 }
1239
1240 #[test]
1243 fn test_open_repository() {
1244 let temp = TempDir::new().unwrap();
1245 Repository::init(temp.path(), None, false).unwrap();
1246
1247 let repo = Repository::open(temp.path()).unwrap();
1248 assert_eq!(repo.list().unwrap().len(), 1);
1249 }
1250
1251 #[test]
1252 fn test_open_repository_not_found() {
1253 let temp = TempDir::new().unwrap();
1254 let result = Repository::open(temp.path());
1255 assert!(result.is_err());
1256 }
1257
1258 #[test]
1259 fn test_open_or_default() {
1260 let temp = TempDir::new().unwrap();
1261 let repo = Repository::open_or_default(temp.path());
1262 assert_eq!(repo.config().adr_dir, PathBuf::from("doc/adr"));
1263 }
1264
1265 #[test]
1266 fn test_open_or_default_existing() {
1267 let temp = TempDir::new().unwrap();
1268 Repository::init(temp.path(), Some("custom".into()), false).unwrap();
1269
1270 let repo = Repository::open_or_default(temp.path());
1271 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
1272 }
1273
1274 #[test]
1277 fn test_create_and_list() {
1278 let temp = TempDir::new().unwrap();
1279 let repo = Repository::init(temp.path(), None, false).unwrap();
1280
1281 let (adr, _) = repo.new_adr("Use Rust").unwrap();
1282 assert_eq!(adr.number, 2);
1283
1284 let adrs = repo.list().unwrap();
1285 assert_eq!(adrs.len(), 2);
1286 }
1287
1288 #[test]
1289 fn test_create_multiple() {
1290 let temp = TempDir::new().unwrap();
1291 let repo = Repository::init(temp.path(), None, false).unwrap();
1292
1293 repo.new_adr("Second").unwrap();
1294 repo.new_adr("Third").unwrap();
1295 repo.new_adr("Fourth").unwrap();
1296
1297 let adrs = repo.list().unwrap();
1298 assert_eq!(adrs.len(), 4);
1299 assert_eq!(adrs[0].number, 1);
1300 assert_eq!(adrs[1].number, 2);
1301 assert_eq!(adrs[2].number, 3);
1302 assert_eq!(adrs[3].number, 4);
1303 }
1304
1305 #[test]
1306 fn test_list_sorted_by_number() {
1307 let temp = TempDir::new().unwrap();
1308 let repo = Repository::init(temp.path(), None, false).unwrap();
1309
1310 repo.new_adr("B").unwrap();
1311 repo.new_adr("A").unwrap();
1312 repo.new_adr("C").unwrap();
1313
1314 let adrs = repo.list().unwrap();
1315 assert!(adrs.windows(2).all(|w| w[0].number < w[1].number));
1316 }
1317
1318 #[test]
1319 fn test_next_number() {
1320 let temp = TempDir::new().unwrap();
1321 let repo = Repository::init(temp.path(), None, false).unwrap();
1322
1323 assert_eq!(repo.next_number().unwrap(), 2);
1324
1325 repo.new_adr("Second").unwrap();
1326 assert_eq!(repo.next_number().unwrap(), 3);
1327 }
1328
1329 #[test]
1330 fn test_create_file_exists() {
1331 let temp = TempDir::new().unwrap();
1332 let repo = Repository::init(temp.path(), None, false).unwrap();
1333
1334 let (_, path) = repo.new_adr("Test ADR").unwrap();
1335 assert!(path.exists());
1336 assert!(path.to_string_lossy().contains("0002-test-adr.md"));
1337 }
1338
1339 #[test]
1340 fn test_new_adr_uses_custom_default_status_from_config() {
1341 let temp = TempDir::new().unwrap();
1342 Repository::init(temp.path(), None, false).unwrap();
1343
1344 std::fs::write(
1345 temp.path().join("adrs.toml"),
1346 r#"
1347adr_dir = "doc/adr"
1348mode = "compatible"
1349default_status = "draft"
1350"#,
1351 )
1352 .unwrap();
1353
1354 let repo = Repository::open(temp.path()).unwrap();
1355 let (adr, _) = repo.new_adr("Custom status ADR").unwrap();
1356
1357 assert_eq!(adr.status, AdrStatus::Custom("draft".into()));
1358 }
1359
1360 #[test]
1363 fn test_get_by_number() {
1364 let temp = TempDir::new().unwrap();
1365 let repo = Repository::init(temp.path(), None, false).unwrap();
1366 repo.new_adr("Second").unwrap();
1367
1368 let adr = repo.get(2).unwrap();
1369 assert_eq!(adr.title, "Second");
1370 }
1371
1372 #[test]
1373 fn test_get_not_found() {
1374 let temp = TempDir::new().unwrap();
1375 let repo = Repository::init(temp.path(), None, false).unwrap();
1376
1377 let result = repo.get(99);
1378 assert!(result.is_err());
1379 }
1380
1381 #[test]
1382 fn test_find_by_number() {
1383 let temp = TempDir::new().unwrap();
1384 let repo = Repository::init(temp.path(), None, false).unwrap();
1385
1386 let adr = repo.find("1").unwrap();
1387 assert_eq!(adr.number, 1);
1388 }
1389
1390 #[test]
1391 fn test_find_by_title() {
1392 let temp = TempDir::new().unwrap();
1393 let repo = Repository::init(temp.path(), None, false).unwrap();
1394
1395 let adr = repo.find("architecture").unwrap();
1396 assert_eq!(adr.number, 1);
1397 }
1398
1399 #[test]
1400 fn test_find_fuzzy_match() {
1401 let temp = TempDir::new().unwrap();
1402 let repo = Repository::init(temp.path(), None, false).unwrap();
1403 repo.new_adr("Use PostgreSQL for database").unwrap();
1404 repo.new_adr("Use Redis for caching").unwrap();
1405
1406 let adr = repo.find("postgres").unwrap();
1407 assert!(adr.title.contains("PostgreSQL"));
1408 }
1409
1410 #[test]
1411 fn test_find_not_found() {
1412 let temp = TempDir::new().unwrap();
1413 let repo = Repository::init(temp.path(), None, false).unwrap();
1414
1415 let result = repo.find("nonexistent");
1416 assert!(result.is_err());
1417 }
1418
1419 #[test]
1422 fn test_supersede() {
1423 let temp = TempDir::new().unwrap();
1424 let repo = Repository::init(temp.path(), None, false).unwrap();
1425
1426 let (new_adr, _) = repo.supersede("New approach", 1).unwrap();
1427 assert_eq!(new_adr.number, 2);
1428 assert_eq!(new_adr.links.len(), 1);
1429 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
1430
1431 let old_adr = repo.get(1).unwrap();
1432 assert_eq!(old_adr.status, AdrStatus::Superseded);
1433 }
1434
1435 #[test]
1436 fn test_supersede_creates_bidirectional_links() {
1437 let temp = TempDir::new().unwrap();
1438 let repo = Repository::init(temp.path(), None, false).unwrap();
1439
1440 repo.supersede("New approach", 1).unwrap();
1441
1442 let old_adr = repo.get(1).unwrap();
1443 assert_eq!(old_adr.links.len(), 1);
1444 assert_eq!(old_adr.links[0].target, 2);
1445 assert_eq!(old_adr.links[0].kind, LinkKind::SupersededBy);
1446
1447 let new_adr = repo.get(2).unwrap();
1448 assert_eq!(new_adr.links.len(), 1);
1449 assert_eq!(new_adr.links[0].target, 1);
1450 assert_eq!(new_adr.links[0].kind, LinkKind::Supersedes);
1451 }
1452
1453 #[test]
1454 fn test_supersede_not_found() {
1455 let temp = TempDir::new().unwrap();
1456 let repo = Repository::init(temp.path(), None, false).unwrap();
1457
1458 let result = repo.supersede("New", 99);
1459 assert!(result.is_err());
1460 }
1461
1462 #[test]
1465 fn test_supersede_generates_functional_links() {
1466 let temp = TempDir::new().unwrap();
1467 let repo = Repository::init(temp.path(), None, false).unwrap();
1468
1469 repo.new_adr("Use MySQL for persistence").unwrap();
1471 repo.supersede("Use PostgreSQL instead", 2).unwrap();
1472
1473 let new_content =
1475 fs::read_to_string(repo.adr_path().join("0003-use-postgresql-instead.md")).unwrap();
1476 assert!(
1477 new_content.contains(
1478 "Supersedes [2. Use MySQL for persistence](0002-use-mysql-for-persistence.md)"
1479 ),
1480 "New ADR should have functional Supersedes link. Got:\n{new_content}"
1481 );
1482
1483 let old_content =
1485 fs::read_to_string(repo.adr_path().join("0002-use-mysql-for-persistence.md")).unwrap();
1486 assert!(
1487 old_content.contains(
1488 "Superseded by [3. Use PostgreSQL instead](0003-use-postgresql-instead.md)"
1489 ),
1490 "Old ADR should have functional Superseded by link. Got:\n{old_content}"
1491 );
1492 }
1493
1494 #[test]
1495 fn test_link_generates_functional_links() {
1496 let temp = TempDir::new().unwrap();
1497 let repo = Repository::init(temp.path(), None, false).unwrap();
1498
1499 repo.new_adr("Use REST API").unwrap();
1500 repo.new_adr("Use JSON for API responses").unwrap();
1501
1502 repo.link(3, 2, LinkKind::Amends, LinkKind::AmendedBy)
1503 .unwrap();
1504
1505 let source_content =
1507 fs::read_to_string(repo.adr_path().join("0003-use-json-for-api-responses.md")).unwrap();
1508 assert!(
1509 source_content.contains("Amends [2. Use REST API](0002-use-rest-api.md)"),
1510 "Source ADR should have functional Amends link. Got:\n{source_content}"
1511 );
1512
1513 let target_content =
1515 fs::read_to_string(repo.adr_path().join("0002-use-rest-api.md")).unwrap();
1516 assert!(
1517 target_content.contains(
1518 "Amended by [3. Use JSON for API responses](0003-use-json-for-api-responses.md)"
1519 ),
1520 "Target ADR should have functional Amended by link. Got:\n{target_content}"
1521 );
1522 }
1523
1524 #[test]
1525 fn test_set_status_superseded_generates_functional_link() {
1526 let temp = TempDir::new().unwrap();
1527 let repo = Repository::init(temp.path(), None, false).unwrap();
1528
1529 repo.new_adr("First Decision").unwrap();
1530 repo.new_adr("Second Decision").unwrap();
1531
1532 repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
1533
1534 let content = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
1535 assert!(
1536 content.contains("Superseded by [3. Second Decision](0003-second-decision.md)"),
1537 "ADR should have functional Superseded by link. Got:\n{content}"
1538 );
1539 }
1540
1541 #[test]
1542 fn test_supersede_chain_generates_functional_links() {
1543 let temp = TempDir::new().unwrap();
1544 let repo = Repository::init(temp.path(), None, false).unwrap();
1545
1546 repo.new_adr("Use SQLite").unwrap();
1549 repo.supersede("Use PostgreSQL", 2).unwrap();
1551 repo.supersede("Use CockroachDB", 3).unwrap();
1553
1554 let adr3_content =
1556 fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
1557 assert!(
1558 adr3_content.contains("Supersedes [2. Use SQLite](0002-use-sqlite.md)"),
1559 "ADR 3 should supersede ADR 2. Got:\n{adr3_content}"
1560 );
1561 assert!(
1562 adr3_content.contains("Superseded by [4. Use CockroachDB](0004-use-cockroachdb.md)"),
1563 "ADR 3 should be superseded by ADR 4. Got:\n{adr3_content}"
1564 );
1565 }
1566
1567 #[test]
1568 fn test_ng_mode_supersede_generates_functional_links() {
1569 let temp = TempDir::new().unwrap();
1570 let repo = Repository::init(temp.path(), None, true).unwrap();
1571
1572 repo.new_adr("Use MySQL").unwrap();
1573 repo.supersede("Use PostgreSQL", 2).unwrap();
1574
1575 let new_content =
1577 fs::read_to_string(repo.adr_path().join("0003-use-postgresql.md")).unwrap();
1578
1579 assert!(
1581 new_content.contains("Supersedes [2. Use MySQL](0002-use-mysql.md)"),
1582 "NG mode should have functional link in body. Got:\n{new_content}"
1583 );
1584 assert!(new_content.contains("links:"));
1586 assert!(new_content.contains("target: 2"));
1587 }
1588
1589 #[test]
1592 fn test_set_status_accepted() {
1593 let temp = TempDir::new().unwrap();
1594 let repo = Repository::init(temp.path(), None, false).unwrap();
1595 repo.new_adr("Test Decision").unwrap();
1596
1597 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1598
1599 let adr = repo.get(2).unwrap();
1600 assert_eq!(adr.status, AdrStatus::Accepted);
1601 }
1602
1603 #[test]
1604 fn test_set_status_deprecated() {
1605 let temp = TempDir::new().unwrap();
1606 let repo = Repository::init(temp.path(), None, false).unwrap();
1607 repo.new_adr("Old Decision").unwrap();
1608
1609 repo.set_status(2, AdrStatus::Deprecated, None).unwrap();
1610
1611 let adr = repo.get(2).unwrap();
1612 assert_eq!(adr.status, AdrStatus::Deprecated);
1613 }
1614
1615 #[test]
1616 fn test_set_status_superseded_with_link() {
1617 let temp = TempDir::new().unwrap();
1618 let repo = Repository::init(temp.path(), None, false).unwrap();
1619 repo.new_adr("First Decision").unwrap();
1620 repo.new_adr("Second Decision").unwrap();
1621
1622 repo.set_status(2, AdrStatus::Superseded, Some(3)).unwrap();
1623
1624 let adr = repo.get(2).unwrap();
1625 assert_eq!(adr.status, AdrStatus::Superseded);
1626 assert_eq!(adr.links.len(), 1);
1627 assert_eq!(adr.links[0].target, 3);
1628 assert_eq!(adr.links[0].kind, LinkKind::SupersededBy);
1629 }
1630
1631 #[test]
1632 fn test_set_status_superseded_without_link() {
1633 let temp = TempDir::new().unwrap();
1634 let repo = Repository::init(temp.path(), None, false).unwrap();
1635 repo.new_adr("Decision").unwrap();
1636
1637 repo.set_status(2, AdrStatus::Superseded, None).unwrap();
1638
1639 let adr = repo.get(2).unwrap();
1640 assert_eq!(adr.status, AdrStatus::Superseded);
1641 assert_eq!(adr.links.len(), 0);
1642 }
1643
1644 #[test]
1645 fn test_set_status_custom() {
1646 let temp = TempDir::new().unwrap();
1647 let repo = Repository::init(temp.path(), None, false).unwrap();
1648 repo.new_adr("Test Decision").unwrap();
1649
1650 repo.set_status(2, AdrStatus::Custom("Draft".into()), None)
1651 .unwrap();
1652
1653 let adr = repo.get(2).unwrap();
1654 assert_eq!(adr.status, AdrStatus::Custom("Draft".into()));
1655 }
1656
1657 #[test]
1658 fn test_set_status_adr_not_found() {
1659 let temp = TempDir::new().unwrap();
1660 let repo = Repository::init(temp.path(), None, false).unwrap();
1661
1662 let result = repo.set_status(99, AdrStatus::Accepted, None);
1663 assert!(result.is_err());
1664 }
1665
1666 #[test]
1667 fn test_set_status_superseded_by_not_found() {
1668 let temp = TempDir::new().unwrap();
1669 let repo = Repository::init(temp.path(), None, false).unwrap();
1670 repo.new_adr("Decision").unwrap();
1671
1672 let result = repo.set_status(2, AdrStatus::Superseded, Some(99));
1673 assert!(result.is_err());
1674 }
1675
1676 #[test]
1679 fn test_link_adrs() {
1680 let temp = TempDir::new().unwrap();
1681 let repo = Repository::init(temp.path(), None, false).unwrap();
1682 repo.new_adr("Second").unwrap();
1683
1684 repo.link(1, 2, LinkKind::Amends, LinkKind::AmendedBy)
1685 .unwrap();
1686
1687 let adr1 = repo.get(1).unwrap();
1688 assert_eq!(adr1.links.len(), 1);
1689 assert_eq!(adr1.links[0].target, 2);
1690 assert_eq!(adr1.links[0].kind, LinkKind::Amends);
1691
1692 let adr2 = repo.get(2).unwrap();
1693 assert_eq!(adr2.links.len(), 1);
1694 assert_eq!(adr2.links[0].target, 1);
1695 assert_eq!(adr2.links[0].kind, LinkKind::AmendedBy);
1696 }
1697
1698 #[test]
1699 fn test_link_relates_to() {
1700 let temp = TempDir::new().unwrap();
1701 let repo = Repository::init(temp.path(), None, false).unwrap();
1702 repo.new_adr("Second").unwrap();
1703
1704 repo.link(1, 2, LinkKind::RelatesTo, LinkKind::RelatesTo)
1705 .unwrap();
1706
1707 let adr1 = repo.get(1).unwrap();
1708 assert_eq!(adr1.links[0].kind, LinkKind::RelatesTo);
1709
1710 let adr2 = repo.get(2).unwrap();
1711 assert_eq!(adr2.links[0].kind, LinkKind::RelatesTo);
1712 }
1713
1714 #[test]
1717 fn test_update_adr() {
1718 let temp = TempDir::new().unwrap();
1719 let repo = Repository::init(temp.path(), None, false).unwrap();
1720
1721 let mut adr = repo.get(1).unwrap();
1722 adr.status = AdrStatus::Deprecated;
1723
1724 repo.update(&adr, BodySectionPatch::default()).unwrap();
1725
1726 let updated = repo.get(1).unwrap();
1727 assert_eq!(updated.status, AdrStatus::Deprecated);
1728 }
1729
1730 #[test]
1731 fn test_update_preserves_content() {
1732 let temp = TempDir::new().unwrap();
1733 let repo = Repository::init(temp.path(), None, false).unwrap();
1734
1735 let mut adr = repo.get(1).unwrap();
1736 let original_title = adr.title.clone();
1737 adr.status = AdrStatus::Deprecated;
1738
1739 repo.update(&adr, BodySectionPatch::default()).unwrap();
1740
1741 let updated = repo.get(1).unwrap();
1742 assert_eq!(updated.title, original_title);
1743 }
1744
1745 #[test]
1748 fn test_read_content() {
1749 let temp = TempDir::new().unwrap();
1750 let repo = Repository::init(temp.path(), None, false).unwrap();
1751
1752 let adr = repo.get(1).unwrap();
1753 let content = repo.read_content(&adr).unwrap();
1754
1755 assert!(content.contains("Record architecture decisions"));
1756 assert!(content.contains("## Status"));
1757 }
1758
1759 #[test]
1760 fn test_write_content() {
1761 let temp = TempDir::new().unwrap();
1762 let repo = Repository::init(temp.path(), None, false).unwrap();
1763
1764 let adr = repo.get(1).unwrap();
1765 let new_content = "# 1. Modified\n\n## Status\n\nAccepted\n";
1766
1767 repo.write_content(&adr, new_content).unwrap();
1768
1769 let content = repo.read_content(&adr).unwrap();
1770 assert!(content.contains("Modified"));
1771 }
1772
1773 #[test]
1776 fn test_with_mode_overrides_compatible_to_ng() {
1777 let temp = TempDir::new().unwrap();
1778 let repo = Repository::init(temp.path(), None, false)
1780 .unwrap()
1781 .with_mode(ConfigMode::NextGen);
1782
1783 let (_, path) = repo.new_adr("Mode Override Test").unwrap();
1784 let content = fs::read_to_string(path).unwrap();
1785
1786 assert!(
1787 content.starts_with("---\n"),
1788 "with_mode(NextGen) on compatible repo should produce YAML frontmatter. Got:\n{content}"
1789 );
1790 assert!(content.contains("status: proposed"));
1791 }
1792
1793 #[test]
1794 fn test_with_mode_ng_to_compatible() {
1795 let temp = TempDir::new().unwrap();
1796 let repo = Repository::init(temp.path(), None, true)
1798 .unwrap()
1799 .with_mode(ConfigMode::Compatible);
1800
1801 let (_, path) = repo.new_adr("Downgrade Mode Test").unwrap();
1802 let content = fs::read_to_string(path).unwrap();
1803
1804 assert!(
1805 !content.starts_with("---\n"),
1806 "with_mode(Compatible) on ng repo should NOT produce YAML frontmatter. Got:\n{content}"
1807 );
1808 }
1809
1810 #[test]
1813 fn test_with_template_format() {
1814 let temp = TempDir::new().unwrap();
1815 let repo = Repository::init(temp.path(), None, false)
1816 .unwrap()
1817 .with_template_format(TemplateFormat::Madr);
1818
1819 let (_, path) = repo.new_adr("MADR Test").unwrap();
1820 let content = fs::read_to_string(path).unwrap();
1821
1822 assert!(content.contains("Context and Problem Statement"));
1823 }
1824
1825 #[test]
1826 fn test_with_custom_template() {
1827 let temp = TempDir::new().unwrap();
1828 let custom = Template::from_string("custom", "# ADR {{ number }}: {{ title }}");
1829 let repo = Repository::init(temp.path(), None, false)
1830 .unwrap()
1831 .with_custom_template(custom);
1832
1833 let (_, path) = repo.new_adr("Custom Test").unwrap();
1834 let content = fs::read_to_string(path).unwrap();
1835
1836 assert_eq!(content, "# ADR 2: Custom Test\n");
1837 }
1838
1839 #[test]
1842 fn test_root() {
1843 let temp = TempDir::new().unwrap();
1844 let repo = Repository::init(temp.path(), None, false).unwrap();
1845
1846 assert_eq!(repo.root(), temp.path());
1847 }
1848
1849 #[test]
1850 fn test_config() {
1851 let temp = TempDir::new().unwrap();
1852 let repo = Repository::init(temp.path(), Some("custom".into()), true).unwrap();
1853
1854 assert_eq!(repo.config().adr_dir, PathBuf::from("custom"));
1855 assert!(repo.config().is_next_gen());
1856 }
1857
1858 #[test]
1859 fn test_adr_path() {
1860 let temp = TempDir::new().unwrap();
1861 let repo = Repository::init(temp.path(), Some("my/adrs".into()), false).unwrap();
1862
1863 assert_eq!(repo.adr_path(), temp.path().join("my/adrs"));
1864 }
1865
1866 #[test]
1869 fn test_ng_mode_creates_frontmatter() {
1870 let temp = TempDir::new().unwrap();
1871 let repo = Repository::init(temp.path(), None, true).unwrap();
1872
1873 let (_, path) = repo.new_adr("NG Test").unwrap();
1874 let content = fs::read_to_string(path).unwrap();
1875
1876 assert!(content.starts_with("---"));
1877 assert!(content.contains("number: 2"));
1878 assert!(content.contains("title: NG Test"));
1879 }
1880
1881 #[test]
1882 fn test_ng_mode_parses_frontmatter() {
1883 let temp = TempDir::new().unwrap();
1884 let repo = Repository::init(temp.path(), None, true).unwrap();
1885
1886 repo.new_adr("NG ADR").unwrap();
1887
1888 let adr = repo.get(2).unwrap();
1889 assert_eq!(adr.title, "NG ADR");
1890 assert_eq!(adr.number, 2);
1891 }
1892
1893 #[test]
1896 fn test_list_empty_after_init_removal() {
1897 let temp = TempDir::new().unwrap();
1898 let repo = Repository::init(temp.path(), None, false).unwrap();
1899
1900 fs::remove_file(
1902 repo.adr_path()
1903 .join("0001-record-architecture-decisions.md"),
1904 )
1905 .unwrap();
1906
1907 let adrs = repo.list().unwrap();
1908 assert!(adrs.is_empty());
1909 }
1910
1911 #[test]
1912 fn test_list_ignores_non_adr_files() {
1913 let temp = TempDir::new().unwrap();
1914 let repo = Repository::init(temp.path(), None, false).unwrap();
1915
1916 fs::write(repo.adr_path().join("README.md"), "# README").unwrap();
1918 fs::write(repo.adr_path().join("notes.txt"), "Notes").unwrap();
1919
1920 let adrs = repo.list().unwrap();
1921 assert_eq!(adrs.len(), 1); }
1923
1924 #[test]
1925 fn test_special_characters_in_title() {
1926 let temp = TempDir::new().unwrap();
1927 let repo = Repository::init(temp.path(), None, false).unwrap();
1928
1929 let (adr, path) = repo.new_adr("Use C++ & Rust!").unwrap();
1930 assert!(path.exists());
1931 assert_eq!(adr.title, "Use C++ & Rust!");
1932 }
1933
1934 #[test]
1937 fn test_set_status_preserves_madr_body() {
1938 let temp = TempDir::new().unwrap();
1939 let repo = Repository::init(temp.path(), None, true).unwrap();
1940
1941 let madr_content = r#"---
1942number: 2
1943title: Use Redis for caching
1944date: 2026-01-15
1945status: proposed
1946---
1947
1948# Use Redis for caching
1949
1950## Context and Problem Statement
1951
1952We need a **fast** caching layer for our [API](https://api.example.com).
1953
1954## Considered Options
1955
1956* Redis
1957* Memcached
1958* In-memory cache
1959
1960## Decision Outcome
1961
1962Chosen option: "Redis", because it supports data structures beyond simple key-value.
1963
1964### Consequences
1965
1966* Good, because it provides pub/sub
1967* Bad, because it adds operational complexity
1968
1969## Pros and Cons of the Options
1970
1971### Redis
1972
1973* Good, because it supports complex data types
1974* Bad, because it requires a separate server
1975
1976### Memcached
1977
1978* Good, because it's simpler
1979* Bad, because it only supports strings
1980"#;
1981 let adr_path = repo.adr_path().join("0002-use-redis-for-caching.md");
1982 fs::write(&adr_path, madr_content).unwrap();
1983
1984 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
1986
1987 let result = fs::read_to_string(&adr_path).unwrap();
1988
1989 assert!(result.contains("status: accepted"));
1991 assert!(!result.contains("status: proposed"));
1992
1993 let body_start = result.find("\n# Use Redis").unwrap();
1995 let original_body_start = madr_content.find("\n# Use Redis").unwrap();
1996 assert_eq!(
1997 &result[body_start..],
1998 &madr_content[original_body_start..],
1999 "Body content was modified"
2000 );
2001 }
2002
2003 #[test]
2004 fn test_set_status_via_mapping_preserves_unknown_keys_and_body() {
2005 let temp = TempDir::new().unwrap();
2008 let repo = Repository::init(temp.path(), None, true).unwrap();
2009
2010 let content_with_comments = r#"---
2011# SPDX-License-Identifier: MIT
2012number: 2
2013title: Use MADR format
2014date: 2026-01-15
2015status: proposed
2016custom-meta: keep-me
2017---
2018
2019## Context and Problem Statement
2020
2021We need a standard ADR format.
2022
2023## Decision Outcome
2024
2025Use MADR 4.0.0.
2026"#;
2027 let adr_path = repo.adr_path().join("0002-use-madr-format.md");
2028 fs::write(&adr_path, content_with_comments).unwrap();
2029
2030 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2031
2032 let result = fs::read_to_string(&adr_path).unwrap();
2033
2034 assert!(result.contains("status: accepted"));
2035 assert!(
2036 result.contains("custom-meta: keep-me"),
2037 "unknown frontmatter key was dropped\n{result}"
2038 );
2039 assert!(
2040 result.contains("## Decision Outcome") && result.contains("Use MADR 4.0.0."),
2041 "markdown body must survive\n{result}"
2042 );
2043 }
2044
2045 #[test]
2046 fn test_set_status_preserves_markdown_links() {
2047 let temp = TempDir::new().unwrap();
2048 let repo = Repository::init(temp.path(), None, true).unwrap();
2049
2050 let content = r#"---
2051number: 2
2052title: Use PostgreSQL
2053date: 2026-01-15
2054status: proposed
2055---
2056
2057## Context
2058
2059See the [PostgreSQL docs](https://www.postgresql.org/docs/) for details.
2060
2061Also see [RFC 7159](https://tools.ietf.org/html/rfc7159) and `inline code`.
2062
2063## Decision
2064
2065We will use **PostgreSQL** version `16.x`.
2066
2067## Consequences
2068
2069- [Monitoring guide](https://example.com/monitoring)
2070- Performance benchmarks in [this report](./benchmarks.md)
2071"#;
2072 let adr_path = repo.adr_path().join("0002-use-postgresql.md");
2073 fs::write(&adr_path, content).unwrap();
2074
2075 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2076
2077 let result = fs::read_to_string(&adr_path).unwrap();
2078
2079 assert!(result.contains("[PostgreSQL docs](https://www.postgresql.org/docs/)"));
2080 assert!(result.contains("[RFC 7159](https://tools.ietf.org/html/rfc7159)"));
2081 assert!(result.contains("`inline code`"));
2082 assert!(result.contains("**PostgreSQL**"));
2083 assert!(result.contains("[Monitoring guide](https://example.com/monitoring)"));
2084 assert!(result.contains("[this report](./benchmarks.md)"));
2085 }
2086
2087 #[test]
2088 fn test_link_preserves_body_content() {
2089 let temp = TempDir::new().unwrap();
2090 let repo = Repository::init(temp.path(), None, true).unwrap();
2091
2092 let content_1 = r#"---
2093number: 2
2094title: First decision
2095date: 2026-01-15
2096status: accepted
2097---
2098
2099## Context
2100
2101Custom context with **bold** and [links](https://example.com).
2102
2103## Decision
2104
2105A detailed decision paragraph.
2106
2107## Consequences
2108
2109- Important consequence 1
2110- Important consequence 2
2111"#;
2112 let content_2 = r#"---
2113number: 3
2114title: Second decision
2115date: 2026-01-16
2116status: accepted
2117---
2118
2119## Context
2120
2121Different context entirely.
2122
2123## Decision
2124
2125Another decision.
2126
2127## Consequences
2128
2129None significant.
2130"#;
2131 fs::write(repo.adr_path().join("0002-first-decision.md"), content_1).unwrap();
2132 fs::write(repo.adr_path().join("0003-second-decision.md"), content_2).unwrap();
2133
2134 repo.link(2, 3, LinkKind::Amends, LinkKind::AmendedBy)
2135 .unwrap();
2136
2137 let result_1 = fs::read_to_string(repo.adr_path().join("0002-first-decision.md")).unwrap();
2138 let result_2 = fs::read_to_string(repo.adr_path().join("0003-second-decision.md")).unwrap();
2139
2140 assert!(result_1.contains("Custom context with **bold** and [links](https://example.com)"));
2142 assert!(result_1.contains("A detailed decision paragraph."));
2143 assert!(result_2.contains("Different context entirely."));
2144 assert!(result_2.contains("None significant."));
2145
2146 assert!(result_1.contains("links:"));
2148 assert!(result_1.contains("target: 3"));
2149 assert!(result_2.contains("links:"));
2150 assert!(result_2.contains("target: 2"));
2151 }
2152
2153 #[test]
2154 fn test_supersede_preserves_old_adr_body() {
2155 let temp = TempDir::new().unwrap();
2156 let repo = Repository::init(temp.path(), None, true).unwrap();
2157
2158 let rich_content = r#"---
2159number: 2
2160title: Original approach
2161date: 2026-01-15
2162status: accepted
2163---
2164
2165## Context and Problem Statement
2166
2167This has **rich** markdown with [links](https://example.com).
2168
2169```rust
2170fn important_code() -> bool {
2171 true
2172}
2173```
2174
2175## Decision Outcome
2176
2177We chose the original approach.
2178
2179| Criteria | Score |
2180|----------|-------|
2181| Speed | 9/10 |
2182| Safety | 8/10 |
2183"#;
2184 fs::write(
2185 repo.adr_path().join("0002-original-approach.md"),
2186 rich_content,
2187 )
2188 .unwrap();
2189
2190 repo.supersede("Better approach", 2).unwrap();
2191
2192 let old_content =
2193 fs::read_to_string(repo.adr_path().join("0002-original-approach.md")).unwrap();
2194
2195 assert!(old_content.contains("```rust"));
2197 assert!(old_content.contains("fn important_code()"));
2198 assert!(old_content.contains("| Criteria | Score |"));
2199 assert!(old_content.contains("[links](https://example.com)"));
2200
2201 assert!(old_content.contains("status: superseded"));
2203 assert!(old_content.contains("target: 3"));
2204 }
2205
2206 #[test]
2207 fn test_set_status_legacy_preserves_sections() {
2208 let temp = TempDir::new().unwrap();
2209 let repo = Repository::init(temp.path(), None, false).unwrap();
2210
2211 let legacy_content = r#"# 2. Use Rust for backend
2212
2213Date: 2026-01-15
2214
2215## Status
2216
2217Proposed
2218
2219## Context
2220
2221We need a fast, safe language for our backend services.
2222
2223See the [Rust book](https://doc.rust-lang.org/book/) for details.
2224
2225## Decision
2226
2227We will use **Rust** with the `tokio` runtime.
2228
2229```toml
2230[dependencies]
2231tokio = { version = "1", features = ["full"] }
2232```
2233
2234## Consequences
2235
2236- Type safety prevents many bugs at compile time
2237- Learning curve for team members
2238"#;
2239 let adr_path = repo.adr_path().join("0002-use-rust-for-backend.md");
2240 fs::write(&adr_path, legacy_content).unwrap();
2241
2242 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2243
2244 let result = fs::read_to_string(&adr_path).unwrap();
2245
2246 assert!(result.contains("Accepted"));
2248
2249 assert!(result.contains("[Rust book](https://doc.rust-lang.org/book/)"));
2251 assert!(result.contains("**Rust**"));
2252 assert!(result.contains("`tokio`"));
2253 assert!(result.contains("```toml"));
2254 assert!(result.contains("tokio = { version = \"1\", features = [\"full\"] }"));
2255 assert!(result.contains("Type safety prevents many bugs"));
2256 }
2257
2258 #[test]
2259 fn test_set_status_frontmatter_with_existing_links() {
2260 let temp = TempDir::new().unwrap();
2261 let repo = Repository::init(temp.path(), None, true).unwrap();
2262
2263 let content = r#"---
2264number: 2
2265title: Updated approach
2266date: 2026-01-15
2267status: proposed
2268links:
2269 - target: 1
2270 kind: amends
2271---
2272
2273## Context
2274
2275Context.
2276
2277## Decision
2278
2279Decision.
2280"#;
2281 let adr_path = repo.adr_path().join("0002-updated-approach.md");
2282 fs::write(&adr_path, content).unwrap();
2283
2284 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2286
2287 let result = fs::read_to_string(&adr_path).unwrap();
2288 assert!(result.contains("status: accepted"));
2289 assert!(result.contains("links:"));
2290 assert!(result.contains("target: 1"));
2291 assert!(result.contains("kind: amends"));
2292 assert!(
2294 !result.contains("\n\n---"),
2295 "Should not have extra blank line before closing ---: {:?}",
2296 result
2297 );
2298 }
2299
2300 #[test]
2301 fn test_set_status_no_extra_newline_before_separator() {
2302 let temp = TempDir::new().unwrap();
2303 let repo = Repository::init(temp.path(), None, true).unwrap();
2304
2305 let content = "---\nnumber: 2\ntitle: Test\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
2306 let adr_path = repo.adr_path().join("0002-test.md");
2307 fs::write(&adr_path, content).unwrap();
2308
2309 repo.set_status(2, AdrStatus::Accepted, None).unwrap();
2310
2311 let result = fs::read_to_string(&adr_path).unwrap();
2312 assert!(result.contains("status: accepted"));
2313 assert!(
2315 result.contains("\n---\n"),
2316 "Should have clean closing separator: {:?}",
2317 result
2318 );
2319 assert!(
2320 !result.contains("\n\n---"),
2321 "Should not have extra blank line before closing ---: {:?}",
2322 result
2323 );
2324 }
2325
2326 #[test]
2327 fn test_set_status_rejects_empty_custom() {
2328 let temp = TempDir::new().unwrap();
2329 let repo = Repository::init(temp.path(), None, true).unwrap();
2330
2331 let content = "---\nnumber: 2\ntitle: Test\ndate: 2026-01-15\nstatus: proposed\n---\n\n## Context\n\nContext.\n";
2332 let adr_path = repo.adr_path().join("0002-test.md");
2333 fs::write(&adr_path, content).unwrap();
2334
2335 for bad in ["", " ", " ", "\t"] {
2337 let err = repo
2338 .set_status(2, AdrStatus::Custom(bad.to_string()), None)
2339 .unwrap_err();
2340 assert!(
2341 matches!(err, Error::InvalidStatus(_)),
2342 "expected InvalidStatus for {:?}, got {:?}",
2343 bad,
2344 err
2345 );
2346 }
2347
2348 let result = fs::read_to_string(&adr_path).unwrap();
2350 assert!(result.contains("status: proposed"));
2351 assert!(repo.get(2).is_ok());
2352 }
2353
2354 #[test]
2357 fn test_update_madr_content_preserves_unmodified_sections() {
2358 let temp = TempDir::new().unwrap();
2359 let repo = Repository::init(temp.path(), None, true).unwrap();
2360
2361 let madr_content = r#"---
2362number: 2
2363title: Use Redis for caching
2364date: 2026-01-15
2365status: proposed
2366---
2367
2368# Use Redis for caching
2369
2370## Context and Problem Statement
2371
2372Original context about caching needs.
2373
2374## Considered Options
2375
2376* Redis
2377* Memcached
2378
2379## Decision Outcome
2380
2381Chosen option: "Redis", because it supports data structures beyond simple key-value.
2382
2383### Consequences
2384
2385* Good, because it provides pub/sub
2386"#;
2387 let adr_path = repo.adr_path().join("0002-use-redis-for-caching.md");
2388 fs::write(&adr_path, madr_content).unwrap();
2389
2390 let mut adr = repo.get(2).unwrap();
2391 adr.context = "Updated context text.".into();
2392 repo.update(
2393 &adr,
2394 BodySectionPatch {
2395 context: Some("Updated context text.".into()),
2396 ..Default::default()
2397 },
2398 )
2399 .unwrap();
2400
2401 let result = fs::read_to_string(&adr_path).unwrap();
2402
2403 assert!(result.contains("## Context and Problem Statement"));
2404 assert!(result.contains("Updated context text."));
2405 assert!(result.contains("## Considered Options"));
2406 assert!(result.contains("* Memcached"));
2407 assert!(result.contains("## Decision Outcome"));
2408 assert!(result.contains("Chosen option: \"Redis\""));
2409 assert!(result.contains("### Consequences"));
2410 assert!(result.contains("* Good, because it provides pub/sub"));
2411 assert!(!result.contains("What is the change that we're proposing"));
2412 assert!(!result.contains("## Context\n"));
2413 assert!(!result.contains("## Decision\n"));
2414 }
2415
2416 #[test]
2417 fn test_update_madr_context_preserves_decision_h3_subsections() {
2418 let temp = TempDir::new().unwrap();
2419 let repo = Repository::init(temp.path(), None, true).unwrap();
2420
2421 let madr_content = r#"---
2422number: 2
2423title: Use Redis
2424date: 2026-01-15
2425status: proposed
2426---
2427
2428## Context and Problem Statement
2429
2430Original context.
2431
2432## Decision Outcome
2433
2434Chosen option: "Redis", because it is fast.
2435
2436### Consequences
2437
2438* Good, because it provides pub/sub
2439* Bad, because it needs memory
2440
2441### Confirmation
2442
2443We will confirm via load tests.
2444"#;
2445 let adr_path = repo.adr_path().join("0002-use-redis.md");
2446 fs::write(&adr_path, madr_content).unwrap();
2447
2448 let mut adr = repo.get(2).unwrap();
2449 adr.context = "Updated context only.".into();
2450 repo.update(
2451 &adr,
2452 BodySectionPatch {
2453 context: Some("Updated context only.".into()),
2454 ..Default::default()
2455 },
2456 )
2457 .unwrap();
2458
2459 let result = fs::read_to_string(&adr_path).unwrap();
2460 assert!(result.contains("Updated context only."));
2461 assert!(result.contains("Chosen option: \"Redis\", because it is fast."));
2462 assert!(result.contains("### Consequences"));
2463 assert!(result.contains("* Good, because it provides pub/sub"));
2464 assert!(result.contains("* Bad, because it needs memory"));
2465 assert!(result.contains("### Confirmation"));
2466 assert!(result.contains("We will confirm via load tests."));
2467 }
2468
2469 #[test]
2470 fn test_update_madr_consequences_patches_h3_subsection() {
2471 let temp = TempDir::new().unwrap();
2472 let repo = Repository::init(temp.path(), None, true).unwrap();
2473
2474 let madr_content = r#"---
2475number: 2
2476title: Use Redis
2477date: 2026-01-15
2478status: proposed
2479---
2480
2481## Context and Problem Statement
2482
2483Context.
2484
2485## Decision Outcome
2486
2487Chosen option: "Redis", because it is fast.
2488
2489### Consequences
2490
2491* Old consequence
2492"#;
2493 let adr_path = repo.adr_path().join("0002-use-redis.md");
2494 fs::write(&adr_path, madr_content).unwrap();
2495
2496 let mut adr = repo.get(2).unwrap();
2497 adr.consequences = "* New consequence".into();
2498 repo.update(
2499 &adr,
2500 BodySectionPatch {
2501 consequences: Some("* New consequence".into()),
2502 ..Default::default()
2503 },
2504 )
2505 .unwrap();
2506
2507 let result = fs::read_to_string(&adr_path).unwrap();
2508 assert!(result.contains("Chosen option: \"Redis\", because it is fast."));
2509 assert!(result.contains("### Consequences"));
2510 assert!(result.contains("* New consequence"));
2511 assert!(!result.contains("* Old consequence"));
2512 }
2513
2514 #[test]
2516 fn test_update_madr_content_round_trip_via_get() {
2517 let temp = TempDir::new().unwrap();
2518 let repo = Repository::init(temp.path(), None, true).unwrap();
2519
2520 let madr_content = r#"---
2521number: 2
2522title: Use PostgreSQL
2523date: 2026-01-15
2524status: proposed
2525---
2526
2527## Context and Problem Statement
2528
2529We need a relational database.
2530
2531## Decision Outcome
2532
2533We will use PostgreSQL 16.
2534"#;
2535 let adr_path = repo.adr_path().join("0002-use-postgresql.md");
2536 fs::write(&adr_path, madr_content).unwrap();
2537
2538 let mut adr = repo.get(2).unwrap();
2539 adr.context = "Updated context only.".into();
2540 repo.update(
2541 &adr,
2542 BodySectionPatch {
2543 context: Some("Updated context only.".into()),
2544 ..Default::default()
2545 },
2546 )
2547 .unwrap();
2548
2549 let reloaded = repo.get(2).unwrap();
2550 assert_eq!(reloaded.context, "Updated context only.");
2551 assert_eq!(reloaded.decision, "We will use PostgreSQL 16.");
2552 }
2553
2554 #[test]
2555 fn test_update_metadata_adds_tags_to_frontmatter() {
2556 let temp = TempDir::new().unwrap();
2557 let repo = Repository::init(temp.path(), None, true).unwrap();
2558
2559 let content = r#"---
2560number: 2
2561title: Tagged ADR
2562date: 2026-01-15
2563status: proposed
2564---
2565
2566## Context
2567
2568Context.
2569"#;
2570 let adr_path = repo.adr_path().join("0002-tagged-adr.md");
2571 fs::write(&adr_path, content).unwrap();
2572
2573 let mut adr = repo.get(2).unwrap();
2574 adr.set_tags(vec!["security".into(), "api".into()]);
2575 repo.update_metadata(&adr).unwrap();
2576
2577 let result = fs::read_to_string(&adr_path).unwrap();
2578 assert!(result.contains("tags:"));
2579 assert!(
2581 result.contains("- security") && result.contains("- api"),
2582 "tags missing from frontmatter\n{result}"
2583 );
2584 assert!(result.contains("## Context\n\nContext."));
2586 }
2587
2588 #[test]
2591 fn test_list_with_errors_all_valid() {
2592 let temp = TempDir::new().unwrap();
2593 let repo = Repository::init(temp.path(), None, true).unwrap();
2594 repo.new_adr("Valid ADR").unwrap();
2595
2596 let (adrs, errors) = repo.list_with_errors().unwrap();
2597 assert_eq!(adrs.len(), 2); assert!(errors.is_empty());
2599 }
2600
2601 #[test]
2602 fn test_list_with_errors_captures_invalid_frontmatter() {
2603 let temp = TempDir::new().unwrap();
2604 let repo = Repository::init(temp.path(), None, true).unwrap();
2605
2606 let bad_content =
2608 "---\nnumber: 2\nstatus: accepted\ndate: not-a-date\n---\n\n# 2. Bad ADR\n";
2609 fs::write(repo.adr_path().join("0002-bad-adr.md"), bad_content).unwrap();
2610
2611 let (adrs, errors) = repo.list_with_errors().unwrap();
2612 assert_eq!(adrs.len(), 1); assert_eq!(errors.len(), 1);
2614 assert!(errors[0].0.to_string_lossy().contains("0002-bad-adr.md"));
2615 }
2616
2617 #[test]
2618 fn test_list_with_errors_mixed_valid_and_invalid() {
2619 let temp = TempDir::new().unwrap();
2620 let repo = Repository::init(temp.path(), None, true).unwrap();
2621
2622 repo.new_adr("Good ADR").unwrap();
2624
2625 let bad_content = "---\n: :\n---\n\n# 3. Broken\n";
2627 fs::write(repo.adr_path().join("0003-broken.md"), bad_content).unwrap();
2628
2629 let (adrs, errors) = repo.list_with_errors().unwrap();
2630 assert_eq!(adrs.len(), 2); assert_eq!(errors.len(), 1); }
2633
2634 #[test]
2635 fn test_list_with_errors_string_decision_makers_is_valid() {
2636 let temp = TempDir::new().unwrap();
2637 let repo = Repository::init(temp.path(), None, true).unwrap();
2638
2639 let content = r#"---
2641number: 2
2642status: accepted
2643date: 2026-03-18
2644decision-makers: mschoettle
2645---
2646
2647# 2. Use Markdown Architectural Decision Records
2648"#;
2649 fs::write(repo.adr_path().join("0002-use-markdown-adrs.md"), content).unwrap();
2650
2651 let (adrs, errors) = repo.list_with_errors().unwrap();
2652 assert!(errors.is_empty(), "string decision-makers should parse");
2653 assert_eq!(adrs.len(), 2);
2654
2655 let adr = adrs.iter().find(|a| a.number == 2).unwrap();
2656 assert_eq!(adr.decision_makers, vec!["mschoettle"]);
2657 }
2658 #[test]
2661 fn test_update_metadata_preserves_link_descriptions() {
2662 let temp = TempDir::new().unwrap();
2663 let repo = Repository::init(temp.path(), None, true).unwrap();
2664
2665 let content = r#"---
2666number: 2
2667title: Linked ADR
2668date: 2026-01-15
2669status: proposed
2670links:
2671 - target: 1
2672 kind: relatesto
2673 description: Explains the connection
2674---
2675
2676## Context
2677
2678Context.
2679"#;
2680 let adr_path = repo.adr_path().join("0002-linked-adr.md");
2681 fs::write(&adr_path, content).unwrap();
2682
2683 let adr = repo.get(2).unwrap();
2684 assert_eq!(
2685 adr.links[0].description.as_deref(),
2686 Some("Explains the connection")
2687 );
2688
2689 repo.update_metadata(&adr).unwrap();
2691 let result = fs::read_to_string(&adr_path).unwrap();
2692 assert!(result.contains("kind: relatesto"));
2693 assert!(result.contains("description: Explains the connection"));
2694 }
2695
2696 #[test]
2697 fn test_update_metadata_kebab_case_kind_round_trips_verbatim() {
2698 let temp = TempDir::new().unwrap();
2703 let repo = Repository::init(temp.path(), None, true).unwrap();
2704
2705 let content = r#"---
2706number: 2
2707title: Linked ADR
2708date: 2026-01-15
2709status: proposed
2710links:
2711 - target: 1
2712 kind: relates-to
2713---
2714
2715## Context
2716
2717Context.
2718"#;
2719 let adr_path = repo.adr_path().join("0002-linked-adr.md");
2720 fs::write(&adr_path, content).unwrap();
2721
2722 let adr = repo.get(2).unwrap();
2723 assert_eq!(adr.links[0].kind, LinkKind::Custom("relates-to".into()));
2724
2725 repo.update_metadata(&adr).unwrap();
2726 let result = fs::read_to_string(&adr_path).unwrap();
2727 assert!(result.contains("kind: relates-to"));
2728 }
2729
2730 #[test]
2731 fn test_resolve_link_titles_uses_actual_filename_for_hand_named_target() {
2732 let temp = TempDir::new().unwrap();
2733 let repo = Repository::init(temp.path(), None, false).unwrap();
2734
2735 let target_content = "# 2. Use Rust for backend services\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nContext.\n";
2737 fs::write(
2738 repo.adr_path().join("0002-use-rust-for-backend.md"),
2739 target_content,
2740 )
2741 .unwrap();
2742
2743 let mut source = repo.get(1).unwrap();
2744 source.add_link(AdrLink::new(2, LinkKind::Amends));
2745
2746 let titles = repo.resolve_link_titles(&source);
2747 let (title, filename) = titles.get(&2).unwrap();
2748 assert_eq!(title, "Use Rust for backend services");
2749 assert_eq!(filename, "0002-use-rust-for-backend.md");
2750 }
2751
2752 #[test]
2753 fn test_link_href_prefers_actual_path_over_slugified_title() {
2754 let target = Adr {
2755 path: Some(PathBuf::from("/repo/doc/adr/0003-use-rust-for-backend.md")),
2756 ..Adr::new(3, "Use Rust for backend services")
2757 };
2758 assert_eq!(
2759 Repository::link_href(&target),
2760 "0003-use-rust-for-backend.md"
2761 );
2762 }
2763
2764 #[test]
2765 fn test_link_href_falls_back_to_slugified_title_when_path_missing() {
2766 let target = Adr {
2767 path: None,
2768 ..Adr::new(3, "Use Rust for backend services")
2769 };
2770 assert_eq!(
2771 Repository::link_href(&target),
2772 "0003-use-rust-for-backend-services.md"
2773 );
2774 }
2775
2776 fn extract_h2_block(content: &str, heading: &str) -> Option<String> {
2780 let lines: Vec<&str> = content.lines().collect();
2781 let marker = format!("## {heading}");
2782 let start = lines.iter().position(|l| l.trim() == marker)?;
2783 let end = lines[(start + 1)..]
2784 .iter()
2785 .position(|l| l.starts_with("## "))
2786 .map(|p| start + 1 + p)
2787 .unwrap_or(lines.len());
2788 Some(lines[start..end].join("\n"))
2789 }
2790
2791 fn assert_h2_block_unchanged(before: &str, after: &str, heading: &str) {
2792 assert_eq!(
2793 extract_h2_block(before, heading),
2794 extract_h2_block(after, heading),
2795 "section `{heading}` should be byte-identical"
2796 );
2797 }
2798
2799 #[test]
2800 fn test_update_ng_nygard_rich_context_preserved_on_decision_patch() {
2801 let temp = TempDir::new().unwrap();
2802 let repo = Repository::init(temp.path(), None, true).unwrap();
2803
2804 let content = r#"---
2805number: 2
2806title: Rich context ADR
2807date: 2026-01-15
2808status: proposed
2809---
2810
2811## Context
2812
2813We need caching. See the [Redis docs](https://redis.io).
2814
2815* Requirement one
2816* Requirement two
2817
2818Use `redis-cli` for debugging.
2819
2820## Decision
2821
2822Old decision.
2823
2824## Consequences
2825
2826Old consequences.
2827"#;
2828 let adr_path = repo.adr_path().join("0002-rich-context-adr.md");
2829 fs::write(&adr_path, content).unwrap();
2830 let before = content.to_string();
2831
2832 let mut adr = repo.get(2).unwrap();
2833 adr.decision = "New decision.".into();
2834 repo.update(
2835 &adr,
2836 BodySectionPatch {
2837 decision: Some("New decision.".into()),
2838 ..Default::default()
2839 },
2840 )
2841 .unwrap();
2842
2843 let after = fs::read_to_string(&adr_path).unwrap();
2844 assert_h2_block_unchanged(&before, &after, "Context");
2845 assert_h2_block_unchanged(&before, &after, "Consequences");
2846 assert!(after.contains("New decision."));
2847 }
2848
2849 #[test]
2850 fn test_update_madr_rich_context_preserved_on_consequences_patch() {
2851 let temp = TempDir::new().unwrap();
2852 let repo = Repository::init(temp.path(), None, true).unwrap();
2853
2854 let content = r#"---
2855number: 2
2856title: Rich MADR context
2857date: 2026-01-15
2858status: proposed
2859---
2860
2861## Context and Problem Statement
2862
2863We need caching. See the [Redis docs](https://redis.io).
2864
2865* Requirement one
2866* Requirement two
2867
2868Use `redis-cli` for debugging.
2869
2870## Decision Outcome
2871
2872Chosen option: "Redis", because it is fast.
2873
2874### Consequences
2875
2876* Old consequence
2877"#;
2878 let adr_path = repo.adr_path().join("0002-rich-madr-context.md");
2879 fs::write(&adr_path, content).unwrap();
2880 let before = content.to_string();
2881
2882 let mut adr = repo.get(2).unwrap();
2883 adr.consequences = "* New consequence".into();
2884 repo.update(
2885 &adr,
2886 BodySectionPatch {
2887 consequences: Some("* New consequence".into()),
2888 ..Default::default()
2889 },
2890 )
2891 .unwrap();
2892
2893 let after = fs::read_to_string(&adr_path).unwrap();
2894 assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
2895 assert!(after.contains("Chosen option: \"Redis\", because it is fast."));
2896 assert!(after.contains("* New consequence"));
2897 assert!(!after.contains("* Old consequence"));
2898 }
2899
2900 #[test]
2901 fn test_update_legacy_rich_context_preserved_on_decision_patch() {
2902 let temp = TempDir::new().unwrap();
2903 let repo = Repository::init(temp.path(), None, false).unwrap();
2904
2905 let content = r#"# 2. Legacy rich context
2906
2907Date: 2026-01-15
2908
2909## Status
2910
2911Proposed
2912
2913## Context
2914
2915We need caching. See the [Redis docs](https://redis.io).
2916
2917* Requirement one
2918* Requirement two
2919
2920Use `redis-cli` for debugging.
2921
2922## Decision
2923
2924Old decision.
2925
2926## Consequences
2927
2928Old consequences.
2929"#;
2930 let adr_path = repo.adr_path().join("0002-legacy-rich-context.md");
2931 fs::write(&adr_path, content).unwrap();
2932 let before = content.to_string();
2933
2934 let mut adr = repo.get(2).unwrap();
2935 adr.decision = "New decision.".into();
2936 repo.update(
2937 &adr,
2938 BodySectionPatch {
2939 decision: Some("New decision.".into()),
2940 ..Default::default()
2941 },
2942 )
2943 .unwrap();
2944
2945 let after = fs::read_to_string(&adr_path).unwrap();
2946 assert_h2_block_unchanged(&before, &after, "Context");
2947 assert_h2_block_unchanged(&before, &after, "Consequences");
2948 assert!(after.contains("New decision."));
2949 }
2950
2951 #[test]
2952 fn test_update_madr_decision_only_preserves_h3_subsections() {
2953 let temp = TempDir::new().unwrap();
2954 let repo = Repository::init(temp.path(), None, true).unwrap();
2955
2956 let content = r#"---
2957number: 2
2958title: Decision patch MADR
2959date: 2026-01-15
2960status: proposed
2961---
2962
2963## Context and Problem Statement
2964
2965Context.
2966
2967## Decision Outcome
2968
2969Old intro text.
2970
2971### Consequences
2972
2973* Good, because it is fast
2974* Bad, because it uses memory
2975
2976### Confirmation
2977
2978Confirm via load tests.
2979"#;
2980 let adr_path = repo.adr_path().join("0002-decision-patch-madr.md");
2981 fs::write(&adr_path, content).unwrap();
2982 let before = content.to_string();
2983
2984 let mut adr = repo.get(2).unwrap();
2985 adr.decision = "New intro text.".into();
2986 repo.update(
2987 &adr,
2988 BodySectionPatch {
2989 decision: Some("New intro text.".into()),
2990 ..Default::default()
2991 },
2992 )
2993 .unwrap();
2994
2995 let after = fs::read_to_string(&adr_path).unwrap();
2996 assert!(after.contains("New intro text."));
2997 assert!(!after.contains("Old intro text."));
2998 assert!(after.contains("### Consequences"));
2999 assert!(after.contains("* Good, because it is fast"));
3000 assert!(after.contains("* Bad, because it uses memory"));
3001 assert!(after.contains("### Confirmation"));
3002 assert!(after.contains("Confirm via load tests."));
3003 assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
3004 }
3005
3006 #[test]
3007 fn test_update_madr_decision_and_consequences_together_preserves_other_h3() {
3008 let temp = TempDir::new().unwrap();
3009 let repo = Repository::init(temp.path(), None, true).unwrap();
3010
3011 let content = r#"---
3012number: 2
3013title: Combined patch MADR
3014date: 2026-01-15
3015status: proposed
3016---
3017
3018## Context and Problem Statement
3019
3020Context.
3021
3022## Decision Outcome
3023
3024Old intro.
3025
3026### Consequences
3027
3028* Old consequence
3029
3030### Confirmation
3031
3032Confirm via load tests.
3033"#;
3034 let adr_path = repo.adr_path().join("0002-combined-patch-madr.md");
3035 fs::write(&adr_path, content).unwrap();
3036 let before = content.to_string();
3037
3038 let mut adr = repo.get(2).unwrap();
3039 adr.decision = "New intro.".into();
3040 adr.consequences = "* New consequence".into();
3041 repo.update(
3042 &adr,
3043 BodySectionPatch {
3044 decision: Some("New intro.".into()),
3045 consequences: Some("* New consequence".into()),
3046 ..Default::default()
3047 },
3048 )
3049 .unwrap();
3050
3051 let after = fs::read_to_string(&adr_path).unwrap();
3052 assert!(after.contains("New intro."));
3053 assert!(after.contains("* New consequence"));
3054 assert!(!after.contains("Old intro."));
3055 assert!(!after.contains("* Old consequence"));
3056 assert!(after.contains("### Confirmation"));
3057 assert!(after.contains("Confirm via load tests."));
3058 assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
3059 }
3060
3061 #[test]
3062 fn test_update_ng_nygard_consequences_only_patch() {
3063 let temp = TempDir::new().unwrap();
3064 let repo = Repository::init(temp.path(), None, true).unwrap();
3065
3066 let content = r#"---
3067number: 2
3068title: Nygard consequences patch
3069date: 2026-01-15
3070status: proposed
3071---
3072
3073## Context
3074
3075Original context.
3076
3077## Decision
3078
3079Original decision.
3080
3081## Consequences
3082
3083* Old item
3084"#;
3085 let adr_path = repo.adr_path().join("0002-nygard-consequences-patch.md");
3086 fs::write(&adr_path, content).unwrap();
3087 let before = content.to_string();
3088
3089 let mut adr = repo.get(2).unwrap();
3090 adr.consequences = "* New item".into();
3091 repo.update(
3092 &adr,
3093 BodySectionPatch {
3094 consequences: Some("* New item".into()),
3095 ..Default::default()
3096 },
3097 )
3098 .unwrap();
3099
3100 let after = fs::read_to_string(&adr_path).unwrap();
3101 assert_h2_block_unchanged(&before, &after, "Context");
3102 assert_h2_block_unchanged(&before, &after, "Decision");
3103 assert!(after.contains("* New item"));
3104 assert!(!after.contains("* Old item"));
3105 }
3106
3107 #[test]
3108 fn test_update_ng_nygard_context_only_preserves_consequences_section() {
3109 let temp = TempDir::new().unwrap();
3110 let repo = Repository::init(temp.path(), None, true).unwrap();
3111
3112 let content = r#"---
3113number: 2
3114title: Nygard context patch
3115date: 2026-01-15
3116status: proposed
3117---
3118
3119## Context
3120
3121Old context.
3122
3123## Decision
3124
3125Original decision.
3126
3127## Consequences
3128
3129* Good, because it is fast
3130* Bad, because it uses memory
3131"#;
3132 let adr_path = repo.adr_path().join("0002-nygard-context-patch.md");
3133 fs::write(&adr_path, content).unwrap();
3134 let before = content.to_string();
3135
3136 let mut adr = repo.get(2).unwrap();
3137 adr.context = "New context.".into();
3138 repo.update(
3139 &adr,
3140 BodySectionPatch {
3141 context: Some("New context.".into()),
3142 ..Default::default()
3143 },
3144 )
3145 .unwrap();
3146
3147 let after = fs::read_to_string(&adr_path).unwrap();
3148 assert_h2_block_unchanged(&before, &after, "Decision");
3149 assert_h2_block_unchanged(&before, &after, "Consequences");
3150 assert!(after.contains("New context."));
3151 }
3152
3153 #[test]
3154 fn test_update_metadata_only_preserves_madr_body_byte_identical() {
3155 let temp = TempDir::new().unwrap();
3156 let repo = Repository::init(temp.path(), None, true).unwrap();
3157
3158 let content = r#"---
3159number: 2
3160title: Metadata only MADR
3161date: 2026-01-15
3162status: proposed
3163---
3164
3165## Context and Problem Statement
3166
3167Context.
3168
3169## Decision Outcome
3170
3171Intro.
3172
3173### Consequences
3174
3175* Good item
3176
3177### Confirmation
3178
3179Confirm via tests.
3180"#;
3181 let adr_path = repo.adr_path().join("0002-metadata-only-madr.md");
3182 fs::write(&adr_path, content).unwrap();
3183 let before = fs::read_to_string(&adr_path).unwrap();
3184 let body_start = before.find("\n\n## Context").unwrap();
3185
3186 let mut adr = repo.get(2).unwrap();
3187 adr.status = AdrStatus::Accepted;
3188 repo.update(&adr, BodySectionPatch::default()).unwrap();
3189
3190 let after = fs::read_to_string(&adr_path).unwrap();
3191 assert_eq!(
3192 &before[body_start..],
3193 &after[after.find("\n\n## Context").unwrap()..]
3194 );
3195 assert!(after.contains("status: accepted"));
3196 }
3197
3198 #[test]
3199 fn test_update_madr_consequences_appends_h3_when_missing() {
3200 let temp = TempDir::new().unwrap();
3201 let repo = Repository::init(temp.path(), None, true).unwrap();
3202
3203 let content = r#"---
3204number: 2
3205title: Append consequences MADR
3206date: 2026-01-15
3207status: proposed
3208---
3209
3210## Context and Problem Statement
3211
3212Context.
3213
3214## Decision Outcome
3215
3216Intro only, no consequences subsection yet.
3217"#;
3218 let adr_path = repo.adr_path().join("0002-append-consequences-madr.md");
3219 fs::write(&adr_path, content).unwrap();
3220
3221 let mut adr = repo.get(2).unwrap();
3222 adr.consequences = "* Appended consequence".into();
3223 repo.update(
3224 &adr,
3225 BodySectionPatch {
3226 consequences: Some("* Appended consequence".into()),
3227 ..Default::default()
3228 },
3229 )
3230 .unwrap();
3231
3232 let after = fs::read_to_string(&adr_path).unwrap();
3233 assert!(after.contains("Intro only, no consequences subsection yet."));
3234 assert!(after.contains("### Consequences"));
3235 assert!(after.contains("* Appended consequence"));
3236 }
3237
3238 #[test]
3239 fn test_update_madr_in_compatible_mode_repo() {
3240 let temp = TempDir::new().unwrap();
3241 let repo = Repository::init(temp.path(), None, false).unwrap();
3242
3243 let content = r#"---
3244number: 2
3245title: Compatible repo MADR file
3246date: 2026-01-15
3247status: proposed
3248---
3249
3250## Context and Problem Statement
3251
3252Original context.
3253
3254## Decision Outcome
3255
3256Chosen option: "Redis", because it is fast.
3257
3258### Consequences
3259
3260* Good item
3261
3262### Confirmation
3263
3264Confirm via tests.
3265"#;
3266 let adr_path = repo.adr_path().join("0002-compatible-repo-madr-file.md");
3267 fs::write(&adr_path, content).unwrap();
3268 let before = content.to_string();
3269
3270 let mut adr = repo.get(2).unwrap();
3271 adr.context = "Updated context.".into();
3272 repo.update(
3273 &adr,
3274 BodySectionPatch {
3275 context: Some("Updated context.".into()),
3276 ..Default::default()
3277 },
3278 )
3279 .unwrap();
3280
3281 let after = fs::read_to_string(&adr_path).unwrap();
3282 assert!(after.contains("Updated context."));
3283 assert_h2_block_unchanged(&before, &after, "Decision Outcome");
3284 }
3285
3286 #[test]
3287 fn test_update_madr_decision_only_without_h3_subsections() {
3288 let temp = TempDir::new().unwrap();
3289 let repo = Repository::init(temp.path(), None, true).unwrap();
3290
3291 let content = r#"---
3292number: 2
3293title: Simple decision MADR
3294date: 2026-01-15
3295status: proposed
3296---
3297
3298## Context and Problem Statement
3299
3300Context.
3301
3302## Decision Outcome
3303
3304Old single-paragraph decision.
3305"#;
3306 let adr_path = repo.adr_path().join("0002-simple-decision-madr.md");
3307 fs::write(&adr_path, content).unwrap();
3308
3309 let mut adr = repo.get(2).unwrap();
3310 adr.decision = "New single-paragraph decision.".into();
3311 repo.update(
3312 &adr,
3313 BodySectionPatch {
3314 decision: Some("New single-paragraph decision.".into()),
3315 ..Default::default()
3316 },
3317 )
3318 .unwrap();
3319
3320 let after = fs::read_to_string(&adr_path).unwrap();
3321 assert!(after.contains("New single-paragraph decision."));
3322 assert!(!after.contains("Old single-paragraph decision."));
3323 }
3324
3325 #[test]
3326 fn test_update_madr_optional_sections_preserved_byte_identical() {
3327 let temp = TempDir::new().unwrap();
3328 let repo = Repository::init(temp.path(), None, true).unwrap();
3329
3330 let content = r#"---
3331number: 2
3332title: MADR optional sections
3333date: 2026-01-15
3334status: proposed
3335---
3336
3337## Context and Problem Statement
3338
3339Original context.
3340
3341## Considered Options
3342
3343* Redis
3344* Memcached
3345
3346## Decision Outcome
3347
3348Chosen option: "Redis", because it is fast.
3349
3350### Consequences
3351
3352* Good item
3353
3354## Pros and Cons of the Options
3355
3356### Redis
3357
3358* Good, because fast
3359* Bad, because memory
3360
3361### Memcached
3362
3363* Good, because simple
3364* Bad, because strings only
3365
3366## More Information
3367
3368See [MADR](https://adr.github.io/madr/) for details.
3369"#;
3370 let adr_path = repo.adr_path().join("0002-madr-optional-sections.md");
3371 fs::write(&adr_path, content).unwrap();
3372 let before = content.to_string();
3373
3374 let mut adr = repo.get(2).unwrap();
3375 adr.context = "Updated context.".into();
3376 repo.update(
3377 &adr,
3378 BodySectionPatch {
3379 context: Some("Updated context.".into()),
3380 ..Default::default()
3381 },
3382 )
3383 .unwrap();
3384
3385 let after = fs::read_to_string(&adr_path).unwrap();
3386 assert!(after.contains("Updated context."));
3387 assert_h2_block_unchanged(&before, &after, "Considered Options");
3388 assert_h2_block_unchanged(&before, &after, "Decision Outcome");
3389 assert_h2_block_unchanged(&before, &after, "Pros and Cons of the Options");
3390 assert_h2_block_unchanged(&before, &after, "More Information");
3391 }
3392
3393 #[test]
3394 fn test_update_unchanged_sections_byte_identical_after_context_patch() {
3395 let temp = TempDir::new().unwrap();
3396 let repo = Repository::init(temp.path(), None, true).unwrap();
3397
3398 let content = r#"---
3399number: 2
3400title: Byte identity check
3401date: 2026-01-15
3402status: proposed
3403---
3404
3405## Context and Problem Statement
3406
3407Original context.
3408
3409## Decision Outcome
3410
3411Intro.
3412
3413### Consequences
3414
3415* Good item
3416
3417### Confirmation
3418
3419Confirm via tests.
3420"#;
3421 let adr_path = repo.adr_path().join("0002-byte-identity-check.md");
3422 fs::write(&adr_path, content).unwrap();
3423 let before = content.to_string();
3424
3425 let mut adr = repo.get(2).unwrap();
3426 adr.context = "Updated context.".into();
3427 repo.update(
3428 &adr,
3429 BodySectionPatch {
3430 context: Some("Updated context.".into()),
3431 ..Default::default()
3432 },
3433 )
3434 .unwrap();
3435
3436 let after = fs::read_to_string(&adr_path).unwrap();
3437 assert_h2_block_unchanged(&before, &after, "Decision Outcome");
3438 }
3439
3440 #[test]
3441 fn test_update_madr_context_patch_does_not_round_trip_lossy_fields() {
3442 let temp = TempDir::new().unwrap();
3443 let repo = Repository::init(temp.path(), None, true).unwrap();
3444
3445 let content = r#"---
3446number: 2
3447title: Lossy parse guard
3448date: 2026-01-15
3449status: proposed
3450---
3451
3452## Context and Problem Statement
3453
3454Original context.
3455
3456## Decision Outcome
3457
3458See [Redis docs](https://redis.io) and use `redis-cli`.
3459
3460* Chosen option: "Redis"
3461* Because it is **fast**
3462
3463### Consequences
3464
3465* Good item
3466"#;
3467 let adr_path = repo.adr_path().join("0002-lossy-parse-guard.md");
3468 fs::write(&adr_path, content).unwrap();
3469 let before = content.to_string();
3470
3471 let mut adr = repo.get(2).unwrap();
3472 adr.context = "Updated context.".into();
3474 repo.update(
3475 &adr,
3476 BodySectionPatch {
3477 context: Some("Updated context.".into()),
3478 ..Default::default()
3479 },
3480 )
3481 .unwrap();
3482
3483 let after = fs::read_to_string(&adr_path).unwrap();
3484 assert_h2_block_unchanged(&before, &after, "Decision Outcome");
3485 assert!(after.contains("[Redis docs](https://redis.io)"));
3486 assert!(after.contains("`redis-cli`"));
3487 assert!(after.contains("**fast**"));
3488 }
3489
3490 #[test]
3493 fn test_fence_in_decision_outcome_preserved_on_consequences_patch() {
3494 let temp = TempDir::new().unwrap();
3495 let repo = Repository::init(temp.path(), None, true).unwrap();
3496
3497 let content = r#"---
3498number: 2
3499title: Fenced example in decision
3500date: 2026-01-15
3501status: proposed
3502---
3503
3504## Context and Problem Statement
3505
3506Context.
3507
3508## Decision Outcome
3509
3510Chosen option: "Redis", because it is fast.
3511
3512```markdown
3513## Consequences
3514
3515Example consequences inside a fence.
3516```
3517
3518Trailing text after the fence.
3519
3520### Consequences
3521
3522* Good, because it provides pub/sub
3523
3524### Confirmation
3525
3526We will confirm via load tests.
3527"#;
3528 let adr_path = repo.adr_path().join("0002-fenced-decision-outcome.md");
3529 fs::write(&adr_path, content).unwrap();
3530 let before = content.to_string();
3531
3532 let mut adr = repo.get(2).unwrap();
3533 adr.consequences = "* Updated consequence".into();
3534 repo.update(
3535 &adr,
3536 BodySectionPatch {
3537 consequences: Some("* Updated consequence".into()),
3538 ..Default::default()
3539 },
3540 )
3541 .unwrap();
3542
3543 let after = fs::read_to_string(&adr_path).unwrap();
3544 assert!(after.contains("```markdown"));
3545 assert!(after.contains("Example consequences inside a fence."));
3546 assert!(after.contains("Trailing text after the fence."));
3547 assert!(after.contains("### Confirmation"));
3548 assert!(after.contains("We will confirm via load tests."));
3549 assert!(after.contains("* Updated consequence"));
3550 assert_h2_block_unchanged(&before, &after, "Context and Problem Statement");
3551 }
3552
3553 #[test]
3554 fn test_body_patch_preserves_sections_without_trailing_newline() {
3555 let temp = TempDir::new().unwrap();
3556 let repo = Repository::init(temp.path(), None, false).unwrap();
3557
3558 let content = "# 2. Compact file\n\nDate: 2026-01-15\n\n## Status\n\nAccepted\n\n## Context\n\nOld context.\n\n## Decision\n\nOld decision.\n\n## Consequences\n\nOld consequences.";
3559 let adr_path = repo.adr_path().join("0002-no-trailing-newline.md");
3560 fs::write(&adr_path, content).unwrap();
3561 assert_ne!(fs::read(&adr_path).unwrap().last(), Some(&b'\n'));
3562
3563 let mut adr = repo.get(2).unwrap();
3564 adr.decision = "New decision.".into();
3565 repo.update(
3566 &adr,
3567 BodySectionPatch {
3568 decision: Some("New decision.".into()),
3569 ..Default::default()
3570 },
3571 )
3572 .unwrap();
3573
3574 let after = fs::read_to_string(&adr_path).unwrap();
3575 assert!(!after.contains("Old context.## Decision"));
3576 let reparsed = repo.get(2).unwrap();
3577 assert!(reparsed.decision.contains("New decision."));
3578 }
3579
3580 #[test]
3581 fn test_people_field_yaml_unchanged_skip_rewrite() {
3582 let temp = TempDir::new().unwrap();
3583 let repo = Repository::init(temp.path(), None, true).unwrap();
3584
3585 let content = r#"---
3586number: 2
3587title: Zero indent consulted
3588date: 2026-01-15
3589status: proposed
3590consulted:
3591- alice
3592- bob
3593---
3594
3595## Context
3596
3597Context.
3598"#;
3599 let adr_path = repo.adr_path().join("0002-zero-indent-consulted.md");
3600 fs::write(&adr_path, content).unwrap();
3601 let before = fs::read_to_string(&adr_path).unwrap();
3602
3603 let adr = repo.get(2).unwrap();
3604 repo.update_metadata(&adr).unwrap();
3605
3606 let after = fs::read_to_string(&adr_path).unwrap();
3607 assert_eq!(before, after);
3608 }
3609
3610 #[test]
3611 fn test_body_only_update_preserves_non_canonical_status() {
3612 let temp = TempDir::new().unwrap();
3613 let repo = Repository::init(temp.path(), None, false).unwrap();
3614
3615 let content = r#"# 2. Non-canonical status
3616
3617Date: 2026-01-15
3618
3619## Status
3620
3621Approved by the architecture board on 2026-01-15.
3622
3623## Context
3624
3625Original context.
3626
3627## Decision
3628
3629Decision.
3630
3631## Consequences
3632
3633Consequences.
3634"#;
3635 let adr_path = repo.adr_path().join("0002-non-canonical-status.md");
3636 fs::write(&adr_path, content).unwrap();
3637
3638 let mut adr = repo.get(2).unwrap();
3639 adr.context = "Updated context.".into();
3640 repo.update(
3641 &adr,
3642 BodySectionPatch {
3643 context: Some("Updated context.".into()),
3644 ..Default::default()
3645 },
3646 )
3647 .unwrap();
3648
3649 let after = fs::read_to_string(&adr_path).unwrap();
3650 assert!(after.contains("Approved by the architecture board"));
3651 assert!(after.contains("Updated context."));
3652 }
3653
3654 #[test]
3655 fn test_missing_section_patch_returns_error() {
3656 let temp = TempDir::new().unwrap();
3657 let repo = Repository::init(temp.path(), None, false).unwrap();
3658
3659 let content = r#"# 2. No decision or consequences sections
3660
3661Date: 2026-01-15
3662
3663## Status
3664
3665Accepted
3666
3667## Context
3668
3669Context only.
3670"#;
3671 let adr_path = repo.adr_path().join("0002-no-consequences.md");
3672 fs::write(&adr_path, content).unwrap();
3673
3674 let mut adr = repo.get(2).unwrap();
3675 adr.consequences = "New consequences.".into();
3676 let err = repo
3677 .update(
3678 &adr,
3679 BodySectionPatch {
3680 consequences: Some("New consequences.".into()),
3681 ..Default::default()
3682 },
3683 )
3684 .unwrap_err();
3685 assert!(err.to_string().contains("consequences patch requested"));
3686 }
3687
3688 #[test]
3689 fn test_nygard_consequences_patch_errors_without_consequences_section() {
3690 let temp = TempDir::new().unwrap();
3691 let repo = Repository::init(temp.path(), None, false).unwrap();
3692
3693 let content = r#"# 3. Nygard decision without consequences section
3694
3695Date: 2026-01-15
3696
3697## Status
3698
3699Accepted
3700
3701## Context
3702
3703Context.
3704
3705## Decision
3706
3707We decided X.
3708"#;
3709 let adr_path = repo.adr_path().join("0003-nygard-no-consequences.md");
3710 fs::write(&adr_path, content).unwrap();
3711
3712 let mut adr = repo.get(3).unwrap();
3713 adr.consequences = "New consequences.".into();
3714 let err = repo
3715 .update(
3716 &adr,
3717 BodySectionPatch {
3718 consequences: Some("New consequences.".into()),
3719 ..Default::default()
3720 },
3721 )
3722 .unwrap_err();
3723 assert!(err.to_string().contains("consequences patch requested"));
3724 let after = fs::read_to_string(&adr_path).unwrap();
3725 assert!(!after.contains("### Consequences"));
3726 assert!(!after.contains("New consequences."));
3727 }
3728
3729 #[test]
3730 fn test_decision_patch_preserves_fence_in_context() {
3731 let temp = TempDir::new().unwrap();
3732 let repo = Repository::init(temp.path(), None, false).unwrap();
3733
3734 let content = r#"# 4. Fence in context
3735
3736Date: 2026-01-15
3737
3738## Status
3739
3740Accepted
3741
3742## Context
3743
3744Example:
3745
3746```
3747## Decision
3748not a real heading
3749```
3750
3751## Decision
3752
3753We decided.
3754"#;
3755 let adr_path = repo.adr_path().join("0004-fence-in-context.md");
3756 fs::write(&adr_path, content).unwrap();
3757
3758 repo.update(
3759 &repo.get(4).unwrap(),
3760 BodySectionPatch {
3761 decision: Some("Updated decision.".into()),
3762 ..Default::default()
3763 },
3764 )
3765 .unwrap();
3766
3767 let after = fs::read_to_string(&adr_path).unwrap();
3768 assert!(after.contains("## Decision\nnot a real heading"));
3769 assert!(after.contains("Updated decision."));
3770 assert!(!after.contains("We decided."));
3771 }
3772}