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