1use 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
15fn 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#[derive(Debug, thiserror::Error)]
28pub enum InitError {
29 #[error("File {0} already has frontmatter. Use force to overwrite.")]
31 FrontmatterExists(PathBuf),
32
33 #[error("{0}")]
35 InvalidOption(String),
36
37 #[error("IO error: {0}")]
39 Io(#[from] std::io::Error),
40}
41
42#[derive(Debug, Clone)]
44pub struct InitResult {
45 pub id: String,
47 pub name: String,
49 pub item_type: ItemType,
51 pub file: PathBuf,
53 pub updated_existing: bool,
55 pub replaced_frontmatter: bool,
57 pub needs_specification: bool,
59}
60
61#[derive(Debug, Default)]
63pub struct InitService;
64
65impl InitService {
66 pub fn new() -> Self {
68 Self
69 }
70
71 pub fn init(&self, opts: &InitOptions) -> Result<InitResult, InitError> {
75 self.validate_options(opts)?;
77
78 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 let id = self.resolve_id(opts);
88 let name = self.resolve_name(opts, &id)?;
89
90 let gen_opts = self.build_generator_options(opts, id.clone(), name.clone());
92
93 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 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 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 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 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 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 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 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 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
263fn 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 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
291pub 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 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"); 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 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 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}