1use std::sync::OnceLock;
4
5use tera::{Context, Tera};
6
7use crate::model::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("id", &self.id);
134 context.insert("type", &item_type_to_yaml(self.item_type));
135 context.insert("name", &escape_yaml_string(&self.name));
136
137 if let Some(ref desc) = self.description {
138 context.insert("description", &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("refines", &self.refines);
146 }
147 }
148 ItemType::SystemRequirement
149 | ItemType::HardwareRequirement
150 | ItemType::SoftwareRequirement => {
151 if !self.derives_from.is_empty() {
152 context.insert("derives_from", &self.derives_from);
153 }
154 }
155 ItemType::SystemArchitecture => {
156 if !self.satisfies.is_empty() {
157 context.insert("satisfies", &self.satisfies);
158 }
159 if let Some(ref platform) = self.platform {
161 context.insert("platform", &escape_yaml_string(platform));
162 }
163 }
164 ItemType::HardwareDetailedDesign | ItemType::SoftwareDetailedDesign => {
165 if !self.satisfies.is_empty() {
166 context.insert("satisfies", &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("specification", &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 item_type_to_yaml(item_type: ItemType) -> &'static str {
220 match item_type {
221 ItemType::Solution => "solution",
222 ItemType::UseCase => "use_case",
223 ItemType::Scenario => "scenario",
224 ItemType::SystemRequirement => "system_requirement",
225 ItemType::SystemArchitecture => "system_architecture",
226 ItemType::HardwareRequirement => "hardware_requirement",
227 ItemType::SoftwareRequirement => "software_requirement",
228 ItemType::HardwareDetailedDesign => "hardware_detailed_design",
229 ItemType::SoftwareDetailedDesign => "software_detailed_design",
230 }
231}
232
233fn escape_yaml_string(s: &str) -> String {
235 s.replace('\\', "\\\\")
236 .replace('"', "\\\"")
237 .replace('\n', "\\n")
238}
239
240pub fn generate_id(item_type: ItemType, sequence: Option<u32>) -> String {
242 let prefix = item_type.prefix();
243 let num = sequence.unwrap_or(1);
244 format!("{}-{:03}", prefix, num)
245}
246
247pub fn suggest_next_id(
252 item_type: ItemType,
253 graph: Option<&crate::graph::KnowledgeGraph>,
254) -> String {
255 let Some(graph) = graph else {
256 return generate_id(item_type, None);
257 };
258
259 let prefix = item_type.prefix();
260 let max_num = graph
261 .items()
262 .filter(|item| item.item_type == item_type)
263 .filter_map(|item| {
264 item.id
265 .as_str()
266 .strip_prefix(prefix)
267 .and_then(|suffix| suffix.trim_start_matches('-').parse::<u32>().ok())
268 })
269 .max()
270 .unwrap_or(0);
271
272 format!("{}-{:03}", prefix, max_num + 1)
273}
274
275pub fn extract_name_from_content(content: &str) -> Option<String> {
277 for line in content.lines() {
278 let trimmed = line.trim();
279 if let Some(heading) = trimmed.strip_prefix("# ") {
280 return Some(heading.trim().to_string());
281 }
282 }
283 None
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn test_generate_frontmatter_solution() {
292 let opts = GeneratorOptions::new(
293 ItemType::Solution,
294 "SOL-001".to_string(),
295 "Test Solution".to_string(),
296 );
297 let frontmatter = generate_frontmatter(&opts);
298
299 assert!(frontmatter.contains("id: \"SOL-001\""));
300 assert!(frontmatter.contains("type: solution"));
301 assert!(frontmatter.contains("name: \"Test Solution\""));
302 }
303
304 #[test]
305 fn test_generate_frontmatter_requirement() {
306 let opts = GeneratorOptions::new(
307 ItemType::SystemRequirement,
308 "SYSREQ-001".to_string(),
309 "Test Requirement".to_string(),
310 )
311 .with_specification("The system SHALL do something.");
312
313 let frontmatter = generate_frontmatter(&opts);
314
315 assert!(frontmatter.contains("specification:"));
316 assert!(frontmatter.contains("The system SHALL do something."));
317 }
318
319 #[test]
320 fn test_generate_document_solution() {
321 let opts = GeneratorOptions::new(
322 ItemType::Solution,
323 "SOL-001".to_string(),
324 "Test Solution".to_string(),
325 );
326 let doc = generate_document(&opts);
327
328 assert!(doc.contains("# Solution: Test Solution"));
329 assert!(doc.contains("## Overview"));
330 assert!(doc.contains("## Goals & KPIs"));
331 }
332
333 #[test]
334 fn test_generate_document_use_case() {
335 let opts = GeneratorOptions::new(
336 ItemType::UseCase,
337 "UC-001".to_string(),
338 "Test Use Case".to_string(),
339 )
340 .with_refines(vec!["SOL-001".to_string()]);
341
342 let doc = generate_document(&opts);
343
344 assert!(doc.contains("# Use Case: Test Use Case"));
345 assert!(doc.contains("## Actor(s)"));
346 assert!(doc.contains("refines:"));
347 assert!(doc.contains("SOL-001"));
348 }
349
350 #[test]
351 fn test_generate_id() {
352 assert_eq!(generate_id(ItemType::Solution, Some(1)), "SOL-001");
353 assert_eq!(generate_id(ItemType::UseCase, Some(42)), "UC-042");
354 assert_eq!(generate_id(ItemType::SystemRequirement, None), "SYSREQ-001");
355 }
356
357 #[test]
358 fn test_extract_name_from_content() {
359 let content = "# My Document\n\nSome content here.";
360 assert_eq!(
361 extract_name_from_content(content),
362 Some("My Document".to_string())
363 );
364 }
365
366 #[test]
367 fn test_generate_frontmatter_system_architecture_with_platform() {
368 let opts = GeneratorOptions::new(
369 ItemType::SystemArchitecture,
370 "SYSARCH-001".to_string(),
371 "Web Platform Architecture".to_string(),
372 )
373 .with_platform("AWS Lambda")
374 .with_satisfies(vec!["SYSREQ-001".to_string()]);
375
376 let frontmatter = generate_frontmatter(&opts);
377
378 assert!(frontmatter.contains("id: \"SYSARCH-001\""));
379 assert!(frontmatter.contains("type: system_architecture"));
380 assert!(frontmatter.contains("platform: \"AWS Lambda\""));
381 assert!(frontmatter.contains("satisfies:"));
382 assert!(frontmatter.contains("SYSREQ-001"));
383 }
384}