1use 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#[derive(Debug, thiserror::Error)]
17pub enum InitError {
18 #[error("File {0} already has frontmatter. Use force to overwrite.")]
20 FrontmatterExists(PathBuf),
21
22 #[error("{0}")]
24 InvalidOption(String),
25
26 #[error("IO error: {0}")]
28 Io(#[from] std::io::Error),
29}
30
31#[derive(Debug, Clone)]
33pub struct InitResult {
34 pub id: String,
36 pub name: String,
38 pub item_type: ItemType,
40 pub file: PathBuf,
42 pub updated_existing: bool,
44 pub replaced_frontmatter: bool,
46 pub needs_specification: bool,
48}
49
50#[derive(Debug, Default)]
52pub struct InitService;
53
54impl InitService {
55 pub fn new() -> Self {
57 Self
58 }
59
60 pub fn init(&self, opts: &InitOptions) -> Result<InitResult, InitError> {
64 self.validate_options(opts)?;
66
67 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 let id = self.resolve_id(opts);
77 let name = self.resolve_name(opts, &id)?;
78
79 let gen_opts = self.build_generator_options(opts, id.clone(), name.clone());
81
82 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 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 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 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 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 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 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 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 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 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 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 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 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 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
300fn 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 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
328pub 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 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"); 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 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 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}