use std::fs;
use std::path::PathBuf;
use crate::generator::{self, OutputFormat};
use crate::model::{AdrStatus, ItemBuilder, ItemId, ItemType, RelationshipType, SourceLocation};
use crate::parser::{extract_name_from_content, has_frontmatter};
#[derive(Debug, Clone)]
pub struct InitOptions {
pub file: PathBuf,
pub id: Option<String>,
pub name: Option<String>,
pub description: Option<String>,
pub force: bool,
pub type_config: TypeConfig,
}
impl InitOptions {
pub fn new(file: PathBuf, type_config: TypeConfig) -> Self {
Self {
file,
id: None,
name: None,
description: None,
force: false,
type_config,
}
}
pub fn item_type(&self) -> ItemType {
self.type_config.item_type()
}
pub fn with_id(mut self, id: impl Into<String>) -> Self {
self.id = Some(id.into());
self
}
pub fn maybe_id(mut self, id: Option<String>) -> Self {
self.id = id;
self
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
pub fn maybe_name(mut self, name: Option<String>) -> Self {
self.name = name;
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn maybe_description(mut self, description: Option<String>) -> Self {
self.description = description;
self
}
pub fn with_force(mut self, force: bool) -> Self {
self.force = force;
self
}
}
#[derive(Debug, Clone)]
pub enum TypeConfig {
Solution,
UseCase {
refines: Vec<String>,
},
Scenario {
refines: Vec<String>,
},
SystemRequirement {
specification: Option<String>,
derives_from: Vec<String>,
depends_on: Vec<String>,
},
SystemArchitecture {
platform: Option<String>,
satisfies: Vec<String>,
},
SoftwareRequirement {
specification: Option<String>,
derives_from: Vec<String>,
depends_on: Vec<String>,
},
HardwareRequirement {
specification: Option<String>,
derives_from: Vec<String>,
depends_on: Vec<String>,
},
SoftwareDetailedDesign {
satisfies: Vec<String>,
},
HardwareDetailedDesign {
satisfies: Vec<String>,
},
Adr {
status: Option<String>,
deciders: Vec<String>,
justifies: Vec<String>,
supersedes: Vec<String>,
superseded_by: Option<String>,
},
}
impl TypeConfig {
pub fn item_type(&self) -> ItemType {
match self {
TypeConfig::Solution => ItemType::Solution,
TypeConfig::UseCase { .. } => ItemType::UseCase,
TypeConfig::Scenario { .. } => ItemType::Scenario,
TypeConfig::SystemRequirement { .. } => ItemType::SystemRequirement,
TypeConfig::SystemArchitecture { .. } => ItemType::SystemArchitecture,
TypeConfig::SoftwareRequirement { .. } => ItemType::SoftwareRequirement,
TypeConfig::HardwareRequirement { .. } => ItemType::HardwareRequirement,
TypeConfig::SoftwareDetailedDesign { .. } => ItemType::SoftwareDetailedDesign,
TypeConfig::HardwareDetailedDesign { .. } => ItemType::HardwareDetailedDesign,
TypeConfig::Adr { .. } => ItemType::ArchitectureDecisionRecord,
}
}
pub fn solution() -> Self {
TypeConfig::Solution
}
pub fn use_case() -> Self {
TypeConfig::UseCase {
refines: Vec::new(),
}
}
pub fn scenario() -> Self {
TypeConfig::Scenario {
refines: Vec::new(),
}
}
pub fn system_requirement() -> Self {
TypeConfig::SystemRequirement {
specification: None,
derives_from: Vec::new(),
depends_on: Vec::new(),
}
}
pub fn system_architecture() -> Self {
TypeConfig::SystemArchitecture {
platform: None,
satisfies: Vec::new(),
}
}
pub fn software_requirement() -> Self {
TypeConfig::SoftwareRequirement {
specification: None,
derives_from: Vec::new(),
depends_on: Vec::new(),
}
}
pub fn hardware_requirement() -> Self {
TypeConfig::HardwareRequirement {
specification: None,
derives_from: Vec::new(),
depends_on: Vec::new(),
}
}
pub fn software_detailed_design() -> Self {
TypeConfig::SoftwareDetailedDesign {
satisfies: Vec::new(),
}
}
pub fn hardware_detailed_design() -> Self {
TypeConfig::HardwareDetailedDesign {
satisfies: Vec::new(),
}
}
pub fn adr() -> Self {
TypeConfig::Adr {
status: None,
deciders: Vec::new(),
justifies: Vec::new(),
supersedes: Vec::new(),
superseded_by: None,
}
}
pub fn from_item_type(item_type: ItemType) -> Self {
match item_type {
ItemType::Solution => TypeConfig::solution(),
ItemType::UseCase => TypeConfig::use_case(),
ItemType::Scenario => TypeConfig::scenario(),
ItemType::SystemRequirement => TypeConfig::system_requirement(),
ItemType::SystemArchitecture => TypeConfig::system_architecture(),
ItemType::SoftwareRequirement => TypeConfig::software_requirement(),
ItemType::HardwareRequirement => TypeConfig::hardware_requirement(),
ItemType::SoftwareDetailedDesign => TypeConfig::software_detailed_design(),
ItemType::HardwareDetailedDesign => TypeConfig::hardware_detailed_design(),
ItemType::ArchitectureDecisionRecord => TypeConfig::adr(),
}
}
}
#[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 item = self.build_item(opts, &id, &name);
let (updated_existing, replaced_frontmatter) = self.write_file(opts, &item)?;
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(|| opts.item_type().generate_id(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_item(&self, opts: &InitOptions, id: &str, name: &str) -> crate::model::Item {
let source = SourceLocation {
repository: PathBuf::new(),
file_path: opts.file.clone(),
git_ref: None,
};
let mut builder = ItemBuilder::new()
.id(ItemId::new_unchecked(id))
.item_type(opts.item_type())
.name(name)
.source(source);
if let Some(ref desc) = opts.description {
builder = builder.description(desc);
}
match &opts.type_config {
TypeConfig::Solution => {}
TypeConfig::UseCase { refines } | TypeConfig::Scenario { refines } => {
builder = builder.relationships(super::ids_to_relationships(
refines,
RelationshipType::Refines,
));
}
TypeConfig::SystemRequirement {
specification,
derives_from,
depends_on,
}
| TypeConfig::SoftwareRequirement {
specification,
derives_from,
depends_on,
}
| TypeConfig::HardwareRequirement {
specification,
derives_from,
depends_on,
} => {
let spec = specification
.clone()
.unwrap_or_else(|| "The system SHALL <describe the requirement>.".to_string());
builder = builder.specification(spec);
builder = builder.relationships(super::ids_to_relationships(
derives_from,
RelationshipType::DerivesFrom,
));
for dep in depends_on {
builder = builder.depends_on(ItemId::new_unchecked(dep));
}
}
TypeConfig::SystemArchitecture {
platform,
satisfies,
} => {
if let Some(p) = platform {
builder = builder.platform(p);
}
builder = builder.relationships(super::ids_to_relationships(
satisfies,
RelationshipType::Satisfies,
));
}
TypeConfig::SoftwareDetailedDesign { satisfies }
| TypeConfig::HardwareDetailedDesign { satisfies } => {
builder = builder.relationships(super::ids_to_relationships(
satisfies,
RelationshipType::Satisfies,
));
}
TypeConfig::Adr {
status,
deciders,
justifies,
supersedes,
superseded_by: _,
} => {
let adr_status = status
.as_deref()
.and_then(|s| match s {
"proposed" => Some(AdrStatus::Proposed),
"accepted" => Some(AdrStatus::Accepted),
"deprecated" => Some(AdrStatus::Deprecated),
"superseded" => Some(AdrStatus::Superseded),
_ => None,
})
.unwrap_or(AdrStatus::Proposed);
builder = builder.status(adr_status);
if !deciders.is_empty() {
builder = builder.deciders(deciders.clone());
} else {
builder = builder.decider("TBD");
}
let mut rels = super::ids_to_relationships(justifies, RelationshipType::Justifies);
rels.extend(super::ids_to_relationships(
supersedes,
RelationshipType::Supersedes,
));
builder = builder.relationships(rels);
for sup in supersedes {
builder = builder.supersedes(ItemId::new_unchecked(sup));
}
}
}
builder.build().expect("Failed to build item for init")
}
fn write_file(
&self,
opts: &InitOptions,
item: &crate::model::Item,
) -> Result<(bool, bool), InitError> {
if opts.file.exists() {
let replaced = self.update_existing_file(opts, item)?;
Ok((true, replaced))
} else {
self.create_new_file(opts, item)?;
Ok((false, false))
}
}
fn update_existing_file(
&self,
opts: &InitOptions,
item: &crate::model::Item,
) -> Result<bool, InitError> {
let content = fs::read_to_string(&opts.file)?;
let frontmatter = generator::generate_metadata(item, OutputFormat::Markdown);
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,
item: &crate::model::Item,
) -> Result<(), InitError> {
let document = generator::generate_document(item, OutputFormat::Markdown);
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 byte_offset = 0;
for line in content.lines() {
let line_end = byte_offset + line.len() + 1;
if line.trim() == "---" {
if !in_frontmatter {
in_frontmatter = true;
} else {
let end = line_end.min(content.len());
return &content[end..];
}
}
byte_offset = line_end;
}
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);
}
}