sara_core/init/
service.rs

1//! Init service implementation.
2
3use std::fs;
4use std::path::PathBuf;
5
6use crate::model::ItemType;
7use crate::parser::has_frontmatter;
8use crate::template::{
9    GeneratorOptions, extract_name_from_content, generate_document, generate_frontmatter,
10    generate_id,
11};
12
13use super::InitOptions;
14
15/// Errors that can occur during initialization.
16#[derive(Debug, thiserror::Error)]
17pub enum InitError {
18    /// File already has frontmatter and force was not set.
19    #[error("File {0} already has frontmatter. Use force to overwrite.")]
20    FrontmatterExists(PathBuf),
21
22    /// Invalid option for the given item type.
23    #[error("{0}")]
24    InvalidOption(String),
25
26    /// IO error.
27    #[error("IO error: {0}")]
28    Io(#[from] std::io::Error),
29}
30
31/// Result of a successful initialization.
32#[derive(Debug, Clone)]
33pub struct InitResult {
34    /// The resolved ID.
35    pub id: String,
36    /// The resolved name.
37    pub name: String,
38    /// The item type.
39    pub item_type: ItemType,
40    /// The file path.
41    pub file: PathBuf,
42    /// Whether an existing file was updated (true) or a new file was created (false).
43    pub updated_existing: bool,
44    /// Whether frontmatter was replaced (only relevant if updated_existing is true).
45    pub replaced_frontmatter: bool,
46    /// Whether specification field needs attention.
47    pub needs_specification: bool,
48}
49
50/// Service for initializing requirement items.
51#[derive(Debug, Default)]
52pub struct InitService;
53
54impl InitService {
55    /// Creates a new init service.
56    pub fn new() -> Self {
57        Self
58    }
59
60    /// Initializes an item based on the provided options.
61    ///
62    /// This will either create a new file or update an existing file with frontmatter.
63    pub fn init(&self, opts: &InitOptions) -> Result<InitResult, InitError> {
64        // Validate type-specific options
65        self.validate_options(opts)?;
66
67        // Check for existing frontmatter
68        if opts.file.exists() && !opts.force {
69            let content = fs::read_to_string(&opts.file)?;
70            if has_frontmatter(&content) {
71                return Err(InitError::FrontmatterExists(opts.file.clone()));
72            }
73        }
74
75        // Resolve ID and name
76        let id = self.resolve_id(opts);
77        let name = self.resolve_name(opts, &id)?;
78
79        // Build generator options
80        let gen_opts = self.build_generator_options(opts, id.clone(), name.clone());
81
82        // Write the file
83        let (updated_existing, replaced_frontmatter) = self.write_file(opts, &gen_opts)?;
84
85        Ok(InitResult {
86            id,
87            name,
88            item_type: opts.item_type,
89            file: opts.file.clone(),
90            updated_existing,
91            replaced_frontmatter,
92            needs_specification: opts.item_type.requires_specification()
93                && opts.specification.is_none(),
94        })
95    }
96
97    /// Validates type-specific options.
98    fn validate_options(&self, opts: &InitOptions) -> Result<(), InitError> {
99        self.validate_refines(opts)?;
100        self.validate_derives_from(opts)?;
101        self.validate_satisfies(opts)?;
102        self.validate_specification(opts)?;
103        self.validate_platform(opts)?;
104        Ok(())
105    }
106
107    /// Validates --refines is only used with use_case or scenario.
108    fn validate_refines(&self, opts: &InitOptions) -> Result<(), InitError> {
109        if opts.refines.is_empty() {
110            return Ok(());
111        }
112
113        match opts.item_type {
114            ItemType::UseCase | ItemType::Scenario => Ok(()),
115            _ => Err(InitError::InvalidOption(format!(
116                "refines is only valid for use_case and scenario types, not {}",
117                opts.item_type.display_name()
118            ))),
119        }
120    }
121
122    /// Validates --derives-from is only used with requirement types.
123    fn validate_derives_from(&self, opts: &InitOptions) -> Result<(), InitError> {
124        if opts.derives_from.is_empty() {
125            return Ok(());
126        }
127
128        match opts.item_type {
129            ItemType::SystemRequirement
130            | ItemType::HardwareRequirement
131            | ItemType::SoftwareRequirement => Ok(()),
132            _ => Err(InitError::InvalidOption(format!(
133                "derives_from is only valid for requirement types, not {}",
134                opts.item_type.display_name()
135            ))),
136        }
137    }
138
139    /// Validates --satisfies is only used with architecture and design types.
140    fn validate_satisfies(&self, opts: &InitOptions) -> Result<(), InitError> {
141        if opts.satisfies.is_empty() {
142            return Ok(());
143        }
144
145        match opts.item_type {
146            ItemType::SystemArchitecture
147            | ItemType::HardwareDetailedDesign
148            | ItemType::SoftwareDetailedDesign => Ok(()),
149            _ => Err(InitError::InvalidOption(format!(
150                "satisfies is only valid for architecture and design types, not {}",
151                opts.item_type.display_name()
152            ))),
153        }
154    }
155
156    /// Validates --specification is only used with requirement types.
157    fn validate_specification(&self, opts: &InitOptions) -> Result<(), InitError> {
158        if opts.specification.is_none() {
159            return Ok(());
160        }
161
162        match opts.item_type {
163            ItemType::SystemRequirement
164            | ItemType::HardwareRequirement
165            | ItemType::SoftwareRequirement => Ok(()),
166            _ => Err(InitError::InvalidOption(format!(
167                "specification is only valid for requirement types, not {}",
168                opts.item_type.display_name()
169            ))),
170        }
171    }
172
173    /// Validates --platform is only used with system_architecture.
174    fn validate_platform(&self, opts: &InitOptions) -> Result<(), InitError> {
175        if opts.platform.is_none() || opts.item_type == ItemType::SystemArchitecture {
176            return Ok(());
177        }
178
179        Err(InitError::InvalidOption(format!(
180            "platform is only valid for system_architecture type, not {}",
181            opts.item_type.display_name()
182        )))
183    }
184
185    /// Resolves the ID from options or generates a new one.
186    fn resolve_id(&self, opts: &InitOptions) -> String {
187        opts.id
188            .clone()
189            .unwrap_or_else(|| generate_id(opts.item_type, None))
190    }
191
192    /// Resolves the name from options, file content, or file stem.
193    fn resolve_name(&self, opts: &InitOptions, id: &str) -> Result<String, InitError> {
194        if let Some(ref name) = opts.name {
195            return Ok(name.clone());
196        }
197
198        if opts.file.exists() {
199            let content = fs::read_to_string(&opts.file)?;
200            if let Some(name) = extract_name_from_content(&content) {
201                return Ok(name);
202            }
203        }
204
205        Ok(self.file_stem_or_fallback(&opts.file, id))
206    }
207
208    /// Returns the file stem as a string, or the fallback if unavailable.
209    fn file_stem_or_fallback(&self, file: &std::path::Path, fallback: &str) -> String {
210        file.file_stem()
211            .map(|s| s.to_string_lossy().to_string())
212            .unwrap_or_else(|| fallback.to_string())
213    }
214
215    /// Builds generator options from init options.
216    fn build_generator_options(
217        &self,
218        opts: &InitOptions,
219        id: String,
220        name: String,
221    ) -> GeneratorOptions {
222        let mut gen_opts = GeneratorOptions::new(opts.item_type, id, name);
223
224        if let Some(ref desc) = opts.description {
225            gen_opts = gen_opts.with_description(desc);
226        }
227        if !opts.refines.is_empty() {
228            gen_opts = gen_opts.with_refines(opts.refines.clone());
229        }
230        if !opts.derives_from.is_empty() {
231            gen_opts = gen_opts.with_derives_from(opts.derives_from.clone());
232        }
233        if !opts.satisfies.is_empty() {
234            gen_opts = gen_opts.with_satisfies(opts.satisfies.clone());
235        }
236        if let Some(ref spec) = opts.specification {
237            gen_opts = gen_opts.with_specification(spec);
238        }
239        if let Some(ref platform) = opts.platform {
240            gen_opts = gen_opts.with_platform(platform);
241        }
242
243        gen_opts
244    }
245
246    /// Writes the file, either updating existing or creating new.
247    /// Returns (updated_existing, replaced_frontmatter).
248    fn write_file(
249        &self,
250        opts: &InitOptions,
251        gen_opts: &GeneratorOptions,
252    ) -> Result<(bool, bool), InitError> {
253        if opts.file.exists() {
254            let replaced = self.update_existing_file(opts, gen_opts)?;
255            Ok((true, replaced))
256        } else {
257            self.create_new_file(opts, gen_opts)?;
258            Ok((false, false))
259        }
260    }
261
262    /// Updates an existing file by adding or replacing frontmatter.
263    /// Returns true if frontmatter was replaced, false if it was added.
264    fn update_existing_file(
265        &self,
266        opts: &InitOptions,
267        gen_opts: &GeneratorOptions,
268    ) -> Result<bool, InitError> {
269        let content = fs::read_to_string(&opts.file)?;
270        let frontmatter = generate_frontmatter(gen_opts);
271
272        let (new_content, replaced) = if has_frontmatter(&content) && opts.force {
273            let body = remove_frontmatter(&content);
274            (format!("{}\n{}", frontmatter, body), true)
275        } else {
276            (format!("{}\n{}", frontmatter, content), false)
277        };
278
279        fs::write(&opts.file, new_content)?;
280        Ok(replaced)
281    }
282
283    /// Creates a new file with the generated document.
284    fn create_new_file(
285        &self,
286        opts: &InitOptions,
287        gen_opts: &GeneratorOptions,
288    ) -> Result<(), InitError> {
289        let document = generate_document(gen_opts);
290
291        if let Some(parent) = opts.file.parent() {
292            fs::create_dir_all(parent)?;
293        }
294
295        fs::write(&opts.file, document)?;
296        Ok(())
297    }
298}
299
300/// Removes frontmatter from content.
301fn remove_frontmatter(content: &str) -> &str {
302    let mut in_frontmatter = false;
303    let mut frontmatter_end = 0;
304
305    for (i, line) in content.lines().enumerate() {
306        if line.trim() == "---" {
307            if !in_frontmatter {
308                in_frontmatter = true;
309            } else {
310                // Found end of frontmatter
311                frontmatter_end = content
312                    .lines()
313                    .take(i + 1)
314                    .map(|l| l.len() + 1)
315                    .sum::<usize>();
316                break;
317            }
318        }
319    }
320
321    if frontmatter_end > 0 && frontmatter_end < content.len() {
322        &content[frontmatter_end..]
323    } else {
324        content
325    }
326}
327
328/// Parses an item type string into ItemType enum.
329pub fn parse_item_type(type_str: &str) -> Option<ItemType> {
330    match type_str.to_lowercase().as_str() {
331        "solution" | "sol" => Some(ItemType::Solution),
332        "use_case" | "usecase" | "uc" => Some(ItemType::UseCase),
333        "scenario" | "scen" => Some(ItemType::Scenario),
334        "system_requirement" | "systemrequirement" | "sysreq" => Some(ItemType::SystemRequirement),
335        "system_architecture" | "systemarchitecture" | "sysarch" => {
336            Some(ItemType::SystemArchitecture)
337        }
338        "hardware_requirement" | "hardwarerequirement" | "hwreq" => {
339            Some(ItemType::HardwareRequirement)
340        }
341        "software_requirement" | "softwarerequirement" | "swreq" => {
342            Some(ItemType::SoftwareRequirement)
343        }
344        "hardware_detailed_design" | "hardwaredetaileddesign" | "hwdd" => {
345            Some(ItemType::HardwareDetailedDesign)
346        }
347        "software_detailed_design" | "softwaredetaileddesign" | "swdd" => {
348            Some(ItemType::SoftwareDetailedDesign)
349        }
350        _ => None,
351    }
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use tempfile::TempDir;
358
359    #[test]
360    fn test_parse_item_type() {
361        assert_eq!(parse_item_type("solution"), Some(ItemType::Solution));
362        assert_eq!(parse_item_type("SOL"), Some(ItemType::Solution));
363        assert_eq!(parse_item_type("use_case"), Some(ItemType::UseCase));
364        assert_eq!(parse_item_type("UC"), Some(ItemType::UseCase));
365        assert_eq!(parse_item_type("invalid"), None);
366    }
367
368    #[test]
369    fn test_remove_frontmatter() {
370        let content = "---\nid: test\n---\n# Body";
371        let body = remove_frontmatter(content);
372        assert_eq!(body.trim(), "# Body");
373    }
374
375    #[test]
376    fn test_init_new_file() {
377        let temp_dir = TempDir::new().unwrap();
378        let file_path = temp_dir.path().join("test.md");
379
380        let opts = InitOptions::new(file_path.clone(), ItemType::Solution)
381            .with_id("SOL-001")
382            .with_name("Test Solution");
383
384        let service = InitService::new();
385        let result = service.init(&opts).unwrap();
386
387        assert_eq!(result.id, "SOL-001");
388        assert_eq!(result.name, "Test Solution");
389        assert!(!result.updated_existing);
390        assert!(file_path.exists());
391
392        let content = fs::read_to_string(&file_path).unwrap();
393        assert!(content.contains("id: \"SOL-001\""));
394        assert!(content.contains("# Solution: Test Solution"));
395    }
396
397    #[test]
398    fn test_init_existing_file_without_frontmatter() {
399        let temp_dir = TempDir::new().unwrap();
400        let file_path = temp_dir.path().join("existing.md");
401
402        // Create existing file without frontmatter
403        fs::write(&file_path, "# My Document\n\nSome content here.").unwrap();
404
405        let opts = InitOptions::new(file_path.clone(), ItemType::UseCase).with_id("UC-001");
406
407        let service = InitService::new();
408        let result = service.init(&opts).unwrap();
409
410        assert_eq!(result.id, "UC-001");
411        assert_eq!(result.name, "My Document"); // Extracted from heading
412        assert!(result.updated_existing);
413        assert!(!result.replaced_frontmatter);
414
415        let content = fs::read_to_string(&file_path).unwrap();
416        assert!(content.contains("id: \"UC-001\""));
417        assert!(content.contains("# My Document"));
418    }
419
420    #[test]
421    fn test_init_existing_file_with_frontmatter_no_force() {
422        let temp_dir = TempDir::new().unwrap();
423        let file_path = temp_dir.path().join("existing.md");
424
425        // Create existing file with frontmatter
426        fs::write(&file_path, "---\nid: OLD-001\n---\n# Content").unwrap();
427
428        let opts = InitOptions::new(file_path, ItemType::Solution).with_id("SOL-001");
429
430        let service = InitService::new();
431        let result = service.init(&opts);
432
433        assert!(matches!(result, Err(InitError::FrontmatterExists(_))));
434    }
435
436    #[test]
437    fn test_init_existing_file_with_frontmatter_force() {
438        let temp_dir = TempDir::new().unwrap();
439        let file_path = temp_dir.path().join("existing.md");
440
441        // Create existing file with frontmatter
442        fs::write(&file_path, "---\nid: OLD-001\n---\n# Content").unwrap();
443
444        let opts = InitOptions::new(file_path.clone(), ItemType::Solution)
445            .with_id("SOL-001")
446            .with_name("New Solution")
447            .with_force(true);
448
449        let service = InitService::new();
450        let result = service.init(&opts).unwrap();
451
452        assert_eq!(result.id, "SOL-001");
453        assert!(result.updated_existing);
454        assert!(result.replaced_frontmatter);
455
456        let content = fs::read_to_string(&file_path).unwrap();
457        assert!(content.contains("id: \"SOL-001\""));
458        assert!(!content.contains("OLD-001"));
459    }
460
461    #[test]
462    fn test_validate_refines_valid() {
463        let temp_dir = TempDir::new().unwrap();
464        let opts = InitOptions::new(temp_dir.path().join("test.md"), ItemType::UseCase)
465            .with_refines(vec!["SOL-001".to_string()]);
466
467        let service = InitService::new();
468        assert!(service.validate_options(&opts).is_ok());
469    }
470
471    #[test]
472    fn test_validate_refines_invalid() {
473        let temp_dir = TempDir::new().unwrap();
474        let opts = InitOptions::new(temp_dir.path().join("test.md"), ItemType::SystemRequirement)
475            .with_refines(vec!["SOL-001".to_string()]);
476
477        let service = InitService::new();
478        assert!(service.validate_options(&opts).is_err());
479    }
480
481    #[test]
482    fn test_validate_platform_valid() {
483        let temp_dir = TempDir::new().unwrap();
484        let opts = InitOptions::new(
485            temp_dir.path().join("test.md"),
486            ItemType::SystemArchitecture,
487        )
488        .with_platform("AWS");
489
490        let service = InitService::new();
491        assert!(service.validate_options(&opts).is_ok());
492    }
493
494    #[test]
495    fn test_validate_platform_invalid() {
496        let temp_dir = TempDir::new().unwrap();
497        let opts = InitOptions::new(temp_dir.path().join("test.md"), ItemType::Solution)
498            .with_platform("AWS");
499
500        let service = InitService::new();
501        assert!(service.validate_options(&opts).is_err());
502    }
503
504    #[test]
505    fn test_needs_specification() {
506        let temp_dir = TempDir::new().unwrap();
507        let file_path = temp_dir.path().join("test.md");
508
509        let opts = InitOptions::new(file_path, ItemType::SystemRequirement).with_id("SYSREQ-001");
510
511        let service = InitService::new();
512        let result = service.init(&opts).unwrap();
513
514        assert!(result.needs_specification);
515    }
516}