1use 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#[derive(Debug)]
16pub struct EditResult {
17 pub item_id: String,
19 pub file_path: PathBuf,
21 pub changes: Vec<FieldChange>,
23}
24
25impl EditResult {
26 pub fn has_changes(&self) -> bool {
28 !self.changes.is_empty()
29 }
30
31 pub fn change_count(&self) -> usize {
33 self.changes.len()
34 }
35}
36
37#[derive(Debug, Clone)]
39pub struct ItemContext {
40 pub id: String,
42 pub item_type: ItemType,
44 pub name: String,
46 pub description: Option<String>,
48 pub specification: Option<String>,
50 pub platform: Option<String>,
52 pub traceability: TraceabilityLinks,
54 pub file_path: PathBuf,
56}
57
58impl ItemContext {
59 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#[derive(Debug, Default)]
76pub struct EditService;
77
78impl EditService {
79 pub fn new() -> Self {
81 Self
82 }
83
84 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 pub fn get_item_context(&self, item: &Item) -> ItemContext {
95 ItemContext::from_item(item)
96 }
97
98 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 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 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 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 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 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 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 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 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 pub fn edit(
317 &self,
318 graph: &KnowledgeGraph,
319 opts: &EditOptions,
320 ) -> Result<EditResult, EditError> {
321 let item = self.lookup_item(graph, &opts.item_id)?;
323 let item_ctx = self.get_item_context(item);
324
325 self.validate_options(opts, item_ctx.item_type)?;
327
328 let new_values = self.merge_values(opts, &item_ctx);
330
331 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 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 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 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 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 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, ¤t);
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}