1use crate::database::Connection;
2use crate::models::Counter;
3use anyhow::{anyhow, Result};
4use regex::Regex;
5use std::collections::HashSet;
6use std::sync::LazyLock;
7
8static TEMPLATE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{(.*?)\}").unwrap());
9
10pub fn render(conn: &Connection, name: &str) -> Result<String> {
11 let mut visited = HashSet::new();
12 render_inner(conn, name, &mut visited)
13}
14
15fn render_inner(conn: &Connection, name: &str, visited: &mut HashSet<String>) -> Result<String> {
16 if !visited.insert(name.to_string()) {
17 return Err(anyhow!(
18 "template cycle detected involving counter '{}'",
19 name
20 ));
21 }
22
23 let counter = match Counter::get(conn.get(), name)? {
24 Some(c) => c,
25 None => return Err(anyhow!("Unable to find counter for templating")),
26 };
27
28 let mut rendered = counter.template.replace("{}", &counter.count.to_string());
29
30 for cap in TEMPLATE_RE.captures_iter(&rendered.clone()) {
31 rendered = rendered.replace(&cap[0], "{}");
32 let sub_template = render_inner(conn, &cap[1], visited)?;
33 rendered = rendered.replace("{}", &sub_template);
34 }
35
36 visited.remove(name);
37 Ok(rendered)
38}
39
40#[cfg(test)]
41mod tests {
42 use super::*;
43 use crate::database::Connection;
44 use crate::models::Counter;
45 use tempfile::TempDir;
46
47 fn fresh_db() -> (TempDir, Connection) {
48 let dir = TempDir::new().unwrap();
49 let path = dir.path().join("test.db");
50 let conn = Connection::new(&path.to_string_lossy()).unwrap();
51 (dir, conn)
52 }
53
54 fn put(conn: &Connection, name: &str, count: i64, template: &str) {
55 let c = Counter {
56 name: name.into(),
57 count,
58 step: 1,
59 template: template.into(),
60 };
61 c.insert(conn.get()).unwrap();
62 }
63
64 #[test]
65 fn renders_plain_counter() {
66 let (_dir, conn) = fresh_db();
67 put(&conn, "a", 5, "{}");
68 assert_eq!(render(&conn, "a").unwrap(), "5");
69 }
70
71 #[test]
72 fn renders_template_with_literal_text() {
73 let (_dir, conn) = fresh_db();
74 put(&conn, "a", 3, "v{}");
75 assert_eq!(render(&conn, "a").unwrap(), "v3");
76 }
77
78 #[test]
79 fn renders_nested_reference() {
80 let (_dir, conn) = fresh_db();
81 put(&conn, "inner", 7, "{}");
82 put(&conn, "outer", 0, "[{inner}]");
83 assert_eq!(render(&conn, "outer").unwrap(), "[7]");
84 }
85
86 #[test]
87 fn missing_counter_errors() {
88 let (_dir, conn) = fresh_db();
89 assert!(render(&conn, "ghost").is_err());
90 }
91
92 #[test]
93 fn direct_self_cycle_errors() {
94 let (_dir, conn) = fresh_db();
95 put(&conn, "a", 0, "{a}");
96 let err = render(&conn, "a").unwrap_err().to_string();
97 assert!(err.contains("cycle"), "got: {err}");
98 }
99
100 #[test]
101 fn indirect_cycle_errors() {
102 let (_dir, conn) = fresh_db();
103 put(&conn, "a", 0, "{b}");
104 put(&conn, "b", 0, "{a}");
105 let err = render(&conn, "a").unwrap_err().to_string();
106 assert!(err.contains("cycle"), "got: {err}");
107 }
108
109 #[test]
110 fn sibling_references_are_not_cycles() {
111 let (_dir, conn) = fresh_db();
112 put(&conn, "leaf", 9, "{}");
113 put(&conn, "root", 0, "{leaf}-{leaf}");
114 assert_eq!(render(&conn, "root").unwrap(), "9-9");
115 }
116}