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> {
335 let parser = Parser::new();
336 let mut adrs = Vec::new();
337
338 if dir.is_dir() {
340 let mut entries: Vec<_> = std::fs::read_dir(dir)?
341 .filter_map(|e| e.ok())
342 .filter(|e| {
343 let path = e.path();
344 path.is_file()
345 && path.extension().is_some_and(|ext| ext == "md")
346 && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
347 n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
349 })
350 })
351 .collect();
352
353 entries.sort_by_key(|e| e.path());
355
356 for entry in entries {
357 let path = entry.path();
358 match parser.parse_file(&path) {
359 Ok(adr) => adrs.push(JsonAdr::from(&adr)),
360 Err(e) => {
361 eprintln!("Warning: Failed to parse {}: {}", path.display(), e);
363 }
364 }
365 }
366 }
367
368 let adr_dir = dir.display().to_string();
369 Ok(JsonAdrBulkExport::new(adrs).with_repository(None, adr_dir))
370}
371
372fn json_adr_to_adr(json_adr: &JsonAdr) -> Result<Adr> {
374 let date = time::Date::parse(
375 &json_adr.date,
376 &time::format_description::well_known::Iso8601::DATE,
377 )
378 .unwrap_or_else(|_| crate::parse::today());
379
380 let status = json_adr.status.parse::<AdrStatus>().unwrap_or_default();
381
382 let links: Vec<AdrLink> = json_adr
383 .links
384 .iter()
385 .map(|l| AdrLink {
386 target: l.target,
387 kind: string_to_link_kind(&l.link_type),
388 description: l.description.clone(),
389 })
390 .collect();
391
392 Ok(Adr {
393 number: json_adr.number,
394 title: json_adr.title.clone(),
395 date,
396 status,
397 links,
398 decision_makers: json_adr.deciders.clone(),
399 consulted: json_adr.consulted.clone(),
400 informed: json_adr.informed.clone(),
401 tags: json_adr.tags.clone(),
402 context: json_adr.context.clone().unwrap_or_default(),
403 decision: json_adr.decision.clone().unwrap_or_default(),
404 consequences: json_adr.consequences.clone().unwrap_or_default(),
405 path: None,
406 })
407}
408
409fn string_to_link_kind(s: &str) -> LinkKind {
410 match s.to_lowercase().as_str() {
411 "supersedes" => LinkKind::Supersedes,
412 "superseded-by" => LinkKind::SupersededBy,
413 "amends" => LinkKind::Amends,
414 "amended-by" => LinkKind::AmendedBy,
415 "relates-to" => LinkKind::RelatesTo,
416 other => LinkKind::Custom(other.to_string()),
417 }
418}
419
420pub fn import_to_directory(
425 json_data: &str,
426 dir: &Path,
427 options: &ImportOptions,
428) -> Result<ImportResult> {
429 let json_adrs: Vec<JsonAdr> =
431 if let Ok(bulk) = serde_json::from_str::<JsonAdrBulkExport>(json_data) {
432 bulk.adrs
433 } else if let Ok(single) = serde_json::from_str::<JsonAdrSingle>(json_data) {
434 vec![single.adr]
435 } else if let Ok(adr) = serde_json::from_str::<JsonAdr>(json_data) {
436 vec![adr]
437 } else {
438 return Err(crate::Error::InvalidFormat {
439 path: PathBuf::new(),
440 reason: "Invalid JSON-ADR format".to_string(),
441 });
442 };
443
444 std::fs::create_dir_all(dir)?;
446
447 let next_number = if options.renumber {
449 find_next_number(dir)?
450 } else {
451 0
452 };
453
454 let mut result = ImportResult {
455 imported: 0,
456 skipped: 0,
457 files: Vec::new(),
458 warnings: Vec::new(),
459 renumber_map: Vec::new(),
460 };
461
462 let config = Config {
464 adr_dir: dir.to_path_buf(),
465 mode: if options.ng_mode {
466 crate::ConfigMode::NextGen
467 } else {
468 crate::ConfigMode::default()
469 },
470 ..Default::default()
471 };
472
473 let engine = TemplateEngine::new();
474
475 let mut adrs_to_import = Vec::new();
477 let mut temp_next_number = next_number;
478
479 for json_adr in json_adrs {
480 let mut adr = json_adr_to_adr(&json_adr)?;
481
482 if options.renumber {
484 let old_number = adr.number;
485 adr.number = temp_next_number;
486 result.renumber_map.push((old_number, temp_next_number));
487 temp_next_number += 1;
488 }
489
490 adrs_to_import.push(adr);
491 }
492
493 let number_map: std::collections::HashMap<u32, u32> = result
495 .renumber_map
496 .iter()
497 .map(|&(old, new)| (old, new))
498 .collect();
499
500 for mut adr in adrs_to_import {
502 if options.renumber {
504 for link in &mut adr.links {
505 if let Some(&new_target) = number_map.get(&link.target) {
506 link.target = new_target;
508 } else {
509 result.warnings.push(format!(
511 "ADR {} links to ADR {} which is not in the import set",
512 adr.number, link.target
513 ));
514 }
515 }
516 }
517
518 let filename = adr.filename();
519 let filepath = dir.join(&filename);
520
521 if filepath.exists() && !options.overwrite {
523 result.skipped += 1;
524 result.warnings.push(format!(
525 "Skipped {}: file already exists (use --overwrite to replace)",
526 filename
527 ));
528 continue;
529 }
530
531 let content = engine.render(&adr, &config)?;
533
534 if !options.dry_run {
536 std::fs::write(&filepath, content)?;
537 }
538
539 result.imported += 1;
540 result.files.push(filepath);
541 }
542
543 Ok(result)
544}
545
546fn find_next_number(dir: &Path) -> Result<u32> {
548 let mut max_number = 0u32;
549
550 if dir.is_dir() {
551 for entry in std::fs::read_dir(dir)?.filter_map(|e| e.ok()) {
552 let path = entry.path();
553 if let Some(name) = path.file_name().and_then(|n| n.to_str())
554 && name.len() > 4
555 && name.ends_with(".md")
556 && let Ok(num) = name[..4].parse::<u32>()
557 {
558 max_number = max_number.max(num);
559 }
560 }
561 }
562
563 Ok(max_number + 1)
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569 use crate::AdrStatus;
570 use time::{Date, Month};
571
572 #[test]
573 fn test_json_adr_from_adr() {
574 let mut adr = Adr::new(1, "Test Decision");
575 adr.status = AdrStatus::Accepted;
576 adr.date = Date::from_calendar_date(2024, Month::January, 15).unwrap();
577 adr.context = "Some context".to_string();
578 adr.decision = "We decided X".to_string();
579 adr.consequences = "This means Y".to_string();
580 adr.decision_makers = vec!["Alice".to_string()];
581 adr.consulted = vec!["Bob".to_string()];
582
583 let json_adr = JsonAdr::from(&adr);
584
585 assert_eq!(json_adr.number, 1);
586 assert_eq!(json_adr.title, "Test Decision");
587 assert_eq!(json_adr.status, "Accepted");
588 assert_eq!(json_adr.date, "2024-01-15");
589 assert_eq!(json_adr.deciders, vec!["Alice"]);
590 assert_eq!(json_adr.consulted, vec!["Bob"]);
591 assert_eq!(json_adr.source_uri, None); }
593
594 #[test]
595 fn test_json_adr_source_uri_field() {
596 let mut adr = JsonAdr {
597 number: 1,
598 title: "Test".to_string(),
599 status: "Accepted".to_string(),
600 date: "2024-01-15".to_string(),
601 deciders: vec![],
602 consulted: vec![],
603 informed: vec![],
604 tags: vec![],
605 source_uri: Some(
606 "https://github.com/org/repo/blob/main/doc/adr/0001-test.md".to_string(),
607 ),
608 context: Some("Test context".to_string()),
609 decision: Some("Test decision".to_string()),
610 consequences: Some("Test consequences".to_string()),
611 decision_drivers: vec![],
612 considered_options: vec![],
613 confirmation: None,
614 links: vec![],
615 custom_sections: std::collections::HashMap::new(),
616 path: None,
617 };
618
619 let json = serde_json::to_string(&adr).unwrap();
620 assert!(json.contains(
621 "\"source_uri\":\"https://github.com/org/repo/blob/main/doc/adr/0001-test.md\""
622 ));
623
624 adr.source_uri = None;
626 let json = serde_json::to_string(&adr).unwrap();
627 assert!(!json.contains("source_uri"));
628 }
629
630 #[test]
631 fn test_json_adr_link_from_adr_link() {
632 let link = AdrLink {
633 target: 2,
634 kind: LinkKind::Supersedes,
635 description: Some("Replaces old approach".to_string()),
636 };
637
638 let json_link = JsonAdrLink::from(&link);
639
640 assert_eq!(json_link.link_type, "supersedes");
641 assert_eq!(json_link.target, 2);
642 assert_eq!(
643 json_link.description,
644 Some("Replaces old approach".to_string())
645 );
646 }
647
648 #[test]
649 fn test_bulk_export_metadata() {
650 let export = JsonAdrBulkExport::new(vec![]);
651
652 assert_eq!(export.version, JSON_ADR_VERSION);
653 assert!(export.schema.is_some());
654 assert!(export.generated_at.is_some());
655 assert!(export.tool.is_some());
656
657 let tool = export.tool.unwrap();
658 assert_eq!(tool.name, "adrs");
659 }
660
661 #[test]
662 fn test_bulk_export_with_repository() {
663 let export = JsonAdrBulkExport::new(vec![])
664 .with_repository(Some("my-project".to_string()), "doc/adr".to_string());
665
666 let repo = export.repository.unwrap();
667 assert_eq!(repo.name, Some("my-project".to_string()));
668 assert_eq!(repo.adr_directory, "doc/adr");
669 }
670
671 #[test]
672 fn test_link_kind_to_string() {
673 assert_eq!(link_kind_to_string(&LinkKind::Supersedes), "supersedes");
674 assert_eq!(
675 link_kind_to_string(&LinkKind::SupersededBy),
676 "superseded-by"
677 );
678 assert_eq!(link_kind_to_string(&LinkKind::Amends), "amends");
679 assert_eq!(link_kind_to_string(&LinkKind::AmendedBy), "amended-by");
680 assert_eq!(link_kind_to_string(&LinkKind::RelatesTo), "relates-to");
681 assert_eq!(
682 link_kind_to_string(&LinkKind::Custom("extends".to_string())),
683 "extends"
684 );
685 }
686
687 #[test]
688 fn test_json_serialization() {
689 let adr = JsonAdr {
690 number: 1,
691 title: "Test".to_string(),
692 status: "Accepted".to_string(),
693 date: "2024-01-15".to_string(),
694 deciders: vec![],
695 consulted: vec![],
696 informed: vec![],
697 tags: vec![],
698 source_uri: None,
699 context: None,
700 decision_drivers: vec![],
701 considered_options: vec![],
702 decision: None,
703 consequences: None,
704 confirmation: None,
705 links: vec![],
706 custom_sections: std::collections::HashMap::new(),
707 path: None,
708 };
709
710 let json = serde_json::to_string(&adr).unwrap();
711 assert!(json.contains("\"number\":1"));
712 assert!(json.contains("\"title\":\"Test\""));
713 assert!(!json.contains("\"deciders\""));
715 assert!(!json.contains("\"decision_drivers\""));
716 assert!(!json.contains("\"considered_options\""));
717 assert!(!json.contains("\"custom_sections\""));
719 }
720
721 #[test]
722 fn test_custom_sections() {
723 let mut adr = JsonAdr {
724 number: 1,
725 title: "Test".to_string(),
726 status: "Accepted".to_string(),
727 date: "2024-01-15".to_string(),
728 deciders: vec![],
729 consulted: vec![],
730 informed: vec![],
731 tags: vec![],
732 source_uri: None,
733 context: None,
734 decision_drivers: vec![],
735 considered_options: vec![],
736 decision: None,
737 consequences: None,
738 confirmation: None,
739 links: vec![],
740 custom_sections: std::collections::HashMap::new(),
741 path: None,
742 };
743
744 adr.custom_sections.insert(
745 "Alternatives Considered".to_string(),
746 "We also looked at MySQL and SQLite.".to_string(),
747 );
748 adr.custom_sections.insert(
749 "Security Review".to_string(),
750 "Approved by security team on 2024-01-10.".to_string(),
751 );
752
753 let json = serde_json::to_string_pretty(&adr).unwrap();
754 assert!(json.contains("\"custom_sections\""));
755 assert!(json.contains("Alternatives Considered"));
756 assert!(json.contains("Security Review"));
757 }
758
759 #[test]
760 fn test_decision_drivers_and_options() {
761 let adr = JsonAdr {
762 number: 1,
763 title: "Choose Database".to_string(),
764 status: "Accepted".to_string(),
765 date: "2024-01-15".to_string(),
766 deciders: vec!["Alice".to_string()],
767 consulted: vec![],
768 informed: vec![],
769 tags: vec![],
770 source_uri: None,
771 context: Some("We need a database for user data".to_string()),
772 decision_drivers: vec![
773 "Performance requirements".to_string(),
774 "Team expertise".to_string(),
775 "Cost constraints".to_string(),
776 ],
777 considered_options: vec![
778 ConsideredOption {
779 name: "PostgreSQL".to_string(),
780 description: Some("Open source relational database".to_string()),
781 pros: vec!["Mature".to_string(), "Feature-rich".to_string()],
782 cons: vec!["Complex setup".to_string()],
783 },
784 ConsideredOption {
785 name: "SQLite".to_string(),
786 description: None,
787 pros: vec!["Simple".to_string()],
788 cons: vec!["Not suitable for high concurrency".to_string()],
789 },
790 ],
791 decision: Some("Use PostgreSQL".to_string()),
792 consequences: Some("Need to set up replication".to_string()),
793 confirmation: Some("Run integration tests with production-like data".to_string()),
794 links: vec![],
795 custom_sections: std::collections::HashMap::new(),
796 path: None,
797 };
798
799 let json = serde_json::to_string_pretty(&adr).unwrap();
800
801 assert!(json.contains("\"decision_drivers\""));
803 assert!(json.contains("Performance requirements"));
804 assert!(json.contains("Team expertise"));
805
806 assert!(json.contains("\"considered_options\""));
808 assert!(json.contains("PostgreSQL"));
809 assert!(json.contains("SQLite"));
810 assert!(json.contains("\"pros\""));
811 assert!(json.contains("\"cons\""));
812 assert!(json.contains("Mature"));
813 assert!(json.contains("Complex setup"));
814
815 assert!(json.contains("\"confirmation\""));
817 assert!(json.contains("integration tests"));
818 }
819
820 #[test]
821 fn test_considered_option_minimal() {
822 let option = ConsideredOption {
823 name: "Option A".to_string(),
824 description: None,
825 pros: vec![],
826 cons: vec![],
827 };
828
829 let json = serde_json::to_string(&option).unwrap();
830 assert!(json.contains("\"name\":\"Option A\""));
831 assert!(!json.contains("\"description\""));
833 assert!(!json.contains("\"pros\""));
834 assert!(!json.contains("\"cons\""));
835 }
836
837 #[test]
840 fn test_import_basic() {
841 use tempfile::TempDir;
842
843 let temp = TempDir::new().unwrap();
844 let json = r#"{
845 "number": 1,
846 "title": "Test Decision",
847 "status": "Proposed",
848 "date": "2024-01-15",
849 "context": "Test context",
850 "decision": "Test decision",
851 "consequences": "Test consequences"
852 }"#;
853
854 let options = ImportOptions {
855 overwrite: false,
856 renumber: false,
857 dry_run: false,
858 ng_mode: false,
859 };
860
861 let result = import_to_directory(json, temp.path(), &options).unwrap();
862
863 assert_eq!(result.imported, 1);
864 assert_eq!(result.skipped, 0);
865 assert_eq!(result.files.len(), 1);
866 assert!(result.files[0].exists());
867 }
868
869 #[test]
870 fn test_import_with_renumber() {
871 use tempfile::TempDir;
872
873 let temp = TempDir::new().unwrap();
874
875 std::fs::create_dir_all(temp.path()).unwrap();
877 std::fs::write(
878 temp.path().join("0001-existing.md"),
879 "# 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",
880 )
881 .unwrap();
882
883 let json = r#"{
884 "version": "1.0.0",
885 "adrs": [
886 {
887 "number": 1,
888 "title": "First Import",
889 "status": "Proposed",
890 "date": "2024-01-15",
891 "context": "Test",
892 "decision": "Test",
893 "consequences": "Test"
894 },
895 {
896 "number": 2,
897 "title": "Second Import",
898 "status": "Proposed",
899 "date": "2024-01-16",
900 "context": "Test",
901 "decision": "Test",
902 "consequences": "Test"
903 }
904 ]
905 }"#;
906
907 let options = ImportOptions {
908 overwrite: false,
909 renumber: true,
910 dry_run: false,
911 ng_mode: false,
912 };
913
914 let result = import_to_directory(json, temp.path(), &options).unwrap();
915
916 assert_eq!(result.imported, 2);
917 assert_eq!(result.renumber_map.len(), 2);
918 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());
923 assert!(temp.path().join("0003-second-import.md").exists());
924 }
925
926 #[test]
927 fn test_import_dry_run() {
928 use tempfile::TempDir;
929
930 let temp = TempDir::new().unwrap();
931 let json = r#"{
932 "number": 1,
933 "title": "Test Decision",
934 "status": "Proposed",
935 "date": "2024-01-15",
936 "context": "Test",
937 "decision": "Test",
938 "consequences": "Test"
939 }"#;
940
941 let options = ImportOptions {
942 overwrite: false,
943 renumber: false,
944 dry_run: true,
945 ng_mode: false,
946 };
947
948 let result = import_to_directory(json, temp.path(), &options).unwrap();
949
950 assert_eq!(result.imported, 1);
951 assert_eq!(result.files.len(), 1);
952
953 assert!(!result.files[0].exists());
955 }
956
957 #[test]
958 fn test_import_dry_run_with_renumber() {
959 use tempfile::TempDir;
960
961 let temp = TempDir::new().unwrap();
962
963 std::fs::create_dir_all(temp.path()).unwrap();
965 for i in 1..=3 {
966 std::fs::write(
967 temp.path().join(format!("{:04}-existing-{}.md", i, i)),
968 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),
969 )
970 .unwrap();
971 }
972
973 let json = r#"{
974 "version": "1.0.0",
975 "adrs": [
976 {
977 "number": 5,
978 "title": "Import Five",
979 "status": "Proposed",
980 "date": "2024-01-15",
981 "context": "Test",
982 "decision": "Test",
983 "consequences": "Test"
984 },
985 {
986 "number": 7,
987 "title": "Import Seven",
988 "status": "Proposed",
989 "date": "2024-01-16",
990 "context": "Test",
991 "decision": "Test",
992 "consequences": "Test"
993 }
994 ]
995 }"#;
996
997 let options = ImportOptions {
998 overwrite: false,
999 renumber: true,
1000 dry_run: true,
1001 ng_mode: false,
1002 };
1003
1004 let result = import_to_directory(json, temp.path(), &options).unwrap();
1005
1006 assert_eq!(result.imported, 2);
1007 assert_eq!(result.renumber_map.len(), 2);
1008 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());
1014 assert!(!temp.path().join("0005-import-seven.md").exists());
1015 }
1016
1017 #[test]
1018 fn test_import_skip_existing() {
1019 use tempfile::TempDir;
1020
1021 let temp = TempDir::new().unwrap();
1022
1023 std::fs::create_dir_all(temp.path()).unwrap();
1025 std::fs::write(
1026 temp.path().join("0001-test-decision.md"),
1027 "# 1. Test Decision\n\nExisting content\n",
1028 )
1029 .unwrap();
1030
1031 let json = r#"{
1032 "number": 1,
1033 "title": "Test Decision",
1034 "status": "Proposed",
1035 "date": "2024-01-15",
1036 "context": "Test",
1037 "decision": "Test",
1038 "consequences": "Test"
1039 }"#;
1040
1041 let options = ImportOptions {
1042 overwrite: false,
1043 renumber: false,
1044 dry_run: false,
1045 ng_mode: false,
1046 };
1047
1048 let result = import_to_directory(json, temp.path(), &options).unwrap();
1049
1050 assert_eq!(result.imported, 0);
1051 assert_eq!(result.skipped, 1);
1052 assert_eq!(result.warnings.len(), 1);
1053 assert!(result.warnings[0].contains("already exists"));
1054 }
1055
1056 #[test]
1057 fn test_import_overwrite() {
1058 use tempfile::TempDir;
1059
1060 let temp = TempDir::new().unwrap();
1061
1062 std::fs::create_dir_all(temp.path()).unwrap();
1064 std::fs::write(
1065 temp.path().join("0001-test-decision.md"),
1066 "# 1. Test Decision\n\nOLD CONTENT\n",
1067 )
1068 .unwrap();
1069
1070 let json = r#"{
1071 "number": 1,
1072 "title": "Test Decision",
1073 "status": "Proposed",
1074 "date": "2024-01-15",
1075 "context": "NEW CONTEXT",
1076 "decision": "Test",
1077 "consequences": "Test"
1078 }"#;
1079
1080 let options = ImportOptions {
1081 overwrite: true,
1082 renumber: false,
1083 dry_run: false,
1084 ng_mode: false,
1085 };
1086
1087 let result = import_to_directory(json, temp.path(), &options).unwrap();
1088
1089 assert_eq!(result.imported, 1);
1090 assert_eq!(result.skipped, 0);
1091
1092 let content = std::fs::read_to_string(temp.path().join("0001-test-decision.md")).unwrap();
1094 assert!(content.contains("NEW CONTEXT"));
1095 assert!(!content.contains("OLD CONTENT"));
1096 }
1097
1098 #[test]
1099 fn test_import_bulk_format() {
1100 use tempfile::TempDir;
1101
1102 let temp = TempDir::new().unwrap();
1103
1104 let json = r#"{
1105 "version": "1.0.0",
1106 "adrs": [
1107 {
1108 "number": 1,
1109 "title": "First",
1110 "status": "Proposed",
1111 "date": "2024-01-15",
1112 "context": "Test",
1113 "decision": "Test",
1114 "consequences": "Test"
1115 },
1116 {
1117 "number": 2,
1118 "title": "Second",
1119 "status": "Accepted",
1120 "date": "2024-01-16",
1121 "context": "Test",
1122 "decision": "Test",
1123 "consequences": "Test"
1124 }
1125 ]
1126 }"#;
1127
1128 let options = ImportOptions {
1129 overwrite: false,
1130 renumber: false,
1131 dry_run: false,
1132 ng_mode: false,
1133 };
1134
1135 let result = import_to_directory(json, temp.path(), &options).unwrap();
1136
1137 assert_eq!(result.imported, 2);
1138 assert!(temp.path().join("0001-first.md").exists());
1139 assert!(temp.path().join("0002-second.md").exists());
1140 }
1141
1142 #[test]
1143 fn test_import_single_wrapper_format() {
1144 use tempfile::TempDir;
1145
1146 let temp = TempDir::new().unwrap();
1147
1148 let json = r#"{
1149 "version": "1.0.0",
1150 "adr": {
1151 "number": 1,
1152 "title": "Test Decision",
1153 "status": "Proposed",
1154 "date": "2024-01-15",
1155 "context": "Test",
1156 "decision": "Test",
1157 "consequences": "Test"
1158 }
1159 }"#;
1160
1161 let options = ImportOptions {
1162 overwrite: false,
1163 renumber: false,
1164 dry_run: false,
1165 ng_mode: false,
1166 };
1167
1168 let result = import_to_directory(json, temp.path(), &options).unwrap();
1169
1170 assert_eq!(result.imported, 1);
1171 assert!(temp.path().join("0001-test-decision.md").exists());
1172 }
1173
1174 #[test]
1177 fn test_import_renumber_with_internal_links() {
1178 use tempfile::TempDir;
1179
1180 let temp = TempDir::new().unwrap();
1181
1182 std::fs::create_dir_all(temp.path()).unwrap();
1184 std::fs::write(
1185 temp.path().join("0001-existing.md"),
1186 "# 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",
1187 )
1188 .unwrap();
1189
1190 let json = r#"{
1192 "version": "1.0.0",
1193 "adrs": [
1194 {
1195 "number": 1,
1196 "title": "First",
1197 "status": "Superseded",
1198 "date": "2024-01-15",
1199 "context": "Test",
1200 "decision": "Test",
1201 "consequences": "Test",
1202 "links": [
1203 {"target": 2, "type": "SupersededBy"}
1204 ]
1205 },
1206 {
1207 "number": 2,
1208 "title": "Second",
1209 "status": "Accepted",
1210 "date": "2024-01-16",
1211 "context": "Test",
1212 "decision": "Test",
1213 "consequences": "Test",
1214 "links": [
1215 {"target": 1, "type": "Supersedes"}
1216 ]
1217 }
1218 ]
1219 }"#;
1220
1221 let options = ImportOptions {
1222 overwrite: false,
1223 renumber: true,
1224 dry_run: false,
1225 ng_mode: false,
1226 };
1227
1228 let result = import_to_directory(json, temp.path(), &options).unwrap();
1229
1230 assert_eq!(result.imported, 2);
1231 assert_eq!(result.renumber_map[0], (1, 2)); assert_eq!(result.renumber_map[1], (2, 3)); let parser = crate::Parser::new();
1236
1237 let adr2 = parser
1238 .parse_file(&temp.path().join("0002-first.md"))
1239 .unwrap();
1240 assert_eq!(adr2.links.len(), 1);
1241 assert_eq!(adr2.links[0].target, 3); let adr3 = parser
1244 .parse_file(&temp.path().join("0003-second.md"))
1245 .unwrap();
1246 assert_eq!(adr3.links.len(), 1);
1247 assert_eq!(adr3.links[0].target, 2); }
1249
1250 #[test]
1251 fn test_import_renumber_with_broken_links() {
1252 use tempfile::TempDir;
1253
1254 let temp = TempDir::new().unwrap();
1255
1256 std::fs::create_dir_all(temp.path()).unwrap();
1258 std::fs::write(
1259 temp.path().join("0001-existing.md"),
1260 "# 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",
1261 )
1262 .unwrap();
1263
1264 let json = r#"{
1266 "version": "1.0.0",
1267 "adrs": [
1268 {
1269 "number": 5,
1270 "title": "Fifth",
1271 "status": "Accepted",
1272 "date": "2024-01-15",
1273 "context": "Test",
1274 "decision": "Test",
1275 "consequences": "Test",
1276 "links": [
1277 {"target": 3, "type": "Extends"}
1278 ]
1279 }
1280 ]
1281 }"#;
1282
1283 let options = ImportOptions {
1284 overwrite: false,
1285 renumber: true,
1286 dry_run: false,
1287 ng_mode: false,
1288 };
1289
1290 let result = import_to_directory(json, temp.path(), &options).unwrap();
1291
1292 assert_eq!(result.imported, 1);
1293 assert_eq!(result.renumber_map[0], (5, 2)); assert_eq!(result.warnings.len(), 1);
1297 assert!(result.warnings[0].contains("ADR 2 links to ADR 3"));
1298 assert!(result.warnings[0].contains("not in the import set"));
1299 }
1300
1301 #[test]
1302 fn test_import_renumber_complex_links() {
1303 use tempfile::TempDir;
1304
1305 let temp = TempDir::new().unwrap();
1306
1307 let json = r#"{
1309 "version": "1.0.0",
1310 "adrs": [
1311 {
1312 "number": 10,
1313 "title": "Ten",
1314 "status": "Accepted",
1315 "date": "2024-01-10",
1316 "context": "Test",
1317 "decision": "Test",
1318 "consequences": "Test",
1319 "links": []
1320 },
1321 {
1322 "number": 20,
1323 "title": "Twenty",
1324 "status": "Accepted",
1325 "date": "2024-01-20",
1326 "context": "Test",
1327 "decision": "Test",
1328 "consequences": "Test",
1329 "links": [
1330 {"target": 10, "type": "Amends"}
1331 ]
1332 },
1333 {
1334 "number": 30,
1335 "title": "Thirty",
1336 "status": "Accepted",
1337 "date": "2024-01-30",
1338 "context": "Test",
1339 "decision": "Test",
1340 "consequences": "Test",
1341 "links": [
1342 {"target": 10, "type": "RelatesTo"},
1343 {"target": 20, "type": "RelatesTo"}
1344 ]
1345 }
1346 ]
1347 }"#;
1348
1349 let options = ImportOptions {
1350 overwrite: false,
1351 renumber: true,
1352 dry_run: false,
1353 ng_mode: false,
1354 };
1355
1356 let result = import_to_directory(json, temp.path(), &options).unwrap();
1357
1358 assert_eq!(result.imported, 3);
1359 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();
1365
1366 let adr2 = parser
1367 .parse_file(&temp.path().join("0002-twenty.md"))
1368 .unwrap();
1369 assert_eq!(adr2.links.len(), 1);
1370 assert_eq!(adr2.links[0].target, 1); let adr3 = parser
1373 .parse_file(&temp.path().join("0003-thirty.md"))
1374 .unwrap();
1375 assert_eq!(adr3.links.len(), 2);
1376 assert_eq!(adr3.links[0].target, 1); assert_eq!(adr3.links[1].target, 2); }
1379}