1use anyhow::{Context, Result};
11use chrono::{DateTime, Utc};
12use serde::{Deserialize, Serialize};
13use std::collections::{HashMap, HashSet};
14use std::fs;
15use std::path::Path;
16use uuid::Uuid;
17
18use crate::models::{
19 Requirement, RequirementPriority, RequirementStatus, RequirementType, RequirementsStore,
20};
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct ImportIssue {
25 pub record_id: String,
27 pub record_title: String,
29 pub issue_type: ImportIssueType,
31 pub description: String,
33 pub can_convert: bool,
35 pub suggested_conversion: Option<String>,
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41pub enum ImportIssueType {
42 UnknownType(String),
44 UnknownStatus(String),
46 UnknownPriority(String),
48 InvalidRelationshipTarget(String),
50 DuplicateSpecId(String),
52 MissingRequiredField(String),
54 InvalidDateFormat(String),
56 SchemaVersionMismatch(String),
58}
59
60impl std::fmt::Display for ImportIssueType {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 ImportIssueType::UnknownType(t) => write!(f, "Unknown type: {}", t),
64 ImportIssueType::UnknownStatus(s) => write!(f, "Unknown status: {}", s),
65 ImportIssueType::UnknownPriority(p) => write!(f, "Unknown priority: {}", p),
66 ImportIssueType::InvalidRelationshipTarget(id) => {
67 write!(f, "Invalid relationship target: {}", id)
68 }
69 ImportIssueType::DuplicateSpecId(id) => write!(f, "Duplicate SPEC-ID: {}", id),
70 ImportIssueType::MissingRequiredField(field) => {
71 write!(f, "Missing required field: {}", field)
72 }
73 ImportIssueType::InvalidDateFormat(field) => {
74 write!(f, "Invalid date in field: {}", field)
75 }
76 ImportIssueType::SchemaVersionMismatch(v) => write!(f, "Schema version: {}", v),
77 }
78 }
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
83pub enum IssueResolution {
84 #[default]
86 Skip,
87 ConvertToDefault,
89 Abort,
91}
92
93impl std::fmt::Display for IssueResolution {
94 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
95 match self {
96 IssueResolution::Skip => write!(f, "Skip"),
97 IssueResolution::ConvertToDefault => write!(f, "Convert"),
98 IssueResolution::Abort => write!(f, "Abort"),
99 }
100 }
101}
102
103#[derive(Debug, Clone, Default)]
105pub struct ImportValidation {
106 pub issues: Vec<ImportIssue>,
108 pub valid_record_count: usize,
110 pub problematic_record_count: usize,
112 pub can_proceed: bool,
114 pub parsed_store: Option<RawImportStore>,
116}
117
118impl ImportValidation {
119 pub fn has_issues(&self) -> bool {
121 !self.issues.is_empty()
122 }
123
124 pub fn issues_by_record(&self) -> HashMap<String, Vec<&ImportIssue>> {
126 let mut map: HashMap<String, Vec<&ImportIssue>> = HashMap::new();
127 for issue in &self.issues {
128 map.entry(issue.record_id.clone()).or_default().push(issue);
129 }
130 map
131 }
132
133 pub fn issues_of_type(&self, issue_type: &ImportIssueType) -> Vec<&ImportIssue> {
135 self.issues
136 .iter()
137 .filter(|i| std::mem::discriminant(&i.issue_type) == std::mem::discriminant(issue_type))
138 .collect()
139 }
140
141 pub fn unknown_type_count(&self) -> usize {
143 self.issues
144 .iter()
145 .filter(|i| matches!(i.issue_type, ImportIssueType::UnknownType(_)))
146 .count()
147 }
148
149 pub fn unknown_status_count(&self) -> usize {
151 self.issues
152 .iter()
153 .filter(|i| matches!(i.issue_type, ImportIssueType::UnknownStatus(_)))
154 .count()
155 }
156
157 pub fn invalid_relationship_count(&self) -> usize {
159 self.issues
160 .iter()
161 .filter(|i| matches!(i.issue_type, ImportIssueType::InvalidRelationshipTarget(_)))
162 .count()
163 }
164}
165
166#[derive(Debug, Clone, Default)]
168pub struct ImportSummary {
169 pub total_records: usize,
171 pub imported_count: usize,
173 pub skipped_count: usize,
175 pub converted_count: usize,
177 pub relationships_skipped: usize,
179 pub backup_path: Option<String>,
181 pub warnings: Vec<String>,
183 pub duration_ms: u64,
185}
186
187impl ImportSummary {
188 pub fn is_clean(&self) -> bool {
190 self.skipped_count == 0 && self.converted_count == 0 && self.relationships_skipped == 0
191 }
192}
193
194#[derive(Debug, Clone)]
196pub struct ImportConfig {
197 pub unknown_type_resolution: IssueResolution,
199 pub unknown_status_resolution: IssueResolution,
201 pub unknown_priority_resolution: IssueResolution,
203 pub create_backup: bool,
205 pub merge_mode: ImportMergeMode,
207 pub default_type: RequirementType,
209 pub default_status: RequirementStatus,
211 pub default_priority: RequirementPriority,
213}
214
215impl Default for ImportConfig {
216 fn default() -> Self {
217 Self {
218 unknown_type_resolution: IssueResolution::Skip,
219 unknown_status_resolution: IssueResolution::ConvertToDefault,
220 unknown_priority_resolution: IssueResolution::ConvertToDefault,
221 create_backup: true,
222 merge_mode: ImportMergeMode::Replace,
223 default_type: RequirementType::Functional,
224 default_status: RequirementStatus::Draft,
225 default_priority: RequirementPriority::Medium,
226 }
227 }
228}
229
230#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
232pub enum ImportMergeMode {
233 #[default]
235 Replace,
236 MergeKeepExisting,
238 MergePreferImported,
240}
241
242#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct RawRequirement {
246 #[serde(default)]
247 pub id: Option<Uuid>,
248 #[serde(default)]
249 pub spec_id: Option<String>,
250 #[serde(default)]
251 pub title: String,
252 #[serde(default)]
253 pub description: String,
254 #[serde(default)]
255 pub req_type: Option<String>,
256 #[serde(default)]
257 pub status: Option<String>,
258 #[serde(default)]
259 pub priority: Option<String>,
260 #[serde(default)]
261 pub owner: String,
262 #[serde(default)]
263 pub feature: String,
264 #[serde(default)]
265 pub tags: HashSet<String>,
266 #[serde(default)]
267 pub created_at: Option<DateTime<Utc>>,
268 #[serde(default)]
269 pub modified_at: Option<DateTime<Utc>>,
270 #[serde(default)]
271 pub relationships: Vec<RawRelationship>,
272 #[serde(default)]
273 pub comments: Vec<serde_yaml::Value>,
274 #[serde(default)]
275 pub history: Vec<serde_yaml::Value>,
276 #[serde(default)]
277 pub urls: Vec<serde_yaml::Value>,
278 #[serde(default)]
279 pub custom_fields: HashMap<String, serde_yaml::Value>,
280 #[serde(default)]
281 pub ai_evaluation: Option<serde_yaml::Value>,
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
286pub struct RawRelationship {
287 #[serde(default)]
288 pub target_id: Option<Uuid>,
289 #[serde(default)]
290 pub target_spec_id: Option<String>,
291 #[serde(default)]
292 pub rel_type: Option<String>,
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize, Default)]
297pub struct RawImportStore {
298 #[serde(default)]
299 pub name: String,
300 #[serde(default)]
301 pub title: String,
302 #[serde(default)]
303 pub description: String,
304 #[serde(default)]
305 pub requirements: Vec<RawRequirement>,
306 #[serde(default)]
307 pub users: Vec<serde_yaml::Value>,
308 #[serde(default)]
309 pub teams: Vec<serde_yaml::Value>,
310 #[serde(default)]
311 pub features: Vec<serde_yaml::Value>,
312 #[serde(default)]
313 pub id_config: Option<serde_yaml::Value>,
314 #[serde(default)]
315 pub type_definitions: Vec<serde_yaml::Value>,
316 #[serde(default)]
317 pub relationship_definitions: Vec<serde_yaml::Value>,
318 #[serde(default)]
319 pub reaction_definitions: Vec<serde_yaml::Value>,
320 #[serde(default)]
321 pub ai_prompts: Option<serde_yaml::Value>,
322 #[serde(default)]
323 pub baselines: Vec<serde_yaml::Value>,
324 #[serde(flatten)]
326 pub extra_fields: HashMap<String, serde_yaml::Value>,
327}
328
329const KNOWN_TYPES: &[&str] = &[
331 "Functional",
332 "NonFunctional",
333 "Non-Functional",
334 "System",
335 "User",
336 "ChangeRequest",
337 "Change Request",
338 "Bug",
339 "Epic",
340 "Story",
341 "Task",
342 "Spike",
343 "Sprint",
344 "Folder",
345];
346
347const KNOWN_STATUSES: &[&str] = &[
349 "Draft",
350 "Approved",
351 "Planned",
352 "In Progress",
353 "Completed",
354 "Rejected",
355];
356
357const KNOWN_PRIORITIES: &[&str] = &["High", "Medium", "Low"];
359
360pub fn parse_requirement_type(s: &str) -> Option<RequirementType> {
362 match s {
363 "Functional" => Some(RequirementType::Functional),
364 "NonFunctional" | "Non-Functional" => Some(RequirementType::NonFunctional),
365 "System" => Some(RequirementType::System),
366 "User" => Some(RequirementType::User),
367 "ChangeRequest" | "Change Request" => Some(RequirementType::ChangeRequest),
368 "Bug" => Some(RequirementType::Bug),
369 "Epic" => Some(RequirementType::Epic),
370 "Story" => Some(RequirementType::Story),
371 "Task" => Some(RequirementType::Task),
372 "Spike" => Some(RequirementType::Spike),
373 "Sprint" => Some(RequirementType::Sprint),
374 "Folder" => Some(RequirementType::Folder),
375 _ => None,
376 }
377}
378
379pub fn parse_requirement_status(s: &str) -> Option<RequirementStatus> {
381 match s {
382 "Draft" => Some(RequirementStatus::Draft),
383 "Approved" => Some(RequirementStatus::Approved),
384 "Completed" => Some(RequirementStatus::Completed),
385 "Rejected" => Some(RequirementStatus::Rejected),
386 _ => None,
387 }
388}
389
390pub fn parse_requirement_priority(s: &str) -> Option<RequirementPriority> {
392 match s {
393 "High" => Some(RequirementPriority::High),
394 "Medium" => Some(RequirementPriority::Medium),
395 "Low" => Some(RequirementPriority::Low),
396 _ => None,
397 }
398}
399
400pub fn validate_import_file(path: &Path) -> Result<ImportValidation> {
402 let content = fs::read_to_string(path)
403 .with_context(|| format!("Failed to read import file: {:?}", path))?;
404
405 validate_import_content(&content)
406}
407
408pub fn validate_import_content(content: &str) -> Result<ImportValidation> {
410 let mut validation = ImportValidation::default();
411
412 let raw_store: RawImportStore = match serde_yaml::from_str(content) {
414 Ok(store) => store,
415 Err(e) => {
416 validation.can_proceed = false;
417 validation.issues.push(ImportIssue {
418 record_id: "PARSE_ERROR".to_string(),
419 record_title: "File Parse Error".to_string(),
420 issue_type: ImportIssueType::SchemaVersionMismatch(e.to_string()),
421 description: format!("Failed to parse YAML: {}", e),
422 can_convert: false,
423 suggested_conversion: None,
424 });
425 return Ok(validation);
426 }
427 };
428
429 let all_spec_ids: HashSet<String> = raw_store
431 .requirements
432 .iter()
433 .filter_map(|r| r.spec_id.clone())
434 .collect();
435
436 if !raw_store.extra_fields.is_empty() {
438 for field_name in raw_store.extra_fields.keys() {
439 validation.issues.push(ImportIssue {
440 record_id: "STORE".to_string(),
441 record_title: "Database Schema".to_string(),
442 issue_type: ImportIssueType::SchemaVersionMismatch(format!(
443 "Unknown field: {}",
444 field_name
445 )),
446 description: format!("Unknown field '{}' in database schema", field_name),
447 can_convert: true,
448 suggested_conversion: Some("Field will be ignored".to_string()),
449 });
450 }
451 }
452
453 let mut seen_spec_ids: HashSet<String> = HashSet::new();
455
456 for raw_req in &raw_store.requirements {
457 let record_id = raw_req
458 .spec_id
459 .clone()
460 .unwrap_or_else(|| raw_req.id.map(|id| id.to_string()).unwrap_or_default());
461 let record_title = raw_req.title.clone();
462
463 if let Some(ref spec_id) = raw_req.spec_id {
465 if !seen_spec_ids.insert(spec_id.clone()) {
466 validation.issues.push(ImportIssue {
467 record_id: record_id.clone(),
468 record_title: record_title.clone(),
469 issue_type: ImportIssueType::DuplicateSpecId(spec_id.clone()),
470 description: format!("Duplicate SPEC-ID: {}", spec_id),
471 can_convert: false,
472 suggested_conversion: None,
473 });
474 }
475 }
476
477 if let Some(ref type_str) = raw_req.req_type {
479 if parse_requirement_type(type_str).is_none() {
480 validation.issues.push(ImportIssue {
481 record_id: record_id.clone(),
482 record_title: record_title.clone(),
483 issue_type: ImportIssueType::UnknownType(type_str.clone()),
484 description: format!(
485 "Unknown requirement type '{}'. Known types: {}",
486 type_str,
487 KNOWN_TYPES.join(", ")
488 ),
489 can_convert: true,
490 suggested_conversion: Some("Functional".to_string()),
491 });
492 validation.problematic_record_count += 1;
493 continue;
494 }
495 }
496
497 if let Some(ref status_str) = raw_req.status {
499 if parse_requirement_status(status_str).is_none() {
500 validation.issues.push(ImportIssue {
501 record_id: record_id.clone(),
502 record_title: record_title.clone(),
503 issue_type: ImportIssueType::UnknownStatus(status_str.clone()),
504 description: format!(
505 "Unknown status '{}'. Known statuses: {}",
506 status_str,
507 KNOWN_STATUSES.join(", ")
508 ),
509 can_convert: true,
510 suggested_conversion: Some("Draft".to_string()),
511 });
512 }
513 }
514
515 if let Some(ref priority_str) = raw_req.priority {
517 if parse_requirement_priority(priority_str).is_none() {
518 validation.issues.push(ImportIssue {
519 record_id: record_id.clone(),
520 record_title: record_title.clone(),
521 issue_type: ImportIssueType::UnknownPriority(priority_str.clone()),
522 description: format!(
523 "Unknown priority '{}'. Known priorities: {}",
524 priority_str,
525 KNOWN_PRIORITIES.join(", ")
526 ),
527 can_convert: true,
528 suggested_conversion: Some("Medium".to_string()),
529 });
530 }
531 }
532
533 for rel in &raw_req.relationships {
535 if let Some(ref target_spec_id) = rel.target_spec_id {
536 if !all_spec_ids.contains(target_spec_id) {
537 validation.issues.push(ImportIssue {
538 record_id: record_id.clone(),
539 record_title: record_title.clone(),
540 issue_type: ImportIssueType::InvalidRelationshipTarget(
541 target_spec_id.clone(),
542 ),
543 description: format!(
544 "Relationship references non-existent requirement: {}",
545 target_spec_id
546 ),
547 can_convert: true,
548 suggested_conversion: Some("Relationship will be skipped".to_string()),
549 });
550 }
551 }
552 }
553
554 if raw_req.title.trim().is_empty() {
556 validation.issues.push(ImportIssue {
557 record_id: record_id.clone(),
558 record_title: "(untitled)".to_string(),
559 issue_type: ImportIssueType::MissingRequiredField("title".to_string()),
560 description: "Requirement is missing a title".to_string(),
561 can_convert: true,
562 suggested_conversion: Some("Untitled Requirement".to_string()),
563 });
564 }
565
566 validation.valid_record_count += 1;
567 }
568
569 validation.problematic_record_count = validation
571 .issues
572 .iter()
573 .map(|i| i.record_id.clone())
574 .collect::<HashSet<_>>()
575 .len();
576
577 let has_fatal_issues = validation.issues.iter().any(|i| {
580 matches!(
581 i.issue_type,
582 ImportIssueType::SchemaVersionMismatch(_) | ImportIssueType::DuplicateSpecId(_)
583 ) && !i.can_convert
584 });
585
586 validation.can_proceed = !has_fatal_issues;
587 validation.parsed_store = Some(raw_store);
588
589 Ok(validation)
590}
591
592pub fn create_backup(source_path: &Path) -> Result<String> {
594 if !source_path.exists() {
595 return Ok(String::new());
596 }
597
598 let backup_path = source_path.with_extension("yaml.backup");
599 fs::copy(source_path, &backup_path)
600 .with_context(|| format!("Failed to create backup at {:?}", backup_path))?;
601
602 Ok(backup_path.to_string_lossy().to_string())
603}
604
605pub fn execute_import(
607 import_content: &str,
608 target_path: &Path,
609 config: &ImportConfig,
610 validation: &ImportValidation,
611) -> Result<ImportSummary> {
612 let start_time = std::time::Instant::now();
613 let mut summary = ImportSummary::default();
614
615 if config.create_backup && target_path.exists() {
617 summary.backup_path = Some(create_backup(target_path)?);
618 }
619
620 let raw_store = validation
622 .parsed_store
623 .as_ref()
624 .context("No parsed store available - validation must be run first")?;
625
626 summary.total_records = raw_store.requirements.len();
627
628 let mut skip_records: HashSet<String> = HashSet::new();
630 let mut convert_records: HashSet<String> = HashSet::new();
631
632 for issue in &validation.issues {
633 let resolution = match &issue.issue_type {
634 ImportIssueType::UnknownType(_) => config.unknown_type_resolution,
635 ImportIssueType::UnknownStatus(_) => config.unknown_status_resolution,
636 ImportIssueType::UnknownPriority(_) => config.unknown_priority_resolution,
637 ImportIssueType::DuplicateSpecId(_) => IssueResolution::Skip,
638 ImportIssueType::InvalidRelationshipTarget(_) => IssueResolution::Skip, ImportIssueType::MissingRequiredField(_) => IssueResolution::ConvertToDefault,
640 ImportIssueType::InvalidDateFormat(_) => IssueResolution::ConvertToDefault,
641 ImportIssueType::SchemaVersionMismatch(_) => IssueResolution::Skip,
642 };
643
644 match resolution {
645 IssueResolution::Abort => {
646 anyhow::bail!("Import aborted due to issue: {}", issue.description);
647 }
648 IssueResolution::Skip => {
649 skip_records.insert(issue.record_id.clone());
650 }
651 IssueResolution::ConvertToDefault => {
652 convert_records.insert(issue.record_id.clone());
653 }
654 }
655 }
656
657 let mut target_store = RequirementsStore::new();
659 target_store.name = raw_store.name.clone();
660 target_store.title = raw_store.title.clone();
661 target_store.description = raw_store.description.clone();
662
663 let mut imported_spec_ids: HashSet<String> = HashSet::new();
665
666 for raw_req in &raw_store.requirements {
668 let record_id = raw_req
669 .spec_id
670 .clone()
671 .unwrap_or_else(|| raw_req.id.map(|id| id.to_string()).unwrap_or_default());
672
673 if skip_records.contains(&record_id) {
675 summary.skipped_count += 1;
676 continue;
677 }
678
679 let needs_conversion = convert_records.contains(&record_id);
680
681 let mut req = Requirement::new(
683 if raw_req.title.trim().is_empty() {
684 "Untitled Requirement".to_string()
685 } else {
686 raw_req.title.clone()
687 },
688 raw_req.description.clone(),
689 );
690
691 if let Some(id) = raw_req.id {
693 req.id = id;
694 }
695
696 req.spec_id = raw_req.spec_id.clone();
698 if let Some(ref spec_id) = req.spec_id {
699 imported_spec_ids.insert(spec_id.clone());
700 }
701
702 if let Some(ref type_str) = raw_req.req_type {
704 req.req_type = parse_requirement_type(type_str).unwrap_or_else(|| {
705 if needs_conversion {
706 summary.converted_count += 1;
707 }
708 config.default_type.clone()
709 });
710 }
711
712 if let Some(ref status_str) = raw_req.status {
714 req.status = parse_requirement_status(status_str).unwrap_or_else(|| {
715 if needs_conversion {
716 summary.converted_count += 1;
717 }
718 config.default_status.clone()
719 });
720 }
721
722 if let Some(ref priority_str) = raw_req.priority {
724 req.priority = parse_requirement_priority(priority_str).unwrap_or_else(|| {
725 if needs_conversion {
726 summary.converted_count += 1;
727 }
728 config.default_priority.clone()
729 });
730 }
731
732 req.owner = raw_req.owner.clone();
734 req.feature = raw_req.feature.clone();
735 req.tags = raw_req.tags.clone();
736
737 if let Some(created_at) = raw_req.created_at {
739 req.created_at = created_at;
740 }
741 if let Some(modified_at) = raw_req.modified_at {
742 req.modified_at = modified_at;
743 }
744
745 target_store.requirements.push(req);
746 summary.imported_count += 1;
747 }
748
749 for (idx, raw_req) in raw_store.requirements.iter().enumerate() {
751 let record_id = raw_req
752 .spec_id
753 .clone()
754 .unwrap_or_else(|| raw_req.id.map(|id| id.to_string()).unwrap_or_default());
755
756 if skip_records.contains(&record_id) {
757 continue;
758 }
759
760 if idx < target_store.requirements.len() {
762 for raw_rel in &raw_req.relationships {
763 let target_exists = raw_rel
765 .target_spec_id
766 .as_ref()
767 .map(|id| imported_spec_ids.contains(id))
768 .unwrap_or(false);
769
770 if !target_exists {
771 summary.relationships_skipped += 1;
772 summary.warnings.push(format!(
773 "Skipped relationship from {} to {} (target not imported)",
774 record_id,
775 raw_rel.target_spec_id.as_deref().unwrap_or("unknown")
776 ));
777 }
778 }
781 }
782 }
783
784 let yaml = serde_yaml::to_string(&target_store)?;
786 fs::write(target_path, yaml)?;
787
788 summary.duration_ms = start_time.elapsed().as_millis() as u64;
789
790 Ok(summary)
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796
797 #[test]
798 fn test_parse_requirement_type() {
799 assert_eq!(
800 parse_requirement_type("Functional"),
801 Some(RequirementType::Functional)
802 );
803 assert_eq!(
804 parse_requirement_type("NonFunctional"),
805 Some(RequirementType::NonFunctional)
806 );
807 assert_eq!(
808 parse_requirement_type("Non-Functional"),
809 Some(RequirementType::NonFunctional)
810 );
811 assert_eq!(
812 parse_requirement_type("ChangeRequest"),
813 Some(RequirementType::ChangeRequest)
814 );
815 assert_eq!(parse_requirement_type("UnknownType"), None);
816 }
817
818 #[test]
819 fn test_parse_requirement_status() {
820 assert_eq!(
821 parse_requirement_status("Draft"),
822 Some(RequirementStatus::Draft)
823 );
824 assert_eq!(
825 parse_requirement_status("Approved"),
826 Some(RequirementStatus::Approved)
827 );
828 assert_eq!(parse_requirement_status("InProgress"), None);
829 }
830
831 #[test]
832 fn test_validate_empty_content() {
833 let content = "requirements: []";
834 let result = validate_import_content(content).unwrap();
835 assert!(result.can_proceed);
836 assert_eq!(result.valid_record_count, 0);
837 }
838
839 #[test]
840 fn test_validate_unknown_type() {
841 let content = r#"
842requirements:
843 - title: Test Requirement
844 description: Test description
845 req_type: UnknownCustomType
846 status: Draft
847"#;
848 let result = validate_import_content(content).unwrap();
849 assert!(result.has_issues());
850 assert_eq!(result.unknown_type_count(), 1);
851 }
852
853 #[test]
854 fn test_validate_invalid_relationship() {
855 let content = r#"
856requirements:
857 - spec_id: FR-0001
858 title: Test Requirement
859 description: Test
860 relationships:
861 - target_spec_id: FR-9999
862 rel_type: parent
863"#;
864 let result = validate_import_content(content).unwrap();
865 assert!(result.has_issues());
866 assert_eq!(result.invalid_relationship_count(), 1);
867 }
868}