prospero_core/
registry.rs1use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12use crate::error::{CoreError, Result};
13
14pub use prospero_types::RepoProviderConfig;
17pub use prospero_types::{
18 CredentialsRef, IsolationConfig, ProviderInfo, ProviderSpec, WorkspaceConfig, WorkspaceInfo,
19 WorkspaceSourceSpec, WorkspaceStatusInfo,
20};
21
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct RegisteredWorkspace {
27 pub name: String,
29 pub root: PathBuf,
31 #[serde(default)]
33 pub config: RepoProviderConfig,
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
38pub struct Registry {
39 #[serde(alias = "repos")]
42 pub workspaces: Vec<RegisteredWorkspace>,
43}
44
45impl Registry {
46 pub fn load(path: &Path) -> Result<Self> {
49 match std::fs::read(path) {
50 Ok(bytes) => Ok(serde_json::from_slice(&bytes)?),
51 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Registry::default()),
52 Err(e) => Err(e.into()),
53 }
54 }
55
56 pub fn save(&self, path: &Path) -> Result<()> {
58 if let Some(parent) = path.parent() {
59 std::fs::create_dir_all(parent)?;
60 }
61 let json = serde_json::to_vec_pretty(self)?;
62 std::fs::write(path, json)?;
63 Ok(())
64 }
65
66 pub fn get(&self, name: &str) -> Option<&RegisteredWorkspace> {
68 self.workspaces.iter().find(|r| r.name == name)
69 }
70
71 pub fn add(&mut self, name: impl Into<String>, root: impl Into<PathBuf>) -> Result<()> {
74 let name = name.into();
75 let root = root.into();
76 if let Some(existing) = self.get(&name) {
77 if existing.root == root {
78 return Ok(());
79 }
80 return Err(CoreError::Conflict(format!(
81 "workspace name '{name}' already registered with a different root"
82 )));
83 }
84 if let Some(other) = self.workspaces.iter().find(|r| r.root == root) {
90 return Err(CoreError::Conflict(format!(
91 "root {} is already registered as workspace '{}'",
92 root.display(),
93 other.name
94 )));
95 }
96 self.workspaces.push(RegisteredWorkspace {
97 name,
98 root,
99 config: RepoProviderConfig::default(),
100 });
101 Ok(())
102 }
103
104 pub fn remove(&mut self, name: &str) -> bool {
106 let before = self.workspaces.len();
107 self.workspaces.retain(|r| r.name != name);
108 self.workspaces.len() != before
109 }
110
111 pub fn set_config(&mut self, name: &str, config: RepoProviderConfig) -> bool {
113 if let Some(r) = self.workspaces.iter_mut().find(|r| r.name == name) {
114 r.config = config;
115 true
116 } else {
117 false
118 }
119 }
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn add_get_remove() {
128 let mut reg = Registry::default();
129 reg.add("prospero", "/dev/prospero").unwrap();
130 assert_eq!(
131 reg.get("prospero").unwrap().root,
132 PathBuf::from("/dev/prospero")
133 );
134 assert!(reg.remove("prospero"));
135 assert!(reg.get("prospero").is_none());
136 assert!(!reg.remove("prospero"));
137 }
138
139 #[test]
140 fn add_same_name_same_root_is_idempotent() {
141 let mut reg = Registry::default();
142 reg.add("p", "/r").unwrap();
143 reg.add("p", "/r").unwrap();
144 assert_eq!(reg.workspaces.len(), 1);
145 }
146
147 #[test]
148 fn add_same_name_different_root_errors() {
149 let mut reg = Registry::default();
150 reg.add("p", "/r1").unwrap();
151 assert!(reg.add("p", "/r2").is_err());
152 }
153
154 #[test]
155 fn add_different_name_same_root_errors() {
156 let mut reg = Registry::default();
159 reg.add("a", "/r").unwrap();
160 let err = reg.add("b", "/r").unwrap_err().to_string();
161 assert!(err.contains("workspace 'a'"), "names the holder: {err}");
162 assert_eq!(reg.workspaces.len(), 1, "the alias must not be registered");
163 }
164
165 #[test]
166 fn legacy_repos_json_loads_via_alias() {
167 let dir = tempfile::tempdir().unwrap();
168 let path = dir.path().join("registry.json");
169 std::fs::write(&path, r#"{"repos":[{"name":"p","root":"/r"}]}"#).unwrap();
171 let reg = Registry::load(&path).unwrap();
172 let ws = reg.get("p").expect("legacy entry loads");
173 assert_eq!(ws.root, PathBuf::from("/r"));
174 assert_eq!(ws.config, RepoProviderConfig::default());
175 }
176
177 #[test]
178 fn load_missing_file_is_empty() {
179 let dir = tempfile::tempdir().unwrap();
180 let path = dir.path().join("registry.json");
181 let reg = Registry::load(&path).unwrap();
182 assert!(reg.workspaces.is_empty());
183 }
184
185 #[test]
186 fn save_then_load_round_trips() {
187 let dir = tempfile::tempdir().unwrap();
188 let path = dir.path().join("nested/registry.json");
189 let mut reg = Registry::default();
190 reg.add("a", "/a").unwrap();
191 reg.add("b", "/b").unwrap();
192 reg.save(&path).unwrap();
193 let loaded = Registry::load(&path).unwrap();
194 assert_eq!(reg, loaded);
195 }
196
197 #[test]
198 fn repo_config_defaults_empty() {
199 let c = RepoProviderConfig::default();
200 assert!(
201 c.provider.is_none()
202 && c.base_url.is_none()
203 && c.api_key_from_env.is_none()
204 && c.env.is_empty()
205 );
206 }
207
208 #[test]
209 fn old_registry_json_without_config_loads_with_default() {
210 let dir = tempfile::tempdir().unwrap();
211 let path = dir.path().join("registry.json");
212 std::fs::write(&path, r#"{"repos":[{"name":"p","root":"/r"}]}"#).unwrap();
213 let reg = Registry::load(&path).unwrap();
214 assert_eq!(reg.get("p").unwrap().config, RepoProviderConfig::default());
215 }
216
217 #[test]
218 fn set_config_updates_and_round_trips() {
219 let dir = tempfile::tempdir().unwrap();
220 let path = dir.path().join("registry.json");
221 let mut reg = Registry::default();
222 reg.add("p", "/r").unwrap();
223 let cfg = RepoProviderConfig {
224 provider: Some("ollama".into()),
225 base_url: Some("http://host:11434".into()),
226 ..Default::default()
227 };
228 assert!(reg.set_config("p", cfg.clone()));
229 assert!(!reg.set_config("missing", cfg.clone()));
230 reg.save(&path).unwrap();
231 let loaded = Registry::load(&path).unwrap();
232 assert_eq!(loaded.get("p").unwrap().config, cfg);
233 }
234}