sara_core/template/
generator.rs

1//! Frontmatter template generation using Tera.
2
3use std::sync::OnceLock;
4
5use tera::{Context, Tera};
6
7use crate::model::{FieldName, ItemType};
8
9/// Embedded templates - compiled into the binary.
10const 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
25/// Global Tera instance, lazily initialized.
26static TERA: OnceLock<Tera> = OnceLock::new();
27
28/// Gets or initializes the global Tera instance.
29fn 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/// Options for generating frontmatter.
56#[derive(Debug, Clone)]
57pub struct GeneratorOptions {
58    /// The item type.
59    pub item_type: ItemType,
60    /// The item ID.
61    pub id: String,
62    /// The item name.
63    pub name: String,
64    /// Optional description.
65    pub description: Option<String>,
66    /// Upstream references (refines).
67    pub refines: Vec<String>,
68    /// Upstream references (derives_from).
69    pub derives_from: Vec<String>,
70    /// Upstream references (satisfies).
71    pub satisfies: Vec<String>,
72    /// Specification text (for requirement types).
73    pub specification: Option<String>,
74    /// Target platform (for system_architecture).
75    pub platform: Option<String>,
76}
77
78impl GeneratorOptions {
79    /// Creates new generator options with defaults.
80    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    /// Sets the description.
95    pub fn with_description(mut self, description: impl Into<String>) -> Self {
96        self.description = Some(description.into());
97        self
98    }
99
100    /// Adds a refines reference.
101    pub fn with_refines(mut self, refs: Vec<String>) -> Self {
102        self.refines = refs;
103        self
104    }
105
106    /// Adds a derives_from reference.
107    pub fn with_derives_from(mut self, refs: Vec<String>) -> Self {
108        self.derives_from = refs;
109        self
110    }
111
112    /// Adds a satisfies reference.
113    pub fn with_satisfies(mut self, refs: Vec<String>) -> Self {
114        self.satisfies = refs;
115        self
116    }
117
118    /// Sets the specification.
119    pub fn with_specification(mut self, spec: impl Into<String>) -> Self {
120        self.specification = Some(spec.into());
121        self
122    }
123
124    /// Sets the target platform (for system_architecture).
125    pub fn with_platform(mut self, platform: impl Into<String>) -> Self {
126        self.platform = Some(platform.into());
127        self
128    }
129
130    /// Builds a Tera context from the options.
131    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        // Insert upstream references based on item type
142        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                // Add platform for system architecture
160                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                // Solutions don't have upstream references
171            }
172        }
173
174        // Add specification for requirement types
175        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    /// Returns the template name for the item type.
187    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
202/// Generates YAML frontmatter for an item.
203pub 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
210/// Generates a complete document with frontmatter and body.
211pub 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
218/// Escapes a string for YAML.
219fn escape_yaml_string(s: &str) -> String {
220    s.replace('\\', "\\\\")
221        .replace('"', "\\\"")
222        .replace('\n', "\\n")
223}
224
225/// Generates a new ID for the given type, optionally using a sequence number.
226pub 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
232/// Suggests the next ID based on existing items in the graph (FR-044).
233///
234/// Finds the highest existing ID for the given type and returns the next sequential ID.
235/// If no graph is provided or no items exist, returns the first ID (e.g., "SOL-001").
236pub 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
260/// Extracts a name from a markdown file's first heading.
261pub 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}