aether_core/
template.rs

1//! Template parsing and management.
2//!
3//! Templates contain slots marked with `{{AI:slot_name}}` syntax that will be
4//! replaced with AI-generated code.
5
6use crate::{AetherError, Result, Slot, SlotKind};
7use regex::Regex;
8use std::sync::OnceLock;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::path::Path;
12
13/// Pattern for matching AI slots in templates.
14/// Format: {{AI:slot_name}} or {{AI:slot_name:kind}}
15const SLOT_PATTERN: &str = r"\{\{AI:([a-zA-Z_][a-zA-Z0-9_]*)(?::([a-zA-Z]+))?\}\}";
16
17static SLOT_REGEX: OnceLock<Regex> = OnceLock::new();
18
19fn get_slot_regex() -> &'static Regex {
20    SLOT_REGEX.get_or_init(|| Regex::new(SLOT_PATTERN).expect("Invalid slot pattern regex"))
21}
22
23/// Represents a template with AI injection slots.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Template {
26    /// Original template content.
27    pub content: String,
28
29    /// Name of this template.
30    pub name: String,
31
32    /// Slots found in the template.
33    pub slots: HashMap<String, Slot>,
34
35    /// Template metadata.
36    pub metadata: TemplateMetadata,
37}
38
39/// Metadata about a template.
40#[derive(Debug, Clone, Default, Serialize, Deserialize)]
41pub struct TemplateMetadata {
42    /// Template description.
43    pub description: Option<String>,
44
45    /// Template language (html, rust, js, etc.).
46    pub language: Option<String>,
47
48    /// Template author.
49    pub author: Option<String>,
50
51    /// Template version.
52    pub version: Option<String>,
53}
54
55/// A parsed slot location in the template.
56#[derive(Debug, Clone)]
57pub struct SlotLocation {
58    /// Slot name.
59    pub name: String,
60
61    /// Start position in template.
62    pub start: usize,
63
64    /// End position in template.
65    pub end: usize,
66
67    /// Optional slot kind from template.
68    pub kind: Option<SlotKind>,
69}
70
71impl Template {
72    /// Create a new template from content.
73    ///
74    /// # Arguments
75    ///
76    /// * `content` - The template content with AI slots
77    ///
78    /// # Example
79    ///
80    /// ```
81    /// use aether_core::Template;
82    ///
83    /// let template = Template::new("<div>{{AI:content}}</div>");
84    /// assert!(template.slots.contains_key("content"));
85    /// ```
86    pub fn new(content: impl Into<String>) -> Self {
87        let content = content.into();
88        let slots = Self::parse_slots(&content);
89
90        Self {
91            content,
92            name: String::from("unnamed"),
93            slots,
94            metadata: TemplateMetadata::default(),
95        }
96    }
97
98    /// Load a template from a file.
99    pub async fn from_file(path: impl AsRef<Path>) -> Result<Self> {
100        let path = path.as_ref();
101        let content = tokio::fs::read_to_string(path).await?;
102        let name = path
103            .file_stem()
104            .and_then(|s| s.to_str())
105            .unwrap_or("unnamed")
106            .to_string();
107
108        Ok(Self {
109            name,
110            slots: Self::parse_slots(&content),
111            content,
112            metadata: TemplateMetadata::default(),
113        })
114    }
115
116    /// Set the template name.
117    pub fn with_name(mut self, name: impl Into<String>) -> Self {
118        self.name = name.into();
119        self
120    }
121
122    /// Set template metadata.
123    pub fn with_metadata(mut self, metadata: TemplateMetadata) -> Self {
124        self.metadata = metadata;
125        self
126    }
127
128    /// Add a slot definition with a custom prompt.
129    ///
130    /// # Arguments
131    ///
132    /// * `name` - Slot name (must match a slot in the template)
133    /// * `prompt` - The AI prompt for generating code
134    pub fn with_slot(mut self, name: impl Into<String>, prompt: impl Into<String>) -> Self {
135        let name = name.into();
136        if let Some(slot) = self.slots.get_mut(&name) {
137            slot.prompt = prompt.into();
138        } else {
139            self.slots.insert(name.clone(), Slot::new(name, prompt));
140        }
141        self
142    }
143
144    /// Configure a slot with detailed options.
145    pub fn configure_slot(mut self, slot: Slot) -> Self {
146        self.slots.insert(slot.name.clone(), slot);
147        self
148    }
149
150    /// Parse slots from template content.
151    fn parse_slots(content: &str) -> HashMap<String, Slot> {
152        let re = get_slot_regex();
153        let mut slots = HashMap::new();
154
155        for cap in re.captures_iter(content) {
156            let name = cap[1].to_string();
157            let kind = cap.get(2).map(|m| Self::parse_kind(m.as_str()));
158
159            let mut slot = Slot::new(&name, format!("Generate code for: {}", name));
160            if let Some(k) = kind {
161                slot = slot.with_kind(k);
162            }
163            slots.insert(name, slot);
164        }
165
166        slots
167    }
168
169    /// Parse slot kind from string.
170    fn parse_kind(s: &str) -> SlotKind {
171        match s.to_lowercase().as_str() {
172            "raw" => SlotKind::Raw,
173            "function" | "fn" => SlotKind::Function,
174            "class" | "struct" => SlotKind::Class,
175            "html" => SlotKind::Html,
176            "css" => SlotKind::Css,
177            "js" | "javascript" => SlotKind::JavaScript,
178            "component" => SlotKind::Component,
179            other => SlotKind::Custom(other.to_string()),
180        }
181    }
182
183    /// Find all slot locations in the template content.
184    fn find_locations(&self) -> Vec<SlotLocation> {
185        let re = get_slot_regex();
186        let mut locations = Vec::new();
187
188        for cap in re.captures_iter(&self.content) {
189            let full_match = cap.get(0).unwrap();
190            locations.push(SlotLocation {
191                name: cap[1].to_string(),
192                start: full_match.start(),
193                end: full_match.end(),
194                kind: cap.get(2).map(|m| Self::parse_kind(m.as_str())),
195            });
196        }
197
198        // Sort by position in reverse order for replacement
199        locations.sort_by(|a, b| b.start.cmp(&a.start));
200        locations
201    }
202
203    /// Render the template with provided code injections.
204    ///
205    /// # Arguments
206    ///
207    /// * `injections` - Map of slot names to generated code
208    pub fn render(&self, injections: &HashMap<String, String>) -> Result<String> {
209        let mut result = self.content.clone();
210        let locations = self.find_locations();
211
212        for loc in locations {
213            let code = if let Some(code) = injections.get(&loc.name) {
214                code.clone()
215            } else if let Some(slot) = self.slots.get(&loc.name) {
216                if slot.required {
217                    return Err(AetherError::SlotNotFound(loc.name));
218                }
219                slot.default.clone().unwrap_or_default()
220            } else {
221                return Err(AetherError::SlotNotFound(loc.name));
222            };
223
224            result.replace_range(loc.start..loc.end, &code);
225        }
226
227        Ok(result)
228    }
229
230    /// Get a list of slot names.
231    pub fn slot_names(&self) -> Vec<&str> {
232        self.slots.keys().map(|s| s.as_str()).collect()
233    }
234
235    /// Check if template has unfilled required slots.
236    pub fn validate(&self, injections: &HashMap<String, String>) -> Result<()> {
237        for (name, slot) in &self.slots {
238            if slot.required && !injections.contains_key(name) {
239                return Err(AetherError::SlotNotFound(name.clone()));
240            }
241        }
242        Ok(())
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    #[test]
251    fn test_parse_slots() {
252        let template = Template::new("Hello {{AI:greeting}} World {{AI:content:html}}");
253        assert_eq!(template.slots.len(), 2);
254        assert!(template.slots.contains_key("greeting"));
255        assert!(template.slots.contains_key("content"));
256    }
257
258    #[test]
259    fn test_render_template() {
260        let template = Template::new("<div>{{AI:content}}</div>");
261        let mut injections = HashMap::new();
262        injections.insert("content".to_string(), "<p>Hello</p>".to_string());
263
264        let result = template.render(&injections).unwrap();
265        assert_eq!(result, "<div><p>Hello</p></div>");
266    }
267
268    #[test]
269    fn test_slot_kind_parsing() {
270        let template = Template::new("{{AI:func:function}} {{AI:style:css}}");
271        assert_eq!(template.slots.get("func").unwrap().kind, SlotKind::Function);
272        assert_eq!(template.slots.get("style").unwrap().kind, SlotKind::Css);
273    }
274}