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