Skip to main content

tally_cli/
template.rs

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}