1use regex::Regex;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fmt::Write;
12use std::sync::LazyLock;
13
14use crate::models::{
15 ContextTemplate, OutputFormat, PromptVariable, TemplateVariable, extract_variables,
16 substitute_variables,
17};
18use crate::{Error, Result};
19
20const MAX_ITERATION_ITEMS: usize = 1000;
22
23const MAX_ITERATION_DEPTH: usize = 1;
25
26static EACH_PATTERN: LazyLock<Regex> = LazyLock::new(|| {
28 Regex::new(r"\{\{#each\s+(\w+)\}\}([\s\S]*?)\{\{/each\}\}").unwrap_or_else(|_| unreachable!())
29});
30
31static ITEM_VAR_PATTERN: LazyLock<Regex> =
33 LazyLock::new(|| Regex::new(r"\{\{(\w+)\.(\w+)\}\}").unwrap_or_else(|_| unreachable!()));
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
37#[serde(untagged)]
38pub enum RenderValue {
39 String(String),
41 List(Vec<HashMap<String, String>>),
43 Object(HashMap<String, String>),
45}
46
47impl RenderValue {
48 #[must_use]
50 pub fn string(value: impl Into<String>) -> Self {
51 Self::String(value.into())
52 }
53
54 #[must_use]
56 pub const fn list(items: Vec<HashMap<String, String>>) -> Self {
57 Self::List(items)
58 }
59
60 #[must_use]
62 pub const fn object(fields: HashMap<String, String>) -> Self {
63 Self::Object(fields)
64 }
65
66 #[must_use]
68 pub fn as_string(&self) -> Option<&str> {
69 match self {
70 Self::String(s) => Some(s),
71 _ => None,
72 }
73 }
74
75 #[must_use]
77 pub fn as_list(&self) -> Option<&[HashMap<String, String>]> {
78 match self {
79 Self::List(l) => Some(l),
80 _ => None,
81 }
82 }
83
84 #[must_use]
86 pub fn to_string_repr(&self) -> String {
87 match self {
88 Self::String(s) => s.clone(),
89 Self::List(l) => serde_json::to_string(l).unwrap_or_else(|_| "[]".to_string()),
90 Self::Object(o) => serde_json::to_string(o).unwrap_or_else(|_| "{}".to_string()),
91 }
92 }
93}
94
95impl From<String> for RenderValue {
96 fn from(s: String) -> Self {
97 Self::String(s)
98 }
99}
100
101impl From<&str> for RenderValue {
102 fn from(s: &str) -> Self {
103 Self::String(s.to_string())
104 }
105}
106
107impl From<Vec<HashMap<String, String>>> for RenderValue {
108 fn from(l: Vec<HashMap<String, String>>) -> Self {
109 Self::List(l)
110 }
111}
112
113#[derive(Debug, Clone, Default)]
115pub struct RenderContext {
116 values: HashMap<String, RenderValue>,
118}
119
120impl RenderContext {
121 #[must_use]
123 pub fn new() -> Self {
124 Self::default()
125 }
126
127 pub fn add_string(&mut self, name: impl Into<String>, value: impl Into<String>) {
129 self.values
130 .insert(name.into(), RenderValue::String(value.into()));
131 }
132
133 pub fn add_list(&mut self, name: impl Into<String>, items: Vec<HashMap<String, String>>) {
135 self.values.insert(name.into(), RenderValue::List(items));
136 }
137
138 pub fn add_object(&mut self, name: impl Into<String>, fields: HashMap<String, String>) {
140 self.values.insert(name.into(), RenderValue::Object(fields));
141 }
142
143 pub fn add_value(&mut self, name: impl Into<String>, value: RenderValue) {
145 self.values.insert(name.into(), value);
146 }
147
148 pub fn set(&mut self, name: impl Into<String>, value: RenderValue) {
150 self.add_value(name, value);
151 }
152
153 #[must_use]
155 pub fn get(&self, name: &str) -> Option<&RenderValue> {
156 self.values.get(name)
157 }
158
159 #[must_use]
161 pub fn get_string(&self, name: &str) -> Option<&str> {
162 self.values.get(name).and_then(RenderValue::as_string)
163 }
164
165 #[must_use]
167 pub fn get_list(&self, name: &str) -> Option<&[HashMap<String, String>]> {
168 self.values.get(name).and_then(RenderValue::as_list)
169 }
170
171 #[must_use]
173 pub fn to_string_map(&self) -> HashMap<String, String> {
174 self.values
175 .iter()
176 .map(|(k, v)| (k.clone(), v.to_string_repr()))
177 .collect()
178 }
179
180 #[must_use]
182 pub fn contains(&self, name: &str) -> bool {
183 self.values.contains_key(name)
184 }
185
186 #[must_use]
188 pub fn len(&self) -> usize {
189 self.values.len()
190 }
191
192 #[must_use]
194 pub fn is_empty(&self) -> bool {
195 self.values.is_empty()
196 }
197}
198
199#[derive(Debug, Clone, Default)]
201pub struct TemplateRenderer {
202 _private: (), }
204
205impl TemplateRenderer {
206 #[must_use]
208 pub fn new() -> Self {
209 Self::default()
210 }
211
212 #[allow(clippy::similar_names)]
221 pub fn render(
222 &self,
223 template: &ContextTemplate,
224 ctx: &RenderContext,
225 format: OutputFormat,
226 ) -> Result<String> {
227 let processed = self.process_iterations(&template.content, ctx)?;
229
230 let values = ctx.to_string_map();
232
233 let remaining_vars = extract_variables(&processed);
236 let prompt_vars: Vec<PromptVariable> = remaining_vars
237 .into_iter()
238 .map(|ev| {
239 template
241 .variables
242 .iter()
243 .find(|v| v.name == ev.name)
244 .cloned()
245 .unwrap_or_else(|| TemplateVariable::new(&ev.name))
246 })
247 .map(Into::into)
248 .collect();
249
250 let rendered = substitute_variables(&processed, &values, &prompt_vars)?;
252
253 self.format_output(&rendered, format)
255 }
256
257 #[allow(clippy::similar_names)]
259 fn process_iterations(&self, input: &str, ctx: &RenderContext) -> Result<String> {
260 let mut result = input.to_string();
261 let mut depth = 0;
262
263 while let Some(captures) = EACH_PATTERN.captures(&result) {
265 depth += 1;
266 if depth > MAX_ITERATION_DEPTH {
267 return Err(Error::InvalidInput(format!(
268 "Maximum iteration depth ({MAX_ITERATION_DEPTH}) exceeded"
269 )));
270 }
271
272 let full_match = captures.get(0).map_or("", |m| m.as_str());
273 let collection_name = captures.get(1).map_or("", |m| m.as_str());
274 let block_body = captures.get(2).map_or("", |m| m.as_str());
275
276 let items = ctx.get_list(collection_name).ok_or_else(|| {
278 Error::InvalidInput(format!(
279 "Iteration collection '{collection_name}' not found or not a list"
280 ))
281 })?;
282
283 if items.len() > MAX_ITERATION_ITEMS {
285 return Err(Error::InvalidInput(format!(
286 "Iteration collection '{collection_name}' has {} items, max is {MAX_ITERATION_ITEMS}",
287 items.len()
288 )));
289 }
290
291 let item_prefix = get_item_prefix(collection_name);
293
294 let rendered_items: Vec<String> = items
296 .iter()
297 .map(|item| self.render_iteration_item(block_body, &item_prefix, item))
298 .collect();
299
300 result = result.replace(full_match, &rendered_items.join(""));
302 }
303
304 Ok(result)
305 }
306
307 #[allow(clippy::unused_self)]
309 fn render_iteration_item(
310 &self,
311 block: &str,
312 item_prefix: &str,
313 item: &HashMap<String, String>,
314 ) -> String {
315 let mut rendered = block.to_string();
316
317 for (field, value) in item {
319 let pattern = format!("{{{{{item_prefix}.{field}}}}}");
320 rendered = rendered.replace(&pattern, value);
321 }
322
323 for (field, value) in item {
325 let pattern = format!("{{{{this.{field}}}}}");
326 rendered = rendered.replace(&pattern, value);
327 }
328
329 rendered = ITEM_VAR_PATTERN
331 .replace_all(&rendered, |caps: ®ex::Captures| {
332 let prefix = caps.get(1).map_or("", |m| m.as_str());
333 let field = caps.get(2).map_or("", |m| m.as_str());
334
335 if prefix == item_prefix || prefix == "this" {
337 item.get(field).cloned().unwrap_or_default()
338 } else {
339 caps.get(0).map_or("", |m| m.as_str()).to_string()
341 }
342 })
343 .to_string();
344
345 rendered
346 }
347
348 fn format_output(&self, markdown: &str, format: OutputFormat) -> Result<String> {
350 match format {
351 OutputFormat::Markdown => Ok(markdown.to_string()),
352 OutputFormat::Json => self.markdown_to_json(markdown),
353 OutputFormat::Xml => self.markdown_to_xml(markdown),
354 }
355 }
356
357 #[allow(clippy::unused_self)]
361 fn markdown_to_json(&self, markdown: &str) -> Result<String> {
362 let mut sections: Vec<serde_json::Value> = Vec::new();
363 let mut current_section: Option<(String, Vec<String>)> = None;
364
365 for line in markdown.lines() {
366 if let Some(title) = line.strip_prefix("# ") {
367 flush_section(&mut sections, &mut current_section, 1);
368 current_section = Some((title.to_string(), Vec::new()));
369 } else if let Some(title) = line.strip_prefix("## ") {
370 flush_section(&mut sections, &mut current_section, 2);
371 current_section = Some((title.to_string(), Vec::new()));
372 } else if let Some(title) = line.strip_prefix("### ") {
373 flush_section(&mut sections, &mut current_section, 2);
374 current_section = Some((title.to_string(), Vec::new()));
375 } else if let Some((_, ref mut lines)) = current_section {
376 lines.push(line.to_string());
377 } else if !line.trim().is_empty() {
378 sections.push(serde_json::json!({
380 "level": 0,
381 "title": "",
382 "content": line
383 }));
384 }
385 }
386
387 flush_section(&mut sections, &mut current_section, 2);
389
390 serde_json::to_string_pretty(&serde_json::json!({
391 "sections": sections,
392 "raw": markdown
393 }))
394 .map_err(|e| Error::OperationFailed {
395 operation: "json_conversion".to_string(),
396 cause: e.to_string(),
397 })
398 }
399
400 #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
402 fn markdown_to_xml(&self, markdown: &str) -> Result<String> {
403 let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<context>\n");
404 let mut state = XmlState::default();
405
406 for line in markdown.lines() {
407 let trimmed = line.trim();
408 if trimmed.is_empty() {
409 continue;
410 }
411 process_xml_line(trimmed, &mut xml, &mut state);
412 }
413
414 while state.level > 0 {
416 let _ = writeln!(xml, "{}</section>", " ".repeat(state.level));
417 state.level -= 1;
418 }
419
420 xml.push_str("</context>");
421 Ok(xml)
422 }
423}
424
425#[derive(Default)]
427struct XmlState {
428 in_section: bool,
429 level: usize,
430}
431
432fn process_xml_line(trimmed: &str, xml: &mut String, state: &mut XmlState) {
434 if let Some(title) = trimmed.strip_prefix("# ") {
435 close_section_if_needed(xml, state);
436 state.level = 1;
437 state.in_section = true;
438 let escaped = escape_xml(title);
439 let _ = writeln!(xml, " <section level=\"1\" title=\"{escaped}\">");
440 } else if let Some(title) = trimmed.strip_prefix("## ") {
441 close_section_at_level(xml, state, 2);
442 state.level = 2;
443 state.in_section = true;
444 let escaped = escape_xml(title);
445 let _ = writeln!(xml, " <section level=\"2\" title=\"{escaped}\">");
446 } else if let Some(title) = trimmed.strip_prefix("### ") {
447 close_section_at_level(xml, state, 3);
448 state.level = 3;
449 state.in_section = true;
450 let escaped = escape_xml(title);
451 let _ = writeln!(xml, " <section level=\"3\" title=\"{escaped}\">");
452 } else if let Some(item_text) = trimmed.strip_prefix("- ") {
453 let escaped = escape_xml(item_text);
454 let indent = " ".repeat(state.level + 1);
455 let _ = writeln!(xml, "{indent}<item>{escaped}</item>");
456 } else {
457 let escaped = escape_xml(trimmed);
458 let indent = " ".repeat(state.level + 1);
459 let _ = writeln!(xml, "{indent}<text>{escaped}</text>");
460 }
461}
462
463fn close_section_if_needed(xml: &mut String, state: &XmlState) {
465 if state.in_section {
466 let _ = writeln!(xml, "{}</section>", " ".repeat(state.level));
467 }
468}
469
470fn close_section_at_level(xml: &mut String, state: &XmlState, target: usize) {
472 if state.in_section && state.level >= target {
473 let _ = writeln!(xml, "{}</section>", " ".repeat(state.level));
474 }
475}
476
477fn flush_section(
479 sections: &mut Vec<serde_json::Value>,
480 current: &mut Option<(String, Vec<String>)>,
481 level: usize,
482) {
483 if let Some((title, lines)) = current.take() {
484 sections.push(serde_json::json!({
485 "level": level,
486 "title": title,
487 "content": lines.join("\n").trim()
488 }));
489 }
490}
491
492fn get_item_prefix(collection_name: &str) -> String {
494 if collection_name == "memories" {
496 return "memory".to_string();
497 }
498 if let Some(stripped) = collection_name.strip_suffix("ies") {
499 return format!("{stripped}y");
501 }
502 if let Some(stripped) = collection_name.strip_suffix('s') {
503 return stripped.to_string();
505 }
506 collection_name.to_string()
508}
509
510fn escape_xml(s: &str) -> String {
512 s.replace('&', "&")
513 .replace('<', "<")
514 .replace('>', ">")
515 .replace('"', """)
516 .replace('\'', "'")
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522 use crate::models::ContextTemplate;
523
524 #[test]
525 fn test_render_value_string() {
526 let value = RenderValue::string("hello");
527 assert_eq!(value.as_string(), Some("hello"));
528 assert!(value.as_list().is_none());
529 assert_eq!(value.to_string_repr(), "hello");
530 }
531
532 #[test]
533 fn test_render_value_list() {
534 let mut item = HashMap::new();
535 item.insert("name".to_string(), "test".to_string());
536 let value = RenderValue::list(vec![item]);
537
538 assert!(value.as_string().is_none());
539 assert!(value.as_list().is_some());
540 assert_eq!(value.as_list().unwrap().len(), 1);
541 }
542
543 #[test]
544 fn test_render_context_add_and_get() {
545 let mut ctx = RenderContext::new();
546 ctx.add_string("name", "Alice");
547 ctx.add_string("count", "42");
548
549 assert_eq!(ctx.get_string("name"), Some("Alice"));
550 assert_eq!(ctx.get_string("count"), Some("42"));
551 assert!(ctx.get_string("missing").is_none());
552 }
553
554 #[test]
555 fn test_render_context_add_list() {
556 let mut ctx = RenderContext::new();
557 let mut item1 = HashMap::new();
558 item1.insert("id".to_string(), "1".to_string());
559 let mut item2 = HashMap::new();
560 item2.insert("id".to_string(), "2".to_string());
561
562 ctx.add_list("items", vec![item1, item2]);
563
564 let list = ctx.get_list("items").unwrap();
565 assert_eq!(list.len(), 2);
566 }
567
568 #[test]
569 fn test_render_context_to_string_map() {
570 let mut ctx = RenderContext::new();
571 ctx.add_string("name", "test");
572 ctx.add_string("value", "123");
573
574 let map = ctx.to_string_map();
575 assert_eq!(map.get("name"), Some(&"test".to_string()));
576 assert_eq!(map.get("value"), Some(&"123".to_string()));
577 }
578
579 #[test]
580 fn test_simple_variable_substitution() {
581 let renderer = TemplateRenderer::new();
582 let template = ContextTemplate::new("test", "Hello {{name}}!");
583
584 let mut ctx = RenderContext::new();
585 ctx.add_string("name", "World");
586
587 let result = renderer
588 .render(&template, &ctx, OutputFormat::Markdown)
589 .unwrap();
590 assert_eq!(result, "Hello World!");
591 }
592
593 #[test]
594 fn test_iteration_with_memories() {
595 let renderer = TemplateRenderer::new();
596 let template = ContextTemplate::new(
597 "test",
598 "Memories:\n{{#each memories}}- {{memory.content}}\n{{/each}}",
599 );
600
601 let mut ctx = RenderContext::new();
602 let mut mem1 = HashMap::new();
603 mem1.insert("content".to_string(), "First memory".to_string());
604 let mut mem2 = HashMap::new();
605 mem2.insert("content".to_string(), "Second memory".to_string());
606 ctx.add_list("memories", vec![mem1, mem2]);
607
608 let result = renderer
609 .render(&template, &ctx, OutputFormat::Markdown)
610 .unwrap();
611 assert!(result.contains("- First memory"));
612 assert!(result.contains("- Second memory"));
613 }
614
615 #[test]
616 fn test_iteration_with_multiple_fields() {
617 let renderer = TemplateRenderer::new();
618 let template = ContextTemplate::new(
619 "test",
620 "{{#each memories}}**{{memory.namespace}}**: {{memory.content}} ({{memory.score}})\n{{/each}}",
621 );
622
623 let mut ctx = RenderContext::new();
624 let mut mem = HashMap::new();
625 mem.insert("namespace".to_string(), "decisions".to_string());
626 mem.insert("content".to_string(), "Use Rust".to_string());
627 mem.insert("score".to_string(), "0.95".to_string());
628 ctx.add_list("memories", vec![mem]);
629
630 let result = renderer
631 .render(&template, &ctx, OutputFormat::Markdown)
632 .unwrap();
633 assert!(result.contains("**decisions**"));
634 assert!(result.contains("Use Rust"));
635 assert!(result.contains("0.95"));
636 }
637
638 #[test]
639 fn test_iteration_empty_collection() {
640 let renderer = TemplateRenderer::new();
641 let template = ContextTemplate::new(
642 "test",
643 "Start\n{{#each memories}}{{memory.content}}\n{{/each}}End",
644 );
645
646 let mut ctx = RenderContext::new();
647 ctx.add_list("memories", vec![]);
648
649 let result = renderer
650 .render(&template, &ctx, OutputFormat::Markdown)
651 .unwrap();
652 assert_eq!(result, "Start\nEnd");
653 }
654
655 #[test]
656 fn test_iteration_missing_collection() {
657 let renderer = TemplateRenderer::new();
658 let template = ContextTemplate::new("test", "{{#each missing}}{{item.value}}{{/each}}");
659
660 let ctx = RenderContext::new();
661 let result = renderer.render(&template, &ctx, OutputFormat::Markdown);
662
663 assert!(result.is_err());
664 let err = result.unwrap_err().to_string();
665 assert!(err.contains("not found"));
666 }
667
668 #[test]
669 fn test_mixed_iteration_and_variables() {
670 let renderer = TemplateRenderer::new();
671 let template = ContextTemplate::new(
672 "test",
673 "# {{title}}\n\n{{#each items}}- {{item.name}}\n{{/each}}\n\nTotal: {{total}}",
674 );
675
676 let mut ctx = RenderContext::new();
677 ctx.add_string("title", "My List");
678 ctx.add_string("total", "2");
679
680 let mut item1 = HashMap::new();
681 item1.insert("name".to_string(), "Item A".to_string());
682 let mut item2 = HashMap::new();
683 item2.insert("name".to_string(), "Item B".to_string());
684 ctx.add_list("items", vec![item1, item2]);
685
686 let result = renderer
687 .render(&template, &ctx, OutputFormat::Markdown)
688 .unwrap();
689 assert!(result.contains("# My List"));
690 assert!(result.contains("- Item A"));
691 assert!(result.contains("- Item B"));
692 assert!(result.contains("Total: 2"));
693 }
694
695 #[test]
696 fn test_get_item_prefix() {
697 assert_eq!(get_item_prefix("memories"), "memory");
698 assert_eq!(get_item_prefix("items"), "item");
699 assert_eq!(get_item_prefix("entries"), "entry");
700 assert_eq!(get_item_prefix("tags"), "tag");
701 assert_eq!(get_item_prefix("data"), "data"); }
703
704 #[test]
705 fn test_format_json() {
706 let renderer = TemplateRenderer::new();
707 let template = ContextTemplate::new("test", "# Title\n\nContent here");
708
709 let ctx = RenderContext::new();
710 let result = renderer
711 .render(&template, &ctx, OutputFormat::Json)
712 .unwrap();
713
714 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
716 assert!(parsed.get("sections").is_some());
717 assert!(parsed.get("raw").is_some());
718 }
719
720 #[test]
721 fn test_format_xml() {
722 let renderer = TemplateRenderer::new();
723 let template = ContextTemplate::new("test", "# Title\n\nContent here\n- Item 1");
724
725 let ctx = RenderContext::new();
726 let result = renderer.render(&template, &ctx, OutputFormat::Xml).unwrap();
727
728 assert!(result.starts_with("<?xml"));
729 assert!(result.contains("<context>"));
730 assert!(result.contains("</context>"));
731 assert!(result.contains("<section"));
732 assert!(result.contains("<item>"));
733 }
734
735 #[test]
736 fn test_escape_xml() {
737 assert_eq!(escape_xml("a < b"), "a < b");
738 assert_eq!(escape_xml("a > b"), "a > b");
739 assert_eq!(escape_xml("a & b"), "a & b");
740 assert_eq!(escape_xml("a \"b\" c"), "a "b" c");
741 }
742
743 #[test]
744 fn test_render_value_from_string() {
745 let value: RenderValue = "test".into();
746 assert_eq!(value.as_string(), Some("test"));
747
748 let owned: RenderValue = String::from("owned").into();
749 assert_eq!(owned.as_string(), Some("owned"));
750 }
751
752 #[test]
753 fn test_iteration_with_this_syntax() {
754 let renderer = TemplateRenderer::new();
755 let template = ContextTemplate::new(
756 "test",
757 "## Memories\n{{#each memories}}- **{{this.namespace}}**: {{this.content}}\n{{/each}}",
758 );
759
760 let mut ctx = RenderContext::new();
761 let mut mem1 = HashMap::new();
762 mem1.insert("namespace".to_string(), "decisions".to_string());
763 mem1.insert("content".to_string(), "Use Rust".to_string());
764 let mut mem2 = HashMap::new();
765 mem2.insert("namespace".to_string(), "learnings".to_string());
766 mem2.insert("content".to_string(), "SQLite is fast".to_string());
767 ctx.add_list("memories", vec![mem1, mem2]);
768
769 let result = renderer
770 .render(&template, &ctx, OutputFormat::Markdown)
771 .unwrap();
772 assert!(result.contains("**decisions**"));
773 assert!(result.contains("Use Rust"));
774 assert!(result.contains("**learnings**"));
775 assert!(result.contains("SQLite is fast"));
776 }
777
778 #[test]
779 fn test_render_context_length() {
780 let mut ctx = RenderContext::new();
781 assert!(ctx.is_empty());
782 assert_eq!(ctx.len(), 0);
783
784 ctx.add_string("a", "1");
785 ctx.add_string("b", "2");
786
787 assert!(!ctx.is_empty());
788 assert_eq!(ctx.len(), 2);
789 assert!(ctx.contains("a"));
790 assert!(!ctx.contains("c"));
791 }
792}