use std::collections::HashMap;
use std::sync::OnceLock;
use tera::{Context, Tera};
use crate::model::{FieldName, Item, ItemAttributes, ItemType, RelationshipType};
#[derive(Debug)]
struct TemplateEntry {
frontmatter_name: &'static str,
document_name: &'static str,
frontmatter: &'static str,
document: &'static str,
}
const TEMPLATE_DEFS: &[(ItemType, TemplateEntry)] = &[
(
ItemType::Solution,
TemplateEntry {
frontmatter_name: "solution_frontmatter.tera",
document_name: "solution.tera",
frontmatter: include_str!("../../templates/solution_frontmatter.tera"),
document: include_str!("../../templates/solution.tera"),
},
),
(
ItemType::UseCase,
TemplateEntry {
frontmatter_name: "use_case_frontmatter.tera",
document_name: "use_case.tera",
frontmatter: include_str!("../../templates/use_case_frontmatter.tera"),
document: include_str!("../../templates/use_case.tera"),
},
),
(
ItemType::Scenario,
TemplateEntry {
frontmatter_name: "scenario_frontmatter.tera",
document_name: "scenario.tera",
frontmatter: include_str!("../../templates/scenario_frontmatter.tera"),
document: include_str!("../../templates/scenario.tera"),
},
),
(
ItemType::SystemRequirement,
TemplateEntry {
frontmatter_name: "system_requirement_frontmatter.tera",
document_name: "system_requirement.tera",
frontmatter: include_str!("../../templates/system_requirement_frontmatter.tera"),
document: include_str!("../../templates/system_requirement.tera"),
},
),
(
ItemType::HardwareRequirement,
TemplateEntry {
frontmatter_name: "hardware_requirement_frontmatter.tera",
document_name: "hardware_requirement.tera",
frontmatter: include_str!("../../templates/hardware_requirement_frontmatter.tera"),
document: include_str!("../../templates/hardware_requirement.tera"),
},
),
(
ItemType::SoftwareRequirement,
TemplateEntry {
frontmatter_name: "software_requirement_frontmatter.tera",
document_name: "software_requirement.tera",
frontmatter: include_str!("../../templates/software_requirement_frontmatter.tera"),
document: include_str!("../../templates/software_requirement.tera"),
},
),
(
ItemType::SystemArchitecture,
TemplateEntry {
frontmatter_name: "system_architecture_frontmatter.tera",
document_name: "system_architecture.tera",
frontmatter: include_str!("../../templates/system_architecture_frontmatter.tera"),
document: include_str!("../../templates/system_architecture.tera"),
},
),
(
ItemType::HardwareDetailedDesign,
TemplateEntry {
frontmatter_name: "hardware_detailed_design_frontmatter.tera",
document_name: "hardware_detailed_design.tera",
frontmatter: include_str!("../../templates/hardware_detailed_design_frontmatter.tera"),
document: include_str!("../../templates/hardware_detailed_design.tera"),
},
),
(
ItemType::SoftwareDetailedDesign,
TemplateEntry {
frontmatter_name: "software_detailed_design_frontmatter.tera",
document_name: "software_detailed_design.tera",
frontmatter: include_str!("../../templates/software_detailed_design_frontmatter.tera"),
document: include_str!("../../templates/software_detailed_design.tera"),
},
),
(
ItemType::ArchitectureDecisionRecord,
TemplateEntry {
frontmatter_name: "adr_frontmatter.tera",
document_name: "adr.tera",
frontmatter: include_str!("../../templates/adr_frontmatter.tera"),
document: include_str!("../../templates/adr.tera"),
},
),
];
struct TemplateRegistry {
tera: Tera,
entries: HashMap<ItemType, &'static TemplateEntry>,
}
static REGISTRY: OnceLock<TemplateRegistry> = OnceLock::new();
fn get_registry() -> &'static TemplateRegistry {
REGISTRY.get_or_init(|| {
let mut tera = Tera::default();
let raw: Vec<(&str, &str)> = TEMPLATE_DEFS
.iter()
.flat_map(|(_, e)| {
[
(e.frontmatter_name, e.frontmatter),
(e.document_name, e.document),
]
})
.collect();
tera.add_raw_templates(raw)
.expect("Failed to load embedded templates");
let entries: HashMap<ItemType, &'static TemplateEntry> = TEMPLATE_DEFS
.iter()
.map(|(item_type, entry)| (*item_type, entry))
.collect();
TemplateRegistry { tera, entries }
})
}
#[must_use]
pub fn generate_document(item: &Item) -> String {
let registry = get_registry();
let context = build_context(item);
let entry = ®istry.entries[&item.item_type];
registry
.tera
.render(entry.document_name, &context)
.expect("Failed to render document template")
}
#[must_use]
pub fn generate_frontmatter(item: &Item) -> String {
let registry = get_registry();
let context = build_context(item);
let entry = ®istry.entries[&item.item_type];
registry
.tera
.render(entry.frontmatter_name, &context)
.expect("Failed to render frontmatter template")
}
fn build_context(item: &Item) -> Context {
let mut context = Context::new();
context.insert(FieldName::Id.as_str(), item.id.as_str());
context.insert(FieldName::Type.as_str(), item.item_type.as_str());
context.insert(FieldName::Name.as_str(), &escape_yaml_string(&item.name));
if let Some(ref desc) = item.description {
context.insert(FieldName::Description.as_str(), &escape_yaml_string(desc));
}
insert_relationship_ids(
&mut context,
item,
RelationshipType::Refines,
FieldName::Refines,
);
insert_relationship_ids(
&mut context,
item,
RelationshipType::DerivesFrom,
FieldName::DerivesFrom,
);
insert_relationship_ids(
&mut context,
item,
RelationshipType::Satisfies,
FieldName::Satisfies,
);
insert_relationship_ids(
&mut context,
item,
RelationshipType::Justifies,
FieldName::Justifies,
);
match &item.attributes {
ItemAttributes::Solution
| ItemAttributes::UseCase
| ItemAttributes::Scenario
| ItemAttributes::SoftwareDetailedDesign
| ItemAttributes::HardwareDetailedDesign => {}
ItemAttributes::SystemRequirement {
specification,
depends_on,
}
| ItemAttributes::SoftwareRequirement {
specification,
depends_on,
}
| ItemAttributes::HardwareRequirement {
specification,
depends_on,
} => {
if !depends_on.is_empty() {
let ids: Vec<&str> = depends_on.iter().map(|id| id.as_str()).collect();
context.insert(FieldName::DependsOn.as_str(), &ids);
}
context.insert(
FieldName::Specification.as_str(),
&escape_yaml_string(specification),
);
}
ItemAttributes::SystemArchitecture { platform } => {
if let Some(plat) = platform {
context.insert(FieldName::Platform.as_str(), &escape_yaml_string(plat));
}
}
ItemAttributes::Adr {
status,
deciders,
supersedes,
} => {
context.insert(FieldName::Status.as_str(), status.as_str());
if !deciders.is_empty() {
context.insert(FieldName::Deciders.as_str(), deciders);
}
if !supersedes.is_empty() {
let ids: Vec<&str> = supersedes.iter().map(|id| id.as_str()).collect();
context.insert(FieldName::Supersedes.as_str(), &ids);
}
}
}
context
}
fn insert_relationship_ids(
context: &mut Context,
item: &Item,
rel_type: RelationshipType,
field: FieldName,
) {
let ids: Vec<&str> = item
.relationship_ids(rel_type)
.map(|id| id.as_str())
.collect();
if !ids.is_empty() {
context.insert(field.as_str(), &ids);
}
}
fn escape_yaml_string(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::SourceLocation;
use crate::model::{AdrStatus, ItemBuilder, ItemId, Relationship, RelationshipType};
use std::path::PathBuf;
fn test_source() -> SourceLocation {
SourceLocation {
repository: PathBuf::from("/repo"),
file_path: PathBuf::from("docs/test.md"),
git_ref: None,
}
}
#[test]
fn test_generate_document_solution() {
let item = ItemBuilder::new()
.id(ItemId::new_unchecked("SOL-001"))
.item_type(ItemType::Solution)
.name("Test Solution")
.source(test_source())
.build()
.unwrap();
let doc = generate_document(&item);
assert!(doc.contains("# Solution: Test Solution"));
assert!(doc.contains("## Overview"));
assert!(doc.contains("## Goals & KPIs"));
}
#[test]
fn test_generate_document_use_case_with_refines() {
let item = ItemBuilder::new()
.id(ItemId::new_unchecked("UC-001"))
.item_type(ItemType::UseCase)
.name("Test Use Case")
.source(test_source())
.relationships(vec![Relationship::new(
ItemId::new_unchecked("SOL-001"),
RelationshipType::Refines,
)])
.build()
.unwrap();
let doc = generate_document(&item);
assert!(doc.contains("# Use Case: Test Use Case"));
assert!(doc.contains("## Actor(s)"));
assert!(doc.contains("refines:"));
assert!(doc.contains("SOL-001"));
}
#[test]
fn test_generate_document_system_architecture_with_platform() {
let item = ItemBuilder::new()
.id(ItemId::new_unchecked("SYSARCH-001"))
.item_type(ItemType::SystemArchitecture)
.name("Web Platform Architecture")
.source(test_source())
.platform("AWS Lambda")
.relationships(vec![Relationship::new(
ItemId::new_unchecked("SYSREQ-001"),
RelationshipType::Satisfies,
)])
.build()
.unwrap();
let doc = generate_document(&item);
assert!(doc.contains("id: \"SYSARCH-001\""));
assert!(doc.contains("type: system_architecture"));
assert!(doc.contains("platform: \"AWS Lambda\""));
assert!(doc.contains("satisfies:"));
assert!(doc.contains("SYSREQ-001"));
}
#[test]
fn test_generate_document_adr() {
let item = ItemBuilder::new()
.id(ItemId::new_unchecked("ADR-001"))
.item_type(ItemType::ArchitectureDecisionRecord)
.name("Use Microservices Architecture")
.description("Decision to adopt microservices")
.source(test_source())
.status(AdrStatus::Proposed)
.deciders(vec!["Alice Smith".to_string(), "Bob Jones".to_string()])
.relationships(vec![Relationship::new(
ItemId::new_unchecked("SYSARCH-001"),
RelationshipType::Justifies,
)])
.build()
.unwrap();
let doc = generate_document(&item);
assert!(doc.contains("id: \"ADR-001\""));
assert!(doc.contains("type: architecture_decision_record"));
assert!(doc.contains("status: proposed"));
assert!(doc.contains("deciders:"));
assert!(doc.contains("Alice Smith"));
assert!(doc.contains("Bob Jones"));
assert!(doc.contains("justifies:"));
assert!(doc.contains("SYSARCH-001"));
assert!(doc.contains("# Architecture Decision: Use Microservices Architecture"));
assert!(doc.contains("## Context and problem statement"));
assert!(doc.contains("## Considered options"));
assert!(doc.contains("## Decision Outcome"));
}
#[test]
fn test_generate_frontmatter_solution() {
let item = ItemBuilder::new()
.id(ItemId::new_unchecked("SOL-001"))
.item_type(ItemType::Solution)
.name("Test Solution")
.source(test_source())
.build()
.unwrap();
let fm = generate_frontmatter(&item);
assert!(fm.starts_with("---"));
assert!(fm.ends_with("---"));
assert!(fm.contains("id: \"SOL-001\""));
assert!(fm.contains("type: solution"));
assert!(fm.contains("name: \"Test Solution\""));
assert!(!fm.contains("## Overview"));
}
#[test]
fn test_generate_document_system_requirement() {
let item = ItemBuilder::new()
.id(ItemId::new_unchecked("SYSREQ-001"))
.item_type(ItemType::SystemRequirement)
.name("Performance Requirement")
.source(test_source())
.specification("The system SHALL respond within 200ms.")
.relationships(vec![Relationship::new(
ItemId::new_unchecked("SCEN-001"),
RelationshipType::DerivesFrom,
)])
.build()
.unwrap();
let doc = generate_document(&item);
assert!(doc.contains("id: \"SYSREQ-001\""));
assert!(doc.contains("type: system_requirement"));
assert!(doc.contains("specification:"));
assert!(doc.contains("derives_from:"));
assert!(doc.contains("SCEN-001"));
}
}