Skip to main content

virtual_agent/
lib.rs

1//! virtual-agent — the profile-custody layer over `awp`.
2//!
3//! awp is a runtime: hand it a `RegistryDocument` and it materializes a
4//! `VirtualAgent`. It deliberately does not care where that document
5//! lives. This crate owns the part awp declines: the canonical store at
6//! `~/.virtual-agent/registry.json`, validated mutation, import/export
7//! for sharing, and delegation to the `awp` binary for execution.
8
9use awp::{Registry, RegistryDocument};
10use std::path::{Path, PathBuf};
11
12pub type Result<T> = std::result::Result<T, Error>;
13
14#[derive(Debug)]
15pub struct Error(pub String);
16
17impl Error {
18    fn msg(m: impl Into<String>) -> Self {
19        Self(m.into())
20    }
21}
22
23impl std::fmt::Display for Error {
24    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25        f.write_str(&self.0)
26    }
27}
28
29impl std::error::Error for Error {}
30
31/// The profile store: one canonical registry document under a root dir.
32pub struct Store {
33    root: PathBuf,
34}
35
36impl Store {
37    /// `$VIRTUAL_AGENT_HOME`, else `~/.virtual-agent`.
38    pub fn default_root() -> PathBuf {
39        if let Some(h) = std::env::var_os("VIRTUAL_AGENT_HOME") {
40            return PathBuf::from(h);
41        }
42        let home = std::env::var_os("HOME")
43            .or_else(|| std::env::var_os("USERPROFILE"))
44            .map(PathBuf::from)
45            .unwrap_or_else(|| PathBuf::from("."));
46        home.join(".virtual-agent")
47    }
48
49    /// Open the store at `root`, creating it (with an empty registry
50    /// document) when absent.
51    pub fn open(root: impl Into<PathBuf>) -> Result<Self> {
52        let store = Self { root: root.into() };
53        std::fs::create_dir_all(&store.root).map_err(|e| Error::msg(e.to_string()))?;
54        if !store.registry_path().exists() {
55            store.save(&RegistryDocument::default())?;
56        }
57        Ok(store)
58    }
59
60    pub fn root(&self) -> &Path {
61        &self.root
62    }
63
64    /// The canonical registry document awp consumes.
65    pub fn registry_path(&self) -> PathBuf {
66        self.root.join("registry.json")
67    }
68
69    pub fn load(&self) -> Result<RegistryDocument> {
70        let text =
71            std::fs::read_to_string(self.registry_path()).map_err(|e| Error::msg(e.to_string()))?;
72        serde_json::from_str(&text)
73            .map_err(|e| Error::msg(format!("registry document corrupt: {e}")))
74    }
75
76    /// Validate then atomically persist. A document that fails awp's
77    /// registry validation never reaches disk.
78    pub fn save(&self, doc: &RegistryDocument) -> Result<()> {
79        Registry::from_document(doc.clone())
80            .map_err(|e| Error::msg(format!("invalid registry document: {e}")))?;
81        let text = serde_json::to_string_pretty(doc).map_err(|e| Error::msg(e.to_string()))?;
82        let path = self.registry_path();
83        let tmp = path.with_extension("json.tmp");
84        std::fs::write(&tmp, text).map_err(|e| Error::msg(e.to_string()))?;
85        std::fs::rename(&tmp, &path).map_err(|e| Error::msg(e.to_string()))
86    }
87
88    /// Merge `other` into the stored document. Existing ids are kept
89    /// unless `force` is set; colliding ids collect into `conflicts`.
90    /// Returns the id lists that were overwritten or skipped.
91    pub fn merge(&self, other: RegistryDocument, force: bool) -> Result<MergeReport> {
92        let mut doc = self.load()?;
93        let mut report = MergeReport::default();
94        for (id, entry) in other.agents {
95            merge_one(&mut doc.agents, id, entry, force, &mut report);
96        }
97        for (id, entry) in other.profiles {
98            merge_one(&mut doc.profiles, id, entry, force, &mut report);
99        }
100        for (id, entry) in other.bindings {
101            merge_one(&mut doc.bindings, id, entry, force, &mut report);
102        }
103        self.save(&doc)?;
104        Ok(report)
105    }
106
107    /// Extract a shareable subset: one profile plus the agent it targets
108    /// and every binding that names it — the unit users hand around.
109    pub fn export_profile(&self, profile_id: &str) -> Result<RegistryDocument> {
110        let doc = self.load()?;
111        let profile = doc
112            .profiles
113            .get(profile_id)
114            .ok_or_else(|| Error::msg(format!("profile not found: {profile_id}")))?
115            .clone();
116        let mut out = RegistryDocument::default();
117        if let Some(agent_id) = profile.agent.as_ref() {
118            if let Some(agent) = doc.agents.get(agent_id) {
119                out.agents.insert(agent_id.clone(), agent.clone());
120            }
121        }
122        out.profiles.insert(profile_id.to_string(), profile);
123        for (name, binding) in &doc.bindings {
124            if binding.profile() == profile_id {
125                out.bindings.insert(name.clone(), binding.clone());
126            }
127        }
128        Ok(out)
129    }
130}
131
132fn merge_one<T>(
133    map: &mut std::collections::BTreeMap<String, T>,
134    id: String,
135    value: T,
136    force: bool,
137    report: &mut MergeReport,
138) {
139    if map.contains_key(&id) {
140        if force {
141            map.insert(id.clone(), value);
142            report.overwritten.push(id);
143        } else {
144            report.skipped.push(id);
145        }
146    } else {
147        map.insert(id.clone(), value);
148        report.inserted.push(id);
149    }
150}
151
152/// Outcome of a merge: which ids were inserted, overwritten (force), or
153/// skipped because they already exist.
154#[derive(Default)]
155pub struct MergeReport {
156    pub inserted: Vec<String>,
157    pub overwritten: Vec<String>,
158    pub skipped: Vec<String>,
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    fn store(tag: &str) -> (Store, PathBuf) {
166        let dir = std::env::temp_dir().join(format!("va-test-{}-{tag}", std::process::id()));
167        let _ = std::fs::remove_dir_all(&dir);
168        let store = Store::open(&dir).unwrap();
169        (store, dir)
170    }
171
172    #[test]
173    fn open_creates_empty_registry() {
174        let (store, dir) = store("open");
175        let doc = store.load().unwrap();
176        assert!(doc.profiles.is_empty() && doc.agents.is_empty() && doc.bindings.is_empty());
177        let _ = std::fs::remove_dir_all(dir);
178    }
179
180    /// A minimal valid document: one agent spec plus a profile naming it
181    /// (`agent` is mandatory on a new profile in awp validation).
182    fn doc_with_profile() -> RegistryDocument {
183        serde_json::from_value(serde_json::json!({
184            "agents": {
185                "example-agent": {
186                    "id": "example-agent",
187                    "lane": "cli",
188                    "command": {
189                        "program": "echo",
190                        "args": [],
191                        "env": { "sanitized": { "inherit": [], "set": {} } }
192                    }
193                }
194            },
195            "profiles": { "p": { "agent": "example-agent", "name": "P" } }
196        }))
197        .unwrap()
198    }
199
200    #[test]
201    fn merge_inserts_and_skips_on_conflict() {
202        let (store, dir) = store("merge-skip");
203        let a = doc_with_profile();
204        let report = store.merge(a.clone(), false).unwrap();
205        assert_eq!(report.inserted, vec!["example-agent", "p"]);
206
207        let report = store.merge(a, false).unwrap();
208        assert_eq!(report.skipped, vec!["example-agent", "p"]);
209        assert_eq!(store.load().unwrap().profiles.len(), 1);
210        let _ = std::fs::remove_dir_all(dir);
211    }
212
213    #[test]
214    fn merge_force_overwrites() {
215        let (store, dir) = store("merge-force");
216        let a = doc_with_profile();
217        store.merge(a.clone(), false).unwrap();
218        let report = store.merge(a, true).unwrap();
219        assert_eq!(report.overwritten, vec!["example-agent", "p"]);
220        let _ = std::fs::remove_dir_all(dir);
221    }
222
223    #[test]
224    fn invalid_document_never_reaches_disk() {
225        let (store, dir) = store("invalid");
226        let mut bad = RegistryDocument::default();
227        // A profile patch without `agent` fails awp validation.
228        bad.profiles
229            .insert("p".into(), awp::ProfilePatch::default());
230        assert!(store.merge(bad, false).is_err());
231        assert!(store.load().unwrap().profiles.is_empty());
232        let _ = std::fs::remove_dir_all(dir);
233    }
234
235    #[test]
236    fn corrupt_registry_reports_error() {
237        let (store, dir) = store("corrupt");
238        std::fs::write(store.registry_path(), "{not json").unwrap();
239        assert!(store.load().is_err());
240        let _ = std::fs::remove_dir_all(dir);
241    }
242}