1use std::path::PathBuf;
8
9use async_trait::async_trait;
10use tracing::{debug, info};
11
12use crate::error::{Error, Result};
13use crate::models::team::{MemberUnion, TeamConfig};
14use crate::util::atomic_write::atomic_write_json;
15use crate::util::file_lock::FileLock;
16use crate::util::validate_name;
17
18#[async_trait]
27pub trait TeamManager: Send + Sync {
28 async fn create_team(&self, name: &str, description: Option<&str>) -> Result<TeamConfig>;
32
33 async fn delete_team(&self, name: &str) -> Result<()>;
38
39 async fn read_config(&self, name: &str) -> Result<TeamConfig>;
41
42 async fn add_member(&self, team: &str, member: MemberUnion) -> Result<()>;
46
47 async fn remove_member(&self, team: &str, member_name: &str) -> Result<()>;
51
52 async fn list_teams(&self) -> Result<Vec<String>>;
54}
55
56pub struct FileTeamManager {
73 base_dir: PathBuf,
74}
75
76impl FileTeamManager {
77 pub fn new(base_dir: impl Into<PathBuf>) -> Self {
79 Self {
80 base_dir: base_dir.into(),
81 }
82 }
83
84 fn team_dir(&self, name: &str) -> PathBuf {
86 self.base_dir.join(name)
87 }
88
89 fn config_path(&self, name: &str) -> PathBuf {
91 self.team_dir(name).join("config.json")
92 }
93
94 fn lock_path(&self, name: &str) -> PathBuf {
96 self.team_dir(name).join("config.json.lock")
97 }
98
99 fn tasks_dir(&self, name: &str) -> PathBuf {
104 let tasks_base = self.base_dir.parent()
106 .map(|p| p.join("tasks"))
107 .unwrap_or_else(|| self.base_dir.join("tasks"));
108 tasks_base.join(name)
109 }
110
111 fn read_config_sync(config_path: &std::path::Path, name: &str) -> Result<TeamConfig> {
113 if !config_path.exists() {
114 return Err(Error::TeamNotFound {
115 name: name.to_string(),
116 });
117 }
118 let data = std::fs::read_to_string(config_path)?;
119 let config: TeamConfig = serde_json::from_str(&data)?;
120 Ok(config)
121 }
122}
123
124impl Default for FileTeamManager {
125 fn default() -> Self {
131 let base = dirs::home_dir()
132 .expect("could not determine home directory")
133 .join(".claude")
134 .join("teams");
135 Self::new(base)
136 }
137}
138
139#[async_trait]
140impl TeamManager for FileTeamManager {
141 async fn create_team(&self, name: &str, description: Option<&str>) -> Result<TeamConfig> {
142 validate_name(name)?;
143
144 let base_dir = self.base_dir.clone();
145 let team_dir = self.team_dir(name);
146 let config_path = self.config_path(name);
147 let tasks_dir = self.tasks_dir(name);
148 let name = name.to_string();
149 let description = description.map(String::from);
150
151 tokio::task::spawn_blocking(move || {
152 std::fs::create_dir_all(&base_dir)?;
154
155 match std::fs::create_dir(&team_dir) {
158 Ok(()) => {}
159 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
160 return Err(Error::TeamAlreadyExists {
161 name: name.clone(),
162 });
163 }
164 Err(e) => return Err(Error::Io(e)),
165 }
166
167 std::fs::create_dir_all(team_dir.join("inboxes"))?;
169
170 std::fs::create_dir_all(&tasks_dir)?;
172 debug!(path = %tasks_dir.display(), "created tasks directory");
173
174 let config = TeamConfig {
175 team_name: name.clone(),
176 description,
177 created_at: None,
178 lead_agent_id: None,
179 lead_session_id: None,
180 members: Vec::new(),
181 };
182
183 atomic_write_json(&config_path, &config)?;
184 info!(team = %name, "created team");
185
186 Ok(config)
187 })
188 .await
189 .map_err(|e| Error::JoinError(format!("{e}")))?
190 }
191
192 async fn delete_team(&self, name: &str) -> Result<()> {
193 validate_name(name)?;
194
195 let team_dir = self.team_dir(name);
196 let lock_path = self.lock_path(name);
197 let config_path = self.config_path(name);
198 let tasks_dir = self.tasks_dir(name);
199 let name = name.to_string();
200
201 tokio::task::spawn_blocking(move || {
202 if !team_dir.exists() {
203 return Err(Error::TeamNotFound {
204 name: name.clone(),
205 });
206 }
207
208 let _lock = FileLock::acquire(&lock_path)?;
211
212 let config = Self::read_config_sync(&config_path, &name)?;
213 let has_teammates = config.members.iter().any(|m| m.is_teammate());
214 if has_teammates {
215 return Err(Error::TeamHasActiveMembers {
216 name: name.clone(),
217 });
218 }
219
220 std::fs::remove_dir_all(&team_dir)?;
222 info!(team = %name, "deleted team directory");
223
224 if tasks_dir.exists() {
226 std::fs::remove_dir_all(&tasks_dir)?;
227 debug!(team = %name, "deleted tasks directory");
228 }
229
230 Ok(())
231 })
232 .await
233 .map_err(|e| Error::JoinError(format!("{e}")))?
234 }
235
236 async fn read_config(&self, name: &str) -> Result<TeamConfig> {
237 validate_name(name)?;
238
239 let config_path = self.config_path(name);
240 let name = name.to_string();
241
242 tokio::task::spawn_blocking(move || Self::read_config_sync(&config_path, &name))
243 .await
244 .map_err(|e| Error::JoinError(format!("{e}")))?
245 }
246
247 async fn add_member(&self, team: &str, member: MemberUnion) -> Result<()> {
248 validate_name(team)?;
249
250 let team_dir = self.team_dir(team);
251 let lock_path = self.lock_path(team);
252 let config_path = self.config_path(team);
253 let team = team.to_string();
254 let member_name = member.name().to_string();
255
256 tokio::task::spawn_blocking(move || {
257 if !team_dir.exists() {
258 return Err(Error::TeamNotFound {
259 name: team.clone(),
260 });
261 }
262
263 let _lock = FileLock::acquire(&lock_path)?;
264 debug!(team = %team, "acquired config lock");
265
266 let mut config = Self::read_config_sync(&config_path, &team)?;
267
268 if config.members.iter().any(|m| m.name() == member_name) {
269 return Err(Error::MemberAlreadyExists {
270 team: team.clone(),
271 member: member_name.clone(),
272 });
273 }
274 config.members.push(member);
275 info!(team = %team, member = %member_name, "added member");
276
277 atomic_write_json(&config_path, &config)?;
278 Ok(())
279 })
280 .await
281 .map_err(|e| Error::JoinError(format!("{e}")))?
282 }
283
284 async fn remove_member(&self, team: &str, member_name: &str) -> Result<()> {
285 validate_name(team)?;
286
287 let team_dir = self.team_dir(team);
288 let lock_path = self.lock_path(team);
289 let config_path = self.config_path(team);
290 let team = team.to_string();
291 let member_name = member_name.to_string();
292
293 tokio::task::spawn_blocking(move || {
294 if !team_dir.exists() {
295 return Err(Error::TeamNotFound {
296 name: team.clone(),
297 });
298 }
299
300 let _lock = FileLock::acquire(&lock_path)?;
301 debug!(team = %team, "acquired config lock");
302
303 let mut config = Self::read_config_sync(&config_path, &team)?;
304
305 let idx = config
306 .members
307 .iter()
308 .position(|m| m.name() == member_name)
309 .ok_or_else(|| Error::MemberNotFound {
310 team: team.clone(),
311 member: member_name.clone(),
312 })?;
313 config.members.remove(idx);
314 info!(team = %team, member = %member_name, "removed member");
315
316 atomic_write_json(&config_path, &config)?;
317 Ok(())
318 })
319 .await
320 .map_err(|e| Error::JoinError(format!("{e}")))?
321 }
322
323 async fn list_teams(&self) -> Result<Vec<String>> {
324 let base_dir = self.base_dir.clone();
325
326 tokio::task::spawn_blocking(move || {
327 if !base_dir.exists() {
328 return Ok(Vec::new());
329 }
330
331 let mut teams = Vec::new();
332 for entry in std::fs::read_dir(&base_dir)? {
333 let entry = entry?;
334 if entry.file_type()?.is_dir() {
335 let config_path = entry.path().join("config.json");
337 if config_path.exists()
338 && let Some(name) = entry.file_name().to_str()
339 {
340 teams.push(name.to_string());
341 }
342 }
343 }
344 teams.sort();
345 Ok(teams)
346 })
347 .await
348 .map_err(|e| Error::JoinError(format!("{e}")))?
349 }
350}
351
352#[cfg(test)]
357mod tests {
358 use super::*;
359 use crate::models::team::{LeadMember, TeammateMember};
360 use std::path::Path;
361
362 fn make_manager(dir: &Path) -> FileTeamManager {
363 FileTeamManager::new(dir)
364 }
365
366 fn lead(name: &str) -> MemberUnion {
367 MemberUnion::Lead(LeadMember {
368 name: name.into(),
369 agent_id: format!("{name}-id"),
370 agent_type: "team-lead".into(),
371 model: None,
372 joined_at: None,
373 tmux_pane_id: None,
374 cwd: None,
375 subscriptions: None,
376 })
377 }
378
379 fn teammate(name: &str) -> MemberUnion {
380 MemberUnion::Teammate(TeammateMember {
381 name: name.into(),
382 agent_id: format!("{name}-id"),
383 agent_type: "general-purpose".into(),
384 prompt: format!("You are {name}."),
385 model: None,
386 color: None,
387 plan_mode_required: None,
388 joined_at: None,
389 tmux_pane_id: None,
390 cwd: None,
391 subscriptions: None,
392 backend_type: None,
393 })
394 }
395
396 #[tokio::test]
397 async fn create_and_read_team() {
398 let tmp = tempfile::tempdir().unwrap();
399 let mgr = make_manager(tmp.path());
400
401 let config = mgr.create_team("alpha", Some("first team")).await.unwrap();
402 assert_eq!(config.team_name, "alpha");
403 assert_eq!(config.description.as_deref(), Some("first team"));
404 assert!(config.members.is_empty());
405
406 let read = mgr.read_config("alpha").await.unwrap();
408 assert_eq!(read.team_name, "alpha");
409
410 assert!(tmp.path().join("alpha/inboxes").is_dir());
412 }
413
414 #[tokio::test]
415 async fn create_duplicate_team_fails() {
416 let tmp = tempfile::tempdir().unwrap();
417 let mgr = make_manager(tmp.path());
418
419 mgr.create_team("dup", None).await.unwrap();
420 let err = mgr.create_team("dup", None).await.unwrap_err();
421 assert!(matches!(err, Error::TeamAlreadyExists { .. }));
422 }
423
424 #[tokio::test]
425 async fn add_and_remove_member() {
426 let tmp = tempfile::tempdir().unwrap();
427 let mgr = make_manager(tmp.path());
428
429 mgr.create_team("beta", None).await.unwrap();
430
431 mgr.add_member("beta", lead("boss")).await.unwrap();
432 mgr.add_member("beta", teammate("worker")).await.unwrap();
433
434 let config = mgr.read_config("beta").await.unwrap();
435 assert_eq!(config.members.len(), 2);
436 assert_eq!(config.members[0].name(), "boss");
437 assert_eq!(config.members[1].name(), "worker");
438
439 mgr.remove_member("beta", "worker").await.unwrap();
441 let config = mgr.read_config("beta").await.unwrap();
442 assert_eq!(config.members.len(), 1);
443 }
444
445 #[tokio::test]
446 async fn add_duplicate_member_fails() {
447 let tmp = tempfile::tempdir().unwrap();
448 let mgr = make_manager(tmp.path());
449
450 mgr.create_team("gamma", None).await.unwrap();
451 mgr.add_member("gamma", lead("lead")).await.unwrap();
452
453 let err = mgr.add_member("gamma", lead("lead")).await.unwrap_err();
454 assert!(matches!(err, Error::MemberAlreadyExists { .. }));
455 }
456
457 #[tokio::test]
458 async fn remove_nonexistent_member_fails() {
459 let tmp = tempfile::tempdir().unwrap();
460 let mgr = make_manager(tmp.path());
461
462 mgr.create_team("delta", None).await.unwrap();
463
464 let err = mgr.remove_member("delta", "ghost").await.unwrap_err();
465 assert!(matches!(err, Error::MemberNotFound { .. }));
466 }
467
468 #[tokio::test]
469 async fn delete_team_with_no_teammates() {
470 let tmp = tempfile::tempdir().unwrap();
471 let mgr = make_manager(tmp.path());
472
473 mgr.create_team("ephemeral", None).await.unwrap();
474 mgr.add_member("ephemeral", lead("lead")).await.unwrap();
475
476 mgr.delete_team("ephemeral").await.unwrap();
477 assert!(!tmp.path().join("ephemeral").exists());
478 }
479
480 #[tokio::test]
481 async fn delete_team_with_teammates_fails() {
482 let tmp = tempfile::tempdir().unwrap();
483 let mgr = make_manager(tmp.path());
484
485 mgr.create_team("sticky", None).await.unwrap();
486 mgr.add_member("sticky", teammate("worker")).await.unwrap();
487
488 let err = mgr.delete_team("sticky").await.unwrap_err();
489 assert!(matches!(err, Error::TeamHasActiveMembers { .. }));
490 }
491
492 #[tokio::test]
493 async fn delete_nonexistent_team_fails() {
494 let tmp = tempfile::tempdir().unwrap();
495 let mgr = make_manager(tmp.path());
496
497 let err = mgr.delete_team("nope").await.unwrap_err();
498 assert!(matches!(err, Error::TeamNotFound { .. }));
499 }
500
501 #[tokio::test]
502 async fn list_teams_sorted() {
503 let tmp = tempfile::tempdir().unwrap();
504 let mgr = make_manager(tmp.path());
505
506 mgr.create_team("zulu", None).await.unwrap();
507 mgr.create_team("alpha", None).await.unwrap();
508 mgr.create_team("mike", None).await.unwrap();
509
510 let teams = mgr.list_teams().await.unwrap();
511 assert_eq!(teams, vec!["alpha", "mike", "zulu"]);
512 }
513
514 #[tokio::test]
515 async fn list_teams_empty_base_dir() {
516 let tmp = tempfile::tempdir().unwrap();
517 let mgr = FileTeamManager::new(tmp.path().join("nonexistent"));
519 let teams = mgr.list_teams().await.unwrap();
520 assert!(teams.is_empty());
521 }
522
523 #[tokio::test]
524 async fn read_config_nonexistent_team_fails() {
525 let tmp = tempfile::tempdir().unwrap();
526 let mgr = make_manager(tmp.path());
527
528 let err = mgr.read_config("ghost").await.unwrap_err();
529 assert!(matches!(err, Error::TeamNotFound { .. }));
530 }
531
532 #[tokio::test]
533 async fn operations_on_nonexistent_team_fail() {
534 let tmp = tempfile::tempdir().unwrap();
535 let mgr = make_manager(tmp.path());
536
537 let err = mgr.add_member("nope", lead("a")).await.unwrap_err();
538 assert!(matches!(err, Error::TeamNotFound { .. }));
539
540 let err = mgr.remove_member("nope", "a").await.unwrap_err();
541 assert!(matches!(err, Error::TeamNotFound { .. }));
542 }
543
544 #[tokio::test]
545 async fn path_traversal_rejected() {
546 let tmp = tempfile::tempdir().unwrap();
547 let mgr = make_manager(tmp.path());
548
549 let err = mgr.create_team("../escape", None).await.unwrap_err();
550 assert!(matches!(err, Error::InvalidName { .. }));
551
552 let err = mgr.create_team("..", None).await.unwrap_err();
553 assert!(matches!(err, Error::InvalidName { .. }));
554
555 let err = mgr.create_team(".", None).await.unwrap_err();
556 assert!(matches!(err, Error::InvalidName { .. }));
557
558 let err = mgr.create_team("", None).await.unwrap_err();
559 assert!(matches!(err, Error::InvalidName { .. }));
560
561 let err = mgr.read_config("foo/bar").await.unwrap_err();
562 assert!(matches!(err, Error::InvalidName { .. }));
563
564 let err = mgr.delete_team("a\\b").await.unwrap_err();
565 assert!(matches!(err, Error::InvalidName { .. }));
566 }
567}