Skip to main content

aida_core/
export.rs

1// trace:META-EXPORT | ai:claude:high
2//! Export/Import functionality for requirements.
3//!
4//! This module provides:
5//! - Mapping file generation (UUID to SPEC-ID)
6//! - JSON export of full store
7//! - Requirements specification export (markdown)
8//! - Implementation records export (markdown)
9//! - Tree export/import for portability between databases
10
11use crate::models::{
12    Comment, MetaSubtype, RelationshipType, Requirement, RequirementPriority, RequirementStatus,
13    RequirementType, RequirementsStore,
14};
15use anyhow::{bail, Context, Result};
16use chrono::{DateTime, Utc};
17use serde::{Deserialize, Serialize};
18use std::collections::{HashMap, HashSet};
19use std::fs;
20use std::path::Path;
21use uuid::Uuid;
22
23#[derive(Debug, Serialize, Deserialize)]
24pub struct MappingFile {
25    pub mappings: HashMap<String, String>, // UUID -> SPEC-ID
26    pub next_spec_number: u32,
27}
28
29impl MappingFile {
30    /// Load existing mapping file or create new
31    pub fn load_or_create(path: &Path) -> Result<Self> {
32        if path.exists() {
33            let content = fs::read_to_string(path)?;
34            let mapping: MappingFile = serde_yaml::from_str(&content)?;
35            Ok(mapping)
36        } else {
37            Ok(MappingFile {
38                mappings: HashMap::new(),
39                next_spec_number: 1,
40            })
41        }
42    }
43
44    /// Save mapping file to disk
45    pub fn save(&self, path: &Path) -> Result<()> {
46        let yaml = serde_yaml::to_string(self)?;
47        fs::write(path, yaml)?;
48        Ok(())
49    }
50
51    /// Get or create SPEC-ID for a UUID
52    pub fn get_or_create_spec_id(&mut self, uuid: &str) -> String {
53        if let Some(spec_id) = self.mappings.get(uuid) {
54            spec_id.clone()
55        } else {
56            let spec_id = format!("SPEC-{:03}", self.next_spec_number);
57            self.mappings.insert(uuid.to_string(), spec_id.clone());
58            self.next_spec_number += 1;
59            spec_id
60        }
61    }
62
63    /// Get UUID for SPEC-ID (reverse lookup)
64    pub fn get_uuid(&self, spec_id: &str) -> Option<String> {
65        for (uuid, sid) in &self.mappings {
66            if sid == spec_id {
67                return Some(uuid.clone());
68            }
69        }
70        None
71    }
72}
73
74/// Generate mapping file (UUID -> SPEC-ID)
75pub fn generate_mapping_file(store: &RequirementsStore, output_path: &Path) -> Result<()> {
76    // Load existing mapping or create new
77    let mut mapping = MappingFile::load_or_create(output_path)?;
78
79    // Generate SPEC-IDs for all requirements
80    for req in &store.requirements {
81        let uuid = req.id.to_string();
82        mapping.get_or_create_spec_id(&uuid);
83    }
84
85    // Save mapping
86    mapping.save(output_path)?;
87
88    println!("Generated mapping file: {}", output_path.display());
89    println!("  Total mappings: {}", mapping.mappings.len());
90    println!("  Next SPEC number: {}", mapping.next_spec_number);
91
92    Ok(())
93}
94
95/// Export requirements to JSON format
96pub fn export_json(store: &RequirementsStore, output_path: &Path) -> Result<()> {
97    let json = serde_json::to_string_pretty(store)?;
98    fs::write(output_path, json)?;
99
100    println!("Exported to JSON: {}", output_path.display());
101    println!("  Total requirements: {}", store.requirements.len());
102
103    Ok(())
104}
105
106/// Export requirements specification (excludes IMPL tasks and implementation details)
107pub fn export_requirements_spec(store: &RequirementsStore, output_path: &Path) -> Result<()> {
108    let mut output = String::new();
109
110    // Title
111    let title = if !store.title.is_empty() {
112        &store.title
113    } else if !store.name.is_empty() {
114        &store.name
115    } else {
116        "Requirements Specification"
117    };
118    output.push_str(&format!("# {}\n\n", title));
119
120    if !store.description.is_empty() {
121        output.push_str(&format!("{}\n\n", store.description));
122    }
123
124    // Group requirements by type, excluding IMPL
125    let mut by_type: HashMap<String, Vec<&crate::models::Requirement>> = HashMap::new();
126
127    for req in &store.requirements {
128        // Skip IMPL tasks
129        let spec_id = req.spec_id.as_deref().unwrap_or("");
130        if spec_id.starts_with("IMPL-") {
131            continue;
132        }
133
134        let type_name = format!("{:?}", req.req_type);
135        by_type.entry(type_name).or_default().push(req);
136    }
137
138    // Sort types for consistent output
139    let mut type_names: Vec<_> = by_type.keys().cloned().collect();
140    type_names.sort();
141
142    for type_name in type_names {
143        if let Some(reqs) = by_type.get(&type_name) {
144            output.push_str(&format!("## {} Requirements\n\n", type_name));
145
146            let mut sorted_reqs = reqs.clone();
147            sorted_reqs.sort_by(|a, b| a.spec_id.cmp(&b.spec_id));
148
149            for req in sorted_reqs {
150                let spec_id = req.spec_id.as_deref().unwrap_or("N/A");
151                output.push_str(&format!("### {} - {}\n\n", spec_id, req.title));
152                output.push_str(&format!(
153                    "**Status:** {:?} | **Priority:** {:?}\n\n",
154                    req.status, req.priority
155                ));
156
157                if !req.description.is_empty() {
158                    output.push_str(&format!("{}\n\n", req.description));
159                }
160
161                // Show parent relationship if exists
162                for rel in &req.relationships {
163                    if rel.rel_type == crate::models::RelationshipType::Parent {
164                        if let Some(parent) =
165                            store.requirements.iter().find(|r| r.id == rel.target_id)
166                        {
167                            let parent_spec_id = parent.spec_id.as_deref().unwrap_or("N/A");
168                            output.push_str(&format!(
169                                "**Parent:** {} - {}\n\n",
170                                parent_spec_id, parent.title
171                            ));
172                        }
173                    }
174                }
175            }
176        }
177    }
178
179    fs::write(output_path, output)?;
180
181    let req_count = store
182        .requirements
183        .iter()
184        .filter(|r| !r.spec_id.as_deref().unwrap_or("").starts_with("IMPL-"))
185        .count();
186
187    println!(
188        "Exported requirements specification: {}",
189        output_path.display()
190    );
191    println!("  Total requirements: {} (excluding IMPL tasks)", req_count);
192
193    Ok(())
194}
195
196/// Export implementation records (IMPL tasks only)
197pub fn export_implementation_records(store: &RequirementsStore, output_path: &Path) -> Result<()> {
198    let mut output = String::new();
199
200    // Title
201    let title = if !store.title.is_empty() {
202        &store.title
203    } else if !store.name.is_empty() {
204        &store.name
205    } else {
206        "Project"
207    };
208    output.push_str(&format!("# {} - Implementation Records\n\n", title));
209    output.push_str("This document contains implementation details and design records.\n\n");
210
211    // Get all IMPL tasks, sorted
212    let mut impl_tasks: Vec<_> = store
213        .requirements
214        .iter()
215        .filter(|r| r.spec_id.as_deref().unwrap_or("").starts_with("IMPL-"))
216        .collect();
217
218    impl_tasks.sort_by(|a, b| a.spec_id.cmp(&b.spec_id));
219
220    for req in &impl_tasks {
221        let spec_id = req.spec_id.as_deref().unwrap_or("N/A");
222        output.push_str(&format!("## {} - {}\n\n", spec_id, req.title));
223        output.push_str(&format!(
224            "**Status:** {:?} | **Date:** {}\n\n",
225            req.status,
226            req.created_at.format("%Y-%m-%d")
227        ));
228
229        // Show parent requirement
230        for rel in &req.relationships {
231            if rel.rel_type == crate::models::RelationshipType::Parent {
232                if let Some(parent) = store.requirements.iter().find(|r| r.id == rel.target_id) {
233                    let parent_spec_id = parent.spec_id.as_deref().unwrap_or("N/A");
234                    output.push_str(&format!(
235                        "**Implements:** {} - {}\n\n",
236                        parent_spec_id, parent.title
237                    ));
238                }
239            }
240        }
241
242        if !req.description.is_empty() {
243            output.push_str(&format!("{}\n\n", req.description));
244        }
245
246        // Include custom fields (implementation_summary, files_changed, etc.)
247        if !req.custom_fields.is_empty() {
248            for (field_name, value) in &req.custom_fields {
249                if !value.is_empty() {
250                    let label = match field_name.as_str() {
251                        "implementation_summary" => "Implementation Summary",
252                        "files_changed" => "Files Changed",
253                        "session_date" => "Session Date",
254                        _ => field_name,
255                    };
256                    output.push_str(&format!("### {}\n\n{}\n\n", label, value));
257                }
258            }
259        }
260
261        output.push_str("---\n\n");
262    }
263
264    fs::write(output_path, output)?;
265
266    println!("Exported implementation records: {}", output_path.display());
267    println!("  Total IMPL tasks: {}", impl_tasks.len());
268
269    Ok(())
270}
271
272// ============================================================================
273// Tree Export/Import
274// ============================================================================
275
276/// Version of the tree export format
277pub const TREE_EXPORT_VERSION: &str = "1.0";
278
279/// A complete exported requirement tree
280#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct ExportedTree {
282    /// Export format version
283    pub version: String,
284    /// When the export was created
285    pub exported_at: DateTime<Utc>,
286    /// Source database name (if available)
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub source_database: Option<String>,
289    /// The root requirement and all its descendants
290    pub root: ExportedRequirement,
291}
292
293/// An exported requirement with its children embedded
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct ExportedRequirement {
296    /// Original UUID from source database
297    pub original_uuid: Uuid,
298    /// Original SPEC-ID from source database
299    #[serde(default, skip_serializing_if = "Option::is_none")]
300    pub original_spec_id: Option<String>,
301    /// Requirement title
302    pub title: String,
303    /// Requirement description
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub description: Option<String>,
306    /// Requirement type
307    pub req_type: RequirementType,
308    /// Meta subtype (for Meta requirements)
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub meta_subtype: Option<MetaSubtype>,
311    /// Status
312    pub status: RequirementStatus,
313    /// Priority
314    pub priority: RequirementPriority,
315    /// Owner
316    #[serde(default, skip_serializing_if = "String::is_empty")]
317    pub owner: String,
318    /// Feature category
319    #[serde(default, skip_serializing_if = "String::is_empty")]
320    pub feature: String,
321    /// Tags
322    #[serde(default, skip_serializing_if = "HashSet::is_empty")]
323    pub tags: HashSet<String>,
324    /// Comments
325    #[serde(default, skip_serializing_if = "Vec::is_empty")]
326    pub comments: Vec<Comment>,
327    /// Custom fields
328    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
329    pub custom_fields: HashMap<String, String>,
330    /// Custom status (for types with custom statuses)
331    #[serde(default, skip_serializing_if = "Option::is_none")]
332    pub custom_status: Option<String>,
333    /// Custom priority (for types with custom priorities)
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub custom_priority: Option<String>,
336    /// Archived flag
337    #[serde(default)]
338    pub archived: bool,
339    /// Child requirements (embedded tree structure)
340    #[serde(default, skip_serializing_if = "Vec::is_empty")]
341    pub children: Vec<ExportedRequirement>,
342    /// Relationships to requirements outside this tree
343    #[serde(default, skip_serializing_if = "Vec::is_empty")]
344    pub external_relationships: Vec<ExternalRelRef>,
345}
346
347/// Reference to a relationship target outside the exported tree
348#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct ExternalRelRef {
350    /// Original UUID of the target
351    pub original_target_uuid: Uuid,
352    /// Original SPEC-ID of the target (if available)
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub original_target_spec_id: Option<String>,
355    /// Relationship type
356    pub rel_type: RelationshipType,
357}
358
359/// Options for importing a tree
360#[derive(Debug, Clone, Default)]
361pub struct TreeImportOptions {
362    /// Parent requirement to attach the imported tree under
363    pub parent_id: Option<String>,
364    /// How to handle conflicts (by title)
365    pub conflict_strategy: ConflictStrategy,
366    /// Created by field for imported requirements
367    pub created_by: Option<String>,
368}
369
370/// Strategy for handling conflicts during import
371#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
372pub enum ConflictStrategy {
373    /// Skip if a requirement with the same title exists
374    #[default]
375    Skip,
376    /// Rename imported requirements (add suffix)
377    Rename,
378    /// Replace existing requirements
379    Replace,
380}
381
382/// Result of an import operation
383#[derive(Debug, Clone, Default)]
384pub struct TreeImportResult {
385    /// Number of requirements successfully imported
386    pub imported_count: usize,
387    /// Number of requirements skipped (due to conflicts)
388    pub skipped_count: usize,
389    /// Mapping from old UUIDs to new UUIDs
390    pub uuid_mapping: HashMap<Uuid, Uuid>,
391    /// Mapping from old SPEC-IDs to new SPEC-IDs
392    pub spec_id_mapping: HashMap<String, String>,
393    /// External relationships that couldn't be resolved
394    pub unresolved_refs: Vec<ExternalRelRef>,
395}
396
397/// Export a requirement and all its descendants to an ExportedTree
398pub fn export_tree(store: &RequirementsStore, root_id: &str) -> Result<ExportedTree> {
399    // Find the root requirement - try SPEC-ID first, then UUID
400    let root = store
401        .get_requirement_by_spec_id(root_id)
402        .or_else(|| {
403            root_id
404                .parse::<Uuid>()
405                .ok()
406                .and_then(|uuid| store.get_requirement_by_id(&uuid))
407        })
408        .context("Root requirement not found")?;
409
410    // Build set of UUIDs in this tree (for external relationship detection)
411    let mut tree_uuids = HashSet::new();
412    collect_descendant_uuids(store, root.id, &mut tree_uuids);
413
414    // Export recursively
415    let exported_root = export_requirement_tree(store, root, &tree_uuids)?;
416
417    Ok(ExportedTree {
418        version: TREE_EXPORT_VERSION.to_string(),
419        exported_at: Utc::now(),
420        source_database: if store.name.is_empty() {
421            None
422        } else {
423            Some(store.name.clone())
424        },
425        root: exported_root,
426    })
427}
428
429/// Collect UUIDs of a requirement and all its descendants
430fn collect_descendant_uuids(store: &RequirementsStore, id: Uuid, uuids: &mut HashSet<Uuid>) {
431    uuids.insert(id);
432    // Get children via Child relationship
433    let child_ids = store.get_relationships_by_type(&id, &RelationshipType::Child);
434    for child_id in child_ids {
435        collect_descendant_uuids(store, child_id, uuids);
436    }
437}
438
439/// Export a single requirement (recursive)
440fn export_requirement_tree(
441    store: &RequirementsStore,
442    req: &Requirement,
443    tree_uuids: &HashSet<Uuid>,
444) -> Result<ExportedRequirement> {
445    // Find external relationships (target not in tree)
446    let external_rels: Vec<ExternalRelRef> = req
447        .relationships
448        .iter()
449        .filter(|rel| !tree_uuids.contains(&rel.target_id))
450        .filter(|rel| {
451            // Exclude Parent/Child relationships (structural)
452            !matches!(
453                rel.rel_type,
454                RelationshipType::Parent | RelationshipType::Child
455            )
456        })
457        .map(|rel| {
458            let target_spec_id = store
459                .get_requirement_by_id(&rel.target_id)
460                .and_then(|r| r.spec_id.clone());
461            ExternalRelRef {
462                original_target_uuid: rel.target_id,
463                original_target_spec_id: target_spec_id,
464                rel_type: rel.rel_type.clone(),
465            }
466        })
467        .collect();
468
469    // Export children recursively - get child IDs via Child relationship
470    let child_ids = store.get_relationships_by_type(&req.id, &RelationshipType::Child);
471    let children: Vec<ExportedRequirement> = child_ids
472        .iter()
473        .filter_map(|child_id| store.get_requirement_by_id(child_id))
474        .filter_map(|child| export_requirement_tree(store, child, tree_uuids).ok())
475        .collect();
476
477    Ok(ExportedRequirement {
478        original_uuid: req.id,
479        original_spec_id: req.spec_id.clone(),
480        title: req.title.clone(),
481        description: if req.description.is_empty() {
482            None
483        } else {
484            Some(req.description.clone())
485        },
486        req_type: req.req_type.clone(),
487        meta_subtype: req.meta_subtype.clone(),
488        status: req.status.clone(),
489        priority: req.priority.clone(),
490        owner: req.owner.clone(),
491        feature: req.feature.clone(),
492        tags: req.tags.clone(),
493        comments: req.comments.clone(),
494        custom_fields: req.custom_fields.clone(),
495        custom_status: req.custom_status.clone(),
496        custom_priority: req.custom_priority.clone(),
497        archived: req.archived,
498        children,
499        external_relationships: external_rels,
500    })
501}
502
503/// Import a tree into a store, returning the result
504pub fn import_tree(
505    store: &mut RequirementsStore,
506    tree: ExportedTree,
507    options: TreeImportOptions,
508) -> Result<TreeImportResult> {
509    let mut result = TreeImportResult::default();
510
511    // Collect all external relationship references
512    collect_external_refs(&tree.root, &mut result.unresolved_refs);
513
514    // Resolve parent_id to UUID if specified
515    let parent_uuid = if let Some(ref parent_id) = options.parent_id {
516        // Try SPEC-ID first, then UUID
517        let uuid = store
518            .get_requirement_by_spec_id(parent_id)
519            .map(|r| r.id)
520            .or_else(|| parent_id.parse::<Uuid>().ok());
521        if uuid.is_none() {
522            bail!("Parent requirement not found: {}", parent_id);
523        }
524        uuid
525    } else {
526        None
527    };
528
529    // Import recursively, starting from root
530    import_requirement_recursive(store, &tree.root, parent_uuid, &options, &mut result)?;
531
532    Ok(result)
533}
534
535/// Collect external relationship references from the tree
536fn collect_external_refs(req: &ExportedRequirement, refs: &mut Vec<ExternalRelRef>) {
537    refs.extend(req.external_relationships.clone());
538    for child in &req.children {
539        collect_external_refs(child, refs);
540    }
541}
542
543/// Import a requirement and its children recursively
544fn import_requirement_recursive(
545    store: &mut RequirementsStore,
546    exported: &ExportedRequirement,
547    parent_uuid: Option<Uuid>,
548    options: &TreeImportOptions,
549    result: &mut TreeImportResult,
550) -> Result<Option<Uuid>> {
551    // Check for conflicts by title
552    let existing_uuid = store
553        .requirements
554        .iter()
555        .find(|r| r.title == exported.title)
556        .map(|r| r.id);
557
558    if let Some(existing_id) = existing_uuid {
559        match options.conflict_strategy {
560            ConflictStrategy::Skip => {
561                result.skipped_count += 1;
562                // Still process children with the existing requirement as parent
563                for child in &exported.children {
564                    import_requirement_recursive(store, child, Some(existing_id), options, result)?;
565                }
566                return Ok(None);
567            }
568            ConflictStrategy::Rename => {
569                // Will create with modified title below
570            }
571            ConflictStrategy::Replace => {
572                // Remove existing requirement by retaining all others
573                store.requirements.retain(|r| r.id != existing_id);
574            }
575        }
576    }
577
578    // Create the new requirement
579    let title = if existing_uuid.is_some() && options.conflict_strategy == ConflictStrategy::Rename
580    {
581        format!("{} (imported)", exported.title)
582    } else {
583        exported.title.clone()
584    };
585
586    let description = exported.description.clone().unwrap_or_default();
587    let mut new_req = Requirement::new(title.clone(), description);
588    new_req.req_type = exported.req_type.clone();
589    new_req.meta_subtype = exported.meta_subtype.clone();
590    new_req.status = exported.status.clone();
591    new_req.priority = exported.priority.clone();
592    new_req.owner = exported.owner.clone();
593    new_req.feature = exported.feature.clone();
594    new_req.tags = exported.tags.clone();
595    new_req.comments = exported.comments.clone();
596    new_req.custom_fields = exported.custom_fields.clone();
597    new_req.custom_status = exported.custom_status.clone();
598    new_req.custom_priority = exported.custom_priority.clone();
599    new_req.archived = exported.archived;
600    new_req.created_by = options.created_by.clone();
601
602    // Capture the new UUID before adding
603    let new_uuid = new_req.id;
604
605    // Get the type prefix for ID generation
606    let type_prefix = store.get_type_prefix(&new_req.req_type);
607
608    // Add the requirement (this assigns a spec_id)
609    store.add_requirement_with_id(new_req, None, type_prefix.as_deref());
610
611    // Get the assigned spec_id
612    let new_spec_id = store
613        .get_requirement_by_id(&new_uuid)
614        .and_then(|r| r.spec_id.clone())
615        .unwrap_or_default();
616
617    // Record the mapping
618    result.uuid_mapping.insert(exported.original_uuid, new_uuid);
619    if let Some(ref old_spec_id) = exported.original_spec_id {
620        result
621            .spec_id_mapping
622            .insert(old_spec_id.clone(), new_spec_id.clone());
623    }
624    result.imported_count += 1;
625
626    // Add parent relationship if specified
627    if let Some(parent_id) = parent_uuid {
628        // Use set_relationship to ensure only one parent
629        store.set_relationship(&new_uuid, RelationshipType::Parent, &parent_id, true)?;
630    }
631
632    // Import children
633    for child in &exported.children {
634        import_requirement_recursive(store, child, Some(new_uuid), options, result)?;
635    }
636
637    Ok(Some(new_uuid))
638}
639
640/// Export a tree to a JSON file
641pub fn export_tree_to_file<P: AsRef<Path>>(
642    store: &RequirementsStore,
643    root_id: &str,
644    output_path: P,
645) -> Result<()> {
646    let tree = export_tree(store, root_id)?;
647    let json = serde_json::to_string_pretty(&tree)?;
648    fs::write(output_path, json)?;
649    Ok(())
650}
651
652/// Import a tree from a JSON file
653pub fn import_tree_from_file<P: AsRef<Path>>(
654    store: &mut RequirementsStore,
655    input_path: P,
656    options: TreeImportOptions,
657) -> Result<TreeImportResult> {
658    let json = fs::read_to_string(input_path)?;
659    let tree: ExportedTree = serde_json::from_str(&json)?;
660
661    // Validate version compatibility
662    if tree.version != TREE_EXPORT_VERSION {
663        bail!(
664            "Unsupported export version: {}. Expected: {}",
665            tree.version,
666            TREE_EXPORT_VERSION
667        );
668    }
669
670    import_tree(store, tree, options)
671}
672
673#[cfg(test)]
674mod tests {
675    use super::*;
676    use tempfile::tempdir;
677
678    #[test]
679    fn test_mapping_file_new() {
680        let mapping = MappingFile {
681            mappings: HashMap::new(),
682            next_spec_number: 1,
683        };
684
685        assert_eq!(mapping.mappings.len(), 0);
686        assert_eq!(mapping.next_spec_number, 1);
687    }
688
689    #[test]
690    fn test_get_or_create_spec_id_new() {
691        let mut mapping = MappingFile {
692            mappings: HashMap::new(),
693            next_spec_number: 1,
694        };
695
696        let uuid = "f7d250bf-5b3e-4ec3-8bd5-2bee2c4b7bb9";
697        let spec_id = mapping.get_or_create_spec_id(uuid);
698
699        assert_eq!(spec_id, "SPEC-001");
700        assert_eq!(mapping.next_spec_number, 2);
701        assert_eq!(mapping.mappings.get(uuid), Some(&"SPEC-001".to_string()));
702    }
703
704    #[test]
705    fn test_get_or_create_spec_id_existing() {
706        let mut mappings = HashMap::new();
707        mappings.insert(
708            "f7d250bf-5b3e-4ec3-8bd5-2bee2c4b7bb9".to_string(),
709            "SPEC-001".to_string(),
710        );
711
712        let mut mapping = MappingFile {
713            mappings,
714            next_spec_number: 2,
715        };
716
717        let uuid = "f7d250bf-5b3e-4ec3-8bd5-2bee2c4b7bb9";
718        let spec_id = mapping.get_or_create_spec_id(uuid);
719
720        assert_eq!(spec_id, "SPEC-001");
721        assert_eq!(mapping.next_spec_number, 2); // Should not increment
722    }
723
724    #[test]
725    fn test_get_uuid() {
726        let mut mappings = HashMap::new();
727        mappings.insert(
728            "f7d250bf-5b3e-4ec3-8bd5-2bee2c4b7bb9".to_string(),
729            "SPEC-001".to_string(),
730        );
731
732        let mapping = MappingFile {
733            mappings,
734            next_spec_number: 2,
735        };
736
737        let uuid = mapping.get_uuid("SPEC-001");
738        assert_eq!(
739            uuid,
740            Some("f7d250bf-5b3e-4ec3-8bd5-2bee2c4b7bb9".to_string())
741        );
742
743        let uuid = mapping.get_uuid("SPEC-999");
744        assert_eq!(uuid, None);
745    }
746
747    #[test]
748    fn test_save_and_load() -> Result<()> {
749        let dir = tempdir()?;
750        let path = dir.path().join("test-mapping.yaml");
751
752        // Create and save
753        let mut mapping = MappingFile {
754            mappings: HashMap::new(),
755            next_spec_number: 1,
756        };
757        mapping.get_or_create_spec_id("uuid-1");
758        mapping.get_or_create_spec_id("uuid-2");
759        mapping.save(&path)?;
760
761        // Load and verify
762        let loaded = MappingFile::load_or_create(&path)?;
763        assert_eq!(loaded.mappings.len(), 2);
764        assert_eq!(loaded.next_spec_number, 3);
765        assert_eq!(loaded.mappings.get("uuid-1"), Some(&"SPEC-001".to_string()));
766        assert_eq!(loaded.mappings.get("uuid-2"), Some(&"SPEC-002".to_string()));
767
768        Ok(())
769    }
770}