sara_core/model/
item.rs

1//! Item types and structures for the knowledge graph.
2
3#![allow(clippy::result_large_err)]
4
5use serde::{Deserialize, Serialize};
6use std::fmt;
7
8use crate::error::ValidationError;
9use crate::model::FieldName;
10
11/// Represents the type of item in the knowledge graph.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum ItemType {
15    Solution,
16    UseCase,
17    Scenario,
18    SystemRequirement,
19    SystemArchitecture,
20    HardwareRequirement,
21    SoftwareRequirement,
22    HardwareDetailedDesign,
23    SoftwareDetailedDesign,
24}
25
26impl ItemType {
27    /// Returns all item types in hierarchy order (upstream to downstream).
28    pub fn all() -> &'static [ItemType] {
29        &[
30            ItemType::Solution,
31            ItemType::UseCase,
32            ItemType::Scenario,
33            ItemType::SystemRequirement,
34            ItemType::SystemArchitecture,
35            ItemType::HardwareRequirement,
36            ItemType::SoftwareRequirement,
37            ItemType::HardwareDetailedDesign,
38            ItemType::SoftwareDetailedDesign,
39        ]
40    }
41
42    /// Returns the display name for this item type.
43    pub fn display_name(&self) -> &'static str {
44        match self {
45            ItemType::Solution => "Solution",
46            ItemType::UseCase => "Use Case",
47            ItemType::Scenario => "Scenario",
48            ItemType::SystemRequirement => "System Requirement",
49            ItemType::SystemArchitecture => "System Architecture",
50            ItemType::HardwareRequirement => "Hardware Requirement",
51            ItemType::SoftwareRequirement => "Software Requirement",
52            ItemType::HardwareDetailedDesign => "Hardware Detailed Design",
53            ItemType::SoftwareDetailedDesign => "Software Detailed Design",
54        }
55    }
56
57    /// Returns the common ID prefix for this item type.
58    pub fn prefix(&self) -> &'static str {
59        match self {
60            ItemType::Solution => "SOL",
61            ItemType::UseCase => "UC",
62            ItemType::Scenario => "SCEN",
63            ItemType::SystemRequirement => "SYSREQ",
64            ItemType::SystemArchitecture => "SYSARCH",
65            ItemType::HardwareRequirement => "HWREQ",
66            ItemType::SoftwareRequirement => "SWREQ",
67            ItemType::HardwareDetailedDesign => "HWDD",
68            ItemType::SoftwareDetailedDesign => "SWDD",
69        }
70    }
71
72    /// Returns the item types that accept the refines field.
73    pub fn refines_types() -> &'static [ItemType] {
74        &[ItemType::UseCase, ItemType::Scenario]
75    }
76
77    /// Returns true if this item type requires the refines field.
78    pub fn requires_refines(&self) -> bool {
79        Self::refines_types().contains(self)
80    }
81
82    /// Returns the item types that accept the derives_from field.
83    pub fn derives_from_types() -> &'static [ItemType] {
84        &[
85            ItemType::SystemRequirement,
86            ItemType::HardwareRequirement,
87            ItemType::SoftwareRequirement,
88        ]
89    }
90
91    /// Returns true if this item type requires the derives_from field.
92    pub fn requires_derives_from(&self) -> bool {
93        Self::derives_from_types().contains(self)
94    }
95
96    /// Returns the item types that accept the satisfies field.
97    pub fn satisfies_types() -> &'static [ItemType] {
98        &[
99            ItemType::SystemArchitecture,
100            ItemType::HardwareDetailedDesign,
101            ItemType::SoftwareDetailedDesign,
102        ]
103    }
104
105    /// Returns true if this item type requires the satisfies field.
106    pub fn requires_satisfies(&self) -> bool {
107        Self::satisfies_types().contains(self)
108    }
109
110    /// Returns the item types that accept the specification field.
111    pub fn specification_types() -> &'static [ItemType] {
112        &[
113            ItemType::SystemRequirement,
114            ItemType::HardwareRequirement,
115            ItemType::SoftwareRequirement,
116        ]
117    }
118
119    /// Returns true if this item type requires/accepts a specification field.
120    pub fn requires_specification(&self) -> bool {
121        Self::specification_types().contains(self)
122    }
123
124    /// Returns the item types that accept the platform field.
125    pub fn platform_types() -> &'static [ItemType] {
126        &[ItemType::SystemArchitecture]
127    }
128
129    /// Returns true if this item type accepts the platform field.
130    pub fn accepts_platform(&self) -> bool {
131        Self::platform_types().contains(self)
132    }
133
134    /// Returns the item types that accept the depends_on field (peer dependencies).
135    pub fn depends_on_types() -> &'static [ItemType] {
136        &[
137            ItemType::SystemRequirement,
138            ItemType::HardwareRequirement,
139            ItemType::SoftwareRequirement,
140        ]
141    }
142
143    /// Returns true if this item type accepts the depends_on field (peer dependencies).
144    pub fn supports_depends_on(&self) -> bool {
145        Self::depends_on_types().contains(self)
146    }
147
148    /// Returns true if this is a root item type (Solution).
149    pub fn is_root(&self) -> bool {
150        matches!(self, ItemType::Solution)
151    }
152
153    /// Returns true if this is a leaf item type (detailed designs).
154    pub fn is_leaf(&self) -> bool {
155        matches!(
156            self,
157            ItemType::HardwareDetailedDesign | ItemType::SoftwareDetailedDesign
158        )
159    }
160
161    /// Returns the required parent item type for this type, if any.
162    /// Solution has no parent (returns None).
163    pub fn required_parent_type(&self) -> Option<ItemType> {
164        match self {
165            ItemType::Solution => None,
166            ItemType::UseCase => Some(ItemType::Solution),
167            ItemType::Scenario => Some(ItemType::UseCase),
168            ItemType::SystemRequirement => Some(ItemType::Scenario),
169            ItemType::SystemArchitecture => Some(ItemType::SystemRequirement),
170            ItemType::HardwareRequirement => Some(ItemType::SystemArchitecture),
171            ItemType::SoftwareRequirement => Some(ItemType::SystemArchitecture),
172            ItemType::HardwareDetailedDesign => Some(ItemType::HardwareRequirement),
173            ItemType::SoftwareDetailedDesign => Some(ItemType::SoftwareRequirement),
174        }
175    }
176
177    /// Returns the upstream traceability field for this item type.
178    pub fn traceability_field(&self) -> Option<FieldName> {
179        match self {
180            ItemType::Solution => None,
181            ItemType::UseCase | ItemType::Scenario => Some(FieldName::Refines),
182            ItemType::SystemRequirement
183            | ItemType::HardwareRequirement
184            | ItemType::SoftwareRequirement => Some(FieldName::DerivesFrom),
185            ItemType::SystemArchitecture
186            | ItemType::HardwareDetailedDesign
187            | ItemType::SoftwareDetailedDesign => Some(FieldName::Satisfies),
188        }
189    }
190
191    /// Returns the YAML value (snake_case string) for this item type.
192    pub fn as_str(&self) -> &'static str {
193        match self {
194            ItemType::Solution => "solution",
195            ItemType::UseCase => "use_case",
196            ItemType::Scenario => "scenario",
197            ItemType::SystemRequirement => "system_requirement",
198            ItemType::SystemArchitecture => "system_architecture",
199            ItemType::HardwareRequirement => "hardware_requirement",
200            ItemType::SoftwareRequirement => "software_requirement",
201            ItemType::HardwareDetailedDesign => "hardware_detailed_design",
202            ItemType::SoftwareDetailedDesign => "software_detailed_design",
203        }
204    }
205
206    /// Returns all traceability configurations for this item type.
207    ///
208    /// Most item types have a single traceability link (e.g., refines, satisfies).
209    /// Requirement types have two: derives_from (hierarchical) and depends_on (peer).
210    /// Solution has no parent and returns an empty vec.
211    pub fn traceability_configs(&self) -> Vec<TraceabilityConfig> {
212        match self {
213            ItemType::Solution => vec![],
214            ItemType::UseCase => vec![TraceabilityConfig {
215                relationship_field: FieldName::Refines,
216                target_type: ItemType::Solution,
217            }],
218            ItemType::Scenario => vec![TraceabilityConfig {
219                relationship_field: FieldName::Refines,
220                target_type: ItemType::UseCase,
221            }],
222            ItemType::SystemRequirement => vec![
223                TraceabilityConfig {
224                    relationship_field: FieldName::DerivesFrom,
225                    target_type: ItemType::Scenario,
226                },
227                TraceabilityConfig {
228                    relationship_field: FieldName::DependsOn,
229                    target_type: ItemType::SystemRequirement,
230                },
231            ],
232            ItemType::SystemArchitecture => vec![TraceabilityConfig {
233                relationship_field: FieldName::Satisfies,
234                target_type: ItemType::SystemRequirement,
235            }],
236            ItemType::HardwareRequirement => vec![
237                TraceabilityConfig {
238                    relationship_field: FieldName::DerivesFrom,
239                    target_type: ItemType::SystemArchitecture,
240                },
241                TraceabilityConfig {
242                    relationship_field: FieldName::DependsOn,
243                    target_type: ItemType::HardwareRequirement,
244                },
245            ],
246            ItemType::SoftwareRequirement => vec![
247                TraceabilityConfig {
248                    relationship_field: FieldName::DerivesFrom,
249                    target_type: ItemType::SystemArchitecture,
250                },
251                TraceabilityConfig {
252                    relationship_field: FieldName::DependsOn,
253                    target_type: ItemType::SoftwareRequirement,
254                },
255            ],
256            ItemType::HardwareDetailedDesign => vec![TraceabilityConfig {
257                relationship_field: FieldName::Satisfies,
258                target_type: ItemType::HardwareRequirement,
259            }],
260            ItemType::SoftwareDetailedDesign => vec![TraceabilityConfig {
261                relationship_field: FieldName::Satisfies,
262                target_type: ItemType::SoftwareRequirement,
263            }],
264        }
265    }
266}
267
268/// Configuration for traceability relationships.
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub struct TraceabilityConfig {
271    /// The relationship field (refines, derives_from, satisfies, depends_on).
272    pub relationship_field: FieldName,
273    /// The target item type to link to (parent for hierarchical, same type for peers).
274    pub target_type: ItemType,
275}
276
277impl fmt::Display for ItemType {
278    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
279        write!(f, "{}", self.display_name())
280    }
281}
282
283/// Unique identifier for an item across all repositories.
284#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
285#[serde(transparent)]
286pub struct ItemId(String);
287
288impl ItemId {
289    /// Creates a new ItemId, validating format.
290    pub fn new(id: impl Into<String>) -> Result<Self, ValidationError> {
291        let id = id.into();
292        if id.is_empty() {
293            return Err(ValidationError::InvalidId {
294                id: id.clone(),
295                reason: "Item ID cannot be empty".to_string(),
296            });
297        }
298
299        // Validate: alphanumeric, hyphens, and underscores only
300        if !id
301            .chars()
302            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
303        {
304            return Err(ValidationError::InvalidId {
305                id: id.clone(),
306                reason:
307                    "Item ID must contain only alphanumeric characters, hyphens, and underscores"
308                        .to_string(),
309            });
310        }
311
312        Ok(Self(id))
313    }
314
315    /// Creates a new ItemId without validation.
316    ///
317    /// Use this when parsing from trusted sources where IDs have already been
318    /// validated or when the ID format is known to be valid.
319    pub fn new_unchecked(id: impl Into<String>) -> Self {
320        Self(id.into())
321    }
322
323    /// Returns the raw identifier string.
324    pub fn as_str(&self) -> &str {
325        &self.0
326    }
327}
328
329impl fmt::Display for ItemId {
330    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
331        write!(f, "{}", self.0)
332    }
333}
334
335impl AsRef<str> for ItemId {
336    fn as_ref(&self) -> &str {
337        &self.0
338    }
339}
340
341/// Upstream relationship references (this item points to parents).
342#[derive(Debug, Clone, Default, Serialize, Deserialize)]
343pub struct UpstreamRefs {
344    /// Items this item refines (for UseCase, Scenario).
345    #[serde(default, skip_serializing_if = "Vec::is_empty")]
346    pub refines: Vec<ItemId>,
347
348    /// Items this item derives from (for SystemRequirement, HW/SW Requirement).
349    #[serde(default, skip_serializing_if = "Vec::is_empty")]
350    pub derives_from: Vec<ItemId>,
351
352    /// Items this item satisfies (for SystemArchitecture, HW/SW DetailedDesign).
353    #[serde(default, skip_serializing_if = "Vec::is_empty")]
354    pub satisfies: Vec<ItemId>,
355}
356
357impl UpstreamRefs {
358    /// Returns all upstream item IDs.
359    pub fn all_ids(&self) -> Vec<&ItemId> {
360        let mut ids = Vec::new();
361        ids.extend(self.refines.iter());
362        ids.extend(self.derives_from.iter());
363        ids.extend(self.satisfies.iter());
364        ids
365    }
366
367    /// Returns true if there are no upstream references.
368    pub fn is_empty(&self) -> bool {
369        self.refines.is_empty() && self.derives_from.is_empty() && self.satisfies.is_empty()
370    }
371}
372
373/// Downstream relationship references (this item points to children).
374#[derive(Debug, Clone, Default, Serialize, Deserialize)]
375pub struct DownstreamRefs {
376    /// Items that refine this item (for Solution, UseCase).
377    #[serde(default, skip_serializing_if = "Vec::is_empty")]
378    pub is_refined_by: Vec<ItemId>,
379
380    /// Items derived from this item (for Scenario, SystemArchitecture).
381    #[serde(default, skip_serializing_if = "Vec::is_empty")]
382    pub derives: Vec<ItemId>,
383
384    /// Items that satisfy this item (for SystemRequirement, HW/SW Requirement).
385    #[serde(default, skip_serializing_if = "Vec::is_empty")]
386    pub is_satisfied_by: Vec<ItemId>,
387}
388
389impl DownstreamRefs {
390    /// Returns all downstream item IDs.
391    pub fn all_ids(&self) -> Vec<&ItemId> {
392        let mut ids = Vec::new();
393        ids.extend(self.is_refined_by.iter());
394        ids.extend(self.derives.iter());
395        ids.extend(self.is_satisfied_by.iter());
396        ids
397    }
398
399    /// Returns true if there are no downstream references.
400    pub fn is_empty(&self) -> bool {
401        self.is_refined_by.is_empty() && self.derives.is_empty() && self.is_satisfied_by.is_empty()
402    }
403}
404
405/// Additional fields depending on item type.
406#[derive(Debug, Clone, Default, Serialize, Deserialize)]
407pub struct ItemAttributes {
408    /// For SystemRequirement, HardwareRequirement, SoftwareRequirement: specification statement.
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub specification: Option<String>,
411
412    /// For SystemRequirement, HardwareRequirement, SoftwareRequirement: peer dependencies.
413    #[serde(default, skip_serializing_if = "Vec::is_empty")]
414    pub depends_on: Vec<ItemId>,
415
416    /// For SystemArchitecture: target platform.
417    #[serde(default, skip_serializing_if = "Option::is_none")]
418    pub platform: Option<String>,
419
420    /// For SystemArchitecture: reserved for future ADR links.
421    #[serde(default, skip_serializing_if = "Option::is_none")]
422    pub justified_by: Option<Vec<ItemId>>,
423}
424
425use crate::model::metadata::SourceLocation;
426
427/// Represents a single document/node in the knowledge graph.
428#[derive(Debug, Clone, Serialize, Deserialize)]
429pub struct Item {
430    /// Unique identifier.
431    pub id: ItemId,
432
433    /// Type of this item.
434    pub item_type: ItemType,
435
436    /// Human-readable name.
437    pub name: String,
438
439    /// Optional description.
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub description: Option<String>,
442
443    /// Source file location.
444    pub source: SourceLocation,
445
446    /// Upstream relationships (toward Solution).
447    #[serde(default)]
448    pub upstream: UpstreamRefs,
449
450    /// Downstream relationships (toward Detailed Designs).
451    #[serde(default)]
452    pub downstream: DownstreamRefs,
453
454    /// Type-specific attributes.
455    #[serde(default)]
456    pub attributes: ItemAttributes,
457}
458
459impl Item {
460    /// Returns all referenced item IDs (both upstream and downstream).
461    pub fn all_references(&self) -> Vec<&ItemId> {
462        let mut refs = Vec::new();
463        refs.extend(self.upstream.all_ids());
464        refs.extend(self.downstream.all_ids());
465        refs.extend(self.attributes.depends_on.iter());
466        if let Some(justified_by) = &self.attributes.justified_by {
467            refs.extend(justified_by.iter());
468        }
469        refs
470    }
471}
472
473/// Builder for constructing Item instances from parsed frontmatter.
474#[derive(Debug, Default)]
475pub struct ItemBuilder {
476    id: Option<ItemId>,
477    item_type: Option<ItemType>,
478    name: Option<String>,
479    description: Option<String>,
480    source: Option<SourceLocation>,
481    upstream: UpstreamRefs,
482    downstream: DownstreamRefs,
483    attributes: ItemAttributes,
484}
485
486impl ItemBuilder {
487    /// Creates a new ItemBuilder.
488    pub fn new() -> Self {
489        Self::default()
490    }
491
492    /// Sets the item ID.
493    pub fn id(mut self, id: ItemId) -> Self {
494        self.id = Some(id);
495        self
496    }
497
498    /// Sets the item type.
499    pub fn item_type(mut self, item_type: ItemType) -> Self {
500        self.item_type = Some(item_type);
501        self
502    }
503
504    /// Sets the item name.
505    pub fn name(mut self, name: impl Into<String>) -> Self {
506        self.name = Some(name.into());
507        self
508    }
509
510    /// Sets the item description.
511    pub fn description(mut self, desc: impl Into<String>) -> Self {
512        self.description = Some(desc.into());
513        self
514    }
515
516    /// Sets the source location.
517    pub fn source(mut self, source: SourceLocation) -> Self {
518        self.source = Some(source);
519        self
520    }
521
522    /// Sets the upstream references.
523    pub fn upstream(mut self, upstream: UpstreamRefs) -> Self {
524        self.upstream = upstream;
525        self
526    }
527
528    /// Sets the downstream references.
529    pub fn downstream(mut self, downstream: DownstreamRefs) -> Self {
530        self.downstream = downstream;
531        self
532    }
533
534    /// Sets the specification text.
535    pub fn specification(mut self, spec: impl Into<String>) -> Self {
536        self.attributes.specification = Some(spec.into());
537        self
538    }
539
540    /// Sets the platform.
541    pub fn platform(mut self, platform: impl Into<String>) -> Self {
542        self.attributes.platform = Some(platform.into());
543        self
544    }
545
546    /// Adds a dependency.
547    pub fn depends_on(mut self, id: ItemId) -> Self {
548        self.attributes.depends_on.push(id);
549        self
550    }
551
552    /// Sets the attributes.
553    pub fn attributes(mut self, attrs: ItemAttributes) -> Self {
554        self.attributes = attrs;
555        self
556    }
557
558    /// Builds the Item, returning an error if required fields are missing.
559    pub fn build(self) -> Result<Item, ValidationError> {
560        let id = self.id.ok_or_else(|| ValidationError::MissingField {
561            field: "id".to_string(),
562            file: self
563                .source
564                .as_ref()
565                .map(|s| s.file_path.display().to_string())
566                .unwrap_or_default(),
567        })?;
568
569        let item_type = self
570            .item_type
571            .ok_or_else(|| ValidationError::MissingField {
572                field: "type".to_string(),
573                file: self
574                    .source
575                    .as_ref()
576                    .map(|s| s.file_path.display().to_string())
577                    .unwrap_or_default(),
578            })?;
579
580        let name = self.name.ok_or_else(|| ValidationError::MissingField {
581            field: "name".to_string(),
582            file: self
583                .source
584                .as_ref()
585                .map(|s| s.file_path.display().to_string())
586                .unwrap_or_default(),
587        })?;
588
589        let source = self.source.ok_or_else(|| ValidationError::MissingField {
590            field: "source".to_string(),
591            file: String::new(),
592        })?;
593
594        // Validate specification field for requirement types
595        if item_type.requires_specification() && self.attributes.specification.is_none() {
596            return Err(ValidationError::MissingField {
597                field: "specification".to_string(),
598                file: source.file_path.display().to_string(),
599            });
600        }
601
602        Ok(Item {
603            id,
604            item_type,
605            name,
606            description: self.description,
607            source,
608            upstream: self.upstream,
609            downstream: self.downstream,
610            attributes: self.attributes,
611        })
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use std::path::PathBuf;
619
620    #[test]
621    fn test_item_id_valid() {
622        assert!(ItemId::new("SOL-001").is_ok());
623        assert!(ItemId::new("UC_002").is_ok());
624        assert!(ItemId::new("SYSREQ-123-A").is_ok());
625    }
626
627    #[test]
628    fn test_item_id_invalid() {
629        assert!(ItemId::new("").is_err());
630        assert!(ItemId::new("SOL 001").is_err());
631        assert!(ItemId::new("SOL.001").is_err());
632    }
633
634    #[test]
635    fn test_item_type_display() {
636        assert_eq!(ItemType::Solution.display_name(), "Solution");
637        assert_eq!(
638            ItemType::SystemRequirement.display_name(),
639            "System Requirement"
640        );
641    }
642
643    #[test]
644    fn test_item_type_requires_specification() {
645        assert!(ItemType::SystemRequirement.requires_specification());
646        assert!(ItemType::HardwareRequirement.requires_specification());
647        assert!(ItemType::SoftwareRequirement.requires_specification());
648        assert!(!ItemType::Solution.requires_specification());
649        assert!(!ItemType::Scenario.requires_specification());
650    }
651
652    #[test]
653    fn test_item_builder() {
654        let source = SourceLocation {
655            repository: PathBuf::from("/repo"),
656            file_path: PathBuf::from("docs/SOL-001.md"),
657            git_ref: None,
658        };
659
660        let item = ItemBuilder::new()
661            .id(ItemId::new_unchecked("SOL-001"))
662            .item_type(ItemType::Solution)
663            .name("Test Solution")
664            .source(source)
665            .build();
666
667        assert!(item.is_ok());
668        let item = item.unwrap();
669        assert_eq!(item.id.as_str(), "SOL-001");
670        assert_eq!(item.name, "Test Solution");
671    }
672}