tally-cli 1.2.0

A persistent, inter-process counter for all your shell scripts
Documentation
use crate::database::Connection;
use crate::models::Counter;
use anyhow::{anyhow, Result};
use regex::Regex;
use std::collections::HashSet;
use std::sync::LazyLock;

static TEMPLATE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"\{(.*?)\}").unwrap());

pub fn render(conn: &Connection, name: &str) -> Result<String> {
    let mut visited = HashSet::new();
    render_inner(conn, name, &mut visited)
}

fn render_inner(conn: &Connection, name: &str, visited: &mut HashSet<String>) -> Result<String> {
    if !visited.insert(name.to_string()) {
        return Err(anyhow!(
            "template cycle detected involving counter '{}'",
            name
        ));
    }

    let counter = match Counter::get(conn.get(), name)? {
        Some(c) => c,
        None => return Err(anyhow!("Unable to find counter for templating")),
    };

    let mut rendered = counter.template.replace("{}", &counter.count.to_string());

    for cap in TEMPLATE_RE.captures_iter(&rendered.clone()) {
        rendered = rendered.replace(&cap[0], "{}");
        let sub_template = render_inner(conn, &cap[1], visited)?;
        rendered = rendered.replace("{}", &sub_template);
    }

    visited.remove(name);
    Ok(rendered)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::database::Connection;
    use crate::models::Counter;
    use tempfile::TempDir;

    fn fresh_db() -> (TempDir, Connection) {
        let dir = TempDir::new().unwrap();
        let path = dir.path().join("test.db");
        let conn = Connection::new(&path.to_string_lossy()).unwrap();
        (dir, conn)
    }

    fn put(conn: &Connection, name: &str, count: i64, template: &str) {
        let c = Counter {
            name: name.into(),
            count,
            step: 1,
            template: template.into(),
        };
        c.insert(conn.get()).unwrap();
    }

    #[test]
    fn renders_plain_counter() {
        let (_dir, conn) = fresh_db();
        put(&conn, "a", 5, "{}");
        assert_eq!(render(&conn, "a").unwrap(), "5");
    }

    #[test]
    fn renders_template_with_literal_text() {
        let (_dir, conn) = fresh_db();
        put(&conn, "a", 3, "v{}");
        assert_eq!(render(&conn, "a").unwrap(), "v3");
    }

    #[test]
    fn renders_nested_reference() {
        let (_dir, conn) = fresh_db();
        put(&conn, "inner", 7, "{}");
        put(&conn, "outer", 0, "[{inner}]");
        assert_eq!(render(&conn, "outer").unwrap(), "[7]");
    }

    #[test]
    fn missing_counter_errors() {
        let (_dir, conn) = fresh_db();
        assert!(render(&conn, "ghost").is_err());
    }

    #[test]
    fn direct_self_cycle_errors() {
        let (_dir, conn) = fresh_db();
        put(&conn, "a", 0, "{a}");
        let err = render(&conn, "a").unwrap_err().to_string();
        assert!(err.contains("cycle"), "got: {err}");
    }

    #[test]
    fn indirect_cycle_errors() {
        let (_dir, conn) = fresh_db();
        put(&conn, "a", 0, "{b}");
        put(&conn, "b", 0, "{a}");
        let err = render(&conn, "a").unwrap_err().to_string();
        assert!(err.contains("cycle"), "got: {err}");
    }

    #[test]
    fn sibling_references_are_not_cycles() {
        let (_dir, conn) = fresh_db();
        put(&conn, "leaf", 9, "{}");
        put(&conn, "root", 0, "{leaf}-{leaf}");
        assert_eq!(render(&conn, "root").unwrap(), "9-9");
    }
}