use regex::Regex;
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::sync::Arc;
use weaver_lang::{CompiledExpr, CompiledTemplate};
use crate::assembler::Slot;
use crate::ContextWeaverError;
#[derive(Clone)]
pub struct Entry {
pub meta: EntryMeta,
pub compiled: Arc<CompiledTemplate>,
pub condition: Option<Arc<CompiledExpr>>,
pub compiled_regex: Vec<Regex>,
pub source_body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EntryMeta {
pub id: String,
#[serde(default)]
pub name: String,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub regex: Vec<String>,
pub condition: Option<String>,
#[serde(default)]
pub scan_depth: Option<usize>,
#[serde(default)]
pub constant: bool,
#[serde(default = "default_priority")]
pub priority: i32,
#[serde(default)]
pub slot: Slot,
#[serde(default)]
pub fallback: Vec<Slot>,
#[serde(default = "default_insertion_order")]
pub insertion_order: i32,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub sticky_turns: usize,
#[serde(default)]
pub cooldown: usize,
#[serde(default)]
pub group: Option<String>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(flatten)]
pub extensions: std::collections::HashMap<String, serde_yaml::Value>,
}
fn default_priority() -> i32 {
100
}
fn default_insertion_order() -> i32 {
50
}
fn default_true() -> bool {
true
}
impl Entry {
pub fn parse(source: &str, file_path: Option<&str>) -> Result<Self, ContextWeaverError> {
let (frontmatter, body) =
split_frontmatter(source).ok_or_else(|| ContextWeaverError::MetaParse {
entry_path: file_path.unwrap_or("<unknown>").to_string(),
message: "missing frontmatter delimiters (---)".to_string(),
})?;
let meta: EntryMeta =
serde_yaml::from_str(frontmatter).map_err(|e| ContextWeaverError::MetaParse {
entry_path: file_path.unwrap_or("<unknown>").to_string(),
message: e.to_string(),
})?;
let compiled = CompiledTemplate::compile(body).map_err(|errors| {
ContextWeaverError::TemplateParse {
entry_id: meta.id.clone(),
errors,
}
})?;
let condition = meta
.condition
.as_ref()
.map(|src| CompiledExpr::compile(src))
.transpose()
.map_err(|errors| ContextWeaverError::TemplateParse {
entry_id: meta.id.clone(),
errors,
})?
.map(Arc::new);
let compiled_regex = meta
.regex
.iter()
.filter_map(|pattern| match Regex::new(pattern) {
Ok(re) => Some(re),
Err(e) => {
tracing::error!(
"warning: entry '{}': invalid regex '{}': {}",
meta.id, pattern, e
);
None
}
})
.collect();
Ok(Entry {
meta,
compiled: Arc::new(compiled),
source_body: body.to_string(),
condition,
compiled_regex,
})
}
pub fn load(path: &Path) -> Result<Self, ContextWeaverError> {
let source = std::fs::read_to_string(path)?;
Self::parse(&source, path.to_str())
}
}
fn split_frontmatter(source: &str) -> Option<(&str, &str)> {
let s = source.strip_prefix("---")?;
let s = s.strip_prefix('\n').or_else(|| s.strip_prefix("\r\n"))?;
let end = s
.find("\n---\n")
.or_else(|| s.find("\r\n---\r\n"))
.or_else(|| s.find("\n---\r\n"))?;
let frontmatter = &s[..end];
let rest = &s[end..];
let body_start = rest.find("---").unwrap() + 3;
let body = &rest[body_start..];
let body = body
.strip_prefix('\n')
.or_else(|| body.strip_prefix("\r\n"))
.unwrap_or(body);
Some((frontmatter, body))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_basic_entry() {
let source = r#"---
id: test_entry
name: Test Entry
keywords: ["hello", "world"]
slot: foundation
priority: 50
---
Hello, {{char:name}}!
"#;
let entry = Entry::parse(source, None).unwrap();
assert_eq!(entry.meta.id, "test_entry");
assert_eq!(entry.meta.keywords, vec!["hello", "world"]);
assert_eq!(entry.meta.priority, 50);
assert_eq!(entry.meta.slot, Slot::Foundation);
}
#[test]
fn test_default_values() {
let source = r#"---
id: minimal
---
content
"#;
let entry = Entry::parse(source, None).unwrap();
assert_eq!(entry.meta.priority, 100);
assert!(entry.meta.enabled);
assert!(entry.meta.keywords.is_empty());
assert!(!entry.meta.constant);
assert_eq!(entry.meta.slot, Slot::Context); assert!(entry.meta.fallback.is_empty());
}
#[test]
fn test_fallback_parsed() {
let source = r#"---
id: with_fallback
slot: reference
fallback: [context, foundation]
---
content
"#;
let entry = Entry::parse(source, None).unwrap();
assert_eq!(entry.meta.slot, Slot::Reference);
assert_eq!(entry.meta.fallback, vec![Slot::Context, Slot::Foundation]);
}
#[test]
fn test_regex_compiled_at_parse_time() {
let source = r#"---
id: regex_entry
regex: ['\b(attack|fight)\b', '\d{3,}']
---
content
"#;
let entry = Entry::parse(source, None).unwrap();
assert_eq!(entry.compiled_regex.len(), 2);
assert!(entry.compiled_regex[0].is_match("attack now"));
assert!(entry.compiled_regex[1].is_match("found 1000 gold"));
}
#[test]
fn test_invalid_regex_skipped() {
let source = r#"---
id: bad_regex
regex: ['[invalid', '\d+']
---
content
"#;
let entry = Entry::parse(source, None).unwrap();
assert_eq!(entry.compiled_regex.len(), 1);
assert!(entry.compiled_regex[0].is_match("42"));
}
#[test]
fn test_extensions_preserved() {
let source = r#"---
id: extended
my_custom_field: "hello"
plugin_data:
foo: bar
---
content
"#;
let entry = Entry::parse(source, None).unwrap();
assert!(entry.meta.extensions.contains_key("my_custom_field"));
assert!(entry.meta.extensions.contains_key("plugin_data"));
}
#[test]
fn test_missing_frontmatter_errors() {
let result = Entry::parse("no frontmatter here", None);
assert!(matches!(result, Err(ContextWeaverError::MetaParse { .. })));
}
}