use std::io::{self, BufRead, Write};
use std::path::{Path, PathBuf};
pub const WORKSPACES_DIR_REL: &str = ".caretta/workspaces";
pub fn workspaces_dir(root: &Path) -> PathBuf {
root.join(WORKSPACES_DIR_REL)
}
pub fn workspace_root(root: &Path, name: &str) -> PathBuf {
workspaces_dir(root).join(name)
}
pub fn workspaces_enabled(root: &Path) -> bool {
workspaces_dir(root).is_dir()
}
pub fn list_workspaces(root: &Path) -> Vec<String> {
let dir = workspaces_dir(root);
let entries = match std::fs::read_dir(&dir) {
Ok(e) => e,
Err(_) => return Vec::new(),
};
let mut names: Vec<String> = entries
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_dir())
.filter_map(|entry| {
entry
.file_name()
.to_str()
.filter(|n| !n.starts_with('.'))
.map(|n| n.to_string())
})
.collect();
names.sort();
names.dedup();
names
}
pub fn pick_workspace_interactive(workspaces: &[String]) -> io::Result<Option<String>> {
if workspaces.is_empty() {
return Ok(None);
}
let stderr = io::stderr();
let mut err = stderr.lock();
writeln!(err, "Detected context workspaces in .caretta/workspaces/:")?;
writeln!(err, " 0) (none — use default context)")?;
for (idx, name) in workspaces.iter().enumerate() {
writeln!(err, " {}) {}", idx + 1, name)?;
}
write!(err, "Select a workspace [0]: ")?;
err.flush()?;
let stdin = io::stdin();
let mut line = String::new();
stdin.lock().read_line(&mut line)?;
let trimmed = line.trim();
if trimmed.is_empty() || trimmed == "0" {
return Ok(None);
}
if let Ok(idx) = trimmed.parse::<usize>() {
if idx == 0 {
return Ok(None);
}
return Ok(workspaces.get(idx - 1).cloned());
}
Ok(workspaces
.iter()
.find(|name| name.as_str() == trimmed)
.cloned())
}
pub fn select_workspace(root: &Path, explicit: Option<&str>, interactive: bool) -> Option<String> {
if let Some(name) = explicit {
let name = name.trim();
if name.is_empty() || name.eq_ignore_ascii_case("none") {
return None;
}
return Some(name.to_string());
}
if !workspaces_enabled(root) {
return None;
}
let names = list_workspaces(root);
if names.is_empty() {
return None;
}
if !interactive {
return None;
}
pick_workspace_interactive(&names).unwrap_or(None)
}
pub fn workspace_relative(
root: &Path,
workspace: Option<&str>,
relative: impl AsRef<Path>,
) -> Option<PathBuf> {
workspace.map(|name| workspace_root(root, name).join(relative))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn list_workspaces_returns_empty_when_dir_missing() {
let repo = tempdir().expect("tempdir");
assert!(list_workspaces(repo.path()).is_empty());
assert!(!workspaces_enabled(repo.path()));
}
#[test]
fn list_workspaces_returns_sorted_directory_names() {
let repo = tempdir().expect("tempdir");
let base = repo.path().join(WORKSPACES_DIR_REL);
fs::create_dir_all(base.join("zeta")).unwrap();
fs::create_dir_all(base.join("alpha")).unwrap();
fs::create_dir_all(base.join("beta")).unwrap();
fs::write(base.join("README.md"), "ignored").unwrap();
fs::create_dir_all(base.join(".keep")).unwrap();
let names = list_workspaces(repo.path());
assert_eq!(names, vec!["alpha", "beta", "zeta"]);
assert!(workspaces_enabled(repo.path()));
}
#[test]
fn select_workspace_respects_explicit_flag() {
let repo = tempdir().expect("tempdir");
assert_eq!(
select_workspace(repo.path(), Some("custom"), false),
Some("custom".to_string())
);
}
#[test]
fn select_workspace_disables_with_none_sentinel() {
let repo = tempdir().expect("tempdir");
let base = repo.path().join(WORKSPACES_DIR_REL);
fs::create_dir_all(base.join("alpha")).unwrap();
assert_eq!(select_workspace(repo.path(), Some("none"), false), None);
assert_eq!(select_workspace(repo.path(), Some(""), false), None);
}
#[test]
fn select_workspace_returns_none_without_interactive_and_without_flag() {
let repo = tempdir().expect("tempdir");
let base = repo.path().join(WORKSPACES_DIR_REL);
fs::create_dir_all(base.join("alpha")).unwrap();
assert_eq!(select_workspace(repo.path(), None, false), None);
}
#[test]
fn select_workspace_returns_none_when_no_workspaces_dir() {
let repo = tempdir().expect("tempdir");
assert_eq!(select_workspace(repo.path(), None, true), None);
}
#[test]
fn workspace_relative_joins_path_when_set() {
let repo = tempdir().expect("tempdir");
let p = workspace_relative(repo.path(), Some("alpha"), "skills/user-personas/SKILL.md")
.expect("Some path");
assert!(p.ends_with(".caretta/workspaces/alpha/skills/user-personas/SKILL.md"));
assert_eq!(workspace_relative(repo.path(), None, "skills/x"), None);
}
}