Skip to main content

agentstatedeveloper_core/
registry.rs

1//! Shared ASD repo registry — `~/.config/asd/repos.toml`.
2//!
3//! Read/write API used by the `asd` CLI, `asd-mcp`, and CTXone. See
4//! `docs/repo-registry.md` for the schema and atomic-write protocol.
5
6use 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
16/// Current schema version persisted in `repos.toml`.
17pub const SCHEMA_VERSION: u32 = 1;
18
19/// Env var that overrides the default registry path.
20pub 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/// One registered repo.
43#[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/// Parsed registry. `BTreeMap` keeps disk order deterministic.
51#[derive(Debug, Clone, Default, PartialEq, Eq)]
52pub struct Registry {
53    active: Option<String>,
54    repos: BTreeMap<String, RepoEntry>,
55}
56
57impl Registry {
58    /// Read from the default path. A missing file yields an empty registry.
59    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    /// Atomic write to the default path.
73    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        // Atomic on POSIX same-filesystem; on error, best-effort cleanup of tmp.
89        if let Err(e) = fs::rename(&tmp, path) {
90            let _ = fs::remove_file(&tmp);
91            return Err(e.into());
92        }
93        // Best-effort dir fsync — ignore failures, they're informational at most.
94        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    /// Active repo, if any. Returns `None` when the active pointer is unset or
111    /// names a repo no longer registered.
112    pub fn active(&self) -> Option<&RepoEntry> {
113        self.active.as_ref().and_then(|n| self.repos.get(n))
114    }
115
116    /// Set the active repo. Errors if no such repo is registered.
117    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    /// Register (or update) a repo. Absolute path required; name must pass
130    /// the rules in the schema doc.
131    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    /// Remove a repo. Clears the active pointer if it pointed at this name.
153    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    /// Remove every registered repo whose `.asd-state.db` no longer exists on
164    /// disk — the dead entries left behind by ephemeral/test databases. Returns
165    /// the removed entries (disk order) for reporting. Clears the active pointer
166    /// if it named a pruned repo.
167    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    /// Resolved default registry path: `$ASD_REGISTRY` if set, else
187    /// `$HOME/.config/asd/repos.toml`. Matches the spec — does NOT defer to
188    /// platform-specific config dirs.
189    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
198/// Walk up from the current directory to the filesystem root, returning the
199/// first `.asd-state.db` found — git-style project discovery. Shared by the CLI
200/// default-db resolution and the `asd-mcp` server so both isolate to the project
201/// the process was started in, independent of the registry's active repo
202/// (Plan Q t-002 / Plan S t-004).
203pub 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// ---------------------------------------------------------------------------
217// Internals
218// ---------------------------------------------------------------------------
219
220/// On-disk shape. Kept private so callers can't accidentally bypass invariants.
221#[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        // Tolerate ~ and relative paths on read by canonicalizing against $HOME.
253        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        // Relative paths in a global registry are almost always a mistake;
333        // resolve against $HOME so the resulting path is at least usable from
334        // a deterministic root.
335        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    // Truncate to seconds so an in-memory timestamp round-trips through TOML
354    // (we serialize with `SecondsFormat::Secs`) without a sub-second mismatch.
355    let now = chrono::Utc::now();
356    DateTime::from_timestamp(now.timestamp(), 0).unwrap_or(now)
357}
358
359// ---------------------------------------------------------------------------
360// Tests
361// ---------------------------------------------------------------------------
362
363#[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        // One entry whose db exists (kept) + two whose db is gone (pruned).
428        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        // Idempotent: a second prune removes nothing.
447        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}