use std::path::PathBuf;
use async_trait::async_trait;
use tracing::{debug, info};
use crate::error::{Error, Result};
use crate::models::team::{MemberUnion, TeamConfig};
use crate::util::atomic_write::atomic_write_json;
use crate::util::file_lock::FileLock;
use crate::util::validate_name;
#[async_trait]
pub trait TeamManager: Send + Sync {
async fn create_team(&self, name: &str, description: Option<&str>) -> Result<TeamConfig>;
async fn delete_team(&self, name: &str) -> Result<()>;
async fn read_config(&self, name: &str) -> Result<TeamConfig>;
async fn add_member(&self, team: &str, member: MemberUnion) -> Result<()>;
async fn remove_member(&self, team: &str, member_name: &str) -> Result<()>;
async fn list_teams(&self) -> Result<Vec<String>>;
}
pub struct FileTeamManager {
base_dir: PathBuf,
}
impl FileTeamManager {
pub fn new(base_dir: impl Into<PathBuf>) -> Self {
Self {
base_dir: base_dir.into(),
}
}
fn team_dir(&self, name: &str) -> PathBuf {
self.base_dir.join(name)
}
fn config_path(&self, name: &str) -> PathBuf {
self.team_dir(name).join("config.json")
}
fn lock_path(&self, name: &str) -> PathBuf {
self.team_dir(name).join("config.json.lock")
}
fn tasks_dir(&self, name: &str) -> PathBuf {
let tasks_base = self.base_dir.parent()
.map(|p| p.join("tasks"))
.unwrap_or_else(|| self.base_dir.join("tasks"));
tasks_base.join(name)
}
fn read_config_sync(config_path: &std::path::Path, name: &str) -> Result<TeamConfig> {
if !config_path.exists() {
return Err(Error::TeamNotFound {
name: name.to_string(),
});
}
let data = std::fs::read_to_string(config_path)?;
let config: TeamConfig = serde_json::from_str(&data)?;
Ok(config)
}
}
impl Default for FileTeamManager {
fn default() -> Self {
let base = dirs::home_dir()
.expect("could not determine home directory")
.join(".claude")
.join("teams");
Self::new(base)
}
}
#[async_trait]
impl TeamManager for FileTeamManager {
async fn create_team(&self, name: &str, description: Option<&str>) -> Result<TeamConfig> {
validate_name(name)?;
let base_dir = self.base_dir.clone();
let team_dir = self.team_dir(name);
let config_path = self.config_path(name);
let tasks_dir = self.tasks_dir(name);
let name = name.to_string();
let description = description.map(String::from);
tokio::task::spawn_blocking(move || {
std::fs::create_dir_all(&base_dir)?;
match std::fs::create_dir(&team_dir) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(Error::TeamAlreadyExists {
name: name.clone(),
});
}
Err(e) => return Err(Error::Io(e)),
}
std::fs::create_dir_all(team_dir.join("inboxes"))?;
std::fs::create_dir_all(&tasks_dir)?;
debug!(path = %tasks_dir.display(), "created tasks directory");
let config = TeamConfig {
team_name: name.clone(),
description,
created_at: None,
lead_agent_id: None,
lead_session_id: None,
members: Vec::new(),
};
atomic_write_json(&config_path, &config)?;
info!(team = %name, "created team");
Ok(config)
})
.await
.map_err(|e| Error::JoinError(format!("{e}")))?
}
async fn delete_team(&self, name: &str) -> Result<()> {
validate_name(name)?;
let team_dir = self.team_dir(name);
let lock_path = self.lock_path(name);
let config_path = self.config_path(name);
let tasks_dir = self.tasks_dir(name);
let name = name.to_string();
tokio::task::spawn_blocking(move || {
if !team_dir.exists() {
return Err(Error::TeamNotFound {
name: name.clone(),
});
}
let _lock = FileLock::acquire(&lock_path)?;
let config = Self::read_config_sync(&config_path, &name)?;
let has_teammates = config.members.iter().any(|m| m.is_teammate());
if has_teammates {
return Err(Error::TeamHasActiveMembers {
name: name.clone(),
});
}
std::fs::remove_dir_all(&team_dir)?;
info!(team = %name, "deleted team directory");
if tasks_dir.exists() {
std::fs::remove_dir_all(&tasks_dir)?;
debug!(team = %name, "deleted tasks directory");
}
Ok(())
})
.await
.map_err(|e| Error::JoinError(format!("{e}")))?
}
async fn read_config(&self, name: &str) -> Result<TeamConfig> {
validate_name(name)?;
let config_path = self.config_path(name);
let name = name.to_string();
tokio::task::spawn_blocking(move || Self::read_config_sync(&config_path, &name))
.await
.map_err(|e| Error::JoinError(format!("{e}")))?
}
async fn add_member(&self, team: &str, member: MemberUnion) -> Result<()> {
validate_name(team)?;
let team_dir = self.team_dir(team);
let lock_path = self.lock_path(team);
let config_path = self.config_path(team);
let team = team.to_string();
let member_name = member.name().to_string();
tokio::task::spawn_blocking(move || {
if !team_dir.exists() {
return Err(Error::TeamNotFound {
name: team.clone(),
});
}
let _lock = FileLock::acquire(&lock_path)?;
debug!(team = %team, "acquired config lock");
let mut config = Self::read_config_sync(&config_path, &team)?;
if config.members.iter().any(|m| m.name() == member_name) {
return Err(Error::MemberAlreadyExists {
team: team.clone(),
member: member_name.clone(),
});
}
config.members.push(member);
info!(team = %team, member = %member_name, "added member");
atomic_write_json(&config_path, &config)?;
Ok(())
})
.await
.map_err(|e| Error::JoinError(format!("{e}")))?
}
async fn remove_member(&self, team: &str, member_name: &str) -> Result<()> {
validate_name(team)?;
let team_dir = self.team_dir(team);
let lock_path = self.lock_path(team);
let config_path = self.config_path(team);
let team = team.to_string();
let member_name = member_name.to_string();
tokio::task::spawn_blocking(move || {
if !team_dir.exists() {
return Err(Error::TeamNotFound {
name: team.clone(),
});
}
let _lock = FileLock::acquire(&lock_path)?;
debug!(team = %team, "acquired config lock");
let mut config = Self::read_config_sync(&config_path, &team)?;
let idx = config
.members
.iter()
.position(|m| m.name() == member_name)
.ok_or_else(|| Error::MemberNotFound {
team: team.clone(),
member: member_name.clone(),
})?;
config.members.remove(idx);
info!(team = %team, member = %member_name, "removed member");
atomic_write_json(&config_path, &config)?;
Ok(())
})
.await
.map_err(|e| Error::JoinError(format!("{e}")))?
}
async fn list_teams(&self) -> Result<Vec<String>> {
let base_dir = self.base_dir.clone();
tokio::task::spawn_blocking(move || {
if !base_dir.exists() {
return Ok(Vec::new());
}
let mut teams = Vec::new();
for entry in std::fs::read_dir(&base_dir)? {
let entry = entry?;
if entry.file_type()?.is_dir() {
let config_path = entry.path().join("config.json");
if config_path.exists()
&& let Some(name) = entry.file_name().to_str()
{
teams.push(name.to_string());
}
}
}
teams.sort();
Ok(teams)
})
.await
.map_err(|e| Error::JoinError(format!("{e}")))?
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::team::{LeadMember, TeammateMember};
use std::path::Path;
fn make_manager(dir: &Path) -> FileTeamManager {
FileTeamManager::new(dir)
}
fn lead(name: &str) -> MemberUnion {
MemberUnion::Lead(LeadMember {
name: name.into(),
agent_id: format!("{name}-id"),
agent_type: "team-lead".into(),
model: None,
joined_at: None,
tmux_pane_id: None,
cwd: None,
subscriptions: None,
})
}
fn teammate(name: &str) -> MemberUnion {
MemberUnion::Teammate(TeammateMember {
name: name.into(),
agent_id: format!("{name}-id"),
agent_type: "general-purpose".into(),
prompt: format!("You are {name}."),
model: None,
color: None,
plan_mode_required: None,
joined_at: None,
tmux_pane_id: None,
cwd: None,
subscriptions: None,
backend_type: None,
})
}
#[tokio::test]
async fn create_and_read_team() {
let tmp = tempfile::tempdir().unwrap();
let mgr = make_manager(tmp.path());
let config = mgr.create_team("alpha", Some("first team")).await.unwrap();
assert_eq!(config.team_name, "alpha");
assert_eq!(config.description.as_deref(), Some("first team"));
assert!(config.members.is_empty());
let read = mgr.read_config("alpha").await.unwrap();
assert_eq!(read.team_name, "alpha");
assert!(tmp.path().join("alpha/inboxes").is_dir());
}
#[tokio::test]
async fn create_duplicate_team_fails() {
let tmp = tempfile::tempdir().unwrap();
let mgr = make_manager(tmp.path());
mgr.create_team("dup", None).await.unwrap();
let err = mgr.create_team("dup", None).await.unwrap_err();
assert!(matches!(err, Error::TeamAlreadyExists { .. }));
}
#[tokio::test]
async fn add_and_remove_member() {
let tmp = tempfile::tempdir().unwrap();
let mgr = make_manager(tmp.path());
mgr.create_team("beta", None).await.unwrap();
mgr.add_member("beta", lead("boss")).await.unwrap();
mgr.add_member("beta", teammate("worker")).await.unwrap();
let config = mgr.read_config("beta").await.unwrap();
assert_eq!(config.members.len(), 2);
assert_eq!(config.members[0].name(), "boss");
assert_eq!(config.members[1].name(), "worker");
mgr.remove_member("beta", "worker").await.unwrap();
let config = mgr.read_config("beta").await.unwrap();
assert_eq!(config.members.len(), 1);
}
#[tokio::test]
async fn add_duplicate_member_fails() {
let tmp = tempfile::tempdir().unwrap();
let mgr = make_manager(tmp.path());
mgr.create_team("gamma", None).await.unwrap();
mgr.add_member("gamma", lead("lead")).await.unwrap();
let err = mgr.add_member("gamma", lead("lead")).await.unwrap_err();
assert!(matches!(err, Error::MemberAlreadyExists { .. }));
}
#[tokio::test]
async fn remove_nonexistent_member_fails() {
let tmp = tempfile::tempdir().unwrap();
let mgr = make_manager(tmp.path());
mgr.create_team("delta", None).await.unwrap();
let err = mgr.remove_member("delta", "ghost").await.unwrap_err();
assert!(matches!(err, Error::MemberNotFound { .. }));
}
#[tokio::test]
async fn delete_team_with_no_teammates() {
let tmp = tempfile::tempdir().unwrap();
let mgr = make_manager(tmp.path());
mgr.create_team("ephemeral", None).await.unwrap();
mgr.add_member("ephemeral", lead("lead")).await.unwrap();
mgr.delete_team("ephemeral").await.unwrap();
assert!(!tmp.path().join("ephemeral").exists());
}
#[tokio::test]
async fn delete_team_with_teammates_fails() {
let tmp = tempfile::tempdir().unwrap();
let mgr = make_manager(tmp.path());
mgr.create_team("sticky", None).await.unwrap();
mgr.add_member("sticky", teammate("worker")).await.unwrap();
let err = mgr.delete_team("sticky").await.unwrap_err();
assert!(matches!(err, Error::TeamHasActiveMembers { .. }));
}
#[tokio::test]
async fn delete_nonexistent_team_fails() {
let tmp = tempfile::tempdir().unwrap();
let mgr = make_manager(tmp.path());
let err = mgr.delete_team("nope").await.unwrap_err();
assert!(matches!(err, Error::TeamNotFound { .. }));
}
#[tokio::test]
async fn list_teams_sorted() {
let tmp = tempfile::tempdir().unwrap();
let mgr = make_manager(tmp.path());
mgr.create_team("zulu", None).await.unwrap();
mgr.create_team("alpha", None).await.unwrap();
mgr.create_team("mike", None).await.unwrap();
let teams = mgr.list_teams().await.unwrap();
assert_eq!(teams, vec!["alpha", "mike", "zulu"]);
}
#[tokio::test]
async fn list_teams_empty_base_dir() {
let tmp = tempfile::tempdir().unwrap();
let mgr = FileTeamManager::new(tmp.path().join("nonexistent"));
let teams = mgr.list_teams().await.unwrap();
assert!(teams.is_empty());
}
#[tokio::test]
async fn read_config_nonexistent_team_fails() {
let tmp = tempfile::tempdir().unwrap();
let mgr = make_manager(tmp.path());
let err = mgr.read_config("ghost").await.unwrap_err();
assert!(matches!(err, Error::TeamNotFound { .. }));
}
#[tokio::test]
async fn operations_on_nonexistent_team_fail() {
let tmp = tempfile::tempdir().unwrap();
let mgr = make_manager(tmp.path());
let err = mgr.add_member("nope", lead("a")).await.unwrap_err();
assert!(matches!(err, Error::TeamNotFound { .. }));
let err = mgr.remove_member("nope", "a").await.unwrap_err();
assert!(matches!(err, Error::TeamNotFound { .. }));
}
#[tokio::test]
async fn path_traversal_rejected() {
let tmp = tempfile::tempdir().unwrap();
let mgr = make_manager(tmp.path());
let err = mgr.create_team("../escape", None).await.unwrap_err();
assert!(matches!(err, Error::InvalidName { .. }));
let err = mgr.create_team("..", None).await.unwrap_err();
assert!(matches!(err, Error::InvalidName { .. }));
let err = mgr.create_team(".", None).await.unwrap_err();
assert!(matches!(err, Error::InvalidName { .. }));
let err = mgr.create_team("", None).await.unwrap_err();
assert!(matches!(err, Error::InvalidName { .. }));
let err = mgr.read_config("foo/bar").await.unwrap_err();
assert!(matches!(err, Error::InvalidName { .. }));
let err = mgr.delete_team("a\\b").await.unwrap_err();
assert!(matches!(err, Error::InvalidName { .. }));
}
}