sara_core/model/
edit.rs

1//! Edit command types for updating document metadata.
2//!
3//! Provides types for FR-054 through FR-066 (Edit Command).
4
5use std::path::PathBuf;
6
7/// Fields to update via CLI flags (non-interactive mode).
8///
9/// Used for non-interactive editing where the user specifies which
10/// fields to update via command-line flags (FR-057, FR-058).
11#[derive(Debug, Default, Clone)]
12pub struct EditUpdates {
13    /// New name for the item.
14    pub name: Option<String>,
15    /// New description for the item.
16    pub description: Option<String>,
17    /// New refines links (for UseCase, Scenario).
18    pub refines: Option<Vec<String>>,
19    /// New derives_from links (for requirements).
20    pub derives_from: Option<Vec<String>>,
21    /// New satisfies links (for architectures, designs).
22    pub satisfies: Option<Vec<String>>,
23    /// New specification (for requirement types).
24    pub specification: Option<String>,
25    /// New platform (for SystemArchitecture).
26    pub platform: Option<String>,
27}
28
29impl EditUpdates {
30    /// Returns true if any field is set (triggers non-interactive mode).
31    pub fn has_updates(&self) -> bool {
32        self.name.is_some()
33            || self.description.is_some()
34            || self.refines.is_some()
35            || self.derives_from.is_some()
36            || self.satisfies.is_some()
37            || self.specification.is_some()
38            || self.platform.is_some()
39    }
40}
41
42/// Summary of changes made during an edit operation.
43#[derive(Debug, Clone)]
44pub struct EditSummary {
45    /// The ID of the edited item.
46    pub item_id: String,
47    /// Path to the modified file.
48    pub file_path: PathBuf,
49    /// List of field changes applied.
50    pub changes: Vec<FieldChange>,
51}
52
53impl EditSummary {
54    /// Returns true if any changes were actually made.
55    pub fn has_changes(&self) -> bool {
56        self.changes.iter().any(|c| c.is_changed())
57    }
58
59    /// Returns only the fields that were actually changed.
60    pub fn actual_changes(&self) -> Vec<&FieldChange> {
61        self.changes.iter().filter(|c| c.is_changed()).collect()
62    }
63}
64
65/// A single field change in an edit operation.
66#[derive(Debug, Clone)]
67pub struct FieldChange {
68    /// Name of the field that was changed.
69    pub field: String,
70    /// Previous value (for display in diff).
71    pub old_value: String,
72    /// New value (for display in diff).
73    pub new_value: String,
74}
75
76impl FieldChange {
77    /// Creates a new field change record.
78    pub fn new(
79        field: impl Into<String>,
80        old_value: impl Into<String>,
81        new_value: impl Into<String>,
82    ) -> Self {
83        Self {
84            field: field.into(),
85            old_value: old_value.into(),
86            new_value: new_value.into(),
87        }
88    }
89
90    /// Returns true if the value actually changed.
91    pub fn is_changed(&self) -> bool {
92        self.old_value != self.new_value
93    }
94}
95
96/// Traceability links as string IDs (for user input and editing).
97///
98/// This struct represents traceability links using plain strings,
99/// suitable for CLI input, interactive prompts, and serialization.
100/// Use `UpstreamRefs` for the validated graph model.
101#[derive(Debug, Default, Clone)]
102pub struct TraceabilityLinks {
103    /// Items this item refines (for UseCase, Scenario).
104    pub refines: Vec<String>,
105    /// Items this item derives from (for requirements).
106    pub derives_from: Vec<String>,
107    /// Items this item satisfies (for architectures, designs).
108    pub satisfies: Vec<String>,
109}
110
111impl TraceabilityLinks {
112    /// Returns true if all traceability fields are empty.
113    pub fn is_empty(&self) -> bool {
114        self.refines.is_empty() && self.derives_from.is_empty() && self.satisfies.is_empty()
115    }
116
117    /// Creates from an Item's upstream references.
118    pub fn from_upstream(upstream: &super::UpstreamRefs) -> Self {
119        Self {
120            refines: upstream
121                .refines
122                .iter()
123                .map(|id| id.as_str().to_string())
124                .collect(),
125            derives_from: upstream
126                .derives_from
127                .iter()
128                .map(|id| id.as_str().to_string())
129                .collect(),
130            satisfies: upstream
131                .satisfies
132                .iter()
133                .map(|id| id.as_str().to_string())
134                .collect(),
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_edit_updates_has_updates_empty() {
145        let updates = EditUpdates::default();
146        assert!(!updates.has_updates());
147    }
148
149    #[test]
150    fn test_edit_updates_has_updates_name() {
151        let updates = EditUpdates {
152            name: Some("New Name".to_string()),
153            ..Default::default()
154        };
155        assert!(updates.has_updates());
156    }
157
158    #[test]
159    fn test_edit_updates_has_updates_traceability() {
160        let updates = EditUpdates {
161            derives_from: Some(vec!["SCEN-001".to_string()]),
162            ..Default::default()
163        };
164        assert!(updates.has_updates());
165    }
166
167    #[test]
168    fn test_field_change_is_changed() {
169        let changed = FieldChange::new("name", "Old", "New");
170        assert!(changed.is_changed());
171
172        let unchanged = FieldChange::new("name", "Same", "Same");
173        assert!(!unchanged.is_changed());
174    }
175
176    #[test]
177    fn test_edit_summary_has_changes() {
178        let summary = EditSummary {
179            item_id: "SREQ-001".to_string(),
180            file_path: PathBuf::from("test.md"),
181            changes: vec![
182                FieldChange::new("name", "Old", "New"),
183                FieldChange::new("description", "Same", "Same"),
184            ],
185        };
186        assert!(summary.has_changes());
187        assert_eq!(summary.actual_changes().len(), 1);
188    }
189
190    #[test]
191    fn test_edit_summary_no_changes() {
192        let summary = EditSummary {
193            item_id: "SREQ-001".to_string(),
194            file_path: PathBuf::from("test.md"),
195            changes: vec![FieldChange::new("name", "Same", "Same")],
196        };
197        assert!(!summary.has_changes());
198        assert_eq!(summary.actual_changes().len(), 0);
199    }
200}