1use std::sync::OnceLock;
4
5use tera::{Context, Tera};
6
7use crate::model::{FieldName, ItemType};
8
9const FRONTMATTER_TEMPLATE: &str = include_str!("../../templates/frontmatter.tera");
11const SOLUTION_TEMPLATE: &str = include_str!("../../templates/solution.tera");
12const USE_CASE_TEMPLATE: &str = include_str!("../../templates/use_case.tera");
13const SCENARIO_TEMPLATE: &str = include_str!("../../templates/scenario.tera");
14const SYSTEM_REQUIREMENT_TEMPLATE: &str = include_str!("../../templates/system_requirement.tera");
15const HARDWARE_REQUIREMENT_TEMPLATE: &str =
16 include_str!("../../templates/hardware_requirement.tera");
17const SOFTWARE_REQUIREMENT_TEMPLATE: &str =
18 include_str!("../../templates/software_requirement.tera");
19const SYSTEM_ARCHITECTURE_TEMPLATE: &str = include_str!("../../templates/system_architecture.tera");
20const HARDWARE_DETAILED_DESIGN_TEMPLATE: &str =
21 include_str!("../../templates/hardware_detailed_design.tera");
22const SOFTWARE_DETAILED_DESIGN_TEMPLATE: &str =
23 include_str!("../../templates/software_detailed_design.tera");
24
25static TERA: OnceLock<Tera> = OnceLock::new();
27
28fn get_tera() -> &'static Tera {
30 TERA.get_or_init(|| {
31 let mut tera = Tera::default();
32 tera.add_raw_templates(vec![
33 ("frontmatter.tera", FRONTMATTER_TEMPLATE),
34 ("solution.tera", SOLUTION_TEMPLATE),
35 ("use_case.tera", USE_CASE_TEMPLATE),
36 ("scenario.tera", SCENARIO_TEMPLATE),
37 ("system_requirement.tera", SYSTEM_REQUIREMENT_TEMPLATE),
38 ("hardware_requirement.tera", HARDWARE_REQUIREMENT_TEMPLATE),
39 ("software_requirement.tera", SOFTWARE_REQUIREMENT_TEMPLATE),
40 ("system_architecture.tera", SYSTEM_ARCHITECTURE_TEMPLATE),
41 (
42 "hardware_detailed_design.tera",
43 HARDWARE_DETAILED_DESIGN_TEMPLATE,
44 ),
45 (
46 "software_detailed_design.tera",
47 SOFTWARE_DETAILED_DESIGN_TEMPLATE,
48 ),
49 ])
50 .expect("Failed to load embedded templates");
51 tera
52 })
53}
54
55#[derive(Debug, Clone)]
57pub struct GeneratorOptions {
58 pub item_type: ItemType,
60 pub id: String,
62 pub name: String,
64 pub description: Option<String>,
66 pub refines: Vec<String>,
68 pub derives_from: Vec<String>,
70 pub satisfies: Vec<String>,
72 pub specification: Option<String>,
74 pub platform: Option<String>,
76}
77
78impl GeneratorOptions {
79 pub fn new(item_type: ItemType, id: String, name: String) -> Self {
81 Self {
82 item_type,
83 id,
84 name,
85 description: None,
86 refines: Vec::new(),
87 derives_from: Vec::new(),
88 satisfies: Vec::new(),
89 specification: None,
90 platform: None,
91 }
92 }
93
94 pub fn with_description(mut self, description: impl Into<String>) -> Self {
96 self.description = Some(description.into());
97 self
98 }
99
100 pub fn with_refines(mut self, refs: Vec<String>) -> Self {
102 self.refines = refs;
103 self
104 }
105
106 pub fn with_derives_from(mut self, refs: Vec<String>) -> Self {
108 self.derives_from = refs;
109 self
110 }
111
112 pub fn with_satisfies(mut self, refs: Vec<String>) -> Self {
114 self.satisfies = refs;
115 self
116 }
117
118 pub fn with_specification(mut self, spec: impl Into<String>) -> Self {
120 self.specification = Some(spec.into());
121 self
122 }
123
124 pub fn with_platform(mut self, platform: impl Into<String>) -> Self {
126 self.platform = Some(platform.into());
127 self
128 }
129
130 fn to_context(&self) -> Context {
132 let mut context = Context::new();
133 context.insert(FieldName::Id.as_str(), &self.id);
134 context.insert(FieldName::Type.as_str(), self.item_type.as_str());
135 context.insert(FieldName::Name.as_str(), &escape_yaml_string(&self.name));
136
137 if let Some(ref desc) = self.description {
138 context.insert(FieldName::Description.as_str(), &escape_yaml_string(desc));
139 }
140
141 match self.item_type {
143 ItemType::UseCase | ItemType::Scenario => {
144 if !self.refines.is_empty() {
145 context.insert(FieldName::Refines.as_str(), &self.refines);
146 }
147 }
148 ItemType::SystemRequirement
149 | ItemType::HardwareRequirement
150 | ItemType::SoftwareRequirement => {
151 if !self.derives_from.is_empty() {
152 context.insert(FieldName::DerivesFrom.as_str(), &self.derives_from);
153 }
154 }
155 ItemType::SystemArchitecture => {
156 if !self.satisfies.is_empty() {
157 context.insert(FieldName::Satisfies.as_str(), &self.satisfies);
158 }
159 if let Some(ref platform) = self.platform {
161 context.insert(FieldName::Platform.as_str(), &escape_yaml_string(platform));
162 }
163 }
164 ItemType::HardwareDetailedDesign | ItemType::SoftwareDetailedDesign => {
165 if !self.satisfies.is_empty() {
166 context.insert(FieldName::Satisfies.as_str(), &self.satisfies);
167 }
168 }
169 ItemType::Solution => {
170 }
172 }
173
174 if self.item_type.requires_specification() {
176 let spec = self
177 .specification
178 .as_deref()
179 .unwrap_or("The system SHALL <describe the requirement>.");
180 context.insert(FieldName::Specification.as_str(), &escape_yaml_string(spec));
181 }
182
183 context
184 }
185
186 fn template_name(&self) -> &'static str {
188 match self.item_type {
189 ItemType::Solution => "solution.tera",
190 ItemType::UseCase => "use_case.tera",
191 ItemType::Scenario => "scenario.tera",
192 ItemType::SystemRequirement => "system_requirement.tera",
193 ItemType::HardwareRequirement => "hardware_requirement.tera",
194 ItemType::SoftwareRequirement => "software_requirement.tera",
195 ItemType::SystemArchitecture => "system_architecture.tera",
196 ItemType::HardwareDetailedDesign => "hardware_detailed_design.tera",
197 ItemType::SoftwareDetailedDesign => "software_detailed_design.tera",
198 }
199 }
200}
201
202pub fn generate_frontmatter(opts: &GeneratorOptions) -> String {
204 let tera = get_tera();
205 let context = opts.to_context();
206 tera.render("frontmatter.tera", &context)
207 .expect("Failed to render frontmatter template")
208}
209
210pub fn generate_document(opts: &GeneratorOptions) -> String {
212 let tera = get_tera();
213 let context = opts.to_context();
214 tera.render(opts.template_name(), &context)
215 .expect("Failed to render document template")
216}
217
218fn escape_yaml_string(s: &str) -> String {
220 s.replace('\\', "\\\\")
221 .replace('"', "\\\"")
222 .replace('\n', "\\n")
223}
224
225pub fn generate_id(item_type: ItemType, sequence: Option<u32>) -> String {
227 let prefix = item_type.prefix();
228 let num = sequence.unwrap_or(1);
229 format!("{}-{:03}", prefix, num)
230}
231
232pub fn suggest_next_id(
237 item_type: ItemType,
238 graph: Option<&crate::graph::KnowledgeGraph>,
239) -> String {
240 let Some(graph) = graph else {
241 return generate_id(item_type, None);
242 };
243
244 let prefix = item_type.prefix();
245 let max_num = graph
246 .items()
247 .filter(|item| item.item_type == item_type)
248 .filter_map(|item| {
249 item.id
250 .as_str()
251 .strip_prefix(prefix)
252 .and_then(|suffix| suffix.trim_start_matches('-').parse::<u32>().ok())
253 })
254 .max()
255 .unwrap_or(0);
256
257 format!("{}-{:03}", prefix, max_num + 1)
258}
259
260pub fn extract_name_from_content(content: &str) -> Option<String> {
262 for line in content.lines() {
263 let trimmed = line.trim();
264 if let Some(heading) = trimmed.strip_prefix("# ") {
265 return Some(heading.trim().to_string());
266 }
267 }
268 None
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274
275 #[test]
276 fn test_generate_frontmatter_solution() {
277 let opts = GeneratorOptions::new(
278 ItemType::Solution,
279 "SOL-001".to_string(),
280 "Test Solution".to_string(),
281 );
282 let frontmatter = generate_frontmatter(&opts);
283
284 assert!(frontmatter.contains("id: \"SOL-001\""));
285 assert!(frontmatter.contains("type: solution"));
286 assert!(frontmatter.contains("name: \"Test Solution\""));
287 }
288
289 #[test]
290 fn test_generate_frontmatter_requirement() {
291 let opts = GeneratorOptions::new(
292 ItemType::SystemRequirement,
293 "SYSREQ-001".to_string(),
294 "Test Requirement".to_string(),
295 )
296 .with_specification("The system SHALL do something.");
297
298 let frontmatter = generate_frontmatter(&opts);
299
300 assert!(frontmatter.contains("specification:"));
301 assert!(frontmatter.contains("The system SHALL do something."));
302 }
303
304 #[test]
305 fn test_generate_document_solution() {
306 let opts = GeneratorOptions::new(
307 ItemType::Solution,
308 "SOL-001".to_string(),
309 "Test Solution".to_string(),
310 );
311 let doc = generate_document(&opts);
312
313 assert!(doc.contains("# Solution: Test Solution"));
314 assert!(doc.contains("## Overview"));
315 assert!(doc.contains("## Goals & KPIs"));
316 }
317
318 #[test]
319 fn test_generate_document_use_case() {
320 let opts = GeneratorOptions::new(
321 ItemType::UseCase,
322 "UC-001".to_string(),
323 "Test Use Case".to_string(),
324 )
325 .with_refines(vec!["SOL-001".to_string()]);
326
327 let doc = generate_document(&opts);
328
329 assert!(doc.contains("# Use Case: Test Use Case"));
330 assert!(doc.contains("## Actor(s)"));
331 assert!(doc.contains("refines:"));
332 assert!(doc.contains("SOL-001"));
333 }
334
335 #[test]
336 fn test_generate_id() {
337 assert_eq!(generate_id(ItemType::Solution, Some(1)), "SOL-001");
338 assert_eq!(generate_id(ItemType::UseCase, Some(42)), "UC-042");
339 assert_eq!(generate_id(ItemType::SystemRequirement, None), "SYSREQ-001");
340 }
341
342 #[test]
343 fn test_extract_name_from_content() {
344 let content = "# My Document\n\nSome content here.";
345 assert_eq!(
346 extract_name_from_content(content),
347 Some("My Document".to_string())
348 );
349 }
350
351 #[test]
352 fn test_generate_frontmatter_system_architecture_with_platform() {
353 let opts = GeneratorOptions::new(
354 ItemType::SystemArchitecture,
355 "SYSARCH-001".to_string(),
356 "Web Platform Architecture".to_string(),
357 )
358 .with_platform("AWS Lambda")
359 .with_satisfies(vec!["SYSREQ-001".to_string()]);
360
361 let frontmatter = generate_frontmatter(&opts);
362
363 assert!(frontmatter.contains("id: \"SYSARCH-001\""));
364 assert!(frontmatter.contains("type: system_architecture"));
365 assert!(frontmatter.contains("platform: \"AWS Lambda\""));
366 assert!(frontmatter.contains("satisfies:"));
367 assert!(frontmatter.contains("SYSREQ-001"));
368 }
369}