use chrono::{DateTime, Local};
use serde_json::Value;
use std::collections::HashMap;
pub(crate) fn iso(t: &DateTime<Local>) -> String {
t.format("%Y-%m-%dT%H:%M:%S%:z").to_string()
}
pub(crate) fn format_checked(t: &DateTime<Local>, fmt: &str) -> Result<String, String> {
use chrono::format::{Item, StrftimeItems};
use std::fmt::Write;
if fmt.is_empty() {
return Err("empty strftime format".into());
}
let items: Vec<Item<'_>> = StrftimeItems::new(fmt).collect();
if items.iter().any(|item| matches!(item, Item::Error)) {
return Err(format!("invalid strftime format {fmt:?}"));
}
let mut out = String::new();
write!(out, "{}", t.format_with_items(items.into_iter()))
.map_err(|_| format!("strftime format {fmt:?} cannot format this time"))?;
Ok(out)
}
pub(crate) struct Expander {
now: DateTime<Local>,
mtimes: HashMap<String, DateTime<Local>>,
}
impl Expander {
pub(crate) fn new(now: DateTime<Local>) -> Self {
Self {
now,
mtimes: HashMap::new(),
}
}
pub(crate) fn now_iso(&self) -> String {
iso(&self.now)
}
pub(crate) fn expand(&mut self, s: &str) -> Result<String, String> {
let mut out = String::with_capacity(s.len());
let mut rest = s;
while let Some(i) = rest.find("{{") {
out.push_str(&rest[..i]);
let after = &rest[i + 2..];
if let Some(tail) = after.strip_prefix("{{") {
out.push_str("{{");
rest = tail;
continue;
}
let Some(j) = after.find("}}") else {
return Err("unterminated placeholder: '{{' without a closing '}}' (write '{{{{' for a literal '{{')".into());
};
out.push_str(&self.resolve(&after[..j])?);
rest = &after[j + 2..];
}
out.push_str(rest);
Ok(out)
}
pub(crate) fn expand_value(&mut self, value: &mut Value) -> Result<(), String> {
match value {
Value::String(s) => *s = self.expand(s)?,
Value::Array(items) => {
for item in items {
self.expand_value(item)?;
}
}
Value::Object(map) => {
for item in map.values_mut() {
self.expand_value(item)?;
}
}
_ => {}
}
Ok(())
}
fn resolve(&mut self, token: &str) -> Result<String, String> {
if token == "now_iso" {
return Ok(iso(&self.now));
}
if let Some(fmt) = token.strip_prefix("now:") {
return format_checked(&self.now, fmt).map_err(|e| format!("{{{{{token}}}}}: {e}"));
}
if let Some(path) = token.strip_prefix("mtime_iso:") {
let t = self.mtime(path).map_err(|e| format!("{{{{{token}}}}}: {e}"))?;
return Ok(iso(&t));
}
if let Some(rest) = token.strip_prefix("mtime:") {
let Some(k) = rest.find(":/") else {
return Err(format!(
"{{{{{token}}}}}: expected mtime:FMT:/absolute/path"
));
};
let (fmt, path) = (&rest[..k], &rest[k + 1..]);
let t = self.mtime(path).map_err(|e| format!("{{{{{token}}}}}: {e}"))?;
return format_checked(&t, fmt).map_err(|e| format!("{{{{{token}}}}}: {e}"));
}
Err(format!(
"unknown placeholder {{{{{token}}}}} (known: now:FMT, now_iso, mtime:FMT:/path, mtime_iso:/path; write {{{{{{{{ for a literal {{{{)"
))
}
fn mtime(&mut self, path: &str) -> Result<DateTime<Local>, String> {
if !std::path::Path::new(path).is_absolute() {
return Err(format!("mtime path {path:?} must be absolute"));
}
if let Some(t) = self.mtimes.get(path) {
return Ok(*t);
}
let modified = std::fs::metadata(path)
.and_then(|m| m.modified())
.map_err(|e| format!("cannot read the mtime of {path}: {e}"))?;
let t = DateTime::<Local>::from(modified);
self.mtimes.insert(path.to_string(), t);
Ok(t)
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn fixed() -> DateTime<Local> {
Local.with_ymd_and_hms(2026, 10, 18, 9, 5, 7).single().unwrap()
}
#[test]
fn now_and_now_iso_use_the_one_clock_reading() {
let mut e = Expander::new(fixed());
let out = e.expand("## r2 ({{now:%H:%M}}) at {{now_iso}}").unwrap();
assert_eq!(out, format!("## r2 (09:05) at {}", iso(&fixed())));
assert!(iso(&fixed()).starts_with("2026-10-18T09:05:07"));
}
#[test]
fn unknown_and_unterminated_placeholders_are_errors() {
let mut e = Expander::new(fixed());
let err = e.expand("a {{nowiso}} b").unwrap_err();
assert!(err.contains("unknown placeholder {{nowiso}}"), "{err}");
let err = e.expand("a {{now_iso b").unwrap_err();
assert!(err.contains("unterminated"), "{err}");
}
#[test]
fn bad_strftime_is_an_error_not_a_panic() {
let mut e = Expander::new(fixed());
for bad in ["{{now:%Q}}", "{{now:%}}", "{{now:}}"] {
let err = e.expand(bad).unwrap_err();
assert!(err.contains("strftime"), "{bad}: {err}");
}
}
#[test]
fn literal_braces_pass_through() {
let mut e = Expander::new(fixed());
assert_eq!(e.expand("{{{{now_iso}}").unwrap(), "{{now_iso}}");
assert_eq!(e.expand("map[k]}} and }} end").unwrap(), "map[k]}} and }} end");
assert_eq!(e.expand("no braces $(x) `y`").unwrap(), "no braces $(x) `y`");
}
#[test]
fn mtime_is_read_once_per_path_and_must_exist() {
let tmp = tempfile::tempdir().unwrap();
let f = tmp.path().join("gate.txt");
std::fs::write(&f, "x").unwrap();
let old = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
std::fs::File::options()
.write(true)
.open(&f)
.unwrap()
.set_modified(old)
.unwrap();
let p = f.to_str().unwrap();
let mut e = Expander::new(fixed());
let first = e.expand(&format!("{{{{mtime_iso:{p}}}}}")).unwrap();
assert_eq!(first, iso(&DateTime::<Local>::from(old)));
std::fs::File::options()
.write(true)
.open(&f)
.unwrap()
.set_modified(old + std::time::Duration::from_secs(3600))
.unwrap();
let again = e.expand(&format!("{{{{mtime:%Y-%m-%dT%H:%M:%S%:z:{p}}}}}")).unwrap();
assert_eq!(again, first);
let missing = tmp.path().join("missing").to_str().unwrap().to_string();
let err = e.expand(&format!("{{{{mtime_iso:{missing}}}}}")).unwrap_err();
assert!(err.contains("cannot read the mtime"), "{err}");
let err = e.expand("{{mtime_iso:relative/path}}").unwrap_err();
assert!(err.contains("must be absolute"), "{err}");
}
#[test]
fn json_string_leaves_expand_and_stay_valid_json() {
let mut e = Expander::new(fixed());
let mut v = serde_json::json!({"ts": "{{now_iso}}", "note": "say \"{{now:%H}}\"\n", "n": 3,
"{{now_iso}}": ["{{now:%M}}"]});
e.expand_value(&mut v).unwrap();
let row = serde_json::to_string(&v).unwrap();
let back: Value = serde_json::from_str(&row).unwrap();
assert_eq!(back["note"], "say \"09\"\n");
assert_eq!(back["{{now_iso}}"][0], "05", "keys are never expanded");
}
}