mesofact_core/proxy/
source_gen.rs1use serde::Deserialize;
21use std::collections::HashMap;
22use std::path::Path;
23use std::sync::Mutex;
24use std::time::{Duration, Instant, UNIX_EPOCH};
25
26const GENERATION_TTL: Duration = Duration::from_secs(1);
27
28#[derive(Debug, Clone)]
31pub struct SourceDef {
32 pub kind: String,
33 pub path: Option<String>,
34}
35
36#[derive(Debug, Deserialize)]
37struct RawConfig {
38 #[serde(default)]
39 sources: HashMap<String, RawSource>,
40}
41
42#[derive(Debug, Deserialize)]
43struct RawSource {
44 kind: String,
45 #[serde(default)]
46 path: Option<String>,
47}
48
49pub struct Generations {
52 defs: HashMap<String, SourceDef>,
53 memo: Mutex<HashMap<String, (String, Instant)>>,
54}
55
56impl Generations {
57 pub fn empty() -> Self {
60 Self { defs: HashMap::new(), memo: Mutex::new(HashMap::new()) }
61 }
62
63 pub fn from_config_str(toml_str: &str) -> Result<Self, toml::de::Error> {
65 let raw: RawConfig = toml::from_str(toml_str)?;
66 let defs = raw
67 .sources
68 .into_iter()
69 .map(|(name, s)| (name, SourceDef { kind: s.kind, path: s.path }))
70 .collect();
71 Ok(Self { defs, memo: Mutex::new(HashMap::new()) })
72 }
73
74 pub fn from_config_file(path: &Path) -> anyhow::Result<Self> {
77 match std::fs::read_to_string(path) {
78 Ok(s) => Ok(Self::from_config_str(&s)?),
79 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Self::empty()),
80 Err(e) => Err(e.into()),
81 }
82 }
83
84 pub fn token(&self, name: &str) -> String {
87 let now = Instant::now();
88 {
89 let memo = self.memo.lock().unwrap();
90 if let Some((tok, at)) = memo.get(name) {
91 if now.saturating_duration_since(*at) < GENERATION_TTL {
92 return tok.clone();
93 }
94 }
95 }
96 let fresh = self.compute(name);
97 self.memo.lock().unwrap().insert(name.to_string(), (fresh.clone(), now));
98 fresh
99 }
100
101 fn compute(&self, name: &str) -> String {
102 let Some(def) = self.defs.get(name) else {
103 return PLACEHOLDER.to_string();
104 };
105 match def.kind.as_str() {
106 "sqlite" => def
107 .path
108 .as_deref()
109 .map(mtime_token)
110 .unwrap_or_else(|| PLACEHOLDER.to_string()),
111 _ => PLACEHOLDER.to_string(),
114 }
115 }
116}
117
118const PLACEHOLDER: &str = "0";
119
120fn mtime_token(path: &str) -> String {
124 match std::fs::metadata(path).and_then(|m| m.modified()) {
125 Ok(t) => t
126 .duration_since(UNIX_EPOCH)
127 .map(|d| d.as_nanos().to_string())
128 .unwrap_or_else(|_| "0".to_string()),
129 Err(_) => "missing".to_string(),
130 }
131}
132
133#[cfg(test)]
136fn token_of(t: std::time::SystemTime) -> String {
137 t.duration_since(UNIX_EPOCH).map(|d| d.as_nanos().to_string()).unwrap_or_default()
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143 use std::io::Write;
144 use std::time::SystemTime;
145
146 #[test]
147 fn unknown_source_is_placeholder() {
148 let g = Generations::empty();
149 assert_eq!(g.token("whatever"), PLACEHOLDER);
150 }
151
152 #[test]
153 fn parses_sources_and_keeps_kind_and_path() {
154 let g = Generations::from_config_str(
155 r#"
156 [sources.project_db]
157 kind = "sqlite"
158 scope = "global"
159 path = "/tmp/x.db"
160
161 [sources.assets]
162 kind = "r2"
163 scope = "global"
164 bucket = "b"
165 endpoint_env = "R2_ENDPOINT"
166 "#,
167 )
168 .unwrap();
169 assert_eq!(g.defs.get("project_db").unwrap().kind, "sqlite");
170 assert_eq!(g.defs.get("project_db").unwrap().path.as_deref(), Some("/tmp/x.db"));
171 assert_eq!(g.token("assets"), PLACEHOLDER);
173 }
174
175 #[test]
176 fn sqlite_token_tracks_file_mtime() {
177 let dir = tempfile::tempdir().unwrap();
178 let db = dir.path().join("project.db");
179 let mut f = std::fs::File::create(&db).unwrap();
180 f.write_all(b"v1").unwrap();
181 f.sync_all().unwrap();
182
183 let cfg = format!(
184 "[sources.project_db]\nkind = \"sqlite\"\nscope = \"global\"\npath = \"{}\"\n",
185 db.display()
186 );
187 let g = Generations::from_config_str(&cfg).unwrap();
188
189 let t1 = g.token("project_db");
190 assert_ne!(t1, "missing");
191 assert_eq!(g.token("project_db"), t1);
193
194 let later = SystemTime::now() + Duration::from_secs(5);
196 let f2 = std::fs::OpenOptions::new().write(true).open(&db).unwrap();
197 f2.set_modified(later).unwrap();
198 std::thread::sleep(Duration::from_millis(1100));
199 let t2 = g.token("project_db");
200 assert_ne!(t1, t2, "token should follow the new mtime");
201 assert_eq!(t2, token_of(later));
202 }
203
204 #[test]
205 fn missing_sqlite_file_is_stable_missing_token() {
206 let g = Generations::from_config_str(
207 "[sources.project_db]\nkind = \"sqlite\"\npath = \"/no/such/file.db\"\n",
208 )
209 .unwrap();
210 assert_eq!(g.token("project_db"), "missing");
211 }
212}