1use 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
31pub struct Store {
33 root: PathBuf,
34}
35
36impl Store {
37 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 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 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 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 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 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#[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 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 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}