Skip to main content

clawft_core/workspace/
mod.rs

1//! Workspace discovery, lifecycle management, and merged config loading.
2//!
3//! Provides the 4-step workspace discovery algorithm and [`WorkspaceManager`]
4//! for creating, listing, loading, and deleting workspaces.
5//!
6//! Per-agent workspace isolation is in the [`agent`] submodule.
7//! Config merge logic is in the [`config`] submodule.
8
9pub 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
19// ── Discovery ────────────────────────────────────────────────────────────
20
21/// 4-step workspace discovery algorithm.
22///
23/// 1. `$CLAWFT_WORKSPACE` environment variable
24/// 2. Walk from `cwd` upward looking for a `.clawft/` directory
25/// 3. Fall back to `~/.clawft/` (global workspace)
26///
27/// Returns `None` only if the home directory cannot be determined.
28pub fn discover_workspace() -> Option<PathBuf> {
29    // Step 1: env var
30    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    // Step 2: walk cwd upward
38    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    // Step 3: global default
52    #[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
58// ── Workspace status ─────────────────────────────────────────────────────
59
60/// Summary of a workspace's current state.
61pub struct WorkspaceStatus {
62    /// Workspace display name.
63    pub name: String,
64
65    /// Absolute path to workspace root.
66    pub path: PathBuf,
67
68    /// Number of session files found in `.clawft/sessions/`.
69    pub session_count: usize,
70
71    /// Whether `.clawft/config.json` exists.
72    pub has_config: bool,
73
74    /// Whether `CLAWFT.md` exists at the workspace root.
75    pub has_clawft_md: bool,
76}
77
78// ── WorkspaceManager ─────────────────────────────────────────────────────
79
80/// The canonical subdirectories created inside `.clawft/`.
81const WORKSPACE_SUBDIRS: &[&str] =
82    &["sessions", "memory", "skills", "agents", "hooks"];
83
84/// Manages workspace lifecycle (create, list, load, status, delete).
85pub struct WorkspaceManager {
86    /// Path to the global registry file (`~/.clawft/workspaces.json`).
87    pub(crate) registry_path: PathBuf,
88
89    /// In-memory copy of the registry.
90    registry: WorkspaceRegistry,
91}
92
93impl WorkspaceManager {
94    /// Create a new manager, loading the registry from the default path.
95    ///
96    /// The default registry path is `~/.clawft/workspaces.json`.
97    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(&registry_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    /// Create a new manager with an explicit registry path.
119    ///
120    /// Useful for testing.
121    pub fn with_registry_path(registry_path: PathBuf) -> Result<Self> {
122        let registry = WorkspaceRegistry::load(&registry_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    /// Create a new workspace.
135    ///
136    /// Creates:
137    /// 1. `.clawft/` and subdirectories (`sessions`, `memory`, `skills`,
138    ///    `agents`, `hooks`)
139    /// 2. `.clawft/config.json` with `{}`
140    /// 3. `CLAWFT.md` with a starter template
141    /// 4. Registers the workspace in the global registry
142    ///
143    /// Returns the absolute path to the workspace root.
144    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        // Create .clawft/ and subdirectories
149        for subdir in WORKSPACE_SUBDIRS {
150            std::fs::create_dir_all(dot_clawft.join(subdir))?;
151        }
152
153        // Create config.json
154        std::fs::write(dot_clawft.join("config.json"), "{}\n")?;
155
156        // Create MEMORY.md and HISTORY.md (empty)
157        std::fs::write(dot_clawft.join("MEMORY.md"), "")?;
158        std::fs::write(dot_clawft.join("HISTORY.md"), "")?;
159
160        // Create CLAWFT.md
161        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        // Register in global registry
170        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    /// List all registered workspaces.
184    pub fn list(&self) -> Vec<&WorkspaceEntry> {
185        self.registry.workspaces.iter().collect()
186    }
187
188    /// Load a workspace by name or path string.
189    ///
190    /// Returns the workspace root path if found.
191    pub fn load(&mut self, name_or_path: &str) -> Result<PathBuf> {
192        // Try by name first
193        if let Some(entry) = self.registry.find_by_name(name_or_path) {
194            return Ok(entry.path.clone());
195        }
196
197        // Try as a path
198        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    /// Get the status of a workspace at the given path.
209    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    /// Delete a workspace by name.
240    ///
241    /// Removes the entry from the registry but does NOT delete files
242    /// from disk (that is the caller's responsibility).
243    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    /// Persist the registry to disk.
254    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    /// Monotonic counter to give each test a unique temp directory.
269    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
270
271    // ── Discovery tests ──────────────────────────────────────────────
272
273    #[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    // ── WorkspaceManager tests ───────────────────────────────────────
313
314    /// Create a unique temp directory and registry path for each test.
315    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(&registry_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}