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::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("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        // 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("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                // Add platform for system architecture
160                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                // 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("specification", &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/// Converts an ItemType to YAML format.
219fn 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
233/// Escapes a string for YAML.
234fn escape_yaml_string(s: &str) -> String {
235    s.replace('\\', "\\\\")
236        .replace('"', "\\\"")
237        .replace('\n', "\\n")
238}
239
240/// Generates a new ID for the given type, optionally using a sequence number.
241pub 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
247/// Suggests the next ID based on existing items in the graph (FR-044).
248///
249/// Finds the highest existing ID for the given type and returns the next sequential ID.
250/// If no graph is provided or no items exist, returns the first ID (e.g., "SOL-001").
251pub 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
275/// Extracts a name from a markdown file's first heading.
276pub 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}