sara_core/init/
service.rs

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