agentstatedeveloper_core/
registry.rs1use std::collections::BTreeMap;
7use std::fs;
8use std::io::Write;
9use std::path::{Path, PathBuf};
10use std::time::SystemTime;
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15
16pub const SCHEMA_VERSION: u32 = 1;
18
19pub const ENV_REGISTRY: &str = "ASD_REGISTRY";
21
22#[derive(Debug, Error)]
23pub enum RegistryError {
24 #[error("io: {0}")]
25 Io(#[from] std::io::Error),
26 #[error("parse: {0}")]
27 Parse(String),
28 #[error("unknown registry version {0} (this build supports up to {SCHEMA_VERSION})")]
29 UnknownVersion(u32),
30 #[error("unknown repo \"{0}\"")]
31 UnknownRepo(String),
32 #[error("invalid repo name \"{0}\": must be 1-64 chars of [A-Za-z0-9_-], and not \"default\"")]
33 InvalidName(String),
34 #[error("repo path must be absolute (was \"{0}\")")]
35 NonAbsolutePath(String),
36 #[error("$HOME is not set; cannot resolve registry path")]
37 NoHome,
38}
39
40pub type Result<T> = std::result::Result<T, RegistryError>;
41
42#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct RepoEntry {
45 pub name: String,
46 pub path: PathBuf,
47 pub registered_at: Option<DateTime<Utc>>,
48}
49
50#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub struct Registry {
53 active: Option<String>,
54 repos: BTreeMap<String, RepoEntry>,
55}
56
57impl Registry {
58 pub fn load() -> Result<Self> {
60 Self::load_from(&Self::path()?)
61 }
62
63 pub fn load_from(path: &Path) -> Result<Self> {
64 let raw = match fs::read_to_string(path) {
65 Ok(s) => s,
66 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Self::default()),
67 Err(e) => return Err(e.into()),
68 };
69 parse(&raw)
70 }
71
72 pub fn save(&self) -> Result<()> {
74 self.save_to(&Self::path()?)
75 }
76
77 pub fn save_to(&self, path: &Path) -> Result<()> {
78 let body = serialize(self);
79 if let Some(parent) = path.parent() {
80 fs::create_dir_all(parent)?;
81 }
82 let tmp = tmp_sibling(path);
83 {
84 let mut f = fs::File::create(&tmp)?;
85 f.write_all(body.as_bytes())?;
86 f.sync_all()?;
87 }
88 if let Err(e) = fs::rename(&tmp, path) {
90 let _ = fs::remove_file(&tmp);
91 return Err(e.into());
92 }
93 if let Some(parent) = path.parent() {
95 if let Ok(d) = fs::File::open(parent) {
96 let _ = d.sync_all();
97 }
98 }
99 Ok(())
100 }
101
102 pub fn list(&self) -> Vec<&RepoEntry> {
103 self.repos.values().collect()
104 }
105
106 pub fn get(&self, name: &str) -> Option<&RepoEntry> {
107 self.repos.get(name)
108 }
109
110 pub fn active(&self) -> Option<&RepoEntry> {
113 self.active.as_ref().and_then(|n| self.repos.get(n))
114 }
115
116 pub fn set_active(&mut self, name: &str) -> Result<()> {
118 if !self.repos.contains_key(name) {
119 return Err(RegistryError::UnknownRepo(name.to_string()));
120 }
121 self.active = Some(name.to_string());
122 Ok(())
123 }
124
125 pub fn clear_active(&mut self) {
126 self.active = None;
127 }
128
129 pub fn register(&mut self, name: &str, path: &Path) -> Result<()> {
132 validate_name(name)?;
133 if !path.is_absolute() {
134 return Err(RegistryError::NonAbsolutePath(path.display().to_string()));
135 }
136 let registered_at = self
137 .repos
138 .get(name)
139 .and_then(|e| e.registered_at)
140 .or_else(|| Some(now_utc()));
141 self.repos.insert(
142 name.to_string(),
143 RepoEntry {
144 name: name.to_string(),
145 path: path.to_path_buf(),
146 registered_at,
147 },
148 );
149 Ok(())
150 }
151
152 pub fn remove(&mut self, name: &str) -> Result<()> {
154 if self.repos.remove(name).is_none() {
155 return Err(RegistryError::UnknownRepo(name.to_string()));
156 }
157 if self.active.as_deref() == Some(name) {
158 self.active = None;
159 }
160 Ok(())
161 }
162
163 pub fn prune_missing(&mut self) -> Vec<RepoEntry> {
168 let dead: Vec<String> = self
169 .repos
170 .values()
171 .filter(|e| !e.path.exists())
172 .map(|e| e.name.clone())
173 .collect();
174 let mut removed = Vec::with_capacity(dead.len());
175 for name in dead {
176 if let Some(entry) = self.repos.remove(&name) {
177 if self.active.as_deref() == Some(name.as_str()) {
178 self.active = None;
179 }
180 removed.push(entry);
181 }
182 }
183 removed
184 }
185
186 pub fn path() -> Result<PathBuf> {
190 if let Some(p) = std::env::var_os(ENV_REGISTRY) {
191 return Ok(PathBuf::from(p));
192 }
193 let home = std::env::var_os("HOME").ok_or(RegistryError::NoHome)?;
194 Ok(PathBuf::from(home).join(".config/asd/repos.toml"))
195 }
196}
197
198pub fn find_db_upwards() -> Option<PathBuf> {
204 let mut dir = std::env::current_dir().ok()?;
205 loop {
206 let candidate = dir.join(".asd-state.db");
207 if candidate.exists() {
208 return Some(candidate);
209 }
210 if !dir.pop() {
211 return None;
212 }
213 }
214}
215
216#[derive(Serialize, Deserialize, Default)]
222struct DiskRoot {
223 #[serde(default)]
224 version: Option<u32>,
225 #[serde(default)]
226 active: Option<DiskActive>,
227 #[serde(default)]
228 repos: BTreeMap<String, DiskRepo>,
229}
230
231#[derive(Serialize, Deserialize, Default)]
232struct DiskActive {
233 #[serde(default)]
234 repo: Option<String>,
235}
236
237#[derive(Serialize, Deserialize)]
238struct DiskRepo {
239 path: String,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 registered_at: Option<String>,
242}
243
244fn parse(raw: &str) -> Result<Registry> {
245 let root: DiskRoot = toml::from_str(raw).map_err(|e| RegistryError::Parse(e.to_string()))?;
246 let version = root.version.unwrap_or(SCHEMA_VERSION);
247 if version > SCHEMA_VERSION {
248 return Err(RegistryError::UnknownVersion(version));
249 }
250 let mut repos = BTreeMap::new();
251 for (name, r) in root.repos.into_iter() {
252 let path = canonicalize_lenient(&r.path);
254 let registered_at = r
255 .registered_at
256 .as_deref()
257 .and_then(|s| DateTime::parse_from_rfc3339(s).ok())
258 .map(|dt| dt.with_timezone(&Utc));
259 repos.insert(
260 name.clone(),
261 RepoEntry {
262 name,
263 path,
264 registered_at,
265 },
266 );
267 }
268 let active = root
269 .active
270 .and_then(|a| a.repo)
271 .filter(|s| !s.is_empty() && repos.contains_key(s));
272 Ok(Registry { active, repos })
273}
274
275fn serialize(reg: &Registry) -> String {
276 let root = DiskRoot {
277 version: Some(SCHEMA_VERSION),
278 active: Some(DiskActive {
279 repo: reg.active.clone(),
280 }),
281 repos: reg
282 .repos
283 .iter()
284 .map(|(n, e)| {
285 (
286 n.clone(),
287 DiskRepo {
288 path: e.path.display().to_string(),
289 registered_at: e
290 .registered_at
291 .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)),
292 },
293 )
294 })
295 .collect(),
296 };
297 toml::to_string_pretty(&root).unwrap_or_else(|_| String::new())
298}
299
300fn validate_name(name: &str) -> Result<()> {
301 let len = name.len();
302 if !(1..=64).contains(&len) {
303 return Err(RegistryError::InvalidName(name.to_string()));
304 }
305 if name == "default" {
306 return Err(RegistryError::InvalidName(name.to_string()));
307 }
308 if !name
309 .chars()
310 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
311 {
312 return Err(RegistryError::InvalidName(name.to_string()));
313 }
314 Ok(())
315}
316
317fn canonicalize_lenient(raw: &str) -> PathBuf {
318 if let Some(rest) = raw.strip_prefix("~/") {
319 if let Some(home) = std::env::var_os("HOME") {
320 return PathBuf::from(home).join(rest);
321 }
322 }
323 if raw == "~" {
324 if let Some(home) = std::env::var_os("HOME") {
325 return PathBuf::from(home);
326 }
327 }
328 let p = PathBuf::from(raw);
329 if p.is_absolute() {
330 p
331 } else if let Some(home) = std::env::var_os("HOME") {
332 PathBuf::from(home).join(p)
336 } else {
337 p
338 }
339}
340
341fn tmp_sibling(path: &Path) -> PathBuf {
342 let pid = std::process::id();
343 let nanos = SystemTime::now()
344 .duration_since(SystemTime::UNIX_EPOCH)
345 .map(|d| d.as_nanos())
346 .unwrap_or(0);
347 let mut tmp = path.as_os_str().to_owned();
348 tmp.push(format!(".tmp.{pid}.{nanos}"));
349 PathBuf::from(tmp)
350}
351
352fn now_utc() -> DateTime<Utc> {
353 let now = chrono::Utc::now();
356 DateTime::from_timestamp(now.timestamp(), 0).unwrap_or(now)
357}
358
359#[cfg(test)]
364mod tests {
365 use super::*;
366
367 fn temp_path(name: &str) -> PathBuf {
368 let mut p = std::env::temp_dir();
369 p.push(format!(
370 "asd-registry-test-{}-{}-{}.toml",
371 std::process::id(),
372 now_utc().timestamp_nanos_opt().unwrap_or(0),
373 name
374 ));
375 p
376 }
377
378 #[test]
379 fn missing_file_is_empty() {
380 let p = temp_path("missing");
381 let r = Registry::load_from(&p).unwrap();
382 assert!(r.list().is_empty());
383 assert!(r.active().is_none());
384 }
385
386 #[test]
387 fn register_then_round_trip() {
388 let p = temp_path("rt");
389 let mut r = Registry::default();
390 r.register("myapp", &PathBuf::from("/tmp/x/.asd-state.db"))
391 .unwrap();
392 r.register("sdk", &PathBuf::from("/tmp/y/.asd-state.db"))
393 .unwrap();
394 r.set_active("sdk").unwrap();
395 r.save_to(&p).unwrap();
396
397 let loaded = Registry::load_from(&p).unwrap();
398 assert_eq!(loaded.list().len(), 2);
399 assert_eq!(loaded.active().unwrap().name, "sdk");
400 assert_eq!(
401 loaded.get("myapp").unwrap().path,
402 PathBuf::from("/tmp/x/.asd-state.db")
403 );
404 let _ = fs::remove_file(&p);
405 }
406
407 #[test]
408 fn set_active_unknown_errors() {
409 let mut r = Registry::default();
410 assert!(matches!(
411 r.set_active("ghost"),
412 Err(RegistryError::UnknownRepo(_))
413 ));
414 }
415
416 #[test]
417 fn remove_clears_active() {
418 let mut r = Registry::default();
419 r.register("a", &PathBuf::from("/tmp/a.db")).unwrap();
420 r.set_active("a").unwrap();
421 r.remove("a").unwrap();
422 assert!(r.active().is_none());
423 }
424
425 #[test]
426 fn prune_missing_drops_dead_and_clears_active() {
427 let live = temp_path("live-db");
429 fs::write(&live, b"x").unwrap();
430 let mut r = Registry::default();
431 r.register("live", &live).unwrap();
432 r.register("dead1", &PathBuf::from("/no/such/dir/dead1/.asd-state.db"))
433 .unwrap();
434 r.register("dead2", &PathBuf::from("/no/such/dir/dead2/.asd-state.db"))
435 .unwrap();
436 r.set_active("dead1").unwrap();
437
438 let removed = r.prune_missing();
439
440 let mut names: Vec<&str> = removed.iter().map(|e| e.name.as_str()).collect();
441 names.sort();
442 assert_eq!(names, ["dead1", "dead2"]);
443 assert!(r.get("live").is_some());
444 assert!(r.get("dead1").is_none());
445 assert!(r.active().is_none(), "active pointed at a pruned repo");
446 assert!(r.prune_missing().is_empty());
448 let _ = fs::remove_file(&live);
449 }
450
451 #[test]
452 fn rejects_non_absolute_path() {
453 let mut r = Registry::default();
454 assert!(matches!(
455 r.register("a", &PathBuf::from("relative/path.db")),
456 Err(RegistryError::NonAbsolutePath(_))
457 ));
458 }
459
460 #[test]
461 fn rejects_bad_names() {
462 let mut r = Registry::default();
463 for bad in [
464 "",
465 "default",
466 "has space",
467 "has/slash",
468 "has.dot",
469 &"x".repeat(65),
470 ] {
471 assert!(
472 matches!(
473 r.register(bad, &PathBuf::from("/tmp/x.db")),
474 Err(RegistryError::InvalidName(_))
475 ),
476 "expected InvalidName for {:?}",
477 bad
478 );
479 }
480 }
481
482 #[test]
483 fn unknown_future_version_rejected() {
484 let toml = "version = 99\n";
485 assert!(matches!(
486 parse(toml),
487 Err(RegistryError::UnknownVersion(99))
488 ));
489 }
490
491 #[test]
492 fn active_pointing_at_missing_repo_treated_as_none() {
493 let toml = "version = 1\n[active]\nrepo = \"ghost\"\n";
494 let r = parse(toml).unwrap();
495 assert!(r.active().is_none());
496 }
497
498 #[test]
499 fn registered_at_preserved_on_round_trip() {
500 let p = temp_path("ts");
501 let mut r = Registry::default();
502 r.register("a", &PathBuf::from("/tmp/a.db")).unwrap();
503 let t0 = r.get("a").unwrap().registered_at;
504 assert!(t0.is_some());
505 r.save_to(&p).unwrap();
506 let loaded = Registry::load_from(&p).unwrap();
507 assert_eq!(loaded.get("a").unwrap().registered_at, t0);
508 let _ = fs::remove_file(&p);
509 }
510
511 #[test]
512 fn re_register_keeps_original_timestamp() {
513 let mut r = Registry::default();
514 r.register("a", &PathBuf::from("/tmp/a.db")).unwrap();
515 let t0 = r.get("a").unwrap().registered_at;
516 r.register("a", &PathBuf::from("/tmp/a-moved.db")).unwrap();
517 assert_eq!(r.get("a").unwrap().registered_at, t0);
518 assert_eq!(r.get("a").unwrap().path, PathBuf::from("/tmp/a-moved.db"));
519 }
520}