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.
334///
335/// Files that look like ADRs but fail to parse are skipped. Use
336/// [`export_directory_with_warnings`] if you need to surface those parse
337/// failures to the caller rather than silently dropping them.
338pub fn export_directory(dir: &Path) -> Result<JsonAdrBulkExport> {
339    export_directory_with_warnings(dir).map(|(export, _warnings)| export)
340}
341
342/// Like [`export_directory`], but also returns a human-readable warning for
343/// every file that looked like an ADR but failed to parse.
344///
345/// This is the library-friendly variant: it never writes to stderr, leaving the
346/// caller in control of how (or whether) to surface skipped files.
347pub fn export_directory_with_warnings(dir: &Path) -> Result<(JsonAdrBulkExport, Vec<String>)> {
348    let parser = Parser::new();
349    let mut adrs = Vec::new();
350    let mut warnings = Vec::new();
351
352    // Scan for markdown files
353    if dir.is_dir() {
354        let mut entries: Vec<_> = std::fs::read_dir(dir)?
355            .filter_map(|e| e.ok())
356            .filter(|e| {
357                let path = e.path();
358                path.is_file()
359                    && path.extension().is_some_and(|ext| ext == "md")
360                    && path.file_name().and_then(|n| n.to_str()).is_some_and(|n| {
361                        // Match NNNN-*.md pattern (adr-tools style)
362                        n.len() > 5 && n[..4].chars().all(|c| c.is_ascii_digit())
363                    })
364            })
365            .collect();
366
367        // Sort by filename for consistent ordering
368        entries.sort_by_key(|e| e.path());
369
370        for entry in entries {
371            let path = entry.path();
372            match parser.parse_file(&path) {
373                Ok(adr) => adrs.push(JsonAdr::from(&adr)),
374                // Collect the warning instead of printing; the caller decides
375                // whether and how to surface skipped files.
376                Err(e) => warnings.push(format!("Failed to parse {}: {}", path.display(), e)),
377            }
378        }
379    }
380
381    let adr_dir = dir.display().to_string();
382    Ok((
383        JsonAdrBulkExport::new(adrs).with_repository(None, adr_dir),
384        warnings,
385    ))
386}
387
388/// Convert a JsonAdr back to an Adr for rendering.
389fn json_adr_to_adr(json_adr: &JsonAdr) -> Result<Adr> {
390    let date = time::Date::parse(
391        &json_adr.date,
392        &time::format_description::well_known::Iso8601::DATE,
393    )
394    .unwrap_or_else(|_| crate::parse::today());
395
396    let status = json_adr.status.parse::<AdrStatus>().unwrap_or_default();
397
398    let links: Vec<AdrLink> = json_adr
399        .links
400        .iter()
401        .map(|l| AdrLink {
402            target: l.target,
403            kind: string_to_link_kind(&l.link_type),
404            description: l.description.clone(),
405        })
406        .collect();
407
408    Ok(Adr {
409        number: json_adr.number,
410        title: json_adr.title.clone(),
411        date,
412        status,
413        links,
414        decision_makers: json_adr.deciders.clone(),
415        consulted: json_adr.consulted.clone(),
416        informed: json_adr.informed.clone(),
417        tags: json_adr.tags.clone(),
418        context: json_adr.context.clone().unwrap_or_default(),
419        decision: json_adr.decision.clone().unwrap_or_default(),
420        consequences: json_adr.consequences.clone().unwrap_or_default(),
421        path: None,
422    })
423}
424
425fn string_to_link_kind(s: &str) -> LinkKind {
426    match s.to_lowercase().as_str() {
427        "supersedes" => LinkKind::Supersedes,
428        "superseded-by" => LinkKind::SupersededBy,
429        "amends" => LinkKind::Amends,
430        "amended-by" => LinkKind::AmendedBy,
431        "relates-to" => LinkKind::RelatesTo,
432        other => LinkKind::Custom(other.to_string()),
433    }
434}
435
436/// Import ADRs from a JSON-ADR bulk export into a directory.
437///
438/// This creates markdown files from the JSON-ADR data. It can be used
439/// to populate a new ADR directory or migrate ADRs between projects.
440pub fn import_to_directory(
441    json_data: &str,
442    dir: &Path,
443    options: &ImportOptions,
444) -> Result<ImportResult> {
445    // Parse the JSON - try bulk format first, then single
446    let json_adrs: Vec<JsonAdr> =
447        if let Ok(bulk) = serde_json::from_str::<JsonAdrBulkExport>(json_data) {
448            bulk.adrs
449        } else if let Ok(single) = serde_json::from_str::<JsonAdrSingle>(json_data) {
450            vec![single.adr]
451        } else if let Ok(adr) = serde_json::from_str::<JsonAdr>(json_data) {
452            vec![adr]
453        } else {
454            return Err(crate::Error::InvalidFormat {
455                path: PathBuf::new(),
456                reason: "Invalid JSON-ADR format".to_string(),
457            });
458        };
459
460    // Ensure directory exists
461    std::fs::create_dir_all(dir)?;
462
463    // If renumbering, find the next available number
464    let next_number = if options.renumber {
465        find_next_number(dir)?
466    } else {
467        0
468    };
469
470    let mut result = ImportResult {
471        imported: 0,
472        skipped: 0,
473        files: Vec::new(),
474        warnings: Vec::new(),
475        renumber_map: Vec::new(),
476    };
477
478    // Create config for template rendering
479    let config = Config {
480        adr_dir: dir.to_path_buf(),
481        mode: if options.ng_mode {
482            crate::ConfigMode::NextGen
483        } else {
484            crate::ConfigMode::default()
485        },
486        ..Default::default()
487    };
488
489    let engine = TemplateEngine::new();
490
491    // First pass: collect all ADRs and build the renumber map
492    let mut adrs_to_import = Vec::new();
493    let mut temp_next_number = next_number;
494
495    for json_adr in json_adrs {
496        let mut adr = json_adr_to_adr(&json_adr)?;
497
498        // Renumber if requested
499        if options.renumber {
500            let old_number = adr.number;
501            adr.number = temp_next_number;
502            result.renumber_map.push((old_number, temp_next_number));
503            temp_next_number += 1;
504        }
505
506        adrs_to_import.push(adr);
507    }
508
509    // Build a map for quick lookup: old_number -> new_number
510    let number_map: std::collections::HashMap<u32, u32> = result
511        .renumber_map
512        .iter()
513        .map(|&(old, new)| (old, new))
514        .collect();
515
516    // Second pass: update links and write files
517    for mut adr in adrs_to_import {
518        // Update links if renumbering
519        if options.renumber {
520            for link in &mut adr.links {
521                if let Some(&new_target) = number_map.get(&link.target) {
522                    // Link target is in the imported set, renumber it
523                    link.target = new_target;
524                } else {
525                    // Link target is NOT in the imported set - this is a broken reference
526                    result.warnings.push(format!(
527                        "ADR {} links to ADR {} which is not in the import set",
528                        adr.number, link.target
529                    ));
530                }
531            }
532        }
533
534        let filename = adr.filename();
535        let filepath = dir.join(&filename);
536
537        // Check if file exists
538        if filepath.exists() && !options.overwrite {
539            result.skipped += 1;
540            result.warnings.push(format!(
541                "Skipped {}: file already exists (use --overwrite to replace)",
542                filename
543            ));
544            continue;
545        }
546
547        // Render the ADR to markdown (no link title resolution for imports)
548        let content = engine.render(&adr, &config, &std::collections::HashMap::new())?;
549
550        // Write the file (unless dry-run)
551        if !options.dry_run {
552            std::fs::write(&filepath, content)?;
553        }
554
555        result.imported += 1;
556        result.files.push(filepath);
557    }
558
559    Ok(result)
560}
561
562/// Find the next available ADR number in a directory.
563fn find_next_number(dir: &Path) -> Result<u32> {
564    let mut max_number = 0u32;
565
566    if dir.is_dir() {
567        for entry in std::fs::read_dir(dir)?.filter_map(|e| e.ok()) {
568            let path = entry.path();
569            if let Some(name) = path.file_name().and_then(|n| n.to_str())
570                && name.len() > 4
571                && name.ends_with(".md")
572                && let Ok(num) = name[..4].parse::<u32>()
573            {
574                max_number = max_number.max(num);
575            }
576        }
577    }
578
579    Ok(max_number + 1)
580}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585    use crate::AdrStatus;
586    use time::{Date, Month};
587
588    #[test]
589    fn test_json_adr_from_adr() {
590        let mut adr = Adr::new(1, "Test Decision");
591        adr.status = AdrStatus::Accepted;
592        adr.date = Date::from_calendar_date(2024, Month::January, 15).unwrap();
593        adr.context = "Some context".to_string();
594        adr.decision = "We decided X".to_string();
595        adr.consequences = "This means Y".to_string();
596        adr.decision_makers = vec!["Alice".to_string()];
597        adr.consulted = vec!["Bob".to_string()];
598
599        let json_adr = JsonAdr::from(&adr);
600
601        assert_eq!(json_adr.number, 1);
602        assert_eq!(json_adr.title, "Test Decision");
603        assert_eq!(json_adr.status, "Accepted");
604        assert_eq!(json_adr.date, "2024-01-15");
605        assert_eq!(json_adr.deciders, vec!["Alice"]);
606        assert_eq!(json_adr.consulted, vec!["Bob"]);
607        assert_eq!(json_adr.source_uri, None); // Not set by default
608    }
609
610    #[test]
611    fn test_json_adr_source_uri_field() {
612        let mut adr = JsonAdr {
613            number: 1,
614            title: "Test".to_string(),
615            status: "Accepted".to_string(),
616            date: "2024-01-15".to_string(),
617            deciders: vec![],
618            consulted: vec![],
619            informed: vec![],
620            tags: vec![],
621            source_uri: Some(
622                "https://github.com/org/repo/blob/main/doc/adr/0001-test.md".to_string(),
623            ),
624            context: Some("Test context".to_string()),
625            decision: Some("Test decision".to_string()),
626            consequences: Some("Test consequences".to_string()),
627            decision_drivers: vec![],
628            considered_options: vec![],
629            confirmation: None,
630            links: vec![],
631            custom_sections: std::collections::HashMap::new(),
632            path: None,
633        };
634
635        let json = serde_json::to_string(&adr).unwrap();
636        assert!(json.contains(
637            "\"source_uri\":\"https://github.com/org/repo/blob/main/doc/adr/0001-test.md\""
638        ));
639
640        // source_uri should be skipped when None
641        adr.source_uri = None;
642        let json = serde_json::to_string(&adr).unwrap();
643        assert!(!json.contains("source_uri"));
644    }
645
646    #[test]
647    fn test_json_adr_link_from_adr_link() {
648        let link = AdrLink {
649            target: 2,
650            kind: LinkKind::Supersedes,
651            description: Some("Replaces old approach".to_string()),
652        };
653
654        let json_link = JsonAdrLink::from(&link);
655
656        assert_eq!(json_link.link_type, "supersedes");
657        assert_eq!(json_link.target, 2);
658        assert_eq!(
659            json_link.description,
660            Some("Replaces old approach".to_string())
661        );
662    }
663
664    #[test]
665    fn test_bulk_export_metadata() {
666        let export = JsonAdrBulkExport::new(vec![]);
667
668        assert_eq!(export.version, JSON_ADR_VERSION);
669        assert!(export.schema.is_some());
670        assert!(export.generated_at.is_some());
671        assert!(export.tool.is_some());
672
673        let tool = export.tool.unwrap();
674        assert_eq!(tool.name, "adrs");
675    }
676
677    #[test]
678    fn test_bulk_export_with_repository() {
679        let export = JsonAdrBulkExport::new(vec![])
680            .with_repository(Some("my-project".to_string()), "doc/adr".to_string());
681
682        let repo = export.repository.unwrap();
683        assert_eq!(repo.name, Some("my-project".to_string()));
684        assert_eq!(repo.adr_directory, "doc/adr");
685    }
686
687    #[test]
688    fn test_link_kind_to_string() {
689        assert_eq!(link_kind_to_string(&LinkKind::Supersedes), "supersedes");
690        assert_eq!(
691            link_kind_to_string(&LinkKind::SupersededBy),
692            "superseded-by"
693        );
694        assert_eq!(link_kind_to_string(&LinkKind::Amends), "amends");
695        assert_eq!(link_kind_to_string(&LinkKind::AmendedBy), "amended-by");
696        assert_eq!(link_kind_to_string(&LinkKind::RelatesTo), "relates-to");
697        assert_eq!(
698            link_kind_to_string(&LinkKind::Custom("extends".to_string())),
699            "extends"
700        );
701    }
702
703    #[test]
704    fn test_json_serialization() {
705        let adr = JsonAdr {
706            number: 1,
707            title: "Test".to_string(),
708            status: "Accepted".to_string(),
709            date: "2024-01-15".to_string(),
710            deciders: vec![],
711            consulted: vec![],
712            informed: vec![],
713            tags: vec![],
714            source_uri: None,
715            context: None,
716            decision_drivers: vec![],
717            considered_options: vec![],
718            decision: None,
719            consequences: None,
720            confirmation: None,
721            links: vec![],
722            custom_sections: std::collections::HashMap::new(),
723            path: None,
724        };
725
726        let json = serde_json::to_string(&adr).unwrap();
727        assert!(json.contains("\"number\":1"));
728        assert!(json.contains("\"title\":\"Test\""));
729        // Empty vecs should be skipped
730        assert!(!json.contains("\"deciders\""));
731        assert!(!json.contains("\"decision_drivers\""));
732        assert!(!json.contains("\"considered_options\""));
733        // Empty custom_sections should be skipped
734        assert!(!json.contains("\"custom_sections\""));
735    }
736
737    #[test]
738    fn test_custom_sections() {
739        let mut adr = JsonAdr {
740            number: 1,
741            title: "Test".to_string(),
742            status: "Accepted".to_string(),
743            date: "2024-01-15".to_string(),
744            deciders: vec![],
745            consulted: vec![],
746            informed: vec![],
747            tags: vec![],
748            source_uri: None,
749            context: None,
750            decision_drivers: vec![],
751            considered_options: vec![],
752            decision: None,
753            consequences: None,
754            confirmation: None,
755            links: vec![],
756            custom_sections: std::collections::HashMap::new(),
757            path: None,
758        };
759
760        adr.custom_sections.insert(
761            "Alternatives Considered".to_string(),
762            "We also looked at MySQL and SQLite.".to_string(),
763        );
764        adr.custom_sections.insert(
765            "Security Review".to_string(),
766            "Approved by security team on 2024-01-10.".to_string(),
767        );
768
769        let json = serde_json::to_string_pretty(&adr).unwrap();
770        assert!(json.contains("\"custom_sections\""));
771        assert!(json.contains("Alternatives Considered"));
772        assert!(json.contains("Security Review"));
773    }
774
775    #[test]
776    fn test_decision_drivers_and_options() {
777        let adr = JsonAdr {
778            number: 1,
779            title: "Choose Database".to_string(),
780            status: "Accepted".to_string(),
781            date: "2024-01-15".to_string(),
782            deciders: vec!["Alice".to_string()],
783            consulted: vec![],
784            informed: vec![],
785            tags: vec![],
786            source_uri: None,
787            context: Some("We need a database for user data".to_string()),
788            decision_drivers: vec![
789                "Performance requirements".to_string(),
790                "Team expertise".to_string(),
791                "Cost constraints".to_string(),
792            ],
793            considered_options: vec![
794                ConsideredOption {
795                    name: "PostgreSQL".to_string(),
796                    description: Some("Open source relational database".to_string()),
797                    pros: vec!["Mature".to_string(), "Feature-rich".to_string()],
798                    cons: vec!["Complex setup".to_string()],
799                },
800                ConsideredOption {
801                    name: "SQLite".to_string(),
802                    description: None,
803                    pros: vec!["Simple".to_string()],
804                    cons: vec!["Not suitable for high concurrency".to_string()],
805                },
806            ],
807            decision: Some("Use PostgreSQL".to_string()),
808            consequences: Some("Need to set up replication".to_string()),
809            confirmation: Some("Run integration tests with production-like data".to_string()),
810            links: vec![],
811            custom_sections: std::collections::HashMap::new(),
812            path: None,
813        };
814
815        let json = serde_json::to_string_pretty(&adr).unwrap();
816
817        // Check decision drivers
818        assert!(json.contains("\"decision_drivers\""));
819        assert!(json.contains("Performance requirements"));
820        assert!(json.contains("Team expertise"));
821
822        // Check considered options
823        assert!(json.contains("\"considered_options\""));
824        assert!(json.contains("PostgreSQL"));
825        assert!(json.contains("SQLite"));
826        assert!(json.contains("\"pros\""));
827        assert!(json.contains("\"cons\""));
828        assert!(json.contains("Mature"));
829        assert!(json.contains("Complex setup"));
830
831        // Check confirmation
832        assert!(json.contains("\"confirmation\""));
833        assert!(json.contains("integration tests"));
834    }
835
836    #[test]
837    fn test_considered_option_minimal() {
838        let option = ConsideredOption {
839            name: "Option A".to_string(),
840            description: None,
841            pros: vec![],
842            cons: vec![],
843        };
844
845        let json = serde_json::to_string(&option).unwrap();
846        assert!(json.contains("\"name\":\"Option A\""));
847        // Empty fields should be skipped
848        assert!(!json.contains("\"description\""));
849        assert!(!json.contains("\"pros\""));
850        assert!(!json.contains("\"cons\""));
851    }
852
853    // ========== Import Tests ==========
854
855    #[test]
856    fn test_import_basic() {
857        use tempfile::TempDir;
858
859        let temp = TempDir::new().unwrap();
860        let json = r#"{
861            "number": 1,
862            "title": "Test Decision",
863            "status": "Proposed",
864            "date": "2024-01-15",
865            "context": "Test context",
866            "decision": "Test decision",
867            "consequences": "Test consequences"
868        }"#;
869
870        let options = ImportOptions {
871            overwrite: false,
872            renumber: false,
873            dry_run: false,
874            ng_mode: false,
875        };
876
877        let result = import_to_directory(json, temp.path(), &options).unwrap();
878
879        assert_eq!(result.imported, 1);
880        assert_eq!(result.skipped, 0);
881        assert_eq!(result.files.len(), 1);
882        assert!(result.files[0].exists());
883    }
884
885    #[test]
886    fn test_import_with_renumber() {
887        use tempfile::TempDir;
888
889        let temp = TempDir::new().unwrap();
890
891        // Create existing ADR 1
892        std::fs::create_dir_all(temp.path()).unwrap();
893        std::fs::write(
894            temp.path().join("0001-existing.md"),
895            "# 1. Existing\n\nDate: 2024-01-01\n\n## Status\n\nAccepted\n\n## Context\n\nTest\n\n## Decision\n\nTest\n\n## Consequences\n\nTest\n",
896        )
897        .unwrap();
898
899        let json = r#"{
900            "version": "1.0.0",
901            "adrs": [
902                {
903                    "number": 1,
904                    "title": "First Import",
905                    "status": "Proposed",
906                    "date": "2024-01-15",
907                    "context": "Test",
908                    "decision": "Test",
909                    "consequences": "Test"
910                },
911                {
912                    "number": 2,
913                    "title": "Second Import",
914                    "status": "Proposed",
915                    "date": "2024-01-16",
916                    "context": "Test",
917                    "decision": "Test",
918                    "consequences": "Test"
919                }
920            ]
921        }"#;
922
923        let options = ImportOptions {
924            overwrite: false,
925            renumber: true,
926            dry_run: false,
927            ng_mode: false,
928        };
929
930        let result = import_to_directory(json, temp.path(), &options).unwrap();
931
932        assert_eq!(result.imported, 2);
933        assert_eq!(result.renumber_map.len(), 2);
934        assert_eq!(result.renumber_map[0], (1, 2)); // ADR 1 -> ADR 2
935        assert_eq!(result.renumber_map[1], (2, 3)); // ADR 2 -> ADR 3
936
937        // Verify files were created with new numbers
938        assert!(temp.path().join("0002-first-import.md").exists());
939        assert!(temp.path().join("0003-second-import.md").exists());
940    }
941
942    #[test]
943    fn test_import_dry_run() {
944        use tempfile::TempDir;
945
946        let temp = TempDir::new().unwrap();
947        let json = r#"{
948            "number": 1,
949            "title": "Test Decision",
950            "status": "Proposed",
951            "date": "2024-01-15",
952            "context": "Test",
953            "decision": "Test",
954            "consequences": "Test"
955        }"#;
956
957        let options = ImportOptions {
958            overwrite: false,
959            renumber: false,
960            dry_run: true,
961            ng_mode: false,
962        };
963
964        let result = import_to_directory(json, temp.path(), &options).unwrap();
965
966        assert_eq!(result.imported, 1);
967        assert_eq!(result.files.len(), 1);
968
969        // File should NOT exist in dry-run mode
970        assert!(!result.files[0].exists());
971    }
972
973    #[test]
974    fn test_import_dry_run_with_renumber() {
975        use tempfile::TempDir;
976
977        let temp = TempDir::new().unwrap();
978
979        // Create existing ADRs 1-3
980        std::fs::create_dir_all(temp.path()).unwrap();
981        for i in 1..=3 {
982            std::fs::write(
983                temp.path().join(format!("{:04}-existing-{}.md", i, i)),
984                format!("# {}. Existing\n\nDate: 2024-01-01\n\n## Status\n\nAccepted\n\n## Context\n\nTest\n\n## Decision\n\nTest\n\n## Consequences\n\nTest\n", i),
985            )
986            .unwrap();
987        }
988
989        let json = r#"{
990            "version": "1.0.0",
991            "adrs": [
992                {
993                    "number": 5,
994                    "title": "Import Five",
995                    "status": "Proposed",
996                    "date": "2024-01-15",
997                    "context": "Test",
998                    "decision": "Test",
999                    "consequences": "Test"
1000                },
1001                {
1002                    "number": 7,
1003                    "title": "Import Seven",
1004                    "status": "Proposed",
1005                    "date": "2024-01-16",
1006                    "context": "Test",
1007                    "decision": "Test",
1008                    "consequences": "Test"
1009                }
1010            ]
1011        }"#;
1012
1013        let options = ImportOptions {
1014            overwrite: false,
1015            renumber: true,
1016            dry_run: true,
1017            ng_mode: false,
1018        };
1019
1020        let result = import_to_directory(json, temp.path(), &options).unwrap();
1021
1022        assert_eq!(result.imported, 2);
1023        assert_eq!(result.renumber_map.len(), 2);
1024        // With gaps in source (5, 7), they should become sequential (4, 5)
1025        assert_eq!(result.renumber_map[0], (5, 4)); // ADR 5 -> ADR 4
1026        assert_eq!(result.renumber_map[1], (7, 5)); // ADR 7 -> ADR 5
1027
1028        // Files should NOT exist in dry-run
1029        assert!(!temp.path().join("0004-import-five.md").exists());
1030        assert!(!temp.path().join("0005-import-seven.md").exists());
1031    }
1032
1033    #[test]
1034    fn test_import_skip_existing() {
1035        use tempfile::TempDir;
1036
1037        let temp = TempDir::new().unwrap();
1038
1039        // Create existing file
1040        std::fs::create_dir_all(temp.path()).unwrap();
1041        std::fs::write(
1042            temp.path().join("0001-test-decision.md"),
1043            "# 1. Test Decision\n\nExisting content\n",
1044        )
1045        .unwrap();
1046
1047        let json = r#"{
1048            "number": 1,
1049            "title": "Test Decision",
1050            "status": "Proposed",
1051            "date": "2024-01-15",
1052            "context": "Test",
1053            "decision": "Test",
1054            "consequences": "Test"
1055        }"#;
1056
1057        let options = ImportOptions {
1058            overwrite: false,
1059            renumber: false,
1060            dry_run: false,
1061            ng_mode: false,
1062        };
1063
1064        let result = import_to_directory(json, temp.path(), &options).unwrap();
1065
1066        assert_eq!(result.imported, 0);
1067        assert_eq!(result.skipped, 1);
1068        assert_eq!(result.warnings.len(), 1);
1069        assert!(result.warnings[0].contains("already exists"));
1070    }
1071
1072    #[test]
1073    fn test_import_overwrite() {
1074        use tempfile::TempDir;
1075
1076        let temp = TempDir::new().unwrap();
1077
1078        // Create existing file
1079        std::fs::create_dir_all(temp.path()).unwrap();
1080        std::fs::write(
1081            temp.path().join("0001-test-decision.md"),
1082            "# 1. Test Decision\n\nOLD CONTENT\n",
1083        )
1084        .unwrap();
1085
1086        let json = r#"{
1087            "number": 1,
1088            "title": "Test Decision",
1089            "status": "Proposed",
1090            "date": "2024-01-15",
1091            "context": "NEW CONTEXT",
1092            "decision": "Test",
1093            "consequences": "Test"
1094        }"#;
1095
1096        let options = ImportOptions {
1097            overwrite: true,
1098            renumber: false,
1099            dry_run: false,
1100            ng_mode: false,
1101        };
1102
1103        let result = import_to_directory(json, temp.path(), &options).unwrap();
1104
1105        assert_eq!(result.imported, 1);
1106        assert_eq!(result.skipped, 0);
1107
1108        // Verify content was overwritten
1109        let content = std::fs::read_to_string(temp.path().join("0001-test-decision.md")).unwrap();
1110        assert!(content.contains("NEW CONTEXT"));
1111        assert!(!content.contains("OLD CONTENT"));
1112    }
1113
1114    #[test]
1115    fn test_import_bulk_format() {
1116        use tempfile::TempDir;
1117
1118        let temp = TempDir::new().unwrap();
1119
1120        let json = r#"{
1121            "version": "1.0.0",
1122            "adrs": [
1123                {
1124                    "number": 1,
1125                    "title": "First",
1126                    "status": "Proposed",
1127                    "date": "2024-01-15",
1128                    "context": "Test",
1129                    "decision": "Test",
1130                    "consequences": "Test"
1131                },
1132                {
1133                    "number": 2,
1134                    "title": "Second",
1135                    "status": "Accepted",
1136                    "date": "2024-01-16",
1137                    "context": "Test",
1138                    "decision": "Test",
1139                    "consequences": "Test"
1140                }
1141            ]
1142        }"#;
1143
1144        let options = ImportOptions {
1145            overwrite: false,
1146            renumber: false,
1147            dry_run: false,
1148            ng_mode: false,
1149        };
1150
1151        let result = import_to_directory(json, temp.path(), &options).unwrap();
1152
1153        assert_eq!(result.imported, 2);
1154        assert!(temp.path().join("0001-first.md").exists());
1155        assert!(temp.path().join("0002-second.md").exists());
1156    }
1157
1158    #[test]
1159    fn test_import_single_wrapper_format() {
1160        use tempfile::TempDir;
1161
1162        let temp = TempDir::new().unwrap();
1163
1164        let json = r#"{
1165            "version": "1.0.0",
1166            "adr": {
1167                "number": 1,
1168                "title": "Test Decision",
1169                "status": "Proposed",
1170                "date": "2024-01-15",
1171                "context": "Test",
1172                "decision": "Test",
1173                "consequences": "Test"
1174            }
1175        }"#;
1176
1177        let options = ImportOptions {
1178            overwrite: false,
1179            renumber: false,
1180            dry_run: false,
1181            ng_mode: false,
1182        };
1183
1184        let result = import_to_directory(json, temp.path(), &options).unwrap();
1185
1186        assert_eq!(result.imported, 1);
1187        assert!(temp.path().join("0001-test-decision.md").exists());
1188    }
1189
1190    // ========== Link Renumbering Tests (Phase 2) ==========
1191
1192    #[test]
1193    fn test_import_renumber_with_internal_links() {
1194        use tempfile::TempDir;
1195
1196        let temp = TempDir::new().unwrap();
1197
1198        // Create existing ADR 1
1199        std::fs::create_dir_all(temp.path()).unwrap();
1200        std::fs::write(
1201            temp.path().join("0001-existing.md"),
1202            "# 1. Existing\n\nDate: 2024-01-01\n\n## Status\n\nAccepted\n\n## Context\n\nTest\n\n## Decision\n\nTest\n\n## Consequences\n\nTest\n",
1203        )
1204        .unwrap();
1205
1206        // Import ADRs with internal links
1207        let json = r#"{
1208            "version": "1.0.0",
1209            "adrs": [
1210                {
1211                    "number": 1,
1212                    "title": "First",
1213                    "status": "Superseded",
1214                    "date": "2024-01-15",
1215                    "context": "Test",
1216                    "decision": "Test",
1217                    "consequences": "Test",
1218                    "links": [
1219                        {"target": 2, "type": "SupersededBy"}
1220                    ]
1221                },
1222                {
1223                    "number": 2,
1224                    "title": "Second",
1225                    "status": "Accepted",
1226                    "date": "2024-01-16",
1227                    "context": "Test",
1228                    "decision": "Test",
1229                    "consequences": "Test",
1230                    "links": [
1231                        {"target": 1, "type": "Supersedes"}
1232                    ]
1233                }
1234            ]
1235        }"#;
1236
1237        let options = ImportOptions {
1238            overwrite: false,
1239            renumber: true,
1240            dry_run: false,
1241            ng_mode: false,
1242        };
1243
1244        let result = import_to_directory(json, temp.path(), &options).unwrap();
1245
1246        assert_eq!(result.imported, 2);
1247        assert_eq!(result.renumber_map[0], (1, 2)); // ADR 1 -> ADR 2
1248        assert_eq!(result.renumber_map[1], (2, 3)); // ADR 2 -> ADR 3
1249
1250        // Read the ADRs back and check links were updated
1251        let parser = crate::Parser::new();
1252
1253        let adr2 = parser
1254            .parse_file(&temp.path().join("0002-first.md"))
1255            .unwrap();
1256        assert_eq!(adr2.links.len(), 1);
1257        assert_eq!(adr2.links[0].target, 3); // Was 2, now 3
1258
1259        let adr3 = parser
1260            .parse_file(&temp.path().join("0003-second.md"))
1261            .unwrap();
1262        assert_eq!(adr3.links.len(), 1);
1263        assert_eq!(adr3.links[0].target, 2); // Was 1, now 2
1264    }
1265
1266    #[test]
1267    fn test_import_renumber_with_broken_links() {
1268        use tempfile::TempDir;
1269
1270        let temp = TempDir::new().unwrap();
1271
1272        // Create existing ADR 1
1273        std::fs::create_dir_all(temp.path()).unwrap();
1274        std::fs::write(
1275            temp.path().join("0001-existing.md"),
1276            "# 1. Existing\n\nDate: 2024-01-01\n\n## Status\n\nAccepted\n\n## Context\n\nTest\n\n## Decision\n\nTest\n\n## Consequences\n\nTest\n",
1277        )
1278        .unwrap();
1279
1280        // Import ADR with link to ADR not in the imported set
1281        let json = r#"{
1282            "version": "1.0.0",
1283            "adrs": [
1284                {
1285                    "number": 5,
1286                    "title": "Fifth",
1287                    "status": "Accepted",
1288                    "date": "2024-01-15",
1289                    "context": "Test",
1290                    "decision": "Test",
1291                    "consequences": "Test",
1292                    "links": [
1293                        {"target": 3, "type": "Extends"}
1294                    ]
1295                }
1296            ]
1297        }"#;
1298
1299        let options = ImportOptions {
1300            overwrite: false,
1301            renumber: true,
1302            dry_run: false,
1303            ng_mode: false,
1304        };
1305
1306        let result = import_to_directory(json, temp.path(), &options).unwrap();
1307
1308        assert_eq!(result.imported, 1);
1309        assert_eq!(result.renumber_map[0], (5, 2)); // ADR 5 -> ADR 2
1310
1311        // Should have a warning about the broken link
1312        assert_eq!(result.warnings.len(), 1);
1313        assert!(result.warnings[0].contains("ADR 2 links to ADR 3"));
1314        assert!(result.warnings[0].contains("not in the import set"));
1315    }
1316
1317    #[test]
1318    fn test_import_renumber_complex_links() {
1319        use tempfile::TempDir;
1320
1321        let temp = TempDir::new().unwrap();
1322
1323        // Import multiple ADRs with various link patterns
1324        let json = r#"{
1325            "version": "1.0.0",
1326            "adrs": [
1327                {
1328                    "number": 10,
1329                    "title": "Ten",
1330                    "status": "Accepted",
1331                    "date": "2024-01-10",
1332                    "context": "Test",
1333                    "decision": "Test",
1334                    "consequences": "Test",
1335                    "links": []
1336                },
1337                {
1338                    "number": 20,
1339                    "title": "Twenty",
1340                    "status": "Accepted",
1341                    "date": "2024-01-20",
1342                    "context": "Test",
1343                    "decision": "Test",
1344                    "consequences": "Test",
1345                    "links": [
1346                        {"target": 10, "type": "Amends"}
1347                    ]
1348                },
1349                {
1350                    "number": 30,
1351                    "title": "Thirty",
1352                    "status": "Accepted",
1353                    "date": "2024-01-30",
1354                    "context": "Test",
1355                    "decision": "Test",
1356                    "consequences": "Test",
1357                    "links": [
1358                        {"target": 10, "type": "RelatesTo"},
1359                        {"target": 20, "type": "RelatesTo"}
1360                    ]
1361                }
1362            ]
1363        }"#;
1364
1365        let options = ImportOptions {
1366            overwrite: false,
1367            renumber: true,
1368            dry_run: false,
1369            ng_mode: false,
1370        };
1371
1372        let result = import_to_directory(json, temp.path(), &options).unwrap();
1373
1374        assert_eq!(result.imported, 3);
1375        assert_eq!(result.renumber_map[0], (10, 1)); // 10 -> 1
1376        assert_eq!(result.renumber_map[1], (20, 2)); // 20 -> 2
1377        assert_eq!(result.renumber_map[2], (30, 3)); // 30 -> 3
1378
1379        // Check links were updated correctly
1380        let parser = crate::Parser::new();
1381
1382        let adr2 = parser
1383            .parse_file(&temp.path().join("0002-twenty.md"))
1384            .unwrap();
1385        assert_eq!(adr2.links.len(), 1);
1386        assert_eq!(adr2.links[0].target, 1); // Was 10, now 1
1387
1388        let adr3 = parser
1389            .parse_file(&temp.path().join("0003-thirty.md"))
1390            .unwrap();
1391        assert_eq!(adr3.links.len(), 2);
1392        assert_eq!(adr3.links[0].target, 1); // Was 10, now 1
1393        assert_eq!(adr3.links[1].target, 2); // Was 20, now 2
1394    }
1395    // ========== ng_mode import tests (issue #236) ==========
1396
1397    #[test]
1398    fn test_import_ng_mode_produces_yaml_frontmatter() {
1399        use tempfile::TempDir;
1400
1401        let temp = TempDir::new().unwrap();
1402        let json = r#"{
1403            "number": 1,
1404            "title": "Test Decision",
1405            "status": "Proposed",
1406            "date": "2024-01-15",
1407            "context": "Test context",
1408            "decision": "Test decision",
1409            "consequences": "Test consequences"
1410        }"#;
1411
1412        let options = ImportOptions {
1413            overwrite: false,
1414            renumber: false,
1415            dry_run: false,
1416            ng_mode: true,
1417        };
1418
1419        let result = import_to_directory(json, temp.path(), &options).unwrap();
1420        assert_eq!(result.imported, 1);
1421
1422        let content = std::fs::read_to_string(&result.files[0]).unwrap();
1423        // ng_mode should produce YAML frontmatter
1424        assert!(
1425            content.starts_with("---"),
1426            "ng_mode import should produce YAML frontmatter. Got:\n{content}"
1427        );
1428        assert!(
1429            content.contains("status: proposed"),
1430            "Frontmatter should contain status"
1431        );
1432    }
1433
1434    #[test]
1435    fn test_import_ng_mode_preserves_tags() {
1436        use tempfile::TempDir;
1437
1438        let temp = TempDir::new().unwrap();
1439        let json = r#"{
1440            "number": 1,
1441            "title": "Tagged Decision",
1442            "status": "Accepted",
1443            "date": "2024-01-15",
1444            "tags": ["database", "infrastructure"]
1445        }"#;
1446
1447        let options = ImportOptions {
1448            overwrite: false,
1449            renumber: false,
1450            dry_run: false,
1451            ng_mode: true,
1452        };
1453
1454        let result = import_to_directory(json, temp.path(), &options).unwrap();
1455        assert_eq!(result.imported, 1);
1456
1457        let content = std::fs::read_to_string(&result.files[0]).unwrap();
1458        assert!(
1459            content.contains("tags:"),
1460            "ng_mode import should preserve tags in frontmatter"
1461        );
1462        assert!(content.contains("database"));
1463        assert!(content.contains("infrastructure"));
1464    }
1465
1466    #[test]
1467    fn test_import_ng_mode_compatible_mode_no_frontmatter() {
1468        use tempfile::TempDir;
1469
1470        let temp = TempDir::new().unwrap();
1471        let json = r#"{
1472            "number": 1,
1473            "title": "Compatible Decision",
1474            "status": "Proposed",
1475            "date": "2024-01-15"
1476        }"#;
1477
1478        let options = ImportOptions {
1479            overwrite: false,
1480            renumber: false,
1481            dry_run: false,
1482            ng_mode: false, // compatible mode
1483        };
1484
1485        let result = import_to_directory(json, temp.path(), &options).unwrap();
1486        assert_eq!(result.imported, 1);
1487
1488        let content = std::fs::read_to_string(&result.files[0]).unwrap();
1489        // compatible mode should NOT produce frontmatter
1490        assert!(
1491            !content.starts_with("---"),
1492            "Compatible mode should not produce YAML frontmatter. Got:\n{content}"
1493        );
1494    }
1495
1496    #[test]
1497    fn test_import_ng_mode_roundtrip_export_import() {
1498        use crate::{Adr, AdrStatus};
1499        use tempfile::TempDir;
1500
1501        // Set up source ADR
1502        let mut adr = Adr::new(1, "Round-Trip Test");
1503        adr.status = AdrStatus::Accepted;
1504        adr.context = "We need a round-trip test.".to_string();
1505        adr.decision = "We will test export and import.".to_string();
1506        adr.consequences = "Better test coverage.".to_string();
1507        adr.tags = vec!["test".to_string(), "ci".to_string()];
1508
1509        // Export the ADR to JSON
1510        let json_adr = export_adr(&adr);
1511        let bulk = JsonAdrBulkExport::new(vec![json_adr]);
1512        let json_str = serde_json::to_string(&bulk).unwrap();
1513
1514        // Import with ng_mode
1515        let dest_temp = TempDir::new().unwrap();
1516        let options = ImportOptions {
1517            overwrite: false,
1518            renumber: false,
1519            dry_run: false,
1520            ng_mode: true,
1521        };
1522
1523        let result = import_to_directory(&json_str, dest_temp.path(), &options).unwrap();
1524        assert_eq!(result.imported, 1);
1525
1526        // Verify the output file has frontmatter
1527        let content = std::fs::read_to_string(&result.files[0]).unwrap();
1528        assert!(
1529            content.starts_with("---"),
1530            "Round-trip ng import should have frontmatter"
1531        );
1532        assert!(content.contains("tags:"));
1533        assert!(content.contains("test"));
1534        assert!(content.contains("ci"));
1535    }
1536
1537    #[test]
1538    fn test_import_ng_mode_renumber_with_frontmatter() {
1539        use tempfile::TempDir;
1540
1541        let temp = TempDir::new().unwrap();
1542        // Create an existing ADR
1543        std::fs::write(
1544            temp.path().join("0001-existing.md"),
1545            "# 1. Existing\n\nDate: 2024-01-01\n\n## Status\n\nAccepted\n\n## Context\n\nTest\n\n## Decision\n\nTest\n\n## Consequences\n\nTest\n",
1546        ).unwrap();
1547
1548        let json = r#"{
1549            "number": 1,
1550            "title": "New Import",
1551            "status": "Proposed",
1552            "date": "2024-01-15",
1553            "tags": ["test"]
1554        }"#;
1555
1556        let options = ImportOptions {
1557            overwrite: false,
1558            renumber: true,
1559            dry_run: false,
1560            ng_mode: true,
1561        };
1562
1563        let result = import_to_directory(json, temp.path(), &options).unwrap();
1564        assert_eq!(result.imported, 1);
1565        assert_eq!(result.renumber_map[0], (1, 2));
1566
1567        let content = std::fs::read_to_string(&result.files[0]).unwrap();
1568        // ng_mode with renumber should still produce frontmatter
1569        assert!(
1570            content.starts_with("---"),
1571            "Renumbered ng import should have frontmatter"
1572        );
1573    }
1574}