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 true if this is a root item type (Solution).
135    pub fn is_root(&self) -> bool {
136        matches!(self, ItemType::Solution)
137    }
138
139    /// Returns true if this is a leaf item type (detailed designs).
140    pub fn is_leaf(&self) -> bool {
141        matches!(
142            self,
143            ItemType::HardwareDetailedDesign | ItemType::SoftwareDetailedDesign
144        )
145    }
146
147    /// Returns the required parent item type for this type, if any.
148    /// Solution has no parent (returns None).
149    pub fn required_parent_type(&self) -> Option<ItemType> {
150        match self {
151            ItemType::Solution => None,
152            ItemType::UseCase => Some(ItemType::Solution),
153            ItemType::Scenario => Some(ItemType::UseCase),
154            ItemType::SystemRequirement => Some(ItemType::Scenario),
155            ItemType::SystemArchitecture => Some(ItemType::SystemRequirement),
156            ItemType::HardwareRequirement => Some(ItemType::SystemArchitecture),
157            ItemType::SoftwareRequirement => Some(ItemType::SystemArchitecture),
158            ItemType::HardwareDetailedDesign => Some(ItemType::HardwareRequirement),
159            ItemType::SoftwareDetailedDesign => Some(ItemType::SoftwareRequirement),
160        }
161    }
162
163    /// Returns the upstream traceability field for this item type.
164    pub fn traceability_field(&self) -> Option<FieldName> {
165        match self {
166            ItemType::Solution => None,
167            ItemType::UseCase | ItemType::Scenario => Some(FieldName::Refines),
168            ItemType::SystemRequirement
169            | ItemType::HardwareRequirement
170            | ItemType::SoftwareRequirement => Some(FieldName::DerivesFrom),
171            ItemType::SystemArchitecture
172            | ItemType::HardwareDetailedDesign
173            | ItemType::SoftwareDetailedDesign => Some(FieldName::Satisfies),
174        }
175    }
176
177    /// Returns the YAML value (snake_case string) for this item type.
178    pub fn as_str(&self) -> &'static str {
179        match self {
180            ItemType::Solution => "solution",
181            ItemType::UseCase => "use_case",
182            ItemType::Scenario => "scenario",
183            ItemType::SystemRequirement => "system_requirement",
184            ItemType::SystemArchitecture => "system_architecture",
185            ItemType::HardwareRequirement => "hardware_requirement",
186            ItemType::SoftwareRequirement => "software_requirement",
187            ItemType::HardwareDetailedDesign => "hardware_detailed_design",
188            ItemType::SoftwareDetailedDesign => "software_detailed_design",
189        }
190    }
191
192    /// Returns the traceability configuration for this item type, if any.
193    ///
194    /// Solution has no parent and returns None.
195    pub fn traceability_config(&self) -> Option<TraceabilityConfig> {
196        match self {
197            ItemType::Solution => None,
198            ItemType::UseCase => Some(TraceabilityConfig {
199                relationship_field: FieldName::Refines,
200                parent_type: ItemType::Solution,
201            }),
202            ItemType::Scenario => Some(TraceabilityConfig {
203                relationship_field: FieldName::Refines,
204                parent_type: ItemType::UseCase,
205            }),
206            ItemType::SystemRequirement => Some(TraceabilityConfig {
207                relationship_field: FieldName::DerivesFrom,
208                parent_type: ItemType::Scenario,
209            }),
210            ItemType::SystemArchitecture => Some(TraceabilityConfig {
211                relationship_field: FieldName::Satisfies,
212                parent_type: ItemType::SystemRequirement,
213            }),
214            ItemType::HardwareRequirement | ItemType::SoftwareRequirement => {
215                Some(TraceabilityConfig {
216                    relationship_field: FieldName::DerivesFrom,
217                    parent_type: ItemType::SystemArchitecture,
218                })
219            }
220            ItemType::HardwareDetailedDesign => Some(TraceabilityConfig {
221                relationship_field: FieldName::Satisfies,
222                parent_type: ItemType::HardwareRequirement,
223            }),
224            ItemType::SoftwareDetailedDesign => Some(TraceabilityConfig {
225                relationship_field: FieldName::Satisfies,
226                parent_type: ItemType::SoftwareRequirement,
227            }),
228        }
229    }
230}
231
232/// Configuration for traceability relationships.
233#[derive(Debug, Clone, Copy, PartialEq, Eq)]
234pub struct TraceabilityConfig {
235    /// The relationship field (refines, derives_from, satisfies).
236    pub relationship_field: FieldName,
237    /// The parent item type to link to.
238    pub parent_type: ItemType,
239}
240
241impl fmt::Display for ItemType {
242    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
243        write!(f, "{}", self.display_name())
244    }
245}
246
247/// Unique identifier for an item across all repositories.
248#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
249#[serde(transparent)]
250pub struct ItemId(String);
251
252impl ItemId {
253    /// Creates a new ItemId, validating format.
254    pub fn new(id: impl Into<String>) -> Result<Self, ValidationError> {
255        let id = id.into();
256        if id.is_empty() {
257            return Err(ValidationError::InvalidId {
258                id: id.clone(),
259                reason: "Item ID cannot be empty".to_string(),
260            });
261        }
262
263        // Validate: alphanumeric, hyphens, and underscores only
264        if !id
265            .chars()
266            .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
267        {
268            return Err(ValidationError::InvalidId {
269                id: id.clone(),
270                reason:
271                    "Item ID must contain only alphanumeric characters, hyphens, and underscores"
272                        .to_string(),
273            });
274        }
275
276        Ok(Self(id))
277    }
278
279    /// Creates a new ItemId without validation.
280    ///
281    /// Use this when parsing from trusted sources where IDs have already been
282    /// validated or when the ID format is known to be valid.
283    pub fn new_unchecked(id: impl Into<String>) -> Self {
284        Self(id.into())
285    }
286
287    /// Returns the raw identifier string.
288    pub fn as_str(&self) -> &str {
289        &self.0
290    }
291}
292
293impl fmt::Display for ItemId {
294    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
295        write!(f, "{}", self.0)
296    }
297}
298
299impl AsRef<str> for ItemId {
300    fn as_ref(&self) -> &str {
301        &self.0
302    }
303}
304
305/// Upstream relationship references (this item points to parents).
306#[derive(Debug, Clone, Default, Serialize, Deserialize)]
307pub struct UpstreamRefs {
308    /// Items this item refines (for UseCase, Scenario).
309    #[serde(default, skip_serializing_if = "Vec::is_empty")]
310    pub refines: Vec<ItemId>,
311
312    /// Items this item derives from (for SystemRequirement, HW/SW Requirement).
313    #[serde(default, skip_serializing_if = "Vec::is_empty")]
314    pub derives_from: Vec<ItemId>,
315
316    /// Items this item satisfies (for SystemArchitecture, HW/SW DetailedDesign).
317    #[serde(default, skip_serializing_if = "Vec::is_empty")]
318    pub satisfies: Vec<ItemId>,
319}
320
321impl UpstreamRefs {
322    /// Returns all upstream item IDs.
323    pub fn all_ids(&self) -> Vec<&ItemId> {
324        let mut ids = Vec::new();
325        ids.extend(self.refines.iter());
326        ids.extend(self.derives_from.iter());
327        ids.extend(self.satisfies.iter());
328        ids
329    }
330
331    /// Returns true if there are no upstream references.
332    pub fn is_empty(&self) -> bool {
333        self.refines.is_empty() && self.derives_from.is_empty() && self.satisfies.is_empty()
334    }
335}
336
337/// Downstream relationship references (this item points to children).
338#[derive(Debug, Clone, Default, Serialize, Deserialize)]
339pub struct DownstreamRefs {
340    /// Items that refine this item (for Solution, UseCase).
341    #[serde(default, skip_serializing_if = "Vec::is_empty")]
342    pub is_refined_by: Vec<ItemId>,
343
344    /// Items derived from this item (for Scenario, SystemArchitecture).
345    #[serde(default, skip_serializing_if = "Vec::is_empty")]
346    pub derives: Vec<ItemId>,
347
348    /// Items that satisfy this item (for SystemRequirement, HW/SW Requirement).
349    #[serde(default, skip_serializing_if = "Vec::is_empty")]
350    pub is_satisfied_by: Vec<ItemId>,
351}
352
353impl DownstreamRefs {
354    /// Returns all downstream item IDs.
355    pub fn all_ids(&self) -> Vec<&ItemId> {
356        let mut ids = Vec::new();
357        ids.extend(self.is_refined_by.iter());
358        ids.extend(self.derives.iter());
359        ids.extend(self.is_satisfied_by.iter());
360        ids
361    }
362
363    /// Returns true if there are no downstream references.
364    pub fn is_empty(&self) -> bool {
365        self.is_refined_by.is_empty() && self.derives.is_empty() && self.is_satisfied_by.is_empty()
366    }
367}
368
369/// Additional fields depending on item type.
370#[derive(Debug, Clone, Default, Serialize, Deserialize)]
371pub struct ItemAttributes {
372    /// For SystemRequirement, HardwareRequirement, SoftwareRequirement: specification statement.
373    #[serde(default, skip_serializing_if = "Option::is_none")]
374    pub specification: Option<String>,
375
376    /// For SystemRequirement, HardwareRequirement, SoftwareRequirement: peer dependencies.
377    #[serde(default, skip_serializing_if = "Vec::is_empty")]
378    pub depends_on: Vec<ItemId>,
379
380    /// For SystemArchitecture: target platform.
381    #[serde(default, skip_serializing_if = "Option::is_none")]
382    pub platform: Option<String>,
383
384    /// For SystemArchitecture: reserved for future ADR links.
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub justified_by: Option<Vec<ItemId>>,
387}
388
389use crate::model::metadata::SourceLocation;
390
391/// Represents a single document/node in the knowledge graph.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct Item {
394    /// Unique identifier.
395    pub id: ItemId,
396
397    /// Type of this item.
398    pub item_type: ItemType,
399
400    /// Human-readable name.
401    pub name: String,
402
403    /// Optional description.
404    #[serde(default, skip_serializing_if = "Option::is_none")]
405    pub description: Option<String>,
406
407    /// Source file location.
408    pub source: SourceLocation,
409
410    /// Upstream relationships (toward Solution).
411    #[serde(default)]
412    pub upstream: UpstreamRefs,
413
414    /// Downstream relationships (toward Detailed Designs).
415    #[serde(default)]
416    pub downstream: DownstreamRefs,
417
418    /// Type-specific attributes.
419    #[serde(default)]
420    pub attributes: ItemAttributes,
421}
422
423impl Item {
424    /// Returns all referenced item IDs (both upstream and downstream).
425    pub fn all_references(&self) -> Vec<&ItemId> {
426        let mut refs = Vec::new();
427        refs.extend(self.upstream.all_ids());
428        refs.extend(self.downstream.all_ids());
429        refs.extend(self.attributes.depends_on.iter());
430        if let Some(justified_by) = &self.attributes.justified_by {
431            refs.extend(justified_by.iter());
432        }
433        refs
434    }
435}
436
437/// Builder for constructing Item instances from parsed frontmatter.
438#[derive(Debug, Default)]
439pub struct ItemBuilder {
440    id: Option<ItemId>,
441    item_type: Option<ItemType>,
442    name: Option<String>,
443    description: Option<String>,
444    source: Option<SourceLocation>,
445    upstream: UpstreamRefs,
446    downstream: DownstreamRefs,
447    attributes: ItemAttributes,
448}
449
450impl ItemBuilder {
451    /// Creates a new ItemBuilder.
452    pub fn new() -> Self {
453        Self::default()
454    }
455
456    /// Sets the item ID.
457    pub fn id(mut self, id: ItemId) -> Self {
458        self.id = Some(id);
459        self
460    }
461
462    /// Sets the item type.
463    pub fn item_type(mut self, item_type: ItemType) -> Self {
464        self.item_type = Some(item_type);
465        self
466    }
467
468    /// Sets the item name.
469    pub fn name(mut self, name: impl Into<String>) -> Self {
470        self.name = Some(name.into());
471        self
472    }
473
474    /// Sets the item description.
475    pub fn description(mut self, desc: impl Into<String>) -> Self {
476        self.description = Some(desc.into());
477        self
478    }
479
480    /// Sets the source location.
481    pub fn source(mut self, source: SourceLocation) -> Self {
482        self.source = Some(source);
483        self
484    }
485
486    /// Sets the upstream references.
487    pub fn upstream(mut self, upstream: UpstreamRefs) -> Self {
488        self.upstream = upstream;
489        self
490    }
491
492    /// Sets the downstream references.
493    pub fn downstream(mut self, downstream: DownstreamRefs) -> Self {
494        self.downstream = downstream;
495        self
496    }
497
498    /// Sets the specification text.
499    pub fn specification(mut self, spec: impl Into<String>) -> Self {
500        self.attributes.specification = Some(spec.into());
501        self
502    }
503
504    /// Sets the platform.
505    pub fn platform(mut self, platform: impl Into<String>) -> Self {
506        self.attributes.platform = Some(platform.into());
507        self
508    }
509
510    /// Adds a dependency.
511    pub fn depends_on(mut self, id: ItemId) -> Self {
512        self.attributes.depends_on.push(id);
513        self
514    }
515
516    /// Sets the attributes.
517    pub fn attributes(mut self, attrs: ItemAttributes) -> Self {
518        self.attributes = attrs;
519        self
520    }
521
522    /// Builds the Item, returning an error if required fields are missing.
523    pub fn build(self) -> Result<Item, ValidationError> {
524        let id = self.id.ok_or_else(|| ValidationError::MissingField {
525            field: "id".to_string(),
526            file: self
527                .source
528                .as_ref()
529                .map(|s| s.file_path.display().to_string())
530                .unwrap_or_default(),
531        })?;
532
533        let item_type = self
534            .item_type
535            .ok_or_else(|| ValidationError::MissingField {
536                field: "type".to_string(),
537                file: self
538                    .source
539                    .as_ref()
540                    .map(|s| s.file_path.display().to_string())
541                    .unwrap_or_default(),
542            })?;
543
544        let name = self.name.ok_or_else(|| ValidationError::MissingField {
545            field: "name".to_string(),
546            file: self
547                .source
548                .as_ref()
549                .map(|s| s.file_path.display().to_string())
550                .unwrap_or_default(),
551        })?;
552
553        let source = self.source.ok_or_else(|| ValidationError::MissingField {
554            field: "source".to_string(),
555            file: String::new(),
556        })?;
557
558        // Validate specification field for requirement types
559        if item_type.requires_specification() && self.attributes.specification.is_none() {
560            return Err(ValidationError::MissingField {
561                field: "specification".to_string(),
562                file: source.file_path.display().to_string(),
563            });
564        }
565
566        Ok(Item {
567            id,
568            item_type,
569            name,
570            description: self.description,
571            source,
572            upstream: self.upstream,
573            downstream: self.downstream,
574            attributes: self.attributes,
575        })
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582    use std::path::PathBuf;
583
584    #[test]
585    fn test_item_id_valid() {
586        assert!(ItemId::new("SOL-001").is_ok());
587        assert!(ItemId::new("UC_002").is_ok());
588        assert!(ItemId::new("SYSREQ-123-A").is_ok());
589    }
590
591    #[test]
592    fn test_item_id_invalid() {
593        assert!(ItemId::new("").is_err());
594        assert!(ItemId::new("SOL 001").is_err());
595        assert!(ItemId::new("SOL.001").is_err());
596    }
597
598    #[test]
599    fn test_item_type_display() {
600        assert_eq!(ItemType::Solution.display_name(), "Solution");
601        assert_eq!(
602            ItemType::SystemRequirement.display_name(),
603            "System Requirement"
604        );
605    }
606
607    #[test]
608    fn test_item_type_requires_specification() {
609        assert!(ItemType::SystemRequirement.requires_specification());
610        assert!(ItemType::HardwareRequirement.requires_specification());
611        assert!(ItemType::SoftwareRequirement.requires_specification());
612        assert!(!ItemType::Solution.requires_specification());
613        assert!(!ItemType::Scenario.requires_specification());
614    }
615
616    #[test]
617    fn test_item_builder() {
618        let source = SourceLocation {
619            repository: PathBuf::from("/repo"),
620            file_path: PathBuf::from("docs/SOL-001.md"),
621            git_ref: None,
622        };
623
624        let item = ItemBuilder::new()
625            .id(ItemId::new_unchecked("SOL-001"))
626            .item_type(ItemType::Solution)
627            .name("Test Solution")
628            .source(source)
629            .build();
630
631        assert!(item.is_ok());
632        let item = item.unwrap();
633        assert_eq!(item.id.as_str(), "SOL-001");
634        assert_eq!(item.name, "Test Solution");
635    }
636}