use serde::Deserialize;
use std::collections::HashMap;
use std::path::Path;
use std::sync::Mutex;
use std::time::{Duration, Instant, UNIX_EPOCH};
const GENERATION_TTL: Duration = Duration::from_secs(1);
#[derive(Debug, Clone)]
pub struct SourceDef {
pub kind: String,
pub path: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawConfig {
#[serde(default)]
sources: HashMap<String, RawSource>,
}
#[derive(Debug, Deserialize)]
struct RawSource {
kind: String,
#[serde(default)]
path: Option<String>,
}
pub struct Generations {
defs: HashMap<String, SourceDef>,
memo: Mutex<HashMap<String, (String, Instant)>>,
}
impl Generations {
pub fn empty() -> Self {
Self { defs: HashMap::new(), memo: Mutex::new(HashMap::new()) }
}
pub fn from_config_str(toml_str: &str) -> Result<Self, toml::de::Error> {
let raw: RawConfig = toml::from_str(toml_str)?;
let defs = raw
.sources
.into_iter()
.map(|(name, s)| (name, SourceDef { kind: s.kind, path: s.path }))
.collect();
Ok(Self { defs, memo: Mutex::new(HashMap::new()) })
}
pub fn from_config_file(path: &Path) -> anyhow::Result<Self> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(Self::from_config_str(&s)?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::empty()),
Err(e) => Err(e.into()),
}
}
pub fn token(&self, name: &str) -> String {
let now = Instant::now();
{
let memo = self.memo.lock().unwrap();
if let Some((tok, at)) = memo.get(name) {
if now.saturating_duration_since(*at) < GENERATION_TTL {
return tok.clone();
}
}
}
let fresh = self.compute(name);
self.memo.lock().unwrap().insert(name.to_string(), (fresh.clone(), now));
fresh
}
fn compute(&self, name: &str) -> String {
let Some(def) = self.defs.get(name) else {
return PLACEHOLDER.to_string();
};
match def.kind.as_str() {
"sqlite" => def
.path
.as_deref()
.map(mtime_token)
.unwrap_or_else(|| PLACEHOLDER.to_string()),
_ => PLACEHOLDER.to_string(),
}
}
}
const PLACEHOLDER: &str = "0";
fn mtime_token(path: &str) -> String {
match std::fs::metadata(path).and_then(|m| m.modified()) {
Ok(t) => t
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos().to_string())
.unwrap_or_else(|_| "0".to_string()),
Err(_) => "missing".to_string(),
}
}
#[cfg(test)]
fn token_of(t: std::time::SystemTime) -> String {
t.duration_since(UNIX_EPOCH).map(|d| d.as_nanos().to_string()).unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
use std::time::SystemTime;
#[test]
fn unknown_source_is_placeholder() {
let g = Generations::empty();
assert_eq!(g.token("whatever"), PLACEHOLDER);
}
#[test]
fn parses_sources_and_keeps_kind_and_path() {
let g = Generations::from_config_str(
r#"
[sources.project_db]
kind = "sqlite"
scope = "global"
path = "/tmp/x.db"
[sources.assets]
kind = "r2"
scope = "global"
bucket = "b"
endpoint_env = "R2_ENDPOINT"
"#,
)
.unwrap();
assert_eq!(g.defs.get("project_db").unwrap().kind, "sqlite");
assert_eq!(g.defs.get("project_db").unwrap().path.as_deref(), Some("/tmp/x.db"));
assert_eq!(g.token("assets"), PLACEHOLDER);
}
#[test]
fn sqlite_token_tracks_file_mtime() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("project.db");
let mut f = std::fs::File::create(&db).unwrap();
f.write_all(b"v1").unwrap();
f.sync_all().unwrap();
let cfg = format!(
"[sources.project_db]\nkind = \"sqlite\"\nscope = \"global\"\npath = \"{}\"\n",
db.display()
);
let g = Generations::from_config_str(&cfg).unwrap();
let t1 = g.token("project_db");
assert_ne!(t1, "missing");
assert_eq!(g.token("project_db"), t1);
let later = SystemTime::now() + Duration::from_secs(5);
let f2 = std::fs::OpenOptions::new().write(true).open(&db).unwrap();
f2.set_modified(later).unwrap();
std::thread::sleep(Duration::from_millis(1100));
let t2 = g.token("project_db");
assert_ne!(t1, t2, "token should follow the new mtime");
assert_eq!(t2, token_of(later));
}
#[test]
fn missing_sqlite_file_is_stable_missing_token() {
let g = Generations::from_config_str(
"[sources.project_db]\nkind = \"sqlite\"\npath = \"/no/such/file.db\"\n",
)
.unwrap();
assert_eq!(g.token("project_db"), "missing");
}
}