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 context: Option<String>,
55
56 #[serde(skip_serializing_if = "Vec::is_empty", default)]
58 pub decision_drivers: Vec<String>,
59
60 #[serde(skip_serializing_if = "Vec::is_empty", default)]
62 pub considered_options: Vec<ConsideredOption>,
63
64 #[serde(skip_serializing_if = "Option::is_none")]
66 pub decision: Option<String>,
67
68 #[serde(skip_serializing_if = "Option::is_none")]
70 pub consequences: Option<String>,
71
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub confirmation: Option<String>,
75
76 #[serde(skip_serializing_if = "Vec::is_empty", default)]
78 pub links: Vec<JsonAdrLink>,
79
80 #[serde(skip_serializing_if = "std::collections::HashMap::is_empty", default)]
82 pub custom_sections: std::collections::HashMap<String, String>,
83
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub path: Option<String>,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ConsideredOption {
92 pub name: String,
94
95 #[serde(skip_serializing_if = "Option::is_none")]
97 pub description: Option<String>,
98
99 #[serde(skip_serializing_if = "Vec::is_empty", default)]
101 pub pros: Vec<String>,
102
103 #[serde(skip_serializing_if = "Vec::is_empty", default)]
105 pub cons: Vec<String>,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct JsonAdrLink {
111 #[serde(rename = "type")]
113 pub link_type: String,
114
115 pub target: u32,
117
118 #[serde(skip_serializing_if = "Option::is_none")]
120 pub description: Option<String>,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct ToolInfo {
126 pub name: String,
128
129 pub version: String,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct RepositoryInfo {
136 #[serde(skip_serializing_if = "Option::is_none")]
138 pub name: Option<String>,
139
140 pub adr_directory: String,
142}
143
144#[derive(Debug, Clone, Serialize, Deserialize)]
146pub struct JsonAdrBulkExport {
147 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
149 pub schema: Option<String>,
150
151 pub version: String,
153
154 #[serde(skip_serializing_if = "Option::is_none")]
156 pub generated_at: Option<String>,
157
158 #[serde(skip_serializing_if = "Option::is_none")]
160 pub tool: Option<ToolInfo>,
161
162 #[serde(skip_serializing_if = "Option::is_none")]
164 pub repository: Option<RepositoryInfo>,
165
166 pub adrs: Vec<JsonAdr>,
168}
169
170impl JsonAdrBulkExport {
171 pub fn new(adrs: Vec<JsonAdr>) -> Self {
173 Self {
174 schema: Some(JSON_ADR_SCHEMA.to_string()),
175 version: JSON_ADR_VERSION.to_string(),
176 generated_at: Some(
177 OffsetDateTime::now_utc()
178 .format(&time::format_description::well_known::Rfc3339)
179 .unwrap_or_default(),
180 ),
181 tool: Some(ToolInfo {
182 name: "adrs".to_string(),
183 version: env!("CARGO_PKG_VERSION").to_string(),
184 }),
185 repository: None,
186 adrs,
187 }
188 }
189
190 pub fn with_repository(mut self, name: Option<String>, adr_directory: String) -> Self {
192 self.repository = Some(RepositoryInfo {
193 name,
194 adr_directory,
195 });
196 self
197 }
198}
199
200impl From<&Adr> for JsonAdr {
201 fn from(adr: &Adr) -> Self {
202 Self {
203 number: adr.number,
204 title: adr.title.clone(),
205 status: adr.status.to_string(),
206 date: adr
207 .date
208 .format(&time::format_description::well_known::Iso8601::DATE)
209 .unwrap_or_default(),
210 deciders: adr.decision_makers.clone(),
211 consulted: adr.consulted.clone(),
212 informed: adr.informed.clone(),
213 tags: Vec::new(), context: if adr.context.is_empty() {
215 None
216 } else {
217 Some(adr.context.clone())
218 },
219 decision_drivers: Vec::new(), considered_options: Vec::new(), decision: if adr.decision.is_empty() {
222 None
223 } else {
224 Some(adr.decision.clone())
225 },
226 consequences: if adr.consequences.is_empty() {
227 None
228 } else {
229 Some(adr.consequences.clone())
230 },
231 confirmation: None, links: adr.links.iter().map(JsonAdrLink::from).collect(),
233 custom_sections: std::collections::HashMap::new(),
234 path: adr.path.as_ref().map(|p| p.display().to_string()),
235 }
236 }
237}
238
239impl From<&AdrLink> for JsonAdrLink {
240 fn from(link: &AdrLink) -> Self {
241 Self {
242 link_type: link_kind_to_string(&link.kind),
243 target: link.target,
244 description: link.description.clone(),
245 }
246 }
247}
248
249fn link_kind_to_string(kind: &LinkKind) -> String {
250 match kind {
251 LinkKind::Supersedes => "supersedes".to_string(),
252 LinkKind::SupersededBy => "superseded-by".to_string(),
253 LinkKind::Amends => "amends".to_string(),
254 LinkKind::AmendedBy => "amended-by".to_string(),
255 LinkKind::RelatesTo => "relates-to".to_string(),
256 LinkKind::Custom(s) => s.clone(),
257 }
258}
259
260pub fn export_repository(repo: &Repository) -> Result<JsonAdrBulkExport> {
262 let adrs = repo.list()?;
263 let json_adrs: Vec<JsonAdr> = adrs.iter().map(JsonAdr::from).collect();
264
265 let adr_dir = repo.config().adr_dir.display().to_string();
266
267 Ok(JsonAdrBulkExport::new(json_adrs).with_repository(None, adr_dir))
268}
269
270pub fn export_adr(adr: &Adr) -> JsonAdr {
272 JsonAdr::from(adr)
273}
274
275#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct JsonAdrSingle {
278 #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
280 pub schema: Option<String>,
281
282 pub version: String,
284
285 pub adr: JsonAdr,
287}
288
289#[derive(Debug, Clone, Default)]
291pub struct ImportOptions {
292 pub overwrite: bool,
294
295 pub renumber: bool,
297
298 pub dry_run: bool,
300
301 pub ng_mode: bool,
303}
304
305#[derive(Debug, Clone)]
307pub struct ImportResult {
308 pub imported: usize,
310
311 pub skipped: usize,
313
314 pub files: Vec<std::path::PathBuf>,
316
317 pub warnings: Vec<String>,
319
320 pub renumber_map: Vec<(u32, u32)>,
322}
323
324pub fn export_directory(dir: &Path) -> Result<JsonAdrBulkExport> {
330 let parser = Parser::new();
331 let mut adrs = Vec::new();
332
333 if dir.is_dir() {
335 let mut entries: Vec<_> = std::fs::read_dir(dir)?
336 .filter_map(|e| e.ok())
337 .filter(|e| {
338 let path = e.path();
339 path.is_file()
340 && path.extension().is_some_and(|ext| ext == "md")
341 && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
342 n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
344 })
345 })
346 .collect();
347
348 entries.sort_by_key(|e| e.path());
350
351 for entry in entries {
352 let path = entry.path();
353 match parser.parse_file(&path) {
354 Ok(adr) => adrs.push(JsonAdr::from(&adr)),
355 Err(e) => {
356 eprintln!("Warning: Failed to parse {}: {}", path.display(), e);
358 }
359 }
360 }
361 }
362
363 let adr_dir = dir.display().to_string();
364 Ok(JsonAdrBulkExport::new(adrs).with_repository(None, adr_dir))
365}
366
367fn json_adr_to_adr(json_adr: &JsonAdr) -> Result<Adr> {
369 let date = time::Date::parse(
370 &json_adr.date,
371 &time::format_description::well_known::Iso8601::DATE,
372 )
373 .unwrap_or_else(|_| crate::parse::today());
374
375 let status = json_adr.status.parse::<AdrStatus>().unwrap_or_default();
376
377 let links: Vec<AdrLink> = json_adr
378 .links
379 .iter()
380 .map(|l| AdrLink {
381 target: l.target,
382 kind: string_to_link_kind(&l.link_type),
383 description: l.description.clone(),
384 })
385 .collect();
386
387 Ok(Adr {
388 number: json_adr.number,
389 title: json_adr.title.clone(),
390 date,
391 status,
392 links,
393 decision_makers: json_adr.deciders.clone(),
394 consulted: json_adr.consulted.clone(),
395 informed: json_adr.informed.clone(),
396 context: json_adr.context.clone().unwrap_or_default(),
397 decision: json_adr.decision.clone().unwrap_or_default(),
398 consequences: json_adr.consequences.clone().unwrap_or_default(),
399 path: None,
400 })
401}
402
403fn string_to_link_kind(s: &str) -> LinkKind {
404 match s.to_lowercase().as_str() {
405 "supersedes" => LinkKind::Supersedes,
406 "superseded-by" => LinkKind::SupersededBy,
407 "amends" => LinkKind::Amends,
408 "amended-by" => LinkKind::AmendedBy,
409 "relates-to" => LinkKind::RelatesTo,
410 other => LinkKind::Custom(other.to_string()),
411 }
412}
413
414pub fn import_to_directory(
419 json_data: &str,
420 dir: &Path,
421 options: &ImportOptions,
422) -> Result<ImportResult> {
423 let json_adrs: Vec<JsonAdr> =
425 if let Ok(bulk) = serde_json::from_str::<JsonAdrBulkExport>(json_data) {
426 bulk.adrs
427 } else if let Ok(single) = serde_json::from_str::<JsonAdrSingle>(json_data) {
428 vec![single.adr]
429 } else if let Ok(adr) = serde_json::from_str::<JsonAdr>(json_data) {
430 vec![adr]
431 } else {
432 return Err(crate::Error::InvalidFormat {
433 path: PathBuf::new(),
434 reason: "Invalid JSON-ADR format".to_string(),
435 });
436 };
437
438 std::fs::create_dir_all(dir)?;
440
441 let next_number = if options.renumber {
443 find_next_number(dir)?
444 } else {
445 0
446 };
447
448 let mut result = ImportResult {
449 imported: 0,
450 skipped: 0,
451 files: Vec::new(),
452 warnings: Vec::new(),
453 renumber_map: Vec::new(),
454 };
455
456 let config = Config {
458 adr_dir: dir.to_path_buf(),
459 mode: if options.ng_mode {
460 crate::ConfigMode::NextGen
461 } else {
462 crate::ConfigMode::default()
463 },
464 ..Default::default()
465 };
466
467 let engine = TemplateEngine::new();
468
469 let mut adrs_to_import = Vec::new();
471 let mut temp_next_number = next_number;
472
473 for json_adr in json_adrs {
474 let mut adr = json_adr_to_adr(&json_adr)?;
475
476 if options.renumber {
478 let old_number = adr.number;
479 adr.number = temp_next_number;
480 result.renumber_map.push((old_number, temp_next_number));
481 temp_next_number += 1;
482 }
483
484 adrs_to_import.push(adr);
485 }
486
487 let number_map: std::collections::HashMap<u32, u32> = result
489 .renumber_map
490 .iter()
491 .map(|&(old, new)| (old, new))
492 .collect();
493
494 for mut adr in adrs_to_import {
496 if options.renumber {
498 for link in &mut adr.links {
499 if let Some(&new_target) = number_map.get(&link.target) {
500 link.target = new_target;
502 } else {
503 result.warnings.push(format!(
505 "ADR {} links to ADR {} which is not in the import set",
506 adr.number, link.target
507 ));
508 }
509 }
510 }
511
512 let filename = adr.filename();
513 let filepath = dir.join(&filename);
514
515 if filepath.exists() && !options.overwrite {
517 result.skipped += 1;
518 result.warnings.push(format!(
519 "Skipped {}: file already exists (use --overwrite to replace)",
520 filename
521 ));
522 continue;
523 }
524
525 let content = engine.render(&adr, &config)?;
527
528 if !options.dry_run {
530 std::fs::write(&filepath, content)?;
531 }
532
533 result.imported += 1;
534 result.files.push(filepath);
535 }
536
537 Ok(result)
538}
539
540fn find_next_number(dir: &Path) -> Result<u32> {
542 let mut max_number = 0u32;
543
544 if dir.is_dir() {
545 for entry in std::fs::read_dir(dir)?.filter_map(|e| e.ok()) {
546 let path = entry.path();
547 if let Some(name) = path.file_name().and_then(|n| n.to_str())
548 && name.len() > 4
549 && name.ends_with(".md")
550 && let Ok(num) = name[..4].parse::<u32>()
551 {
552 max_number = max_number.max(num);
553 }
554 }
555 }
556
557 Ok(max_number + 1)
558}
559
560#[cfg(test)]
561mod tests {
562 use super::*;
563 use crate::AdrStatus;
564 use time::{Date, Month};
565
566 #[test]
567 fn test_json_adr_from_adr() {
568 let mut adr = Adr::new(1, "Test Decision");
569 adr.status = AdrStatus::Accepted;
570 adr.date = Date::from_calendar_date(2024, Month::January, 15).unwrap();
571 adr.context = "Some context".to_string();
572 adr.decision = "We decided X".to_string();
573 adr.consequences = "This means Y".to_string();
574 adr.decision_makers = vec!["Alice".to_string()];
575 adr.consulted = vec!["Bob".to_string()];
576
577 let json_adr = JsonAdr::from(&adr);
578
579 assert_eq!(json_adr.number, 1);
580 assert_eq!(json_adr.title, "Test Decision");
581 assert_eq!(json_adr.status, "Accepted");
582 assert_eq!(json_adr.date, "2024-01-15");
583 assert_eq!(json_adr.deciders, vec!["Alice"]);
584 assert_eq!(json_adr.consulted, vec!["Bob"]);
585 }
586
587 #[test]
588 fn test_json_adr_link_from_adr_link() {
589 let link = AdrLink {
590 target: 2,
591 kind: LinkKind::Supersedes,
592 description: Some("Replaces old approach".to_string()),
593 };
594
595 let json_link = JsonAdrLink::from(&link);
596
597 assert_eq!(json_link.link_type, "supersedes");
598 assert_eq!(json_link.target, 2);
599 assert_eq!(
600 json_link.description,
601 Some("Replaces old approach".to_string())
602 );
603 }
604
605 #[test]
606 fn test_bulk_export_metadata() {
607 let export = JsonAdrBulkExport::new(vec![]);
608
609 assert_eq!(export.version, JSON_ADR_VERSION);
610 assert!(export.schema.is_some());
611 assert!(export.generated_at.is_some());
612 assert!(export.tool.is_some());
613
614 let tool = export.tool.unwrap();
615 assert_eq!(tool.name, "adrs");
616 }
617
618 #[test]
619 fn test_bulk_export_with_repository() {
620 let export = JsonAdrBulkExport::new(vec![])
621 .with_repository(Some("my-project".to_string()), "doc/adr".to_string());
622
623 let repo = export.repository.unwrap();
624 assert_eq!(repo.name, Some("my-project".to_string()));
625 assert_eq!(repo.adr_directory, "doc/adr");
626 }
627
628 #[test]
629 fn test_link_kind_to_string() {
630 assert_eq!(link_kind_to_string(&LinkKind::Supersedes), "supersedes");
631 assert_eq!(
632 link_kind_to_string(&LinkKind::SupersededBy),
633 "superseded-by"
634 );
635 assert_eq!(link_kind_to_string(&LinkKind::Amends), "amends");
636 assert_eq!(link_kind_to_string(&LinkKind::AmendedBy), "amended-by");
637 assert_eq!(link_kind_to_string(&LinkKind::RelatesTo), "relates-to");
638 assert_eq!(
639 link_kind_to_string(&LinkKind::Custom("extends".to_string())),
640 "extends"
641 );
642 }
643
644 #[test]
645 fn test_json_serialization() {
646 let adr = JsonAdr {
647 number: 1,
648 title: "Test".to_string(),
649 status: "Accepted".to_string(),
650 date: "2024-01-15".to_string(),
651 deciders: vec![],
652 consulted: vec![],
653 informed: vec![],
654 tags: vec![],
655 context: None,
656 decision_drivers: vec![],
657 considered_options: vec![],
658 decision: None,
659 consequences: None,
660 confirmation: None,
661 links: vec![],
662 custom_sections: std::collections::HashMap::new(),
663 path: None,
664 };
665
666 let json = serde_json::to_string(&adr).unwrap();
667 assert!(json.contains("\"number\":1"));
668 assert!(json.contains("\"title\":\"Test\""));
669 assert!(!json.contains("\"deciders\""));
671 assert!(!json.contains("\"decision_drivers\""));
672 assert!(!json.contains("\"considered_options\""));
673 assert!(!json.contains("\"custom_sections\""));
675 }
676
677 #[test]
678 fn test_custom_sections() {
679 let mut adr = JsonAdr {
680 number: 1,
681 title: "Test".to_string(),
682 status: "Accepted".to_string(),
683 date: "2024-01-15".to_string(),
684 deciders: vec![],
685 consulted: vec![],
686 informed: vec![],
687 tags: vec![],
688 context: None,
689 decision_drivers: vec![],
690 considered_options: vec![],
691 decision: None,
692 consequences: None,
693 confirmation: None,
694 links: vec![],
695 custom_sections: std::collections::HashMap::new(),
696 path: None,
697 };
698
699 adr.custom_sections.insert(
700 "Alternatives Considered".to_string(),
701 "We also looked at MySQL and SQLite.".to_string(),
702 );
703 adr.custom_sections.insert(
704 "Security Review".to_string(),
705 "Approved by security team on 2024-01-10.".to_string(),
706 );
707
708 let json = serde_json::to_string_pretty(&adr).unwrap();
709 assert!(json.contains("\"custom_sections\""));
710 assert!(json.contains("Alternatives Considered"));
711 assert!(json.contains("Security Review"));
712 }
713
714 #[test]
715 fn test_decision_drivers_and_options() {
716 let adr = JsonAdr {
717 number: 1,
718 title: "Choose Database".to_string(),
719 status: "Accepted".to_string(),
720 date: "2024-01-15".to_string(),
721 deciders: vec!["Alice".to_string()],
722 consulted: vec![],
723 informed: vec![],
724 tags: vec![],
725 context: Some("We need a database for user data".to_string()),
726 decision_drivers: vec![
727 "Performance requirements".to_string(),
728 "Team expertise".to_string(),
729 "Cost constraints".to_string(),
730 ],
731 considered_options: vec![
732 ConsideredOption {
733 name: "PostgreSQL".to_string(),
734 description: Some("Open source relational database".to_string()),
735 pros: vec!["Mature".to_string(), "Feature-rich".to_string()],
736 cons: vec!["Complex setup".to_string()],
737 },
738 ConsideredOption {
739 name: "SQLite".to_string(),
740 description: None,
741 pros: vec!["Simple".to_string()],
742 cons: vec!["Not suitable for high concurrency".to_string()],
743 },
744 ],
745 decision: Some("Use PostgreSQL".to_string()),
746 consequences: Some("Need to set up replication".to_string()),
747 confirmation: Some("Run integration tests with production-like data".to_string()),
748 links: vec![],
749 custom_sections: std::collections::HashMap::new(),
750 path: None,
751 };
752
753 let json = serde_json::to_string_pretty(&adr).unwrap();
754
755 assert!(json.contains("\"decision_drivers\""));
757 assert!(json.contains("Performance requirements"));
758 assert!(json.contains("Team expertise"));
759
760 assert!(json.contains("\"considered_options\""));
762 assert!(json.contains("PostgreSQL"));
763 assert!(json.contains("SQLite"));
764 assert!(json.contains("\"pros\""));
765 assert!(json.contains("\"cons\""));
766 assert!(json.contains("Mature"));
767 assert!(json.contains("Complex setup"));
768
769 assert!(json.contains("\"confirmation\""));
771 assert!(json.contains("integration tests"));
772 }
773
774 #[test]
775 fn test_considered_option_minimal() {
776 let option = ConsideredOption {
777 name: "Option A".to_string(),
778 description: None,
779 pros: vec![],
780 cons: vec![],
781 };
782
783 let json = serde_json::to_string(&option).unwrap();
784 assert!(json.contains("\"name\":\"Option A\""));
785 assert!(!json.contains("\"description\""));
787 assert!(!json.contains("\"pros\""));
788 assert!(!json.contains("\"cons\""));
789 }
790
791 #[test]
794 fn test_import_basic() {
795 use tempfile::TempDir;
796
797 let temp = TempDir::new().unwrap();
798 let json = r#"{
799 "number": 1,
800 "title": "Test Decision",
801 "status": "Proposed",
802 "date": "2024-01-15",
803 "context": "Test context",
804 "decision": "Test decision",
805 "consequences": "Test consequences"
806 }"#;
807
808 let options = ImportOptions {
809 overwrite: false,
810 renumber: false,
811 dry_run: false,
812 ng_mode: false,
813 };
814
815 let result = import_to_directory(json, temp.path(), &options).unwrap();
816
817 assert_eq!(result.imported, 1);
818 assert_eq!(result.skipped, 0);
819 assert_eq!(result.files.len(), 1);
820 assert!(result.files[0].exists());
821 }
822
823 #[test]
824 fn test_import_with_renumber() {
825 use tempfile::TempDir;
826
827 let temp = TempDir::new().unwrap();
828
829 std::fs::create_dir_all(temp.path()).unwrap();
831 std::fs::write(
832 temp.path().join("0001-existing.md"),
833 "# 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",
834 )
835 .unwrap();
836
837 let json = r#"{
838 "version": "1.0.0",
839 "adrs": [
840 {
841 "number": 1,
842 "title": "First Import",
843 "status": "Proposed",
844 "date": "2024-01-15",
845 "context": "Test",
846 "decision": "Test",
847 "consequences": "Test"
848 },
849 {
850 "number": 2,
851 "title": "Second Import",
852 "status": "Proposed",
853 "date": "2024-01-16",
854 "context": "Test",
855 "decision": "Test",
856 "consequences": "Test"
857 }
858 ]
859 }"#;
860
861 let options = ImportOptions {
862 overwrite: false,
863 renumber: true,
864 dry_run: false,
865 ng_mode: false,
866 };
867
868 let result = import_to_directory(json, temp.path(), &options).unwrap();
869
870 assert_eq!(result.imported, 2);
871 assert_eq!(result.renumber_map.len(), 2);
872 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());
877 assert!(temp.path().join("0003-second-import.md").exists());
878 }
879
880 #[test]
881 fn test_import_dry_run() {
882 use tempfile::TempDir;
883
884 let temp = TempDir::new().unwrap();
885 let json = r#"{
886 "number": 1,
887 "title": "Test Decision",
888 "status": "Proposed",
889 "date": "2024-01-15",
890 "context": "Test",
891 "decision": "Test",
892 "consequences": "Test"
893 }"#;
894
895 let options = ImportOptions {
896 overwrite: false,
897 renumber: false,
898 dry_run: true,
899 ng_mode: false,
900 };
901
902 let result = import_to_directory(json, temp.path(), &options).unwrap();
903
904 assert_eq!(result.imported, 1);
905 assert_eq!(result.files.len(), 1);
906
907 assert!(!result.files[0].exists());
909 }
910
911 #[test]
912 fn test_import_dry_run_with_renumber() {
913 use tempfile::TempDir;
914
915 let temp = TempDir::new().unwrap();
916
917 std::fs::create_dir_all(temp.path()).unwrap();
919 for i in 1..=3 {
920 std::fs::write(
921 temp.path().join(format!("{:04}-existing-{}.md", i, i)),
922 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),
923 )
924 .unwrap();
925 }
926
927 let json = r#"{
928 "version": "1.0.0",
929 "adrs": [
930 {
931 "number": 5,
932 "title": "Import Five",
933 "status": "Proposed",
934 "date": "2024-01-15",
935 "context": "Test",
936 "decision": "Test",
937 "consequences": "Test"
938 },
939 {
940 "number": 7,
941 "title": "Import Seven",
942 "status": "Proposed",
943 "date": "2024-01-16",
944 "context": "Test",
945 "decision": "Test",
946 "consequences": "Test"
947 }
948 ]
949 }"#;
950
951 let options = ImportOptions {
952 overwrite: false,
953 renumber: true,
954 dry_run: true,
955 ng_mode: false,
956 };
957
958 let result = import_to_directory(json, temp.path(), &options).unwrap();
959
960 assert_eq!(result.imported, 2);
961 assert_eq!(result.renumber_map.len(), 2);
962 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());
968 assert!(!temp.path().join("0005-import-seven.md").exists());
969 }
970
971 #[test]
972 fn test_import_skip_existing() {
973 use tempfile::TempDir;
974
975 let temp = TempDir::new().unwrap();
976
977 std::fs::create_dir_all(temp.path()).unwrap();
979 std::fs::write(
980 temp.path().join("0001-test-decision.md"),
981 "# 1. Test Decision\n\nExisting content\n",
982 )
983 .unwrap();
984
985 let json = r#"{
986 "number": 1,
987 "title": "Test Decision",
988 "status": "Proposed",
989 "date": "2024-01-15",
990 "context": "Test",
991 "decision": "Test",
992 "consequences": "Test"
993 }"#;
994
995 let options = ImportOptions {
996 overwrite: false,
997 renumber: false,
998 dry_run: false,
999 ng_mode: false,
1000 };
1001
1002 let result = import_to_directory(json, temp.path(), &options).unwrap();
1003
1004 assert_eq!(result.imported, 0);
1005 assert_eq!(result.skipped, 1);
1006 assert_eq!(result.warnings.len(), 1);
1007 assert!(result.warnings[0].contains("already exists"));
1008 }
1009
1010 #[test]
1011 fn test_import_overwrite() {
1012 use tempfile::TempDir;
1013
1014 let temp = TempDir::new().unwrap();
1015
1016 std::fs::create_dir_all(temp.path()).unwrap();
1018 std::fs::write(
1019 temp.path().join("0001-test-decision.md"),
1020 "# 1. Test Decision\n\nOLD CONTENT\n",
1021 )
1022 .unwrap();
1023
1024 let json = r#"{
1025 "number": 1,
1026 "title": "Test Decision",
1027 "status": "Proposed",
1028 "date": "2024-01-15",
1029 "context": "NEW CONTEXT",
1030 "decision": "Test",
1031 "consequences": "Test"
1032 }"#;
1033
1034 let options = ImportOptions {
1035 overwrite: true,
1036 renumber: false,
1037 dry_run: false,
1038 ng_mode: false,
1039 };
1040
1041 let result = import_to_directory(json, temp.path(), &options).unwrap();
1042
1043 assert_eq!(result.imported, 1);
1044 assert_eq!(result.skipped, 0);
1045
1046 let content = std::fs::read_to_string(temp.path().join("0001-test-decision.md")).unwrap();
1048 assert!(content.contains("NEW CONTEXT"));
1049 assert!(!content.contains("OLD CONTENT"));
1050 }
1051
1052 #[test]
1053 fn test_import_bulk_format() {
1054 use tempfile::TempDir;
1055
1056 let temp = TempDir::new().unwrap();
1057
1058 let json = r#"{
1059 "version": "1.0.0",
1060 "adrs": [
1061 {
1062 "number": 1,
1063 "title": "First",
1064 "status": "Proposed",
1065 "date": "2024-01-15",
1066 "context": "Test",
1067 "decision": "Test",
1068 "consequences": "Test"
1069 },
1070 {
1071 "number": 2,
1072 "title": "Second",
1073 "status": "Accepted",
1074 "date": "2024-01-16",
1075 "context": "Test",
1076 "decision": "Test",
1077 "consequences": "Test"
1078 }
1079 ]
1080 }"#;
1081
1082 let options = ImportOptions {
1083 overwrite: false,
1084 renumber: false,
1085 dry_run: false,
1086 ng_mode: false,
1087 };
1088
1089 let result = import_to_directory(json, temp.path(), &options).unwrap();
1090
1091 assert_eq!(result.imported, 2);
1092 assert!(temp.path().join("0001-first.md").exists());
1093 assert!(temp.path().join("0002-second.md").exists());
1094 }
1095
1096 #[test]
1097 fn test_import_single_wrapper_format() {
1098 use tempfile::TempDir;
1099
1100 let temp = TempDir::new().unwrap();
1101
1102 let json = r#"{
1103 "version": "1.0.0",
1104 "adr": {
1105 "number": 1,
1106 "title": "Test Decision",
1107 "status": "Proposed",
1108 "date": "2024-01-15",
1109 "context": "Test",
1110 "decision": "Test",
1111 "consequences": "Test"
1112 }
1113 }"#;
1114
1115 let options = ImportOptions {
1116 overwrite: false,
1117 renumber: false,
1118 dry_run: false,
1119 ng_mode: false,
1120 };
1121
1122 let result = import_to_directory(json, temp.path(), &options).unwrap();
1123
1124 assert_eq!(result.imported, 1);
1125 assert!(temp.path().join("0001-test-decision.md").exists());
1126 }
1127
1128 #[test]
1131 fn test_import_renumber_with_internal_links() {
1132 use tempfile::TempDir;
1133
1134 let temp = TempDir::new().unwrap();
1135
1136 std::fs::create_dir_all(temp.path()).unwrap();
1138 std::fs::write(
1139 temp.path().join("0001-existing.md"),
1140 "# 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",
1141 )
1142 .unwrap();
1143
1144 let json = r#"{
1146 "version": "1.0.0",
1147 "adrs": [
1148 {
1149 "number": 1,
1150 "title": "First",
1151 "status": "Superseded",
1152 "date": "2024-01-15",
1153 "context": "Test",
1154 "decision": "Test",
1155 "consequences": "Test",
1156 "links": [
1157 {"target": 2, "type": "SupersededBy"}
1158 ]
1159 },
1160 {
1161 "number": 2,
1162 "title": "Second",
1163 "status": "Accepted",
1164 "date": "2024-01-16",
1165 "context": "Test",
1166 "decision": "Test",
1167 "consequences": "Test",
1168 "links": [
1169 {"target": 1, "type": "Supersedes"}
1170 ]
1171 }
1172 ]
1173 }"#;
1174
1175 let options = ImportOptions {
1176 overwrite: false,
1177 renumber: true,
1178 dry_run: false,
1179 ng_mode: false,
1180 };
1181
1182 let result = import_to_directory(json, temp.path(), &options).unwrap();
1183
1184 assert_eq!(result.imported, 2);
1185 assert_eq!(result.renumber_map[0], (1, 2)); assert_eq!(result.renumber_map[1], (2, 3)); let parser = crate::Parser::new();
1190
1191 let adr2 = parser
1192 .parse_file(&temp.path().join("0002-first.md"))
1193 .unwrap();
1194 assert_eq!(adr2.links.len(), 1);
1195 assert_eq!(adr2.links[0].target, 3); let adr3 = parser
1198 .parse_file(&temp.path().join("0003-second.md"))
1199 .unwrap();
1200 assert_eq!(adr3.links.len(), 1);
1201 assert_eq!(adr3.links[0].target, 2); }
1203
1204 #[test]
1205 fn test_import_renumber_with_broken_links() {
1206 use tempfile::TempDir;
1207
1208 let temp = TempDir::new().unwrap();
1209
1210 std::fs::create_dir_all(temp.path()).unwrap();
1212 std::fs::write(
1213 temp.path().join("0001-existing.md"),
1214 "# 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",
1215 )
1216 .unwrap();
1217
1218 let json = r#"{
1220 "version": "1.0.0",
1221 "adrs": [
1222 {
1223 "number": 5,
1224 "title": "Fifth",
1225 "status": "Accepted",
1226 "date": "2024-01-15",
1227 "context": "Test",
1228 "decision": "Test",
1229 "consequences": "Test",
1230 "links": [
1231 {"target": 3, "type": "Extends"}
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, 1);
1247 assert_eq!(result.renumber_map[0], (5, 2)); assert_eq!(result.warnings.len(), 1);
1251 assert!(result.warnings[0].contains("ADR 2 links to ADR 3"));
1252 assert!(result.warnings[0].contains("not in the import set"));
1253 }
1254
1255 #[test]
1256 fn test_import_renumber_complex_links() {
1257 use tempfile::TempDir;
1258
1259 let temp = TempDir::new().unwrap();
1260
1261 let json = r#"{
1263 "version": "1.0.0",
1264 "adrs": [
1265 {
1266 "number": 10,
1267 "title": "Ten",
1268 "status": "Accepted",
1269 "date": "2024-01-10",
1270 "context": "Test",
1271 "decision": "Test",
1272 "consequences": "Test",
1273 "links": []
1274 },
1275 {
1276 "number": 20,
1277 "title": "Twenty",
1278 "status": "Accepted",
1279 "date": "2024-01-20",
1280 "context": "Test",
1281 "decision": "Test",
1282 "consequences": "Test",
1283 "links": [
1284 {"target": 10, "type": "Amends"}
1285 ]
1286 },
1287 {
1288 "number": 30,
1289 "title": "Thirty",
1290 "status": "Accepted",
1291 "date": "2024-01-30",
1292 "context": "Test",
1293 "decision": "Test",
1294 "consequences": "Test",
1295 "links": [
1296 {"target": 10, "type": "RelatesTo"},
1297 {"target": 20, "type": "RelatesTo"}
1298 ]
1299 }
1300 ]
1301 }"#;
1302
1303 let options = ImportOptions {
1304 overwrite: false,
1305 renumber: true,
1306 dry_run: false,
1307 ng_mode: false,
1308 };
1309
1310 let result = import_to_directory(json, temp.path(), &options).unwrap();
1311
1312 assert_eq!(result.imported, 3);
1313 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();
1319
1320 let adr2 = parser
1321 .parse_file(&temp.path().join("0002-twenty.md"))
1322 .unwrap();
1323 assert_eq!(adr2.links.len(), 1);
1324 assert_eq!(adr2.links[0].target, 1); let adr3 = parser
1327 .parse_file(&temp.path().join("0003-thirty.md"))
1328 .unwrap();
1329 assert_eq!(adr3.links.len(), 2);
1330 assert_eq!(adr3.links[0].target, 1); assert_eq!(adr3.links[1].target, 2); }
1333}