1use 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>, pub next_spec_number: u32,
27}
28
29impl MappingFile {
30 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 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 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 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
74pub fn generate_mapping_file(store: &RequirementsStore, output_path: &Path) -> Result<()> {
76 let mut mapping = MappingFile::load_or_create(output_path)?;
78
79 for req in &store.requirements {
81 let uuid = req.id.to_string();
82 mapping.get_or_create_spec_id(&uuid);
83 }
84
85 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
95pub 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
106pub fn export_requirements_spec(store: &RequirementsStore, output_path: &Path) -> Result<()> {
108 let mut output = String::new();
109
110 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 let mut by_type: HashMap<String, Vec<&crate::models::Requirement>> = HashMap::new();
126
127 for req in &store.requirements {
128 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 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 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
196pub fn export_implementation_records(store: &RequirementsStore, output_path: &Path) -> Result<()> {
198 let mut output = String::new();
199
200 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 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 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 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
272pub const TREE_EXPORT_VERSION: &str = "1.0";
278
279#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct ExportedTree {
282 pub version: String,
284 pub exported_at: DateTime<Utc>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub source_database: Option<String>,
289 pub root: ExportedRequirement,
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct ExportedRequirement {
296 pub original_uuid: Uuid,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub original_spec_id: Option<String>,
301 pub title: String,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub description: Option<String>,
306 pub req_type: RequirementType,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub meta_subtype: Option<MetaSubtype>,
311 pub status: RequirementStatus,
313 pub priority: RequirementPriority,
315 #[serde(default, skip_serializing_if = "String::is_empty")]
317 pub owner: String,
318 #[serde(default, skip_serializing_if = "String::is_empty")]
320 pub feature: String,
321 #[serde(default, skip_serializing_if = "HashSet::is_empty")]
323 pub tags: HashSet<String>,
324 #[serde(default, skip_serializing_if = "Vec::is_empty")]
326 pub comments: Vec<Comment>,
327 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
329 pub custom_fields: HashMap<String, String>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub custom_status: Option<String>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub custom_priority: Option<String>,
336 #[serde(default)]
338 pub archived: bool,
339 #[serde(default, skip_serializing_if = "Vec::is_empty")]
341 pub children: Vec<ExportedRequirement>,
342 #[serde(default, skip_serializing_if = "Vec::is_empty")]
344 pub external_relationships: Vec<ExternalRelRef>,
345}
346
347#[derive(Debug, Clone, Serialize, Deserialize)]
349pub struct ExternalRelRef {
350 pub original_target_uuid: Uuid,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
354 pub original_target_spec_id: Option<String>,
355 pub rel_type: RelationshipType,
357}
358
359#[derive(Debug, Clone, Default)]
361pub struct TreeImportOptions {
362 pub parent_id: Option<String>,
364 pub conflict_strategy: ConflictStrategy,
366 pub created_by: Option<String>,
368}
369
370#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
372pub enum ConflictStrategy {
373 #[default]
375 Skip,
376 Rename,
378 Replace,
380}
381
382#[derive(Debug, Clone, Default)]
384pub struct TreeImportResult {
385 pub imported_count: usize,
387 pub skipped_count: usize,
389 pub uuid_mapping: HashMap<Uuid, Uuid>,
391 pub spec_id_mapping: HashMap<String, String>,
393 pub unresolved_refs: Vec<ExternalRelRef>,
395}
396
397pub fn export_tree(store: &RequirementsStore, root_id: &str) -> Result<ExportedTree> {
399 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 let mut tree_uuids = HashSet::new();
412 collect_descendant_uuids(store, root.id, &mut tree_uuids);
413
414 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
429fn collect_descendant_uuids(store: &RequirementsStore, id: Uuid, uuids: &mut HashSet<Uuid>) {
431 uuids.insert(id);
432 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
439fn export_requirement_tree(
441 store: &RequirementsStore,
442 req: &Requirement,
443 tree_uuids: &HashSet<Uuid>,
444) -> Result<ExportedRequirement> {
445 let external_rels: Vec<ExternalRelRef> = req
447 .relationships
448 .iter()
449 .filter(|rel| !tree_uuids.contains(&rel.target_id))
450 .filter(|rel| {
451 !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 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
503pub 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_external_refs(&tree.root, &mut result.unresolved_refs);
513
514 let parent_uuid = if let Some(ref parent_id) = options.parent_id {
516 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_requirement_recursive(store, &tree.root, parent_uuid, &options, &mut result)?;
531
532 Ok(result)
533}
534
535fn 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
543fn 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 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 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 }
571 ConflictStrategy::Replace => {
572 store.requirements.retain(|r| r.id != existing_id);
574 }
575 }
576 }
577
578 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 let new_uuid = new_req.id;
604
605 let type_prefix = store.get_type_prefix(&new_req.req_type);
607
608 store.add_requirement_with_id(new_req, None, type_prefix.as_deref());
610
611 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 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 if let Some(parent_id) = parent_uuid {
628 store.set_relationship(&new_uuid, RelationshipType::Parent, &parent_id, true)?;
630 }
631
632 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
640pub 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
652pub 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 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); }
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 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 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}