pub mod agent;
mod config;
pub use config::{load_merged_config, load_merged_config_from};
use std::path::{Path, PathBuf};
use clawft_types::workspace::{WorkspaceEntry, WorkspaceRegistry};
use clawft_types::{ClawftError, Result};
pub fn discover_workspace() -> Option<PathBuf> {
if let Ok(ws) = std::env::var("CLAWFT_WORKSPACE") {
let path = PathBuf::from(&ws);
if path.join(".clawft").is_dir() {
return Some(path);
}
}
if let Ok(cwd) = std::env::current_dir() {
let mut dir: &Path = cwd.as_path();
loop {
if dir.join(".clawft").is_dir() {
return Some(dir.to_path_buf());
}
match dir.parent() {
Some(parent) => dir = parent,
None => break,
}
}
}
#[cfg(feature = "native")]
{ dirs::home_dir().map(|h| h.join(".clawft")) }
#[cfg(not(feature = "native"))]
{ Some(std::path::PathBuf::from(".clawft")) }
}
pub struct WorkspaceStatus {
pub name: String,
pub path: PathBuf,
pub session_count: usize,
pub has_config: bool,
pub has_clawft_md: bool,
}
const WORKSPACE_SUBDIRS: &[&str] =
&["sessions", "memory", "skills", "agents", "hooks"];
pub struct WorkspaceManager {
pub(crate) registry_path: PathBuf,
registry: WorkspaceRegistry,
}
impl WorkspaceManager {
pub fn new() -> Result<Self> {
#[cfg(feature = "native")]
let home =
dirs::home_dir().ok_or_else(|| ClawftError::ConfigInvalid {
reason: "cannot determine home directory".into(),
})?;
#[cfg(not(feature = "native"))]
let home = std::path::PathBuf::from(".clawft");
let registry_path = home.join(".clawft").join("workspaces.json");
let registry = WorkspaceRegistry::load(®istry_path).map_err(|e| {
ClawftError::ConfigInvalid {
reason: format!("failed to load workspace registry: {e}"),
}
})?;
Ok(Self {
registry_path,
registry,
})
}
pub fn with_registry_path(registry_path: PathBuf) -> Result<Self> {
let registry = WorkspaceRegistry::load(®istry_path).map_err(|e| {
ClawftError::ConfigInvalid {
reason: format!("failed to load workspace registry: {e}"),
}
})?;
Ok(Self {
registry_path,
registry,
})
}
pub fn create(&mut self, name: &str, parent_dir: &Path) -> Result<PathBuf> {
let ws_root = parent_dir.join(name);
let dot_clawft = ws_root.join(".clawft");
for subdir in WORKSPACE_SUBDIRS {
std::fs::create_dir_all(dot_clawft.join(subdir))?;
}
std::fs::write(dot_clawft.join("config.json"), "{}\n")?;
std::fs::write(dot_clawft.join("MEMORY.md"), "")?;
std::fs::write(dot_clawft.join("HISTORY.md"), "")?;
let clawft_md = format!(
"# {name}\n\n\
Workspace created by clawft.\n\n\
## Configuration\n\n\
Edit `.clawft/config.json` to customize this workspace.\n"
);
std::fs::write(ws_root.join("CLAWFT.md"), clawft_md)?;
let now = chrono::Utc::now();
let entry = WorkspaceEntry {
name: name.into(),
path: ws_root.clone(),
last_accessed: Some(now),
created_at: Some(now),
};
self.registry.register(entry);
self.save_registry()?;
crate::chain_event!(
"workspace",
crate::chain_event::EVENT_KIND_WORKSPACE_CREATE,
{
"name": name,
"path": ws_root.display()
}
);
Ok(ws_root)
}
pub fn list(&self) -> Vec<&WorkspaceEntry> {
self.registry.workspaces.iter().collect()
}
pub fn load(&mut self, name_or_path: &str) -> Result<PathBuf> {
if let Some(entry) = self.registry.find_by_name(name_or_path) {
return Ok(entry.path.clone());
}
let path = PathBuf::from(name_or_path);
if path.join(".clawft").is_dir() {
return Ok(path);
}
Err(ClawftError::ConfigInvalid {
reason: format!("workspace not found: {name_or_path}"),
})
}
pub fn status(&self, path: &Path) -> Result<WorkspaceStatus> {
let dot_clawft = path.join(".clawft");
let name = self
.registry
.find_by_path(path)
.map(|e| e.name.clone())
.unwrap_or_else(|| {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| "unknown".into())
});
let sessions_dir = dot_clawft.join("sessions");
let session_count = if sessions_dir.is_dir() {
std::fs::read_dir(&sessions_dir)
.map(|rd| rd.count())
.unwrap_or(0)
} else {
0
};
Ok(WorkspaceStatus {
name,
path: path.to_path_buf(),
session_count,
has_config: dot_clawft.join("config.json").exists(),
has_clawft_md: path.join("CLAWFT.md").exists(),
})
}
pub fn delete(&mut self, name: &str) -> Result<()> {
if !self.registry.remove_by_name(name) {
return Err(ClawftError::ConfigInvalid {
reason: format!("workspace not found: {name}"),
});
}
self.save_registry()?;
Ok(())
}
fn save_registry(&self) -> Result<()> {
self.registry
.save(&self.registry_path)
.map_err(|e| ClawftError::ConfigInvalid {
reason: format!("failed to save workspace registry: {e}"),
})
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
#[test]
fn discover_workspace_returns_some() {
let result = discover_workspace();
assert!(result.is_some(), "discover_workspace should return Some");
}
#[test]
fn discover_workspace_env_var() {
let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir()
.join(format!("clawft-test-discover-env-{n}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join(".clawft")).unwrap();
let result =
temp_env::with_var("CLAWFT_WORKSPACE", Some(dir.to_str().unwrap()), || {
discover_workspace()
});
assert_eq!(result, Some(dir.clone()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn discover_workspace_env_var_invalid_skipped() {
let result = temp_env::with_var(
"CLAWFT_WORKSPACE",
Some("/nonexistent/path/for/test"),
|| discover_workspace(),
);
assert!(result.is_some());
assert_ne!(
result.unwrap(),
PathBuf::from("/nonexistent/path/for/test")
);
}
pub(crate) fn temp_registry(label: &str) -> (PathBuf, PathBuf) {
let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
let dir = std::env::temp_dir()
.join(format!("clawft-test-wm-{label}-{n}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let registry_path = dir.join("workspaces.json");
(dir, registry_path)
}
#[test]
fn workspace_manager_create_directories() {
let (dir, registry_path) = temp_registry("create-dirs");
let mut wm =
WorkspaceManager::with_registry_path(registry_path).unwrap();
let ws_path = wm.create("test-ws", &dir).unwrap();
let dot_clawft = ws_path.join(".clawft");
for subdir in WORKSPACE_SUBDIRS {
assert!(
dot_clawft.join(subdir).is_dir(),
"missing subdir: {subdir}"
);
}
assert!(dot_clawft.join("config.json").exists());
assert!(dot_clawft.join("MEMORY.md").exists());
assert!(dot_clawft.join("HISTORY.md").exists());
assert!(ws_path.join("CLAWFT.md").exists());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn workspace_manager_create_registers_in_registry() {
let (dir, registry_path) = temp_registry("create-reg");
let mut wm =
WorkspaceManager::with_registry_path(registry_path.clone()).unwrap();
wm.create("reg-test", &dir).unwrap();
let loaded = WorkspaceRegistry::load(®istry_path).unwrap();
assert!(loaded.find_by_name("reg-test").is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn workspace_manager_list() {
let (dir, registry_path) = temp_registry("list");
let mut wm =
WorkspaceManager::with_registry_path(registry_path).unwrap();
assert!(wm.list().is_empty(), "fresh registry should be empty");
wm.create("ws-a", &dir).unwrap();
wm.create("ws-b", &dir).unwrap();
let list = wm.list();
assert_eq!(list.len(), 2);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn workspace_manager_load_by_name() {
let (dir, registry_path) = temp_registry("load-name");
let mut wm =
WorkspaceManager::with_registry_path(registry_path).unwrap();
let created = wm.create("load-test", &dir).unwrap();
let loaded = wm.load("load-test").unwrap();
assert_eq!(created, loaded);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn workspace_manager_load_by_path() {
let (dir, registry_path) = temp_registry("load-path");
let mut wm =
WorkspaceManager::with_registry_path(registry_path).unwrap();
let created = wm.create("path-test", &dir).unwrap();
let loaded = wm.load(created.to_str().unwrap()).unwrap();
assert_eq!(created, loaded);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn workspace_manager_load_not_found() {
let (dir, registry_path) = temp_registry("load-notfound");
let mut wm =
WorkspaceManager::with_registry_path(registry_path).unwrap();
let result = wm.load("nonexistent");
assert!(result.is_err());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn workspace_manager_status() {
let (dir, registry_path) = temp_registry("status");
let mut wm =
WorkspaceManager::with_registry_path(registry_path).unwrap();
let ws_path = wm.create("status-test", &dir).unwrap();
let status = wm.status(&ws_path).unwrap();
assert_eq!(status.name, "status-test");
assert_eq!(status.path, ws_path);
assert_eq!(status.session_count, 0);
assert!(status.has_config);
assert!(status.has_clawft_md);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn workspace_manager_delete() {
let (dir, registry_path) = temp_registry("delete");
let mut wm =
WorkspaceManager::with_registry_path(registry_path).unwrap();
wm.create("del-test", &dir).unwrap();
assert_eq!(wm.list().len(), 1);
wm.delete("del-test").unwrap();
assert!(wm.list().is_empty());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn workspace_manager_delete_not_found() {
let (dir, registry_path) = temp_registry("delete-notfound");
let mut wm =
WorkspaceManager::with_registry_path(registry_path).unwrap();
let result = wm.delete("nonexistent");
assert!(result.is_err());
let _ = std::fs::remove_dir_all(&dir);
}
}