1use 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
13const 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#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct Template {
26 pub content: String,
28
29 pub name: String,
31
32 pub slots: HashMap<String, Slot>,
34
35 pub metadata: TemplateMetadata,
37}
38
39#[derive(Debug, Clone, Default, Serialize, Deserialize)]
41pub struct TemplateMetadata {
42 pub description: Option<String>,
44
45 pub language: Option<String>,
47
48 pub author: Option<String>,
50
51 pub version: Option<String>,
53}
54
55#[derive(Debug, Clone)]
57pub struct SlotLocation {
58 pub name: String,
60
61 pub start: usize,
63
64 pub end: usize,
66
67 pub kind: Option<SlotKind>,
69}
70
71impl Template {
72 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 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 pub fn with_name(mut self, name: impl Into<String>) -> Self {
118 self.name = name.into();
119 self
120 }
121
122 pub fn with_metadata(mut self, metadata: TemplateMetadata) -> Self {
124 self.metadata = metadata;
125 self
126 }
127
128 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 pub fn configure_slot(mut self, slot: Slot) -> Self {
146 self.slots.insert(slot.name.clone(), slot);
147 self
148 }
149
150 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 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 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 locations.sort_by(|a, b| b.start.cmp(&a.start));
200 locations
201 }
202
203 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 pub fn slot_names(&self) -> Vec<&str> {
232 self.slots.keys().map(|s| s.as_str()).collect()
233 }
234
235 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}