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_id,
};
use super::{InitOptions, TypeConfig};
#[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> {
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 item_type = opts.item_type();
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)?;
let needs_specification = self.check_needs_specification(&opts.type_config);
Ok(InitResult {
id,
name,
item_type,
file: opts.file.clone(),
updated_existing,
replaced_frontmatter,
needs_specification,
})
}
fn check_needs_specification(&self, type_config: &TypeConfig) -> bool {
match type_config {
TypeConfig::SystemRequirement { specification, .. }
| TypeConfig::SoftwareRequirement { specification, .. }
| TypeConfig::HardwareRequirement { specification, .. } => specification.is_none(),
_ => false,
}
}
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);
}
match &opts.type_config {
TypeConfig::Solution => {}
TypeConfig::UseCase { refines } => {
if !refines.is_empty() {
gen_opts = gen_opts.with_refines(refines.clone());
}
}
TypeConfig::Scenario { refines } => {
if !refines.is_empty() {
gen_opts = gen_opts.with_refines(refines.clone());
}
}
TypeConfig::SystemRequirement {
specification,
derives_from,
depends_on,
} => {
if let Some(spec) = specification {
gen_opts = gen_opts.with_specification(spec);
}
if !derives_from.is_empty() {
gen_opts = gen_opts.with_derives_from(derives_from.clone());
}
if !depends_on.is_empty() {
gen_opts = gen_opts.with_depends_on(depends_on.clone());
}
}
TypeConfig::SystemArchitecture {
platform,
satisfies,
} => {
if let Some(p) = platform {
gen_opts = gen_opts.with_platform(p);
}
if !satisfies.is_empty() {
gen_opts = gen_opts.with_satisfies(satisfies.clone());
}
}
TypeConfig::SoftwareRequirement {
specification,
derives_from,
depends_on,
} => {
if let Some(spec) = specification {
gen_opts = gen_opts.with_specification(spec);
}
if !derives_from.is_empty() {
gen_opts = gen_opts.with_derives_from(derives_from.clone());
}
if !depends_on.is_empty() {
gen_opts = gen_opts.with_depends_on(depends_on.clone());
}
}
TypeConfig::HardwareRequirement {
specification,
derives_from,
depends_on,
} => {
if let Some(spec) = specification {
gen_opts = gen_opts.with_specification(spec);
}
if !derives_from.is_empty() {
gen_opts = gen_opts.with_derives_from(derives_from.clone());
}
if !depends_on.is_empty() {
gen_opts = gen_opts.with_depends_on(depends_on.clone());
}
}
TypeConfig::SoftwareDetailedDesign { satisfies } => {
if !satisfies.is_empty() {
gen_opts = gen_opts.with_satisfies(satisfies.clone());
}
}
TypeConfig::HardwareDetailedDesign { satisfies } => {
if !satisfies.is_empty() {
gen_opts = gen_opts.with_satisfies(satisfies.clone());
}
}
TypeConfig::Adr {
status,
deciders,
justifies,
supersedes,
superseded_by,
} => {
if let Some(s) = status {
gen_opts = gen_opts.with_status(s);
}
if !deciders.is_empty() {
gen_opts = gen_opts.with_deciders(deciders.clone());
}
if !justifies.is_empty() {
gen_opts = gen_opts.with_justifies(justifies.clone());
}
if !supersedes.is_empty() {
gen_opts = gen_opts.with_supersedes(supersedes.clone());
}
if let Some(sb) = superseded_by {
gen_opts = gen_opts.with_superseded_by(sb);
}
}
}
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 document = generate_document(gen_opts);
let frontmatter = extract_frontmatter(&document);
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 extract_frontmatter(content: &str) -> &str {
if !content.starts_with("---") {
return "";
}
let after_first = &content[3..];
if let Some(end_pos) = after_first.find("\n---") {
&content[..end_pos + 3 + 4] } else {
""
}
}
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)
}
"architecture_decision_record" | "architecturedecisionrecord" | "adr" => {
Some(ItemType::ArchitectureDecisionRecord)
}
_ => 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(), TypeConfig::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(), TypeConfig::use_case()).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, TypeConfig::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(), TypeConfig::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_needs_specification() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.md");
let opts =
InitOptions::new(file_path, TypeConfig::system_requirement()).with_id("SYSREQ-001");
let service = InitService::new();
let result = service.init(&opts).unwrap();
assert!(result.needs_specification);
}
#[test]
fn test_needs_specification_when_provided() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.md");
let type_config = TypeConfig::SystemRequirement {
specification: Some("The system SHALL do something".to_string()),
derives_from: Vec::new(),
depends_on: Vec::new(),
};
let opts = InitOptions::new(file_path, type_config).with_id("SYSREQ-001");
let service = InitService::new();
let result = service.init(&opts).unwrap();
assert!(!result.needs_specification);
}
}