clawft_core/workspace/
mod.rs1pub mod agent;
10mod config;
11
12pub use config::{load_merged_config, load_merged_config_from};
13
14use std::path::{Path, PathBuf};
15
16use clawft_types::workspace::{WorkspaceEntry, WorkspaceRegistry};
17use clawft_types::{ClawftError, Result};
18
19pub fn discover_workspace() -> Option<PathBuf> {
29 if let Ok(ws) = std::env::var("CLAWFT_WORKSPACE") {
31 let path = PathBuf::from(&ws);
32 if path.join(".clawft").is_dir() {
33 return Some(path);
34 }
35 }
36
37 if let Ok(cwd) = std::env::current_dir() {
39 let mut dir: &Path = cwd.as_path();
40 loop {
41 if dir.join(".clawft").is_dir() {
42 return Some(dir.to_path_buf());
43 }
44 match dir.parent() {
45 Some(parent) => dir = parent,
46 None => break,
47 }
48 }
49 }
50
51 #[cfg(feature = "native")]
53 { dirs::home_dir().map(|h| h.join(".clawft")) }
54 #[cfg(not(feature = "native"))]
55 { Some(std::path::PathBuf::from(".clawft")) }
56}
57
58pub struct WorkspaceStatus {
62 pub name: String,
64
65 pub path: PathBuf,
67
68 pub session_count: usize,
70
71 pub has_config: bool,
73
74 pub has_clawft_md: bool,
76}
77
78const WORKSPACE_SUBDIRS: &[&str] =
82 &["sessions", "memory", "skills", "agents", "hooks"];
83
84pub struct WorkspaceManager {
86 pub(crate) registry_path: PathBuf,
88
89 registry: WorkspaceRegistry,
91}
92
93impl WorkspaceManager {
94 pub fn new() -> Result<Self> {
98 #[cfg(feature = "native")]
99 let home =
100 dirs::home_dir().ok_or_else(|| ClawftError::ConfigInvalid {
101 reason: "cannot determine home directory".into(),
102 })?;
103 #[cfg(not(feature = "native"))]
104 let home = std::path::PathBuf::from(".clawft");
105 let registry_path = home.join(".clawft").join("workspaces.json");
106 let registry = WorkspaceRegistry::load(®istry_path).map_err(|e| {
107 ClawftError::ConfigInvalid {
108 reason: format!("failed to load workspace registry: {e}"),
109 }
110 })?;
111
112 Ok(Self {
113 registry_path,
114 registry,
115 })
116 }
117
118 pub fn with_registry_path(registry_path: PathBuf) -> Result<Self> {
122 let registry = WorkspaceRegistry::load(®istry_path).map_err(|e| {
123 ClawftError::ConfigInvalid {
124 reason: format!("failed to load workspace registry: {e}"),
125 }
126 })?;
127
128 Ok(Self {
129 registry_path,
130 registry,
131 })
132 }
133
134 pub fn create(&mut self, name: &str, parent_dir: &Path) -> Result<PathBuf> {
145 let ws_root = parent_dir.join(name);
146 let dot_clawft = ws_root.join(".clawft");
147
148 for subdir in WORKSPACE_SUBDIRS {
150 std::fs::create_dir_all(dot_clawft.join(subdir))?;
151 }
152
153 std::fs::write(dot_clawft.join("config.json"), "{}\n")?;
155
156 std::fs::write(dot_clawft.join("MEMORY.md"), "")?;
158 std::fs::write(dot_clawft.join("HISTORY.md"), "")?;
159
160 let clawft_md = format!(
162 "# {name}\n\n\
163 Workspace created by clawft.\n\n\
164 ## Configuration\n\n\
165 Edit `.clawft/config.json` to customize this workspace.\n"
166 );
167 std::fs::write(ws_root.join("CLAWFT.md"), clawft_md)?;
168
169 let now = chrono::Utc::now();
171 let entry = WorkspaceEntry {
172 name: name.into(),
173 path: ws_root.clone(),
174 last_accessed: Some(now),
175 created_at: Some(now),
176 };
177 self.registry.register(entry);
178 self.save_registry()?;
179
180 Ok(ws_root)
181 }
182
183 pub fn list(&self) -> Vec<&WorkspaceEntry> {
185 self.registry.workspaces.iter().collect()
186 }
187
188 pub fn load(&mut self, name_or_path: &str) -> Result<PathBuf> {
192 if let Some(entry) = self.registry.find_by_name(name_or_path) {
194 return Ok(entry.path.clone());
195 }
196
197 let path = PathBuf::from(name_or_path);
199 if path.join(".clawft").is_dir() {
200 return Ok(path);
201 }
202
203 Err(ClawftError::ConfigInvalid {
204 reason: format!("workspace not found: {name_or_path}"),
205 })
206 }
207
208 pub fn status(&self, path: &Path) -> Result<WorkspaceStatus> {
210 let dot_clawft = path.join(".clawft");
211 let name = self
212 .registry
213 .find_by_path(path)
214 .map(|e| e.name.clone())
215 .unwrap_or_else(|| {
216 path.file_name()
217 .map(|n| n.to_string_lossy().into_owned())
218 .unwrap_or_else(|| "unknown".into())
219 });
220
221 let sessions_dir = dot_clawft.join("sessions");
222 let session_count = if sessions_dir.is_dir() {
223 std::fs::read_dir(&sessions_dir)
224 .map(|rd| rd.count())
225 .unwrap_or(0)
226 } else {
227 0
228 };
229
230 Ok(WorkspaceStatus {
231 name,
232 path: path.to_path_buf(),
233 session_count,
234 has_config: dot_clawft.join("config.json").exists(),
235 has_clawft_md: path.join("CLAWFT.md").exists(),
236 })
237 }
238
239 pub fn delete(&mut self, name: &str) -> Result<()> {
244 if !self.registry.remove_by_name(name) {
245 return Err(ClawftError::ConfigInvalid {
246 reason: format!("workspace not found: {name}"),
247 });
248 }
249 self.save_registry()?;
250 Ok(())
251 }
252
253 fn save_registry(&self) -> Result<()> {
255 self.registry
256 .save(&self.registry_path)
257 .map_err(|e| ClawftError::ConfigInvalid {
258 reason: format!("failed to save workspace registry: {e}"),
259 })
260 }
261}
262
263#[cfg(test)]
264pub(crate) mod tests {
265 use super::*;
266 use std::sync::atomic::{AtomicU64, Ordering};
267
268 static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
270
271 #[test]
274 fn discover_workspace_returns_some() {
275 let result = discover_workspace();
276 assert!(result.is_some(), "discover_workspace should return Some");
277 }
278
279 #[test]
280 fn discover_workspace_env_var() {
281 let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
282 let dir = std::env::temp_dir()
283 .join(format!("clawft-test-discover-env-{n}"));
284 let _ = std::fs::remove_dir_all(&dir);
285 std::fs::create_dir_all(dir.join(".clawft")).unwrap();
286
287 let result =
288 temp_env::with_var("CLAWFT_WORKSPACE", Some(dir.to_str().unwrap()), || {
289 discover_workspace()
290 });
291
292 assert_eq!(result, Some(dir.clone()));
293
294 let _ = std::fs::remove_dir_all(&dir);
295 }
296
297 #[test]
298 fn discover_workspace_env_var_invalid_skipped() {
299 let result = temp_env::with_var(
300 "CLAWFT_WORKSPACE",
301 Some("/nonexistent/path/for/test"),
302 || discover_workspace(),
303 );
304
305 assert!(result.is_some());
306 assert_ne!(
307 result.unwrap(),
308 PathBuf::from("/nonexistent/path/for/test")
309 );
310 }
311
312 pub(crate) fn temp_registry(label: &str) -> (PathBuf, PathBuf) {
316 let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
317 let dir = std::env::temp_dir()
318 .join(format!("clawft-test-wm-{label}-{n}"));
319 let _ = std::fs::remove_dir_all(&dir);
320 std::fs::create_dir_all(&dir).unwrap();
321 let registry_path = dir.join("workspaces.json");
322 (dir, registry_path)
323 }
324
325 #[test]
326 fn workspace_manager_create_directories() {
327 let (dir, registry_path) = temp_registry("create-dirs");
328 let mut wm =
329 WorkspaceManager::with_registry_path(registry_path).unwrap();
330
331 let ws_path = wm.create("test-ws", &dir).unwrap();
332 let dot_clawft = ws_path.join(".clawft");
333
334 for subdir in WORKSPACE_SUBDIRS {
335 assert!(
336 dot_clawft.join(subdir).is_dir(),
337 "missing subdir: {subdir}"
338 );
339 }
340
341 assert!(dot_clawft.join("config.json").exists());
342 assert!(dot_clawft.join("MEMORY.md").exists());
343 assert!(dot_clawft.join("HISTORY.md").exists());
344 assert!(ws_path.join("CLAWFT.md").exists());
345
346 let _ = std::fs::remove_dir_all(&dir);
347 }
348
349 #[test]
350 fn workspace_manager_create_registers_in_registry() {
351 let (dir, registry_path) = temp_registry("create-reg");
352 let mut wm =
353 WorkspaceManager::with_registry_path(registry_path.clone()).unwrap();
354
355 wm.create("reg-test", &dir).unwrap();
356
357 let loaded = WorkspaceRegistry::load(®istry_path).unwrap();
358 assert!(loaded.find_by_name("reg-test").is_some());
359
360 let _ = std::fs::remove_dir_all(&dir);
361 }
362
363 #[test]
364 fn workspace_manager_list() {
365 let (dir, registry_path) = temp_registry("list");
366 let mut wm =
367 WorkspaceManager::with_registry_path(registry_path).unwrap();
368
369 assert!(wm.list().is_empty(), "fresh registry should be empty");
370
371 wm.create("ws-a", &dir).unwrap();
372 wm.create("ws-b", &dir).unwrap();
373
374 let list = wm.list();
375 assert_eq!(list.len(), 2);
376
377 let _ = std::fs::remove_dir_all(&dir);
378 }
379
380 #[test]
381 fn workspace_manager_load_by_name() {
382 let (dir, registry_path) = temp_registry("load-name");
383 let mut wm =
384 WorkspaceManager::with_registry_path(registry_path).unwrap();
385
386 let created = wm.create("load-test", &dir).unwrap();
387 let loaded = wm.load("load-test").unwrap();
388 assert_eq!(created, loaded);
389
390 let _ = std::fs::remove_dir_all(&dir);
391 }
392
393 #[test]
394 fn workspace_manager_load_by_path() {
395 let (dir, registry_path) = temp_registry("load-path");
396 let mut wm =
397 WorkspaceManager::with_registry_path(registry_path).unwrap();
398
399 let created = wm.create("path-test", &dir).unwrap();
400 let loaded = wm.load(created.to_str().unwrap()).unwrap();
401 assert_eq!(created, loaded);
402
403 let _ = std::fs::remove_dir_all(&dir);
404 }
405
406 #[test]
407 fn workspace_manager_load_not_found() {
408 let (dir, registry_path) = temp_registry("load-notfound");
409 let mut wm =
410 WorkspaceManager::with_registry_path(registry_path).unwrap();
411
412 let result = wm.load("nonexistent");
413 assert!(result.is_err());
414
415 let _ = std::fs::remove_dir_all(&dir);
416 }
417
418 #[test]
419 fn workspace_manager_status() {
420 let (dir, registry_path) = temp_registry("status");
421 let mut wm =
422 WorkspaceManager::with_registry_path(registry_path).unwrap();
423
424 let ws_path = wm.create("status-test", &dir).unwrap();
425 let status = wm.status(&ws_path).unwrap();
426
427 assert_eq!(status.name, "status-test");
428 assert_eq!(status.path, ws_path);
429 assert_eq!(status.session_count, 0);
430 assert!(status.has_config);
431 assert!(status.has_clawft_md);
432
433 let _ = std::fs::remove_dir_all(&dir);
434 }
435
436 #[test]
437 fn workspace_manager_delete() {
438 let (dir, registry_path) = temp_registry("delete");
439 let mut wm =
440 WorkspaceManager::with_registry_path(registry_path).unwrap();
441
442 wm.create("del-test", &dir).unwrap();
443 assert_eq!(wm.list().len(), 1);
444
445 wm.delete("del-test").unwrap();
446 assert!(wm.list().is_empty());
447
448 let _ = std::fs::remove_dir_all(&dir);
449 }
450
451 #[test]
452 fn workspace_manager_delete_not_found() {
453 let (dir, registry_path) = temp_registry("delete-notfound");
454 let mut wm =
455 WorkspaceManager::with_registry_path(registry_path).unwrap();
456
457 let result = wm.delete("nonexistent");
458 assert!(result.is_err());
459
460 let _ = std::fs::remove_dir_all(&dir);
461 }
462}