Skip to main content

adrs_core/
export.rs

1//! JSON-ADR export functionality.
2//!
3//! Provides types and functions for exporting ADRs to the JSON-ADR format,
4//! a machine-readable interchange format for Architecture Decision Records.
5
6use 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
14/// JSON-ADR schema version.
15pub const JSON_ADR_VERSION: &str = "1.0.0";
16
17/// JSON-ADR schema URL.
18pub const JSON_ADR_SCHEMA: &str =
19    "https://raw.githubusercontent.com/joshrotenberg/adrs/main/schema/json-adr/v1.json";
20
21/// A single ADR in JSON-ADR format.
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct JsonAdr {
24    /// Unique identifier for the ADR.
25    pub number: u32,
26
27    /// Title of the decision.
28    pub title: String,
29
30    /// Current status.
31    pub status: String,
32
33    /// Date when the decision was made (ISO 8601).
34    pub date: String,
35
36    /// People who made the decision.
37    #[serde(skip_serializing_if = "Vec::is_empty", default)]
38    pub deciders: Vec<String>,
39
40    /// People whose opinions were sought.
41    #[serde(skip_serializing_if = "Vec::is_empty", default)]
42    pub consulted: Vec<String>,
43
44    /// People informed after the decision.
45    #[serde(skip_serializing_if = "Vec::is_empty", default)]
46    pub informed: Vec<String>,
47
48    /// Categorization labels.
49    #[serde(skip_serializing_if = "Vec::is_empty", default)]
50    pub tags: Vec<String>,
51
52    /// URI to the source ADR file (for federation/reference).
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub source_uri: Option<String>,
55
56    /// Background and problem statement.
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub context: Option<String>,
59
60    /// Forces and concerns influencing the decision.
61    #[serde(skip_serializing_if = "Vec::is_empty", default)]
62    pub decision_drivers: Vec<String>,
63
64    /// Alternatives that were evaluated.
65    #[serde(skip_serializing_if = "Vec::is_empty", default)]
66    pub considered_options: Vec<ConsideredOption>,
67
68    /// The decision that was made.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub decision: Option<String>,
71
72    /// Outcomes and implications.
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub consequences: Option<String>,
75
76    /// How to validate the decision was implemented correctly.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub confirmation: Option<String>,
79
80    /// Relationships to other ADRs.
81    #[serde(skip_serializing_if = "Vec::is_empty", default)]
82    pub links: Vec<JsonAdrLink>,
83
84    /// Custom sections not covered by standard fields.
85    #[serde(skip_serializing_if = "std::collections::HashMap::is_empty", default)]
86    pub custom_sections: std::collections::HashMap<String, String>,
87
88    /// Relative path to the source file.
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub path: Option<String>,
91}
92
93/// A considered option with pros and cons.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct ConsideredOption {
96    /// Name of the option.
97    pub name: String,
98
99    /// Description of the option.
100    #[serde(skip_serializing_if = "Option::is_none")]
101    pub description: Option<String>,
102
103    /// Arguments in favor of this option.
104    #[serde(skip_serializing_if = "Vec::is_empty", default)]
105    pub pros: Vec<String>,
106
107    /// Arguments against this option.
108    #[serde(skip_serializing_if = "Vec::is_empty", default)]
109    pub cons: Vec<String>,
110}
111
112/// A link between ADRs in JSON-ADR format.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct JsonAdrLink {
115    /// Link type.
116    #[serde(rename = "type")]
117    pub link_type: String,
118
119    /// ADR number being linked to.
120    pub target: u32,
121
122    /// Optional description.
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub description: Option<String>,
125}
126
127/// Tool metadata for bulk exports.
128#[derive(Debug, Clone, Serialize, Deserialize)]
129pub struct ToolInfo {
130    /// Tool name.
131    pub name: String,
132
133    /// Tool version.
134    pub version: String,
135}
136
137/// Repository metadata for bulk exports.
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct RepositoryInfo {
140    /// Repository/project name.
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub name: Option<String>,
143
144    /// ADR directory path.
145    pub adr_directory: String,
146}
147
148/// Bulk export of multiple ADRs.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct JsonAdrBulkExport {
151    /// JSON Schema reference.
152    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
153    pub schema: Option<String>,
154
155    /// JSON-ADR version.
156    pub version: String,
157
158    /// When the export was generated.
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub generated_at: Option<String>,
161
162    /// Tool that generated the export.
163    #[serde(skip_serializing_if = "Option::is_none")]
164    pub tool: Option<ToolInfo>,
165
166    /// Repository metadata.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub repository: Option<RepositoryInfo>,
169
170    /// The ADRs.
171    pub adrs: Vec<JsonAdr>,
172}
173
174impl JsonAdrBulkExport {
175    /// Create a new bulk export with default metadata.
176    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    /// Set repository metadata.
195    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, // Set externally when exporting with --base-url
219            context: if adr.context.is_empty() {
220                None
221            } else {
222                Some(adr.context.clone())
223            },
224            decision_drivers: Vec::new(),   // Not yet in Adr type
225            considered_options: Vec::new(), // Not yet in Adr type
226            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, // Not yet in Adr type
237            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
265/// Export all ADRs from a repository to JSON-ADR format.
266pub 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
275/// Export a single ADR to JSON-ADR format.
276pub fn export_adr(adr: &Adr) -> JsonAdr {
277    JsonAdr::from(adr)
278}
279
280/// Single ADR wrapper for JSON-ADR format (used for single ADR import/export).
281#[derive(Debug, Clone, Serialize, Deserialize)]
282pub struct JsonAdrSingle {
283    /// JSON Schema reference.
284    #[serde(rename = "$schema", skip_serializing_if = "Option::is_none")]
285    pub schema: Option<String>,
286
287    /// JSON-ADR version.
288    pub version: String,
289
290    /// The ADR.
291    pub adr: JsonAdr,
292}
293
294/// Options for importing ADRs.
295#[derive(Debug, Clone, Default)]
296pub struct ImportOptions {
297    /// Overwrite existing files.
298    pub overwrite: bool,
299
300    /// Renumber ADRs starting from the next available number.
301    pub renumber: bool,
302
303    /// Preview import without writing files.
304    pub dry_run: bool,
305
306    /// Use next-gen mode (YAML frontmatter).
307    pub ng_mode: bool,
308}
309
310/// Result of an import operation.
311#[derive(Debug, Clone)]
312pub struct ImportResult {
313    /// Number of ADRs successfully imported.
314    pub imported: usize,
315
316    /// Number of ADRs skipped (already exist).
317    pub skipped: usize,
318
319    /// Paths of imported files.
320    pub files: Vec<std::path::PathBuf>,
321
322    /// Warnings encountered during import.
323    pub warnings: Vec<String>,
324
325    /// Mapping of old numbers to new numbers (when renumbering).
326    pub renumber_map: Vec<(u32, u32)>,
327}
328
329/// Export all ADRs from a directory to JSON-ADR format.
330///
331/// This function scans a directory for markdown files that look like ADRs
332/// (files matching `NNNN-*.md` pattern) and parses them. Unlike `export_repository`,
333/// this does not require an initialized adrs repository.
334pub fn export_directory(dir: &Path) -> Result<JsonAdrBulkExport> {
335    let parser = Parser::new();
336    let mut adrs = Vec::new();
337
338    // Scan for markdown files
339    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                        // Match NNNN-*.md pattern (adr-tools style)
348                        n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
349                    })
350            })
351            .collect();
352
353        // Sort by filename for consistent ordering
354        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                    // Log warning but continue with other files
362                    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
372/// Convert a JsonAdr back to an Adr for rendering.
373fn 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
420/// Import ADRs from a JSON-ADR bulk export into a directory.
421///
422/// This creates markdown files from the JSON-ADR data. It can be used
423/// to populate a new ADR directory or migrate ADRs between projects.
424pub fn import_to_directory(
425    json_data: &str,
426    dir: &Path,
427    options: &ImportOptions,
428) -> Result<ImportResult> {
429    // Parse the JSON - try bulk format first, then single
430    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    // Ensure directory exists
445    std::fs::create_dir_all(dir)?;
446
447    // If renumbering, find the next available number
448    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    // Create config for template rendering
463    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    // First pass: collect all ADRs and build the renumber map
476    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        // Renumber if requested
483        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    // Build a map for quick lookup: old_number -> new_number
494    let number_map: std::collections::HashMap<u32, u32> = result
495        .renumber_map
496        .iter()
497        .map(|&(old, new)| (old, new))
498        .collect();
499
500    // Second pass: update links and write files
501    for mut adr in adrs_to_import {
502        // Update links if renumbering
503        if options.renumber {
504            for link in &mut adr.links {
505                if let Some(&new_target) = number_map.get(&link.target) {
506                    // Link target is in the imported set, renumber it
507                    link.target = new_target;
508                } else {
509                    // Link target is NOT in the imported set - this is a broken reference
510                    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        // Check if file exists
522        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        // Render the ADR to markdown
532        let content = engine.render(&adr, &config)?;
533
534        // Write the file (unless dry-run)
535        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
546/// Find the next available ADR number in a directory.
547fn 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); // Not set by default
592    }
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        // source_uri should be skipped when None
625        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        // Empty vecs should be skipped
714        assert!(!json.contains("\"deciders\""));
715        assert!(!json.contains("\"decision_drivers\""));
716        assert!(!json.contains("\"considered_options\""));
717        // Empty custom_sections should be skipped
718        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        // Check decision drivers
802        assert!(json.contains("\"decision_drivers\""));
803        assert!(json.contains("Performance requirements"));
804        assert!(json.contains("Team expertise"));
805
806        // Check considered options
807        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        // Check confirmation
816        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        // Empty fields should be skipped
832        assert!(!json.contains("\"description\""));
833        assert!(!json.contains("\"pros\""));
834        assert!(!json.contains("\"cons\""));
835    }
836
837    // ========== Import Tests ==========
838
839    #[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        // Create existing ADR 1
876        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)); // ADR 1 -> ADR 2
919        assert_eq!(result.renumber_map[1], (2, 3)); // ADR 2 -> ADR 3
920
921        // Verify files were created with new numbers
922        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        // File should NOT exist in dry-run mode
954        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        // Create existing ADRs 1-3
964        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        // With gaps in source (5, 7), they should become sequential (4, 5)
1009        assert_eq!(result.renumber_map[0], (5, 4)); // ADR 5 -> ADR 4
1010        assert_eq!(result.renumber_map[1], (7, 5)); // ADR 7 -> ADR 5
1011
1012        // Files should NOT exist in dry-run
1013        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        // Create existing file
1024        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        // Create existing file
1063        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        // Verify content was overwritten
1093        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    // ========== Link Renumbering Tests (Phase 2) ==========
1175
1176    #[test]
1177    fn test_import_renumber_with_internal_links() {
1178        use tempfile::TempDir;
1179
1180        let temp = TempDir::new().unwrap();
1181
1182        // Create existing ADR 1
1183        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        // Import ADRs with internal links
1191        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)); // ADR 1 -> ADR 2
1232        assert_eq!(result.renumber_map[1], (2, 3)); // ADR 2 -> ADR 3
1233
1234        // Read the ADRs back and check links were updated
1235        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); // Was 2, now 3
1242
1243        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); // Was 1, now 2
1248    }
1249
1250    #[test]
1251    fn test_import_renumber_with_broken_links() {
1252        use tempfile::TempDir;
1253
1254        let temp = TempDir::new().unwrap();
1255
1256        // Create existing ADR 1
1257        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        // Import ADR with link to ADR not in the imported set
1265        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)); // ADR 5 -> ADR 2
1294
1295        // Should have a warning about the broken link
1296        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        // Import multiple ADRs with various link patterns
1308        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)); // 10 -> 1
1360        assert_eq!(result.renumber_map[1], (20, 2)); // 20 -> 2
1361        assert_eq!(result.renumber_map[2], (30, 3)); // 30 -> 3
1362
1363        // Check links were updated correctly
1364        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); // Was 10, now 1
1371
1372        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); // Was 10, now 1
1377        assert_eq!(adr3.links[1].target, 2); // Was 20, now 2
1378    }
1379}