use std::fs;
use std::path::PathBuf;
use crate::model::ItemType;
use crate::parser::has_frontmatter;
use crate::template::{
GeneratorOptions, extract_name_from_content, generate_document, generate_frontmatter,
generate_id,
};
use super::InitOptions;
#[derive(Debug, thiserror::Error)]
pub enum InitError {
#[error("File {0} already has frontmatter. Use force to overwrite.")]
FrontmatterExists(PathBuf),
#[error("{0}")]
InvalidOption(String),
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone)]
pub struct InitResult {
pub id: String,
pub name: String,
pub item_type: ItemType,
pub file: PathBuf,
pub updated_existing: bool,
pub replaced_frontmatter: bool,
pub needs_specification: bool,
}
#[derive(Debug, Default)]
pub struct InitService;
impl InitService {
pub fn new() -> Self {
Self
}
pub fn init(&self, opts: &InitOptions) -> Result<InitResult, InitError> {
self.validate_options(opts)?;
if opts.file.exists() && !opts.force {
let content = fs::read_to_string(&opts.file)?;
if has_frontmatter(&content) {
return Err(InitError::FrontmatterExists(opts.file.clone()));
}
}
let id = self.resolve_id(opts);
let name = self.resolve_name(opts, &id)?;
let gen_opts = self.build_generator_options(opts, id.clone(), name.clone());
let (updated_existing, replaced_frontmatter) = self.write_file(opts, &gen_opts)?;
Ok(InitResult {
id,
name,
item_type: opts.item_type,
file: opts.file.clone(),
updated_existing,
replaced_frontmatter,
needs_specification: opts.item_type.requires_specification()
&& opts.specification.is_none(),
})
}
fn validate_options(&self, opts: &InitOptions) -> Result<(), InitError> {
self.validate_refines(opts)?;
self.validate_derives_from(opts)?;
self.validate_satisfies(opts)?;
self.validate_specification(opts)?;
self.validate_platform(opts)?;
Ok(())
}
fn validate_refines(&self, opts: &InitOptions) -> Result<(), InitError> {
if opts.refines.is_empty() {
return Ok(());
}
match opts.item_type {
ItemType::UseCase | ItemType::Scenario => Ok(()),
_ => Err(InitError::InvalidOption(format!(
"refines is only valid for use_case and scenario types, not {}",
opts.item_type.display_name()
))),
}
}
fn validate_derives_from(&self, opts: &InitOptions) -> Result<(), InitError> {
if opts.derives_from.is_empty() {
return Ok(());
}
match opts.item_type {
ItemType::SystemRequirement
| ItemType::HardwareRequirement
| ItemType::SoftwareRequirement => Ok(()),
_ => Err(InitError::InvalidOption(format!(
"derives_from is only valid for requirement types, not {}",
opts.item_type.display_name()
))),
}
}
fn validate_satisfies(&self, opts: &InitOptions) -> Result<(), InitError> {
if opts.satisfies.is_empty() {
return Ok(());
}
match opts.item_type {
ItemType::SystemArchitecture
| ItemType::HardwareDetailedDesign
| ItemType::SoftwareDetailedDesign => Ok(()),
_ => Err(InitError::InvalidOption(format!(
"satisfies is only valid for architecture and design types, not {}",
opts.item_type.display_name()
))),
}
}
fn validate_specification(&self, opts: &InitOptions) -> Result<(), InitError> {
if opts.specification.is_none() {
return Ok(());
}
match opts.item_type {
ItemType::SystemRequirement
| ItemType::HardwareRequirement
| ItemType::SoftwareRequirement => Ok(()),
_ => Err(InitError::InvalidOption(format!(
"specification is only valid for requirement types, not {}",
opts.item_type.display_name()
))),
}
}
fn validate_platform(&self, opts: &InitOptions) -> Result<(), InitError> {
if opts.platform.is_none() || opts.item_type == ItemType::SystemArchitecture {
return Ok(());
}
Err(InitError::InvalidOption(format!(
"platform is only valid for system_architecture type, not {}",
opts.item_type.display_name()
)))
}
fn resolve_id(&self, opts: &InitOptions) -> String {
opts.id
.clone()
.unwrap_or_else(|| generate_id(opts.item_type, None))
}
fn resolve_name(&self, opts: &InitOptions, id: &str) -> Result<String, InitError> {
if let Some(ref name) = opts.name {
return Ok(name.clone());
}
if opts.file.exists() {
let content = fs::read_to_string(&opts.file)?;
if let Some(name) = extract_name_from_content(&content) {
return Ok(name);
}
}
Ok(self.file_stem_or_fallback(&opts.file, id))
}
fn file_stem_or_fallback(&self, file: &std::path::Path, fallback: &str) -> String {
file.file_stem()
.map(|s| s.to_string_lossy().to_string())
.unwrap_or_else(|| fallback.to_string())
}
fn build_generator_options(
&self,
opts: &InitOptions,
id: String,
name: String,
) -> GeneratorOptions {
let mut gen_opts = GeneratorOptions::new(opts.item_type, id, name);
if let Some(ref desc) = opts.description {
gen_opts = gen_opts.with_description(desc);
}
if !opts.refines.is_empty() {
gen_opts = gen_opts.with_refines(opts.refines.clone());
}
if !opts.derives_from.is_empty() {
gen_opts = gen_opts.with_derives_from(opts.derives_from.clone());
}
if !opts.satisfies.is_empty() {
gen_opts = gen_opts.with_satisfies(opts.satisfies.clone());
}
if let Some(ref spec) = opts.specification {
gen_opts = gen_opts.with_specification(spec);
}
if let Some(ref platform) = opts.platform {
gen_opts = gen_opts.with_platform(platform);
}
gen_opts
}
fn write_file(
&self,
opts: &InitOptions,
gen_opts: &GeneratorOptions,
) -> Result<(bool, bool), InitError> {
if opts.file.exists() {
let replaced = self.update_existing_file(opts, gen_opts)?;
Ok((true, replaced))
} else {
self.create_new_file(opts, gen_opts)?;
Ok((false, false))
}
}
fn update_existing_file(
&self,
opts: &InitOptions,
gen_opts: &GeneratorOptions,
) -> Result<bool, InitError> {
let content = fs::read_to_string(&opts.file)?;
let frontmatter = generate_frontmatter(gen_opts);
let (new_content, replaced) = if has_frontmatter(&content) && opts.force {
let body = remove_frontmatter(&content);
(format!("{}\n{}", frontmatter, body), true)
} else {
(format!("{}\n{}", frontmatter, content), false)
};
fs::write(&opts.file, new_content)?;
Ok(replaced)
}
fn create_new_file(
&self,
opts: &InitOptions,
gen_opts: &GeneratorOptions,
) -> Result<(), InitError> {
let document = generate_document(gen_opts);
if let Some(parent) = opts.file.parent() {
fs::create_dir_all(parent)?;
}
fs::write(&opts.file, document)?;
Ok(())
}
}
fn remove_frontmatter(content: &str) -> &str {
let mut in_frontmatter = false;
let mut frontmatter_end = 0;
for (i, line) in content.lines().enumerate() {
if line.trim() == "---" {
if !in_frontmatter {
in_frontmatter = true;
} else {
frontmatter_end = content
.lines()
.take(i + 1)
.map(|l| l.len() + 1)
.sum::<usize>();
break;
}
}
}
if frontmatter_end > 0 && frontmatter_end < content.len() {
&content[frontmatter_end..]
} else {
content
}
}
pub fn parse_item_type(type_str: &str) -> Option<ItemType> {
match type_str.to_lowercase().as_str() {
"solution" | "sol" => Some(ItemType::Solution),
"use_case" | "usecase" | "uc" => Some(ItemType::UseCase),
"scenario" | "scen" => Some(ItemType::Scenario),
"system_requirement" | "systemrequirement" | "sysreq" => Some(ItemType::SystemRequirement),
"system_architecture" | "systemarchitecture" | "sysarch" => {
Some(ItemType::SystemArchitecture)
}
"hardware_requirement" | "hardwarerequirement" | "hwreq" => {
Some(ItemType::HardwareRequirement)
}
"software_requirement" | "softwarerequirement" | "swreq" => {
Some(ItemType::SoftwareRequirement)
}
"hardware_detailed_design" | "hardwaredetaileddesign" | "hwdd" => {
Some(ItemType::HardwareDetailedDesign)
}
"software_detailed_design" | "softwaredetaileddesign" | "swdd" => {
Some(ItemType::SoftwareDetailedDesign)
}
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_parse_item_type() {
assert_eq!(parse_item_type("solution"), Some(ItemType::Solution));
assert_eq!(parse_item_type("SOL"), Some(ItemType::Solution));
assert_eq!(parse_item_type("use_case"), Some(ItemType::UseCase));
assert_eq!(parse_item_type("UC"), Some(ItemType::UseCase));
assert_eq!(parse_item_type("invalid"), None);
}
#[test]
fn test_remove_frontmatter() {
let content = "---\nid: test\n---\n# Body";
let body = remove_frontmatter(content);
assert_eq!(body.trim(), "# Body");
}
#[test]
fn test_init_new_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.md");
let opts = InitOptions::new(file_path.clone(), ItemType::Solution)
.with_id("SOL-001")
.with_name("Test Solution");
let service = InitService::new();
let result = service.init(&opts).unwrap();
assert_eq!(result.id, "SOL-001");
assert_eq!(result.name, "Test Solution");
assert!(!result.updated_existing);
assert!(file_path.exists());
let content = fs::read_to_string(&file_path).unwrap();
assert!(content.contains("id: \"SOL-001\""));
assert!(content.contains("# Solution: Test Solution"));
}
#[test]
fn test_init_existing_file_without_frontmatter() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("existing.md");
fs::write(&file_path, "# My Document\n\nSome content here.").unwrap();
let opts = InitOptions::new(file_path.clone(), ItemType::UseCase).with_id("UC-001");
let service = InitService::new();
let result = service.init(&opts).unwrap();
assert_eq!(result.id, "UC-001");
assert_eq!(result.name, "My Document"); assert!(result.updated_existing);
assert!(!result.replaced_frontmatter);
let content = fs::read_to_string(&file_path).unwrap();
assert!(content.contains("id: \"UC-001\""));
assert!(content.contains("# My Document"));
}
#[test]
fn test_init_existing_file_with_frontmatter_no_force() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("existing.md");
fs::write(&file_path, "---\nid: OLD-001\n---\n# Content").unwrap();
let opts = InitOptions::new(file_path, ItemType::Solution).with_id("SOL-001");
let service = InitService::new();
let result = service.init(&opts);
assert!(matches!(result, Err(InitError::FrontmatterExists(_))));
}
#[test]
fn test_init_existing_file_with_frontmatter_force() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("existing.md");
fs::write(&file_path, "---\nid: OLD-001\n---\n# Content").unwrap();
let opts = InitOptions::new(file_path.clone(), ItemType::Solution)
.with_id("SOL-001")
.with_name("New Solution")
.with_force(true);
let service = InitService::new();
let result = service.init(&opts).unwrap();
assert_eq!(result.id, "SOL-001");
assert!(result.updated_existing);
assert!(result.replaced_frontmatter);
let content = fs::read_to_string(&file_path).unwrap();
assert!(content.contains("id: \"SOL-001\""));
assert!(!content.contains("OLD-001"));
}
#[test]
fn test_validate_refines_valid() {
let temp_dir = TempDir::new().unwrap();
let opts = InitOptions::new(temp_dir.path().join("test.md"), ItemType::UseCase)
.with_refines(vec!["SOL-001".to_string()]);
let service = InitService::new();
assert!(service.validate_options(&opts).is_ok());
}
#[test]
fn test_validate_refines_invalid() {
let temp_dir = TempDir::new().unwrap();
let opts = InitOptions::new(temp_dir.path().join("test.md"), ItemType::SystemRequirement)
.with_refines(vec!["SOL-001".to_string()]);
let service = InitService::new();
assert!(service.validate_options(&opts).is_err());
}
#[test]
fn test_validate_platform_valid() {
let temp_dir = TempDir::new().unwrap();
let opts = InitOptions::new(
temp_dir.path().join("test.md"),
ItemType::SystemArchitecture,
)
.with_platform("AWS");
let service = InitService::new();
assert!(service.validate_options(&opts).is_ok());
}
#[test]
fn test_validate_platform_invalid() {
let temp_dir = TempDir::new().unwrap();
let opts = InitOptions::new(temp_dir.path().join("test.md"), ItemType::Solution)
.with_platform("AWS");
let service = InitService::new();
assert!(service.validate_options(&opts).is_err());
}
#[test]
fn test_needs_specification() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.md");
let opts = InitOptions::new(file_path, ItemType::SystemRequirement).with_id("SYSREQ-001");
let service = InitService::new();
let result = service.init(&opts).unwrap();
assert!(result.needs_specification);
}
}