construct/config/
workspace.rs1use anyhow::{Context, Result, bail};
8use serde::{Deserialize, Serialize};
9use std::collections::HashMap;
10use std::path::{Path, PathBuf};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct WorkspaceProfile {
15 pub name: String,
17 #[serde(default)]
19 pub allowed_domains: Vec<String>,
20 #[serde(default)]
22 pub credential_profile: Option<String>,
23 #[serde(default)]
25 pub memory_namespace: Option<String>,
26 #[serde(default)]
28 pub audit_namespace: Option<String>,
29 #[serde(default)]
31 pub tool_restrictions: Vec<String>,
32}
33
34impl WorkspaceProfile {
35 pub fn effective_memory_namespace(&self) -> &str {
37 self.memory_namespace
38 .as_deref()
39 .unwrap_or(self.name.as_str())
40 }
41
42 pub fn effective_audit_namespace(&self) -> &str {
44 self.audit_namespace
45 .as_deref()
46 .unwrap_or(self.name.as_str())
47 }
48
49 pub fn is_tool_restricted(&self, tool_name: &str) -> bool {
51 self.tool_restrictions
52 .iter()
53 .any(|r| r.eq_ignore_ascii_case(tool_name))
54 }
55
56 pub fn is_domain_allowed(&self, domain: &str) -> bool {
59 if self.allowed_domains.is_empty() {
60 return true;
61 }
62 let domain_lower = domain.to_ascii_lowercase();
63 self.allowed_domains
64 .iter()
65 .any(|d| domain_lower == d.to_ascii_lowercase())
66 }
67}
68
69#[derive(Debug, Clone)]
71pub struct WorkspaceManager {
72 workspaces_dir: PathBuf,
74 profiles: HashMap<String, WorkspaceProfile>,
76 active: Option<String>,
78}
79
80impl WorkspaceManager {
81 pub fn new(workspaces_dir: PathBuf) -> Self {
83 Self {
84 workspaces_dir,
85 profiles: HashMap::new(),
86 active: None,
87 }
88 }
89
90 pub async fn load_profiles(&mut self) -> Result<()> {
95 self.profiles.clear();
96
97 let dir = &self.workspaces_dir;
98 if !dir.exists() {
99 return Ok(());
100 }
101
102 let mut entries = tokio::fs::read_dir(dir)
103 .await
104 .with_context(|| format!("reading workspaces directory: {}", dir.display()))?;
105
106 while let Some(entry) = entries.next_entry().await? {
107 let path = entry.path();
108 if !path.is_dir() {
109 continue;
110 }
111 let profile_path = path.join("profile.toml");
112 if !profile_path.exists() {
113 continue;
114 }
115 match tokio::fs::read_to_string(&profile_path).await {
116 Ok(contents) => match toml::from_str::<WorkspaceProfile>(&contents) {
117 Ok(profile) => {
118 self.profiles.insert(profile.name.clone(), profile);
119 }
120 Err(e) => {
121 tracing::warn!(
122 "skipping malformed workspace profile {}: {e}",
123 profile_path.display()
124 );
125 }
126 },
127 Err(e) => {
128 tracing::warn!(
129 "skipping unreadable workspace profile {}: {e}",
130 profile_path.display()
131 );
132 }
133 }
134 }
135
136 Ok(())
137 }
138
139 pub fn switch(&mut self, name: &str) -> Result<&WorkspaceProfile> {
141 if !self.profiles.contains_key(name) {
142 bail!("workspace '{}' not found", name);
143 }
144 self.active = Some(name.to_string());
145 Ok(&self.profiles[name])
146 }
147
148 pub fn active_profile(&self) -> Option<&WorkspaceProfile> {
150 self.active
151 .as_deref()
152 .and_then(|name| self.profiles.get(name))
153 }
154
155 pub fn active_name(&self) -> Option<&str> {
157 self.active.as_deref()
158 }
159
160 pub fn list(&self) -> Vec<&str> {
162 let mut names: Vec<&str> = self.profiles.keys().map(String::as_str).collect();
163 names.sort_unstable();
164 names
165 }
166
167 pub fn get(&self, name: &str) -> Option<&WorkspaceProfile> {
169 self.profiles.get(name)
170 }
171
172 pub async fn create(&mut self, name: &str) -> Result<&WorkspaceProfile> {
174 if name.is_empty() {
175 bail!("workspace name must not be empty");
176 }
177 if !name
179 .chars()
180 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
181 {
182 bail!(
183 "workspace name must contain only alphanumeric characters, hyphens, or underscores"
184 );
185 }
186 if self.profiles.contains_key(name) {
187 bail!("workspace '{}' already exists", name);
188 }
189
190 let ws_dir = self.workspaces_dir.join(name);
191 tokio::fs::create_dir_all(&ws_dir)
192 .await
193 .with_context(|| format!("creating workspace directory: {}", ws_dir.display()))?;
194
195 let profile = WorkspaceProfile {
196 name: name.to_string(),
197 allowed_domains: Vec::new(),
198 credential_profile: None,
199 memory_namespace: Some(name.to_string()),
200 audit_namespace: Some(name.to_string()),
201 tool_restrictions: Vec::new(),
202 };
203
204 let toml_str = toml::to_string_pretty(&profile).context("serializing workspace profile")?;
205 let profile_path = ws_dir.join("profile.toml");
206 tokio::fs::write(&profile_path, toml_str)
207 .await
208 .with_context(|| format!("writing workspace profile: {}", profile_path.display()))?;
209
210 self.profiles.insert(name.to_string(), profile);
211 Ok(&self.profiles[name])
212 }
213
214 pub fn export(&self, name: &str) -> Result<String> {
216 let profile = self
217 .profiles
218 .get(name)
219 .with_context(|| format!("workspace '{}' not found", name))?;
220
221 let export = WorkspaceProfile {
223 credential_profile: profile
224 .credential_profile
225 .as_ref()
226 .map(|_| "***".to_string()),
227 ..profile.clone()
228 };
229
230 toml::to_string_pretty(&export).context("serializing workspace profile for export")
231 }
232
233 pub fn workspace_dir(&self, name: &str) -> PathBuf {
235 self.workspaces_dir.join(name)
236 }
237
238 pub fn workspaces_dir(&self) -> &Path {
240 &self.workspaces_dir
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use tempfile::TempDir;
248
249 fn sample_profile(name: &str) -> WorkspaceProfile {
250 WorkspaceProfile {
251 name: name.to_string(),
252 allowed_domains: vec!["example.com".to_string()],
253 credential_profile: Some("test-creds".to_string()),
254 memory_namespace: Some(format!("{name}_mem")),
255 audit_namespace: Some(format!("{name}_audit")),
256 tool_restrictions: vec!["shell".to_string()],
257 }
258 }
259
260 #[test]
261 fn workspace_profile_tool_restriction_check() {
262 let profile = sample_profile("client_a");
263 assert!(profile.is_tool_restricted("shell"));
264 assert!(profile.is_tool_restricted("Shell"));
265 assert!(!profile.is_tool_restricted("file_read"));
266 }
267
268 #[test]
269 fn workspace_profile_domain_allowlist_empty_allows_all() {
270 let mut profile = sample_profile("client_a");
271 profile.allowed_domains.clear();
272 assert!(profile.is_domain_allowed("anything.com"));
273 }
274
275 #[test]
276 fn workspace_profile_domain_allowlist_enforced() {
277 let profile = sample_profile("client_a");
278 assert!(profile.is_domain_allowed("example.com"));
279 assert!(!profile.is_domain_allowed("other.com"));
280 }
281
282 #[test]
283 fn workspace_profile_effective_namespaces() {
284 let profile = sample_profile("client_a");
285 assert_eq!(profile.effective_memory_namespace(), "client_a_mem");
286 assert_eq!(profile.effective_audit_namespace(), "client_a_audit");
287
288 let fallback = WorkspaceProfile {
289 name: "test_ws".to_string(),
290 memory_namespace: None,
291 audit_namespace: None,
292 ..sample_profile("test_ws")
293 };
294 assert_eq!(fallback.effective_memory_namespace(), "test_ws");
295 assert_eq!(fallback.effective_audit_namespace(), "test_ws");
296 }
297
298 #[tokio::test]
299 async fn workspace_manager_create_and_list() {
300 let tmp = TempDir::new().unwrap();
301 let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
302
303 mgr.create("client_alpha").await.unwrap();
304 mgr.create("client_beta").await.unwrap();
305
306 let names = mgr.list();
307 assert_eq!(names, vec!["client_alpha", "client_beta"]);
308 }
309
310 #[tokio::test]
311 async fn workspace_manager_create_rejects_duplicate() {
312 let tmp = TempDir::new().unwrap();
313 let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
314
315 mgr.create("client_a").await.unwrap();
316 let result = mgr.create("client_a").await;
317 assert!(result.is_err());
318 }
319
320 #[tokio::test]
321 async fn workspace_manager_create_rejects_invalid_name() {
322 let tmp = TempDir::new().unwrap();
323 let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
324
325 assert!(mgr.create("").await.is_err());
326 assert!(mgr.create("bad name").await.is_err());
327 assert!(mgr.create("../escape").await.is_err());
328 }
329
330 #[tokio::test]
331 async fn workspace_manager_switch_and_active() {
332 let tmp = TempDir::new().unwrap();
333 let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
334
335 mgr.create("ws_one").await.unwrap();
336 assert!(mgr.active_profile().is_none());
337
338 mgr.switch("ws_one").unwrap();
339 assert_eq!(mgr.active_name(), Some("ws_one"));
340 assert!(mgr.active_profile().is_some());
341 }
342
343 #[test]
344 fn workspace_manager_switch_nonexistent_fails() {
345 let mgr = WorkspaceManager::new(PathBuf::from("/tmp/nonexistent"));
346 let mut mgr = mgr;
347 assert!(mgr.switch("no_such_ws").is_err());
348 }
349
350 #[tokio::test]
351 async fn workspace_manager_load_profiles_from_disk() {
352 let tmp = TempDir::new().unwrap();
353 let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
354
355 mgr.create("loaded_ws").await.unwrap();
357
358 let mut mgr2 = WorkspaceManager::new(tmp.path().to_path_buf());
360 mgr2.load_profiles().await.unwrap();
361
362 assert_eq!(mgr2.list(), vec!["loaded_ws"]);
363 let profile = mgr2.get("loaded_ws").unwrap();
364 assert_eq!(profile.name, "loaded_ws");
365 }
366
367 #[tokio::test]
368 async fn workspace_manager_export_redacts_credentials() {
369 let tmp = TempDir::new().unwrap();
370 let mut mgr = WorkspaceManager::new(tmp.path().to_path_buf());
371 mgr.create("export_test").await.unwrap();
372
373 if let Some(profile) = mgr.profiles.get_mut("export_test") {
375 profile.credential_profile = Some("secret-cred-id".to_string());
376 }
377
378 let exported = mgr.export("export_test").unwrap();
379 assert!(exported.contains("***"));
380 assert!(!exported.contains("secret-cred-id"));
381 }
382}