1use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8use time::OffsetDateTime;
9
10use crate::{
11 Adr, AdrLink, AdrStatus, Config, LinkKind, Parser, Repository, Result, TemplateEngine,
12};
13
14pub const JSON_ADR_VERSION: &str = "1.0.0";
16
17pub const JSON_ADR_SCHEMA: &str =
19 "https://raw.githubusercontent.com/joshrotenberg/adrs/main/schema/json-adr/v1.json";
20
21#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct JsonAdr {
24 pub number: u32,
26
27 pub title: String,
29
30 pub status: String,
32
33 pub date: String,
35
36 #[serde(skip_serializing_if = "Vec::is_empty", default)]
38 pub deciders: Vec<String>,
39
40 #[serde(skip_serializing_if = "Vec::is_empty", default)]
42 pub consulted: Vec<String>,
43
44 #[serde(skip_serializing_if = "Vec::is_empty", default)]
46 pub informed: Vec<String>,
47
48 #[serde(skip_serializing_if = "Vec::is_empty", default)]
50 pub tags: Vec<String>,
51
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub source_uri: Option<String>,
55
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub context: Option<String>,
59
60 #[serde(skip_serializing_if = "Vec::is_empty", default)]
62 pub decision_drivers: Vec<String>,
63
64 #[serde(skip_serializing_if = "Vec::is_empty", default)]
66 pub considered_options: Vec<ConsideredOption>,
67
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub decision: Option<String>,
71
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub consequences: Option<String>,
75
76 #[serde(skip_serializing_if = "Option::is_none")]
78 pub confirmation: Option<String>,
79
80 #[serde(skip_serializing_if = "Vec::is_empty", default)]
82 pub links: Vec<JsonAdrLink>,
83
84 #[serde(skip_serializing_if = "std::collections::HashMap::is_empty", default)]
86 pub custom_sections: std::collections::HashMap<String, String>,
87
88 #[serde(skip_serializing_if = "Option::is_none")]
90 pub path: Option<String>,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct ConsideredOption {
96 pub name: String,
98
99 #[serde(skip_serializing_if = "Option::is_none")]
101 pub description: Option<String>,
102
103 #[serde(skip_serializing_if = "Vec::is_empty", default)]
105 pub pros: Vec<String>,
106
107 #[serde(skip_serializing_if = "Vec::is_empty", default)]
109 pub cons: Vec<String>,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct JsonAdrLink {
115 #[serde(rename = "type")]
117 pub link_type: String,
118
119 pub target: u32,
121
122 #[serde(skip_serializing_if = "Option::is_none")]
124 pub description: Option<String>,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct ToolInfo {
130 pub name: String,
132
133 pub version: String,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct RepositoryInfo {
140 #[serde(skip_serializing_if = "Option::is_none")]
142 pub name: Option<String>,
143
144 pub adr_directory: String,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct JsonAdrBulkExport {
151 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
153 pub schema: Option<String>,
154
155 pub version: String,
157
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub generated_at: Option<String>,
161
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub tool: Option<ToolInfo>,
165
166 #[serde(skip_serializing_if = "Option::is_none")]
168 pub repository: Option<RepositoryInfo>,
169
170 pub adrs: Vec<JsonAdr>,
172}
173
174impl JsonAdrBulkExport {
175 pub fn new(adrs: Vec<JsonAdr>) -> Self {
177 Self {
178 schema: Some(JSON_ADR_SCHEMA.to_string()),
179 version: JSON_ADR_VERSION.to_string(),
180 generated_at: Some(
181 OffsetDateTime::now_utc()
182 .format(&time::format_description::well_known::Rfc3339)
183 .unwrap_or_default(),
184 ),
185 tool: Some(ToolInfo {
186 name: "adrs".to_string(),
187 version: env!("CARGO_PKG_VERSION").to_string(),
188 }),
189 repository: None,
190 adrs,
191 }
192 }
193
194 pub fn with_repository(mut self, name: Option<String>, adr_directory: String) -> Self {
196 self.repository = Some(RepositoryInfo {
197 name,
198 adr_directory,
199 });
200 self
201 }
202}
203
204impl From<&Adr> for JsonAdr {
205 fn from(adr: &Adr) -> Self {
206 Self {
207 number: adr.number,
208 title: adr.title.clone(),
209 status: adr.status.to_string(),
210 date: adr
211 .date
212 .format(&time::format_description::well_known::Iso8601::DATE)
213 .unwrap_or_default(),
214 deciders: adr.decision_makers.clone(),
215 consulted: adr.consulted.clone(),
216 informed: adr.informed.clone(),
217 tags: adr.tags.clone(),
218 source_uri: None, context: if adr.context.is_empty() {
220 None
221 } else {
222 Some(adr.context.clone())
223 },
224 decision_drivers: Vec::new(), considered_options: Vec::new(), decision: if adr.decision.is_empty() {
227 None
228 } else {
229 Some(adr.decision.clone())
230 },
231 consequences: if adr.consequences.is_empty() {
232 None
233 } else {
234 Some(adr.consequences.clone())
235 },
236 confirmation: None, links: adr.links.iter().map(JsonAdrLink::from).collect(),
238 custom_sections: std::collections::HashMap::new(),
239 path: adr.path.as_ref().map(|p| p.display().to_string()),
240 }
241 }
242}
243
244impl From<&AdrLink> for JsonAdrLink {
245 fn from(link: &AdrLink) -> Self {
246 Self {
247 link_type: link_kind_to_string(&link.kind),
248 target: link.target,
249 description: link.description.clone(),
250 }
251 }
252}
253
254fn link_kind_to_string(kind: &LinkKind) -> String {
255 match kind {
256 LinkKind::Supersedes => "supersedes".to_string(),
257 LinkKind::SupersededBy => "superseded-by".to_string(),
258 LinkKind::Amends => "amends".to_string(),
259 LinkKind::AmendedBy => "amended-by".to_string(),
260 LinkKind::RelatesTo => "relates-to".to_string(),
261 LinkKind::Custom(s) => s.clone(),
262 }
263}
264
265pub fn export_repository(repo: &Repository) -> Result<JsonAdrBulkExport> {
267 let adrs = repo.list()?;
268 let json_adrs: Vec<JsonAdr> = adrs.iter().map(JsonAdr::from).collect();
269
270 let adr_dir = repo.config().adr_dir.display().to_string();
271
272 Ok(JsonAdrBulkExport::new(json_adrs).with_repository(None, adr_dir))
273}
274
275pub fn export_adr(adr: &Adr) -> JsonAdr {
277 JsonAdr::from(adr)
278}
279
280#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct JsonAdrSingle {
283 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
285 pub schema: Option<String>,
286
287 pub version: String,
289
290 pub adr: JsonAdr,
292}
293
294#[derive(Debug, Clone, Default)]
296pub struct ImportOptions {
297 pub overwrite: bool,
299
300 pub renumber: bool,
302
303 pub dry_run: bool,
305
306 pub ng_mode: bool,
308}
309
310#[derive(Debug, Clone)]
312pub struct ImportResult {
313 pub imported: usize,
315
316 pub skipped: usize,
318
319 pub files: Vec<std::path::PathBuf>,
321
322 pub warnings: Vec<String>,
324
325 pub renumber_map: Vec<(u32, u32)>,
327}
328
329pub fn export_directory(dir: &Path) -> Result<JsonAdrBulkExport> {
339 export_directory_with_warnings(dir).map(|(export, _warnings)| export)
340}
341
342pub fn export_directory_with_warnings(dir: &Path) -> Result<(JsonAdrBulkExport, Vec<String>)> {
348 let parser = Parser::new();
349 let mut adrs = Vec::new();
350 let mut warnings = Vec::new();
351
352 if dir.is_dir() {
354 let mut entries: Vec<_> = std::fs::read_dir(dir)?
355 .filter_map(|e| e.ok())
356 .filter(|e| {
357 let path = e.path();
358 path.is_file()
359 && path.extension().is_some_and(|ext| ext == "md")
360 && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
361 n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
363 })
364 })
365 .collect();
366
367 entries.sort_by_key(|e| e.path());
369
370 for entry in entries {
371 let path = entry.path();
372 match parser.parse_file(&path) {
373 Ok(adr) => adrs.push(JsonAdr::from(&adr)),
374 Err(e) => warnings.push(format!("Failed to parse {}: {}", path.display(), e)),
377 }
378 }
379 }
380
381 let adr_dir = dir.display().to_string();
382 Ok((
383 JsonAdrBulkExport::new(adrs).with_repository(None, adr_dir),
384 warnings,
385 ))
386}
387
388fn json_adr_to_adr(json_adr: &JsonAdr) -> Result<Adr> {
390 let date = time::Date::parse(
391 &json_adr.date,
392 &time::format_description::well_known::Iso8601::DATE,
393 )
394 .unwrap_or_else(|_| crate::parse::today());
395
396 let status = json_adr.status.parse::<AdrStatus>().unwrap_or_default();
397
398 let links: Vec<AdrLink> = json_adr
399 .links
400 .iter()
401 .map(|l| AdrLink {
402 target: l.target,
403 kind: string_to_link_kind(&l.link_type),
404 description: l.description.clone(),
405 })
406 .collect();
407
408 Ok(Adr {
409 number: json_adr.number,
410 title: json_adr.title.clone(),
411 date,
412 status,
413 links,
414 decision_makers: json_adr.deciders.clone(),
415 consulted: json_adr.consulted.clone(),
416 informed: json_adr.informed.clone(),
417 tags: json_adr.tags.clone(),
418 context: json_adr.context.clone().unwrap_or_default(),
419 decision: json_adr.decision.clone().unwrap_or_default(),
420 consequences: json_adr.consequences.clone().unwrap_or_default(),
421 path: None,
422 })
423}
424
425fn string_to_link_kind(s: &str) -> LinkKind {
426 match s.to_lowercase().as_str() {
427 "supersedes" => LinkKind::Supersedes,
428 "superseded-by" => LinkKind::SupersededBy,
429 "amends" => LinkKind::Amends,
430 "amended-by" => LinkKind::AmendedBy,
431 "relates-to" => LinkKind::RelatesTo,
432 other => LinkKind::Custom(other.to_string()),
433 }
434}
435
436pub fn import_to_directory(
441 json_data: &str,
442 dir: &Path,
443 options: &ImportOptions,
444) -> Result<ImportResult> {
445 let json_adrs: Vec<JsonAdr> =
447 if let Ok(bulk) = serde_json::from_str::<JsonAdrBulkExport>(json_data) {
448 bulk.adrs
449 } else if let Ok(single) = serde_json::from_str::<JsonAdrSingle>(json_data) {
450 vec![single.adr]
451 } else if let Ok(adr) = serde_json::from_str::<JsonAdr>(json_data) {
452 vec![adr]
453 } else {
454 return Err(crate::Error::InvalidFormat {
455 path: PathBuf::new(),
456 reason: "Invalid JSON-ADR format".to_string(),
457 });
458 };
459
460 std::fs::create_dir_all(dir)?;
462
463 let next_number = if options.renumber {
465 find_next_number(dir)?
466 } else {
467 0
468 };
469
470 let mut result = ImportResult {
471 imported: 0,
472 skipped: 0,
473 files: Vec::new(),
474 warnings: Vec::new(),
475 renumber_map: Vec::new(),
476 };
477
478 let config = Config {
480 adr_dir: dir.to_path_buf(),
481 mode: if options.ng_mode {
482 crate::ConfigMode::NextGen
483 } else {
484 crate::ConfigMode::default()
485 },
486 ..Default::default()
487 };
488
489 let engine = TemplateEngine::new();
490
491 let mut adrs_to_import = Vec::new();
493 let mut temp_next_number = next_number;
494
495 for json_adr in json_adrs {
496 let mut adr = json_adr_to_adr(&json_adr)?;
497
498 if options.renumber {
500 let old_number = adr.number;
501 adr.number = temp_next_number;
502 result.renumber_map.push((old_number, temp_next_number));
503 temp_next_number += 1;
504 }
505
506 adrs_to_import.push(adr);
507 }
508
509 let number_map: std::collections::HashMap<u32, u32> = result
511 .renumber_map
512 .iter()
513 .map(|&(old, new)| (old, new))
514 .collect();
515
516 for mut adr in adrs_to_import {
518 if options.renumber {
520 for link in &mut adr.links {
521 if let Some(&new_target) = number_map.get(&link.target) {
522 link.target = new_target;
524 } else {
525 result.warnings.push(format!(
527 "ADR {} links to ADR {} which is not in the import set",
528 adr.number, link.target
529 ));
530 }
531 }
532 }
533
534 let filename = adr.filename();
535 let filepath = dir.join(&filename);
536
537 if filepath.exists() && !options.overwrite {
539 result.skipped += 1;
540 result.warnings.push(format!(
541 "Skipped {}: file already exists (use --overwrite to replace)",
542 filename
543 ));
544 continue;
545 }
546
547 let content = engine.render(&adr, &config, &std::collections::HashMap::new())?;
549
550 if !options.dry_run {
552 std::fs::write(&filepath, content)?;
553 }
554
555 result.imported += 1;
556 result.files.push(filepath);
557 }
558
559 Ok(result)
560}
561
562fn find_next_number(dir: &Path) -> Result<u32> {
564 let mut max_number = 0u32;
565
566 if dir.is_dir() {
567 for entry in std::fs::read_dir(dir)?.filter_map(|e| e.ok()) {
568 let path = entry.path();
569 if let Some(name) = path.file_name().and_then(|n| n.to_str())
570 && name.len() > 4
571 && name.ends_with(".md")
572 && let Ok(num) = name[..4].parse::<u32>()
573 {
574 max_number = max_number.max(num);
575 }
576 }
577 }
578
579 Ok(max_number + 1)
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use crate::AdrStatus;
586 use time::{Date, Month};
587
588 #[test]
589 fn test_json_adr_from_adr() {
590 let mut adr = Adr::new(1, "Test Decision");
591 adr.status = AdrStatus::Accepted;
592 adr.date = Date::from_calendar_date(2024, Month::January, 15).unwrap();
593 adr.context = "Some context".to_string();
594 adr.decision = "We decided X".to_string();
595 adr.consequences = "This means Y".to_string();
596 adr.decision_makers = vec!["Alice".to_string()];
597 adr.consulted = vec!["Bob".to_string()];
598
599 let json_adr = JsonAdr::from(&adr);
600
601 assert_eq!(json_adr.number, 1);
602 assert_eq!(json_adr.title, "Test Decision");
603 assert_eq!(json_adr.status, "Accepted");
604 assert_eq!(json_adr.date, "2024-01-15");
605 assert_eq!(json_adr.deciders, vec!["Alice"]);
606 assert_eq!(json_adr.consulted, vec!["Bob"]);
607 assert_eq!(json_adr.source_uri, None); }
609
610 #[test]
611 fn test_json_adr_source_uri_field() {
612 let mut adr = JsonAdr {
613 number: 1,
614 title: "Test".to_string(),
615 status: "Accepted".to_string(),
616 date: "2024-01-15".to_string(),
617 deciders: vec![],
618 consulted: vec![],
619 informed: vec![],
620 tags: vec![],
621 source_uri: Some(
622 "https://github.com/org/repo/blob/main/doc/adr/0001-test.md".to_string(),
623 ),
624 context: Some("Test context".to_string()),
625 decision: Some("Test decision".to_string()),
626 consequences: Some("Test consequences".to_string()),
627 decision_drivers: vec![],
628 considered_options: vec![],
629 confirmation: None,
630 links: vec![],
631 custom_sections: std::collections::HashMap::new(),
632 path: None,
633 };
634
635 let json = serde_json::to_string(&adr).unwrap();
636 assert!(json.contains(
637 "\"source_uri\":\"https://github.com/org/repo/blob/main/doc/adr/0001-test.md\""
638 ));
639
640 adr.source_uri = None;
642 let json = serde_json::to_string(&adr).unwrap();
643 assert!(!json.contains("source_uri"));
644 }
645
646 #[test]
647 fn test_json_adr_link_from_adr_link() {
648 let link = AdrLink {
649 target: 2,
650 kind: LinkKind::Supersedes,
651 description: Some("Replaces old approach".to_string()),
652 };
653
654 let json_link = JsonAdrLink::from(&link);
655
656 assert_eq!(json_link.link_type, "supersedes");
657 assert_eq!(json_link.target, 2);
658 assert_eq!(
659 json_link.description,
660 Some("Replaces old approach".to_string())
661 );
662 }
663
664 #[test]
665 fn test_bulk_export_metadata() {
666 let export = JsonAdrBulkExport::new(vec![]);
667
668 assert_eq!(export.version, JSON_ADR_VERSION);
669 assert!(export.schema.is_some());
670 assert!(export.generated_at.is_some());
671 assert!(export.tool.is_some());
672
673 let tool = export.tool.unwrap();
674 assert_eq!(tool.name, "adrs");
675 }
676
677 #[test]
678 fn test_bulk_export_with_repository() {
679 let export = JsonAdrBulkExport::new(vec![])
680 .with_repository(Some("my-project".to_string()), "doc/adr".to_string());
681
682 let repo = export.repository.unwrap();
683 assert_eq!(repo.name, Some("my-project".to_string()));
684 assert_eq!(repo.adr_directory, "doc/adr");
685 }
686
687 #[test]
688 fn test_link_kind_to_string() {
689 assert_eq!(link_kind_to_string(&LinkKind::Supersedes), "supersedes");
690 assert_eq!(
691 link_kind_to_string(&LinkKind::SupersededBy),
692 "superseded-by"
693 );
694 assert_eq!(link_kind_to_string(&LinkKind::Amends), "amends");
695 assert_eq!(link_kind_to_string(&LinkKind::AmendedBy), "amended-by");
696 assert_eq!(link_kind_to_string(&LinkKind::RelatesTo), "relates-to");
697 assert_eq!(
698 link_kind_to_string(&LinkKind::Custom("extends".to_string())),
699 "extends"
700 );
701 }
702
703 #[test]
704 fn test_json_serialization() {
705 let adr = JsonAdr {
706 number: 1,
707 title: "Test".to_string(),
708 status: "Accepted".to_string(),
709 date: "2024-01-15".to_string(),
710 deciders: vec![],
711 consulted: vec![],
712 informed: vec![],
713 tags: vec![],
714 source_uri: None,
715 context: None,
716 decision_drivers: vec![],
717 considered_options: vec![],
718 decision: None,
719 consequences: None,
720 confirmation: None,
721 links: vec![],
722 custom_sections: std::collections::HashMap::new(),
723 path: None,
724 };
725
726 let json = serde_json::to_string(&adr).unwrap();
727 assert!(json.contains("\"number\":1"));
728 assert!(json.contains("\"title\":\"Test\""));
729 assert!(!json.contains("\"deciders\""));
731 assert!(!json.contains("\"decision_drivers\""));
732 assert!(!json.contains("\"considered_options\""));
733 assert!(!json.contains("\"custom_sections\""));
735 }
736
737 #[test]
738 fn test_custom_sections() {
739 let mut adr = JsonAdr {
740 number: 1,
741 title: "Test".to_string(),
742 status: "Accepted".to_string(),
743 date: "2024-01-15".to_string(),
744 deciders: vec![],
745 consulted: vec![],
746 informed: vec![],
747 tags: vec![],
748 source_uri: None,
749 context: None,
750 decision_drivers: vec![],
751 considered_options: vec![],
752 decision: None,
753 consequences: None,
754 confirmation: None,
755 links: vec![],
756 custom_sections: std::collections::HashMap::new(),
757 path: None,
758 };
759
760 adr.custom_sections.insert(
761 "Alternatives Considered".to_string(),
762 "We also looked at MySQL and SQLite.".to_string(),
763 );
764 adr.custom_sections.insert(
765 "Security Review".to_string(),
766 "Approved by security team on 2024-01-10.".to_string(),
767 );
768
769 let json = serde_json::to_string_pretty(&adr).unwrap();
770 assert!(json.contains("\"custom_sections\""));
771 assert!(json.contains("Alternatives Considered"));
772 assert!(json.contains("Security Review"));
773 }
774
775 #[test]
776 fn test_decision_drivers_and_options() {
777 let adr = JsonAdr {
778 number: 1,
779 title: "Choose Database".to_string(),
780 status: "Accepted".to_string(),
781 date: "2024-01-15".to_string(),
782 deciders: vec!["Alice".to_string()],
783 consulted: vec![],
784 informed: vec![],
785 tags: vec![],
786 source_uri: None,
787 context: Some("We need a database for user data".to_string()),
788 decision_drivers: vec![
789 "Performance requirements".to_string(),
790 "Team expertise".to_string(),
791 "Cost constraints".to_string(),
792 ],
793 considered_options: vec![
794 ConsideredOption {
795 name: "PostgreSQL".to_string(),
796 description: Some("Open source relational database".to_string()),
797 pros: vec!["Mature".to_string(), "Feature-rich".to_string()],
798 cons: vec!["Complex setup".to_string()],
799 },
800 ConsideredOption {
801 name: "SQLite".to_string(),
802 description: None,
803 pros: vec!["Simple".to_string()],
804 cons: vec!["Not suitable for high concurrency".to_string()],
805 },
806 ],
807 decision: Some("Use PostgreSQL".to_string()),
808 consequences: Some("Need to set up replication".to_string()),
809 confirmation: Some("Run integration tests with production-like data".to_string()),
810 links: vec![],
811 custom_sections: std::collections::HashMap::new(),
812 path: None,
813 };
814
815 let json = serde_json::to_string_pretty(&adr).unwrap();
816
817 assert!(json.contains("\"decision_drivers\""));
819 assert!(json.contains("Performance requirements"));
820 assert!(json.contains("Team expertise"));
821
822 assert!(json.contains("\"considered_options\""));
824 assert!(json.contains("PostgreSQL"));
825 assert!(json.contains("SQLite"));
826 assert!(json.contains("\"pros\""));
827 assert!(json.contains("\"cons\""));
828 assert!(json.contains("Mature"));
829 assert!(json.contains("Complex setup"));
830
831 assert!(json.contains("\"confirmation\""));
833 assert!(json.contains("integration tests"));
834 }
835
836 #[test]
837 fn test_considered_option_minimal() {
838 let option = ConsideredOption {
839 name: "Option A".to_string(),
840 description: None,
841 pros: vec![],
842 cons: vec![],
843 };
844
845 let json = serde_json::to_string(&option).unwrap();
846 assert!(json.contains("\"name\":\"Option A\""));
847 assert!(!json.contains("\"description\""));
849 assert!(!json.contains("\"pros\""));
850 assert!(!json.contains("\"cons\""));
851 }
852
853 #[test]
856 fn test_import_basic() {
857 use tempfile::TempDir;
858
859 let temp = TempDir::new().unwrap();
860 let json = r#"{
861 "number": 1,
862 "title": "Test Decision",
863 "status": "Proposed",
864 "date": "2024-01-15",
865 "context": "Test context",
866 "decision": "Test decision",
867 "consequences": "Test consequences"
868 }"#;
869
870 let options = ImportOptions {
871 overwrite: false,
872 renumber: false,
873 dry_run: false,
874 ng_mode: false,
875 };
876
877 let result = import_to_directory(json, temp.path(), &options).unwrap();
878
879 assert_eq!(result.imported, 1);
880 assert_eq!(result.skipped, 0);
881 assert_eq!(result.files.len(), 1);
882 assert!(result.files[0].exists());
883 }
884
885 #[test]
886 fn test_import_with_renumber() {
887 use tempfile::TempDir;
888
889 let temp = TempDir::new().unwrap();
890
891 std::fs::create_dir_all(temp.path()).unwrap();
893 std::fs::write(
894 temp.path().join("0001-existing.md"),
895 "# 1. Existing\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",
896 )
897 .unwrap();
898
899 let json = r#"{
900 "version": "1.0.0",
901 "adrs": [
902 {
903 "number": 1,
904 "title": "First Import",
905 "status": "Proposed",
906 "date": "2024-01-15",
907 "context": "Test",
908 "decision": "Test",
909 "consequences": "Test"
910 },
911 {
912 "number": 2,
913 "title": "Second Import",
914 "status": "Proposed",
915 "date": "2024-01-16",
916 "context": "Test",
917 "decision": "Test",
918 "consequences": "Test"
919 }
920 ]
921 }"#;
922
923 let options = ImportOptions {
924 overwrite: false,
925 renumber: true,
926 dry_run: false,
927 ng_mode: false,
928 };
929
930 let result = import_to_directory(json, temp.path(), &options).unwrap();
931
932 assert_eq!(result.imported, 2);
933 assert_eq!(result.renumber_map.len(), 2);
934 assert_eq!(result.renumber_map[0], (1, 2)); assert_eq!(result.renumber_map[1], (2, 3)); assert!(temp.path().join("0002-first-import.md").exists());
939 assert!(temp.path().join("0003-second-import.md").exists());
940 }
941
942 #[test]
943 fn test_import_dry_run() {
944 use tempfile::TempDir;
945
946 let temp = TempDir::new().unwrap();
947 let json = r#"{
948 "number": 1,
949 "title": "Test Decision",
950 "status": "Proposed",
951 "date": "2024-01-15",
952 "context": "Test",
953 "decision": "Test",
954 "consequences": "Test"
955 }"#;
956
957 let options = ImportOptions {
958 overwrite: false,
959 renumber: false,
960 dry_run: true,
961 ng_mode: false,
962 };
963
964 let result = import_to_directory(json, temp.path(), &options).unwrap();
965
966 assert_eq!(result.imported, 1);
967 assert_eq!(result.files.len(), 1);
968
969 assert!(!result.files[0].exists());
971 }
972
973 #[test]
974 fn test_import_dry_run_with_renumber() {
975 use tempfile::TempDir;
976
977 let temp = TempDir::new().unwrap();
978
979 std::fs::create_dir_all(temp.path()).unwrap();
981 for i in 1..=3 {
982 std::fs::write(
983 temp.path().join(format!("{:04}-existing-{}.md", i, i)),
984 format!("# {}. Existing\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", i),
985 )
986 .unwrap();
987 }
988
989 let json = r#"{
990 "version": "1.0.0",
991 "adrs": [
992 {
993 "number": 5,
994 "title": "Import Five",
995 "status": "Proposed",
996 "date": "2024-01-15",
997 "context": "Test",
998 "decision": "Test",
999 "consequences": "Test"
1000 },
1001 {
1002 "number": 7,
1003 "title": "Import Seven",
1004 "status": "Proposed",
1005 "date": "2024-01-16",
1006 "context": "Test",
1007 "decision": "Test",
1008 "consequences": "Test"
1009 }
1010 ]
1011 }"#;
1012
1013 let options = ImportOptions {
1014 overwrite: false,
1015 renumber: true,
1016 dry_run: true,
1017 ng_mode: false,
1018 };
1019
1020 let result = import_to_directory(json, temp.path(), &options).unwrap();
1021
1022 assert_eq!(result.imported, 2);
1023 assert_eq!(result.renumber_map.len(), 2);
1024 assert_eq!(result.renumber_map[0], (5, 4)); assert_eq!(result.renumber_map[1], (7, 5)); assert!(!temp.path().join("0004-import-five.md").exists());
1030 assert!(!temp.path().join("0005-import-seven.md").exists());
1031 }
1032
1033 #[test]
1034 fn test_import_skip_existing() {
1035 use tempfile::TempDir;
1036
1037 let temp = TempDir::new().unwrap();
1038
1039 std::fs::create_dir_all(temp.path()).unwrap();
1041 std::fs::write(
1042 temp.path().join("0001-test-decision.md"),
1043 "# 1. Test Decision\n\nExisting content\n",
1044 )
1045 .unwrap();
1046
1047 let json = r#"{
1048 "number": 1,
1049 "title": "Test Decision",
1050 "status": "Proposed",
1051 "date": "2024-01-15",
1052 "context": "Test",
1053 "decision": "Test",
1054 "consequences": "Test"
1055 }"#;
1056
1057 let options = ImportOptions {
1058 overwrite: false,
1059 renumber: false,
1060 dry_run: false,
1061 ng_mode: false,
1062 };
1063
1064 let result = import_to_directory(json, temp.path(), &options).unwrap();
1065
1066 assert_eq!(result.imported, 0);
1067 assert_eq!(result.skipped, 1);
1068 assert_eq!(result.warnings.len(), 1);
1069 assert!(result.warnings[0].contains("already exists"));
1070 }
1071
1072 #[test]
1073 fn test_import_overwrite() {
1074 use tempfile::TempDir;
1075
1076 let temp = TempDir::new().unwrap();
1077
1078 std::fs::create_dir_all(temp.path()).unwrap();
1080 std::fs::write(
1081 temp.path().join("0001-test-decision.md"),
1082 "# 1. Test Decision\n\nOLD CONTENT\n",
1083 )
1084 .unwrap();
1085
1086 let json = r#"{
1087 "number": 1,
1088 "title": "Test Decision",
1089 "status": "Proposed",
1090 "date": "2024-01-15",
1091 "context": "NEW CONTEXT",
1092 "decision": "Test",
1093 "consequences": "Test"
1094 }"#;
1095
1096 let options = ImportOptions {
1097 overwrite: true,
1098 renumber: false,
1099 dry_run: false,
1100 ng_mode: false,
1101 };
1102
1103 let result = import_to_directory(json, temp.path(), &options).unwrap();
1104
1105 assert_eq!(result.imported, 1);
1106 assert_eq!(result.skipped, 0);
1107
1108 let content = std::fs::read_to_string(temp.path().join("0001-test-decision.md")).unwrap();
1110 assert!(content.contains("NEW CONTEXT"));
1111 assert!(!content.contains("OLD CONTENT"));
1112 }
1113
1114 #[test]
1115 fn test_import_bulk_format() {
1116 use tempfile::TempDir;
1117
1118 let temp = TempDir::new().unwrap();
1119
1120 let json = r#"{
1121 "version": "1.0.0",
1122 "adrs": [
1123 {
1124 "number": 1,
1125 "title": "First",
1126 "status": "Proposed",
1127 "date": "2024-01-15",
1128 "context": "Test",
1129 "decision": "Test",
1130 "consequences": "Test"
1131 },
1132 {
1133 "number": 2,
1134 "title": "Second",
1135 "status": "Accepted",
1136 "date": "2024-01-16",
1137 "context": "Test",
1138 "decision": "Test",
1139 "consequences": "Test"
1140 }
1141 ]
1142 }"#;
1143
1144 let options = ImportOptions {
1145 overwrite: false,
1146 renumber: false,
1147 dry_run: false,
1148 ng_mode: false,
1149 };
1150
1151 let result = import_to_directory(json, temp.path(), &options).unwrap();
1152
1153 assert_eq!(result.imported, 2);
1154 assert!(temp.path().join("0001-first.md").exists());
1155 assert!(temp.path().join("0002-second.md").exists());
1156 }
1157
1158 #[test]
1159 fn test_import_single_wrapper_format() {
1160 use tempfile::TempDir;
1161
1162 let temp = TempDir::new().unwrap();
1163
1164 let json = r#"{
1165 "version": "1.0.0",
1166 "adr": {
1167 "number": 1,
1168 "title": "Test Decision",
1169 "status": "Proposed",
1170 "date": "2024-01-15",
1171 "context": "Test",
1172 "decision": "Test",
1173 "consequences": "Test"
1174 }
1175 }"#;
1176
1177 let options = ImportOptions {
1178 overwrite: false,
1179 renumber: false,
1180 dry_run: false,
1181 ng_mode: false,
1182 };
1183
1184 let result = import_to_directory(json, temp.path(), &options).unwrap();
1185
1186 assert_eq!(result.imported, 1);
1187 assert!(temp.path().join("0001-test-decision.md").exists());
1188 }
1189
1190 #[test]
1193 fn test_import_renumber_with_internal_links() {
1194 use tempfile::TempDir;
1195
1196 let temp = TempDir::new().unwrap();
1197
1198 std::fs::create_dir_all(temp.path()).unwrap();
1200 std::fs::write(
1201 temp.path().join("0001-existing.md"),
1202 "# 1. Existing\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",
1203 )
1204 .unwrap();
1205
1206 let json = r#"{
1208 "version": "1.0.0",
1209 "adrs": [
1210 {
1211 "number": 1,
1212 "title": "First",
1213 "status": "Superseded",
1214 "date": "2024-01-15",
1215 "context": "Test",
1216 "decision": "Test",
1217 "consequences": "Test",
1218 "links": [
1219 {"target": 2, "type": "SupersededBy"}
1220 ]
1221 },
1222 {
1223 "number": 2,
1224 "title": "Second",
1225 "status": "Accepted",
1226 "date": "2024-01-16",
1227 "context": "Test",
1228 "decision": "Test",
1229 "consequences": "Test",
1230 "links": [
1231 {"target": 1, "type": "Supersedes"}
1232 ]
1233 }
1234 ]
1235 }"#;
1236
1237 let options = ImportOptions {
1238 overwrite: false,
1239 renumber: true,
1240 dry_run: false,
1241 ng_mode: false,
1242 };
1243
1244 let result = import_to_directory(json, temp.path(), &options).unwrap();
1245
1246 assert_eq!(result.imported, 2);
1247 assert_eq!(result.renumber_map[0], (1, 2)); assert_eq!(result.renumber_map[1], (2, 3)); let parser = crate::Parser::new();
1252
1253 let adr2 = parser
1254 .parse_file(&temp.path().join("0002-first.md"))
1255 .unwrap();
1256 assert_eq!(adr2.links.len(), 1);
1257 assert_eq!(adr2.links[0].target, 3); let adr3 = parser
1260 .parse_file(&temp.path().join("0003-second.md"))
1261 .unwrap();
1262 assert_eq!(adr3.links.len(), 1);
1263 assert_eq!(adr3.links[0].target, 2); }
1265
1266 #[test]
1267 fn test_import_renumber_with_broken_links() {
1268 use tempfile::TempDir;
1269
1270 let temp = TempDir::new().unwrap();
1271
1272 std::fs::create_dir_all(temp.path()).unwrap();
1274 std::fs::write(
1275 temp.path().join("0001-existing.md"),
1276 "# 1. Existing\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",
1277 )
1278 .unwrap();
1279
1280 let json = r#"{
1282 "version": "1.0.0",
1283 "adrs": [
1284 {
1285 "number": 5,
1286 "title": "Fifth",
1287 "status": "Accepted",
1288 "date": "2024-01-15",
1289 "context": "Test",
1290 "decision": "Test",
1291 "consequences": "Test",
1292 "links": [
1293 {"target": 3, "type": "Extends"}
1294 ]
1295 }
1296 ]
1297 }"#;
1298
1299 let options = ImportOptions {
1300 overwrite: false,
1301 renumber: true,
1302 dry_run: false,
1303 ng_mode: false,
1304 };
1305
1306 let result = import_to_directory(json, temp.path(), &options).unwrap();
1307
1308 assert_eq!(result.imported, 1);
1309 assert_eq!(result.renumber_map[0], (5, 2)); assert_eq!(result.warnings.len(), 1);
1313 assert!(result.warnings[0].contains("ADR 2 links to ADR 3"));
1314 assert!(result.warnings[0].contains("not in the import set"));
1315 }
1316
1317 #[test]
1318 fn test_import_renumber_complex_links() {
1319 use tempfile::TempDir;
1320
1321 let temp = TempDir::new().unwrap();
1322
1323 let json = r#"{
1325 "version": "1.0.0",
1326 "adrs": [
1327 {
1328 "number": 10,
1329 "title": "Ten",
1330 "status": "Accepted",
1331 "date": "2024-01-10",
1332 "context": "Test",
1333 "decision": "Test",
1334 "consequences": "Test",
1335 "links": []
1336 },
1337 {
1338 "number": 20,
1339 "title": "Twenty",
1340 "status": "Accepted",
1341 "date": "2024-01-20",
1342 "context": "Test",
1343 "decision": "Test",
1344 "consequences": "Test",
1345 "links": [
1346 {"target": 10, "type": "Amends"}
1347 ]
1348 },
1349 {
1350 "number": 30,
1351 "title": "Thirty",
1352 "status": "Accepted",
1353 "date": "2024-01-30",
1354 "context": "Test",
1355 "decision": "Test",
1356 "consequences": "Test",
1357 "links": [
1358 {"target": 10, "type": "RelatesTo"},
1359 {"target": 20, "type": "RelatesTo"}
1360 ]
1361 }
1362 ]
1363 }"#;
1364
1365 let options = ImportOptions {
1366 overwrite: false,
1367 renumber: true,
1368 dry_run: false,
1369 ng_mode: false,
1370 };
1371
1372 let result = import_to_directory(json, temp.path(), &options).unwrap();
1373
1374 assert_eq!(result.imported, 3);
1375 assert_eq!(result.renumber_map[0], (10, 1)); assert_eq!(result.renumber_map[1], (20, 2)); assert_eq!(result.renumber_map[2], (30, 3)); let parser = crate::Parser::new();
1381
1382 let adr2 = parser
1383 .parse_file(&temp.path().join("0002-twenty.md"))
1384 .unwrap();
1385 assert_eq!(adr2.links.len(), 1);
1386 assert_eq!(adr2.links[0].target, 1); let adr3 = parser
1389 .parse_file(&temp.path().join("0003-thirty.md"))
1390 .unwrap();
1391 assert_eq!(adr3.links.len(), 2);
1392 assert_eq!(adr3.links[0].target, 1); assert_eq!(adr3.links[1].target, 2); }
1395 #[test]
1398 fn test_import_ng_mode_produces_yaml_frontmatter() {
1399 use tempfile::TempDir;
1400
1401 let temp = TempDir::new().unwrap();
1402 let json = r#"{
1403 "number": 1,
1404 "title": "Test Decision",
1405 "status": "Proposed",
1406 "date": "2024-01-15",
1407 "context": "Test context",
1408 "decision": "Test decision",
1409 "consequences": "Test consequences"
1410 }"#;
1411
1412 let options = ImportOptions {
1413 overwrite: false,
1414 renumber: false,
1415 dry_run: false,
1416 ng_mode: true,
1417 };
1418
1419 let result = import_to_directory(json, temp.path(), &options).unwrap();
1420 assert_eq!(result.imported, 1);
1421
1422 let content = std::fs::read_to_string(&result.files[0]).unwrap();
1423 assert!(
1425 content.starts_with("---"),
1426 "ng_mode import should produce YAML frontmatter. Got:\n{content}"
1427 );
1428 assert!(
1429 content.contains("status: proposed"),
1430 "Frontmatter should contain status"
1431 );
1432 }
1433
1434 #[test]
1435 fn test_import_ng_mode_preserves_tags() {
1436 use tempfile::TempDir;
1437
1438 let temp = TempDir::new().unwrap();
1439 let json = r#"{
1440 "number": 1,
1441 "title": "Tagged Decision",
1442 "status": "Accepted",
1443 "date": "2024-01-15",
1444 "tags": ["database", "infrastructure"]
1445 }"#;
1446
1447 let options = ImportOptions {
1448 overwrite: false,
1449 renumber: false,
1450 dry_run: false,
1451 ng_mode: true,
1452 };
1453
1454 let result = import_to_directory(json, temp.path(), &options).unwrap();
1455 assert_eq!(result.imported, 1);
1456
1457 let content = std::fs::read_to_string(&result.files[0]).unwrap();
1458 assert!(
1459 content.contains("tags:"),
1460 "ng_mode import should preserve tags in frontmatter"
1461 );
1462 assert!(content.contains("database"));
1463 assert!(content.contains("infrastructure"));
1464 }
1465
1466 #[test]
1467 fn test_import_ng_mode_compatible_mode_no_frontmatter() {
1468 use tempfile::TempDir;
1469
1470 let temp = TempDir::new().unwrap();
1471 let json = r#"{
1472 "number": 1,
1473 "title": "Compatible Decision",
1474 "status": "Proposed",
1475 "date": "2024-01-15"
1476 }"#;
1477
1478 let options = ImportOptions {
1479 overwrite: false,
1480 renumber: false,
1481 dry_run: false,
1482 ng_mode: false, };
1484
1485 let result = import_to_directory(json, temp.path(), &options).unwrap();
1486 assert_eq!(result.imported, 1);
1487
1488 let content = std::fs::read_to_string(&result.files[0]).unwrap();
1489 assert!(
1491 !content.starts_with("---"),
1492 "Compatible mode should not produce YAML frontmatter. Got:\n{content}"
1493 );
1494 }
1495
1496 #[test]
1497 fn test_import_ng_mode_roundtrip_export_import() {
1498 use crate::{Adr, AdrStatus};
1499 use tempfile::TempDir;
1500
1501 let mut adr = Adr::new(1, "Round-Trip Test");
1503 adr.status = AdrStatus::Accepted;
1504 adr.context = "We need a round-trip test.".to_string();
1505 adr.decision = "We will test export and import.".to_string();
1506 adr.consequences = "Better test coverage.".to_string();
1507 adr.tags = vec!["test".to_string(), "ci".to_string()];
1508
1509 let json_adr = export_adr(&adr);
1511 let bulk = JsonAdrBulkExport::new(vec![json_adr]);
1512 let json_str = serde_json::to_string(&bulk).unwrap();
1513
1514 let dest_temp = TempDir::new().unwrap();
1516 let options = ImportOptions {
1517 overwrite: false,
1518 renumber: false,
1519 dry_run: false,
1520 ng_mode: true,
1521 };
1522
1523 let result = import_to_directory(&json_str, dest_temp.path(), &options).unwrap();
1524 assert_eq!(result.imported, 1);
1525
1526 let content = std::fs::read_to_string(&result.files[0]).unwrap();
1528 assert!(
1529 content.starts_with("---"),
1530 "Round-trip ng import should have frontmatter"
1531 );
1532 assert!(content.contains("tags:"));
1533 assert!(content.contains("test"));
1534 assert!(content.contains("ci"));
1535 }
1536
1537 #[test]
1538 fn test_import_ng_mode_renumber_with_frontmatter() {
1539 use tempfile::TempDir;
1540
1541 let temp = TempDir::new().unwrap();
1542 std::fs::write(
1544 temp.path().join("0001-existing.md"),
1545 "# 1. Existing\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",
1546 ).unwrap();
1547
1548 let json = r#"{
1549 "number": 1,
1550 "title": "New Import",
1551 "status": "Proposed",
1552 "date": "2024-01-15",
1553 "tags": ["test"]
1554 }"#;
1555
1556 let options = ImportOptions {
1557 overwrite: false,
1558 renumber: true,
1559 dry_run: false,
1560 ng_mode: true,
1561 };
1562
1563 let result = import_to_directory(json, temp.path(), &options).unwrap();
1564 assert_eq!(result.imported, 1);
1565 assert_eq!(result.renumber_map[0], (1, 2));
1566
1567 let content = std::fs::read_to_string(&result.files[0]).unwrap();
1568 assert!(
1570 content.starts_with("---"),
1571 "Renumbered ng import should have frontmatter"
1572 );
1573 }
1574}