sara_core/edit/
service.rs

1//! Edit service implementation.
2
3use std::fs;
4use std::path::PathBuf;
5
6use crate::error::EditError;
7use crate::graph::KnowledgeGraph;
8use crate::model::{FieldChange, FieldName, Item, ItemType, TraceabilityLinks};
9use crate::parser::update_frontmatter;
10use crate::query::lookup_item_or_suggest;
11
12use super::{EditOptions, EditedValues};
13
14/// Result of a successful edit operation.
15#[derive(Debug)]
16pub struct EditResult {
17    /// The item ID that was edited.
18    pub item_id: String,
19    /// The file path that was modified.
20    pub file_path: PathBuf,
21    /// The changes that were applied.
22    pub changes: Vec<FieldChange>,
23}
24
25impl EditResult {
26    /// Returns true if any changes were made.
27    pub fn has_changes(&self) -> bool {
28        !self.changes.is_empty()
29    }
30
31    /// Returns the number of changes.
32    pub fn change_count(&self) -> usize {
33        self.changes.len()
34    }
35}
36
37/// Context for the item being edited.
38#[derive(Debug, Clone)]
39pub struct ItemContext {
40    /// The item ID.
41    pub id: String,
42    /// The item type.
43    pub item_type: ItemType,
44    /// The current name.
45    pub name: String,
46    /// The current description.
47    pub description: Option<String>,
48    /// The current specification.
49    pub specification: Option<String>,
50    /// The current platform.
51    pub platform: Option<String>,
52    /// The current traceability links.
53    pub traceability: TraceabilityLinks,
54    /// The file path.
55    pub file_path: PathBuf,
56}
57
58impl ItemContext {
59    /// Creates a context from an Item.
60    pub fn from_item(item: &Item) -> Self {
61        Self {
62            id: item.id.as_str().to_string(),
63            item_type: item.item_type,
64            name: item.name.clone(),
65            description: item.description.clone(),
66            specification: item.attributes.specification.clone(),
67            platform: item.attributes.platform.clone(),
68            traceability: TraceabilityLinks::from_upstream(&item.upstream),
69            file_path: item.source.full_path(),
70        }
71    }
72}
73
74/// Service for editing requirement items.
75#[derive(Debug, Default)]
76pub struct EditService;
77
78impl EditService {
79    /// Creates a new edit service.
80    pub fn new() -> Self {
81        Self
82    }
83
84    /// Looks up an item by ID with fuzzy suggestions on failure.
85    pub fn lookup_item<'a>(
86        &self,
87        graph: &'a KnowledgeGraph,
88        item_id: &str,
89    ) -> Result<&'a Item, EditError> {
90        lookup_item_or_suggest(graph, item_id)
91    }
92
93    /// Gets the context for an item.
94    pub fn get_item_context(&self, item: &Item) -> ItemContext {
95        ItemContext::from_item(item)
96    }
97
98    /// Validates edit options against the item type.
99    pub fn validate_options(
100        &self,
101        opts: &EditOptions,
102        item_type: ItemType,
103    ) -> Result<(), EditError> {
104        if opts.specification.is_some() && !item_type.requires_specification() {
105            return Err(EditError::IoError(format!(
106                "--specification is only valid for requirement types, not {}",
107                item_type.display_name()
108            )));
109        }
110
111        if opts.platform.is_some() && item_type != ItemType::SystemArchitecture {
112            return Err(EditError::IoError(
113                "--platform is only valid for System Architecture items".to_string(),
114            ));
115        }
116
117        Ok(())
118    }
119
120    /// Merges edit options with current item values.
121    pub fn merge_values(&self, opts: &EditOptions, current: &ItemContext) -> EditedValues {
122        EditedValues {
123            name: opts.name.clone().unwrap_or_else(|| current.name.clone()),
124            description: opts
125                .description
126                .clone()
127                .or_else(|| current.description.clone()),
128            specification: opts
129                .specification
130                .clone()
131                .or_else(|| current.specification.clone()),
132            platform: opts.platform.clone().or_else(|| current.platform.clone()),
133            traceability: TraceabilityLinks {
134                refines: opts
135                    .refines
136                    .clone()
137                    .unwrap_or_else(|| current.traceability.refines.clone()),
138                derives_from: opts
139                    .derives_from
140                    .clone()
141                    .unwrap_or_else(|| current.traceability.derives_from.clone()),
142                satisfies: opts
143                    .satisfies
144                    .clone()
145                    .unwrap_or_else(|| current.traceability.satisfies.clone()),
146            },
147        }
148    }
149
150    /// Builds a change summary comparing old and new values.
151    pub fn build_change_summary(&self, old: &ItemContext, new: &EditedValues) -> Vec<FieldChange> {
152        let mut changes = Vec::new();
153
154        changes.push(FieldChange::new(FieldName::Name, &old.name, &new.name));
155        changes.push(FieldChange::new(
156            FieldName::Description,
157            old.description.as_deref().unwrap_or("(none)"),
158            new.description.as_deref().unwrap_or("(none)"),
159        ));
160
161        // Traceability changes
162        self.add_traceability_change(
163            &mut changes,
164            FieldName::Refines,
165            &old.traceability.refines,
166            &new.traceability.refines,
167        );
168        self.add_traceability_change(
169            &mut changes,
170            FieldName::DerivesFrom,
171            &old.traceability.derives_from,
172            &new.traceability.derives_from,
173        );
174        self.add_traceability_change(
175            &mut changes,
176            FieldName::Satisfies,
177            &old.traceability.satisfies,
178            &new.traceability.satisfies,
179        );
180
181        // Type-specific
182        if old.specification.is_some() || new.specification.is_some() {
183            changes.push(FieldChange::new(
184                FieldName::Specification,
185                old.specification.as_deref().unwrap_or("(none)"),
186                new.specification.as_deref().unwrap_or("(none)"),
187            ));
188        }
189
190        if old.platform.is_some() || new.platform.is_some() {
191            changes.push(FieldChange::new(
192                FieldName::Platform,
193                old.platform.as_deref().unwrap_or("(none)"),
194                new.platform.as_deref().unwrap_or("(none)"),
195            ));
196        }
197
198        changes
199    }
200
201    /// Adds a traceability field change if values differ.
202    fn add_traceability_change(
203        &self,
204        changes: &mut Vec<FieldChange>,
205        field: FieldName,
206        old: &[String],
207        new: &[String],
208    ) {
209        if old.is_empty() && new.is_empty() {
210            return;
211        }
212
213        let old_str = if old.is_empty() {
214            "(none)".to_string()
215        } else {
216            old.join(", ")
217        };
218        let new_str = if new.is_empty() {
219            "(none)".to_string()
220        } else {
221            new.join(", ")
222        };
223
224        changes.push(FieldChange::new(field, &old_str, &new_str));
225    }
226
227    /// Applies changes to the file.
228    pub fn apply_changes(
229        &self,
230        item_id: &str,
231        item_type: ItemType,
232        new_values: &EditedValues,
233        file_path: &PathBuf,
234    ) -> Result<(), EditError> {
235        let content =
236            fs::read_to_string(file_path).map_err(|e| EditError::IoError(e.to_string()))?;
237        let new_yaml = self.build_frontmatter_yaml(item_id, item_type, new_values);
238        let updated_content = update_frontmatter(&content, &new_yaml);
239        fs::write(file_path, updated_content).map_err(|e| EditError::IoError(e.to_string()))?;
240        Ok(())
241    }
242
243    /// Builds YAML frontmatter string from edit values.
244    pub fn build_frontmatter_yaml(
245        &self,
246        item_id: &str,
247        item_type: ItemType,
248        values: &EditedValues,
249    ) -> String {
250        let mut yaml = format!(
251            "{}: \"{}\"\n{}: {}\n{}: \"{}\"\n",
252            FieldName::Id.as_str(),
253            item_id,
254            FieldName::Type.as_str(),
255            item_type.as_str(),
256            FieldName::Name.as_str(),
257            values.name.replace('"', "\\\"")
258        );
259
260        if let Some(ref desc) = values.description {
261            yaml += &format!(
262                "{}: \"{}\"\n",
263                FieldName::Description.as_str(),
264                desc.replace('"', "\\\"")
265            );
266        }
267
268        self.append_traceability_yaml(
269            &mut yaml,
270            FieldName::Refines.as_str(),
271            &values.traceability.refines,
272        );
273        self.append_traceability_yaml(
274            &mut yaml,
275            FieldName::DerivesFrom.as_str(),
276            &values.traceability.derives_from,
277        );
278        self.append_traceability_yaml(
279            &mut yaml,
280            FieldName::Satisfies.as_str(),
281            &values.traceability.satisfies,
282        );
283
284        if let Some(ref spec) = values.specification {
285            yaml += &format!(
286                "{}: \"{}\"\n",
287                FieldName::Specification.as_str(),
288                spec.replace('"', "\\\"")
289            );
290        }
291
292        if let Some(ref plat) = values.platform {
293            yaml += &format!(
294                "{}: \"{}\"\n",
295                FieldName::Platform.as_str(),
296                plat.replace('"', "\\\"")
297            );
298        }
299
300        yaml
301    }
302
303    /// Appends a traceability list to YAML if non-empty.
304    fn append_traceability_yaml(&self, yaml: &mut String, field: &str, ids: &[String]) {
305        if ids.is_empty() {
306            return;
307        }
308
309        *yaml += &format!("{}:\n", field);
310        for id in ids {
311            *yaml += &format!("  - \"{}\"\n", id);
312        }
313    }
314
315    /// Performs a non-interactive edit operation.
316    pub fn edit(
317        &self,
318        graph: &KnowledgeGraph,
319        opts: &EditOptions,
320    ) -> Result<EditResult, EditError> {
321        // Look up the item
322        let item = self.lookup_item(graph, &opts.item_id)?;
323        let item_ctx = self.get_item_context(item);
324
325        // Validate options
326        self.validate_options(opts, item_ctx.item_type)?;
327
328        // Merge values
329        let new_values = self.merge_values(opts, &item_ctx);
330
331        // Build change summary
332        let changes: Vec<FieldChange> = self
333            .build_change_summary(&item_ctx, &new_values)
334            .into_iter()
335            .filter(|c| c.is_changed())
336            .collect();
337
338        // Apply changes
339        self.apply_changes(
340            &item_ctx.id,
341            item_ctx.item_type,
342            &new_values,
343            &item_ctx.file_path,
344        )?;
345
346        Ok(EditResult {
347            item_id: item_ctx.id,
348            file_path: item_ctx.file_path,
349            changes,
350        })
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::model::{ItemBuilder, ItemId, SourceLocation};
358
359    fn create_test_item(id: &str, item_type: ItemType, name: &str) -> Item {
360        let source = SourceLocation::new(PathBuf::from("/repo"), format!("{}.md", id));
361        let mut builder = ItemBuilder::new()
362            .id(ItemId::new_unchecked(id))
363            .item_type(item_type)
364            .name(name)
365            .source(source);
366
367        if item_type.requires_specification() {
368            builder = builder.specification("Test specification");
369        }
370
371        builder.build().unwrap()
372    }
373
374    #[test]
375    fn test_edit_options_has_updates() {
376        let opts = EditOptions::new("SOL-001");
377        assert!(!opts.has_updates());
378
379        let opts_with_name = EditOptions::new("SOL-001").with_name("New Name");
380        assert!(opts_with_name.has_updates());
381    }
382
383    #[test]
384    fn test_item_context_from_item() {
385        let item = create_test_item("SOL-001", ItemType::Solution, "Test Solution");
386        let ctx = ItemContext::from_item(&item);
387
388        assert_eq!(ctx.id, "SOL-001");
389        assert_eq!(ctx.name, "Test Solution");
390        assert_eq!(ctx.item_type, ItemType::Solution);
391    }
392
393    #[test]
394    fn test_validate_options_specification() {
395        let service = EditService::new();
396
397        // Valid: specification on requirement type
398        let opts = EditOptions::new("SYSREQ-001").with_specification("new spec");
399        assert!(
400            service
401                .validate_options(&opts, ItemType::SystemRequirement)
402                .is_ok()
403        );
404
405        // Invalid: specification on solution type
406        let opts = EditOptions::new("SOL-001").with_specification("new spec");
407        assert!(service.validate_options(&opts, ItemType::Solution).is_err());
408    }
409
410    #[test]
411    fn test_validate_options_platform() {
412        let service = EditService::new();
413
414        // Valid: platform on system architecture
415        let opts = EditOptions::new("SYSARCH-001").with_platform("AWS");
416        assert!(
417            service
418                .validate_options(&opts, ItemType::SystemArchitecture)
419                .is_ok()
420        );
421
422        // Invalid: platform on solution
423        let opts = EditOptions::new("SOL-001").with_platform("AWS");
424        assert!(service.validate_options(&opts, ItemType::Solution).is_err());
425    }
426
427    #[test]
428    fn test_merge_values() {
429        let service = EditService::new();
430
431        let current = ItemContext {
432            id: "SOL-001".to_string(),
433            item_type: ItemType::Solution,
434            name: "Old Name".to_string(),
435            description: Some("Old Description".to_string()),
436            specification: None,
437            platform: None,
438            traceability: TraceabilityLinks::default(),
439            file_path: PathBuf::from("/test.md"),
440        };
441
442        let opts = EditOptions::new("SOL-001").with_name("New Name");
443
444        let merged = service.merge_values(&opts, &current);
445
446        assert_eq!(merged.name, "New Name");
447        assert_eq!(merged.description, Some("Old Description".to_string()));
448    }
449
450    #[test]
451    fn test_build_change_summary() {
452        let service = EditService::new();
453
454        let old = ItemContext {
455            id: "SOL-001".to_string(),
456            item_type: ItemType::Solution,
457            name: "Old Name".to_string(),
458            description: None,
459            specification: None,
460            platform: None,
461            traceability: TraceabilityLinks::default(),
462            file_path: PathBuf::from("/test.md"),
463        };
464
465        let new = EditedValues::new("New Name");
466
467        let changes = service.build_change_summary(&old, &new);
468
469        let name_change = changes.iter().find(|c| c.field == FieldName::Name).unwrap();
470        assert!(name_change.is_changed());
471        assert_eq!(name_change.old_value, "Old Name");
472        assert_eq!(name_change.new_value, "New Name");
473    }
474
475    #[test]
476    fn test_build_frontmatter_yaml() {
477        let service = EditService::new();
478
479        let values = EditedValues::new("Test Solution")
480            .with_description(Some("A test solution".to_string()));
481
482        let yaml = service.build_frontmatter_yaml("SOL-001", ItemType::Solution, &values);
483
484        assert!(yaml.contains("id: \"SOL-001\""));
485        assert!(yaml.contains("type: solution"));
486        assert!(yaml.contains("name: \"Test Solution\""));
487        assert!(yaml.contains("description: \"A test solution\""));
488    }
489}