1use regex::Regex;
19use serde::{Deserialize, Serialize};
20use std::path::Path;
21use std::sync::Arc;
22use weaver_lang::{CompiledExpr, CompiledTemplate};
23
24use crate::ContextWeaverError;
25use crate::assembler::Slot;
26
27#[derive(Clone)]
31pub struct Entry {
32 pub meta: EntryMeta,
33 pub compiled: Arc<CompiledTemplate>,
34 pub condition: Option<Arc<CompiledExpr>>,
36 pub compiled_regex: Vec<Regex>,
39 pub source_body: String,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct EntryMeta {
50 pub id: String,
52
53 #[serde(default)]
55 pub name: String,
56
57 #[serde(default)]
61 pub keywords: Vec<String>,
62
63 #[serde(default)]
66 pub regex: Vec<String>,
67
68 pub condition: Option<String>,
70
71 #[serde(default)]
74 pub scan_depth: Option<usize>,
75
76 #[serde(default)]
78 pub constant: bool,
79
80 #[serde(default = "default_priority")]
84 pub priority: i32,
85
86 #[serde(default)]
92 pub slot: Slot,
93
94 #[serde(default)]
98 pub fallback: Vec<Slot>,
99
100 #[serde(default = "default_insertion_order")]
102 pub insertion_order: i32,
103
104 #[serde(default = "default_true")]
107 pub enabled: bool,
108
109 #[serde(default)]
112 pub sticky_turns: usize,
113
114 #[serde(default)]
116 pub cooldown: usize,
117
118 #[serde(default)]
122 pub group: Option<String>,
123
124 #[serde(default)]
126 pub tags: Vec<String>,
127
128 #[serde(flatten)]
131 pub extensions: std::collections::HashMap<String, serde_yaml::Value>,
132}
133
134fn default_priority() -> i32 {
135 100
136}
137fn default_insertion_order() -> i32 {
138 50
139}
140fn default_true() -> bool {
141 true
142}
143
144impl Entry {
147 pub fn parse(source: &str, file_path: Option<&str>) -> Result<Self, ContextWeaverError> {
149 let (frontmatter, body) =
150 split_frontmatter(source).ok_or_else(|| ContextWeaverError::MetaParse {
151 entry_path: file_path.unwrap_or("<unknown>").to_string(),
152 message: "missing frontmatter delimiters (---)".to_string(),
153 })?;
154
155 let meta: EntryMeta =
156 serde_yaml::from_str(frontmatter).map_err(|e| ContextWeaverError::MetaParse {
157 entry_path: file_path.unwrap_or("<unknown>").to_string(),
158 message: e.to_string(),
159 })?;
160
161 let compiled = CompiledTemplate::compile(body).map_err(|errors| {
162 ContextWeaverError::TemplateParse {
163 entry_id: meta.id.clone(),
164 errors,
165 }
166 })?;
167
168 let condition = meta
169 .condition
170 .as_ref()
171 .map(|src| CompiledExpr::compile(src))
172 .transpose()
173 .map_err(|errors| ContextWeaverError::TemplateParse {
174 entry_id: meta.id.clone(),
175 errors,
176 })?
177 .map(Arc::new);
178
179 let compiled_regex = meta
181 .regex
182 .iter()
183 .filter_map(|pattern| match Regex::new(pattern) {
184 Ok(re) => Some(re),
185 Err(e) => {
186 tracing::error!(
188 "warning: entry '{}': invalid regex '{}': {}",
189 meta.id,
190 pattern,
191 e
192 );
193 None
194 }
195 })
196 .collect();
197
198 Ok(Entry {
199 meta,
200 compiled: Arc::new(compiled),
201 source_body: body.to_string(),
202 condition,
203 compiled_regex,
204 })
205 }
206
207 pub fn load(path: &Path) -> Result<Self, ContextWeaverError> {
209 let source = std::fs::read_to_string(path)?;
210 Self::parse(&source, path.to_str())
211 }
212
213 pub fn to_source(&self) -> String {
214 self.source_body.clone()
215 }
216}
217
218fn split_frontmatter(source: &str) -> Option<(&str, &str)> {
223 let s = source.strip_prefix("---")?;
224 let s = s.strip_prefix('\n').or_else(|| s.strip_prefix("\r\n"))?;
225
226 let end = s
227 .find("\n---\n")
228 .or_else(|| s.find("\r\n---\r\n"))
229 .or_else(|| s.find("\n---\r\n"))?;
230
231 let frontmatter = &s[..end];
232 let rest = &s[end..];
233
234 let body_start = rest.find("---").unwrap() + 3;
236 let body = &rest[body_start..];
237 let body = body
238 .strip_prefix('\n')
239 .or_else(|| body.strip_prefix("\r\n"))
240 .unwrap_or(body);
241
242 Some((frontmatter, body))
243}
244
245#[cfg(test)]
248mod tests {
249 use super::*;
250
251 #[test]
252 fn test_parse_basic_entry() {
253 let source = r#"---
254id: test_entry
255name: Test Entry
256keywords: ["hello", "world"]
257slot: coda
258priority: 50
259---
260Hello, {{char:name}}!
261"#;
262 let entry = Entry::parse(source, None).unwrap();
263 assert_eq!(entry.meta.id, "test_entry");
264 assert_eq!(entry.meta.keywords, vec!["hello", "world"]);
265 assert_eq!(entry.meta.priority, 50);
266 assert_eq!(entry.meta.slot, Slot::Coda);
267 }
268
269 #[test]
270 fn test_default_values() {
271 let source = r#"---
272id: minimal
273---
274content
275"#;
276 let entry = Entry::parse(source, None).unwrap();
277 assert_eq!(entry.meta.priority, 100);
278 assert!(entry.meta.enabled);
279 assert!(entry.meta.keywords.is_empty());
280 assert!(!entry.meta.constant);
281 assert_eq!(entry.meta.slot, Slot::Backdrop); assert!(entry.meta.fallback.is_empty());
283 }
284
285 #[test]
286 fn test_fallback_parsed() {
287 let source = r#"---
288id: with_fallback
289slot: preamble
290fallback: [backdrop, coda]
291---
292content
293"#;
294 let entry = Entry::parse(source, None).unwrap();
295 assert_eq!(entry.meta.slot, Slot::Preamble);
296 assert_eq!(entry.meta.fallback, vec![Slot::Backdrop, Slot::Coda]);
297 }
298
299 #[test]
300 fn test_regex_compiled_at_parse_time() {
301 let source = r#"---
302id: regex_entry
303regex: ['\b(attack|fight)\b', '\d{3,}']
304---
305content
306"#;
307 let entry = Entry::parse(source, None).unwrap();
308 assert_eq!(entry.compiled_regex.len(), 2);
309 assert!(entry.compiled_regex[0].is_match("attack now"));
310 assert!(entry.compiled_regex[1].is_match("found 1000 gold"));
311 }
312
313 #[test]
314 fn test_invalid_regex_skipped() {
315 let source = r#"---
316id: bad_regex
317regex: ['[invalid', '\d+']
318---
319content
320"#;
321 let entry = Entry::parse(source, None).unwrap();
322 assert_eq!(entry.compiled_regex.len(), 1);
324 assert!(entry.compiled_regex[0].is_match("42"));
325 }
326
327 #[test]
328 fn test_extensions_preserved() {
329 let source = r#"---
330id: extended
331my_custom_field: "hello"
332plugin_data:
333 foo: bar
334---
335content
336"#;
337 let entry = Entry::parse(source, None).unwrap();
338 assert!(entry.meta.extensions.contains_key("my_custom_field"));
339 assert!(entry.meta.extensions.contains_key("plugin_data"));
340 }
341
342 #[test]
343 fn test_missing_frontmatter_errors() {
344 let result = Entry::parse("no frontmatter here", None);
345 assert!(matches!(result, Err(ContextWeaverError::MetaParse { .. })));
346 }
347}