1use std::fs;
28use std::path::{Path, PathBuf};
29
30use anyhow::{Context, Result};
31use serde::{Deserialize, Serialize};
32
33const FILE: &str = "teams.json";
35
36#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
40pub struct TeamEntry {
41 #[serde(default)]
43 pub project_id: String,
44 #[serde(default)]
47 pub root: PathBuf,
48 #[serde(default)]
50 pub tmux_prefix: String,
51 #[serde(default)]
53 pub agents: Vec<String>,
54 #[serde(default)]
57 pub started_at: String,
58}
59
60#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct Registry {
64 #[serde(default)]
65 pub teams: Vec<TeamEntry>,
66}
67
68pub fn config_dir() -> Option<PathBuf> {
75 std::env::var_os("HOME")
76 .or_else(|| std::env::var_os("USERPROFILE"))
77 .map(|home| PathBuf::from(home).join(".config/teamctl"))
78}
79
80pub fn now_rfc3339() -> String {
84 chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
85}
86
87pub fn load(dir: &Path) -> Result<Registry> {
94 let path = dir.join(FILE);
95 if !path.exists() {
96 return Ok(Registry::default());
97 }
98 let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
99 Ok(serde_json::from_str(&raw).unwrap_or_default())
100}
101
102pub fn upsert(dir: &Path, entry: TeamEntry) -> Result<()> {
104 upsert_many(dir, vec![entry])
105}
106
107pub fn upsert_many(dir: &Path, entries: Vec<TeamEntry>) -> Result<()> {
113 if entries.is_empty() {
114 return Ok(());
115 }
116 let mut reg = load(dir)?;
117 for mut entry in entries {
118 if let Some(existing) = reg
119 .teams
120 .iter_mut()
121 .find(|t| t.project_id == entry.project_id && t.root == entry.root)
122 {
123 if !existing.started_at.is_empty() {
124 entry.started_at = existing.started_at.clone();
125 }
126 *existing = entry;
127 } else {
128 reg.teams.push(entry);
129 }
130 }
131 reg.teams.sort_by(|a, b| {
132 a.root
133 .cmp(&b.root)
134 .then_with(|| a.project_id.cmp(&b.project_id))
135 });
136 save(dir, ®)
137}
138
139pub fn clear(dir: &Path, root: &Path, project: Option<&str>) -> Result<()> {
144 let mut reg = load(dir)?;
145 let before = reg.teams.len();
146 reg.teams
147 .retain(|t| t.root != root || project.is_some_and(|p| t.project_id != p));
148 if reg.teams.len() != before {
149 save(dir, ®)?;
150 }
151 Ok(())
152}
153
154pub fn is_orphan(entry: &TeamEntry, path_exists: &impl Fn(&Path) -> bool) -> bool {
164 !(path_exists(&entry.root.join("team-compose.yaml"))
165 || path_exists(&entry.root.join(".team").join("team-compose.yaml")))
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
172pub struct RosterEntry {
173 pub project_id: String,
174 pub agent: String,
175 pub tmux_session: String,
176}
177
178impl RosterEntry {
179 pub fn id(&self) -> String {
181 format!("{}:{}", self.project_id, self.agent)
182 }
183}
184
185impl Registry {
186 pub fn roster_for_root(&self, root: &Path) -> Vec<RosterEntry> {
193 self.teams
194 .iter()
195 .filter(|t| t.root == root)
196 .flat_map(|t| {
197 t.agents.iter().map(move |agent| RosterEntry {
198 project_id: t.project_id.clone(),
199 agent: agent.clone(),
200 tmux_session: format!("{}{}-{}", t.tmux_prefix, t.project_id, agent),
201 })
202 })
203 .collect()
204 }
205}
206
207pub fn reap_targets(
215 roster: &[RosterEntry],
216 desired: &std::collections::HashSet<String>,
217 scoped: Option<&str>,
218 per_agent: bool,
219) -> Vec<RosterEntry> {
220 if per_agent {
221 return Vec::new();
222 }
223 roster
224 .iter()
225 .filter(|e| scoped.is_none_or(|p| e.project_id == p))
226 .filter(|e| !desired.contains(&e.id()))
227 .cloned()
228 .collect()
229}
230
231pub fn orphans_for_root(
238 dir: &Path,
239 root: &Path,
240 desired: &std::collections::HashSet<String>,
241 scoped: Option<&str>,
242 per_agent: bool,
243) -> Result<Vec<RosterEntry>> {
244 let reg = load(dir)?;
245 Ok(reap_targets(
246 ®.roster_for_root(root),
247 desired,
248 scoped,
249 per_agent,
250 ))
251}
252
253pub fn same_name_other_root(
262 reg: &Registry,
263 project_id: &str,
264 root: &Path,
265 path_exists: &impl Fn(&Path) -> bool,
266) -> Option<PathBuf> {
267 reg.teams
268 .iter()
269 .find(|t| t.project_id == project_id && t.root != root && !is_orphan(t, path_exists))
270 .map(|t| t.root.clone())
271}
272
273fn save(dir: &Path, reg: &Registry) -> Result<()> {
279 fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
280 let path = dir.join(FILE);
281 let tmp = dir.join(format!("{FILE}.{}.tmp", std::process::id()));
282 let body = serde_json::to_string_pretty(reg)?;
283 fs::write(&tmp, body).with_context(|| format!("write {}", tmp.display()))?;
284 fs::rename(&tmp, &path).with_context(|| format!("rename into {}", path.display()))?;
285 Ok(())
286}
287
288#[cfg(test)]
289mod tests {
290 use super::*;
291 use std::collections::HashSet;
292
293 fn entry(project: &str, root: &str, agents: &[&str], started: &str) -> TeamEntry {
294 TeamEntry {
295 project_id: project.into(),
296 root: PathBuf::from(root),
297 tmux_prefix: "t-".into(),
298 agents: agents.iter().map(|s| (*s).to_string()).collect(),
299 started_at: started.into(),
300 }
301 }
302
303 #[test]
304 fn load_missing_file_is_empty() {
305 let dir = tempfile::tempdir().unwrap();
306 let reg = load(dir.path()).unwrap();
307 assert!(reg.teams.is_empty());
308 }
309
310 #[test]
311 fn upsert_then_load_roundtrips() {
312 let dir = tempfile::tempdir().unwrap();
313 upsert(
314 dir.path(),
315 entry(
316 "main",
317 "/r/a/.team",
318 &["compass", "scout"],
319 "2026-06-13T00:00:00Z",
320 ),
321 )
322 .unwrap();
323 let reg = load(dir.path()).unwrap();
324 assert_eq!(reg.teams.len(), 1);
325 let t = ®.teams[0];
326 assert_eq!(t.project_id, "main");
327 assert_eq!(t.root, PathBuf::from("/r/a/.team"));
328 assert_eq!(t.tmux_prefix, "t-");
329 assert_eq!(t.agents, vec!["compass", "scout"]);
330 assert_eq!(t.started_at, "2026-06-13T00:00:00Z");
331 }
332
333 #[test]
334 fn upsert_same_key_replaces_and_preserves_started_at() {
335 let dir = tempfile::tempdir().unwrap();
336 upsert(dir.path(), entry("main", "/r/a/.team", &["compass"], "T0")).unwrap();
337 upsert(
339 dir.path(),
340 entry("main", "/r/a/.team", &["compass", "scribe"], "T1"),
341 )
342 .unwrap();
343 let reg = load(dir.path()).unwrap();
344 assert_eq!(reg.teams.len(), 1, "same (project,root) is one row");
345 assert_eq!(
346 reg.teams[0].agents,
347 vec!["compass", "scribe"],
348 "roster refreshed"
349 );
350 assert_eq!(
351 reg.teams[0].started_at, "T0",
352 "uptime preserved across re-up"
353 );
354 }
355
356 #[test]
357 fn distinct_keys_coexist_and_sort() {
358 let dir = tempfile::tempdir().unwrap();
359 upsert_many(
362 dir.path(),
363 vec![
364 entry("main", "/r/b/.team", &["x"], "T0"),
365 entry("main", "/r/a/.team", &["x"], "T0"),
366 entry("ops", "/r/a/.team", &["y"], "T0"),
367 ],
368 )
369 .unwrap();
370 let reg = load(dir.path()).unwrap();
371 assert_eq!(reg.teams.len(), 3);
372 assert_eq!(
374 reg.teams
375 .iter()
376 .map(|t| (t.root.to_string_lossy().into_owned(), t.project_id.clone()))
377 .collect::<Vec<_>>(),
378 vec![
379 ("/r/a/.team".into(), "main".into()),
380 ("/r/a/.team".into(), "ops".into()),
381 ("/r/b/.team".into(), "main".into()),
382 ]
383 );
384 }
385
386 #[test]
387 fn clear_whole_root_drops_all_its_entries() {
388 let dir = tempfile::tempdir().unwrap();
389 upsert_many(
390 dir.path(),
391 vec![
392 entry("main", "/r/a/.team", &["x"], "T0"),
393 entry("ops", "/r/a/.team", &["y"], "T0"),
394 entry("main", "/r/b/.team", &["z"], "T0"),
395 ],
396 )
397 .unwrap();
398 clear(dir.path(), Path::new("/r/a/.team"), None).unwrap();
399 let reg = load(dir.path()).unwrap();
400 assert_eq!(reg.teams.len(), 1);
401 assert_eq!(reg.teams[0].root, PathBuf::from("/r/b/.team"));
402 }
403
404 #[test]
405 fn clear_scoped_project_keeps_sibling_at_same_root() {
406 let dir = tempfile::tempdir().unwrap();
407 upsert_many(
408 dir.path(),
409 vec![
410 entry("main", "/r/a/.team", &["x"], "T0"),
411 entry("ops", "/r/a/.team", &["y"], "T0"),
412 ],
413 )
414 .unwrap();
415 clear(dir.path(), Path::new("/r/a/.team"), Some("main")).unwrap();
416 let reg = load(dir.path()).unwrap();
417 assert_eq!(reg.teams.len(), 1, "only the scoped project is dropped");
418 assert_eq!(reg.teams[0].project_id, "ops");
419 }
420
421 #[test]
422 fn save_is_atomic_and_leaves_no_temp() {
423 let dir = tempfile::tempdir().unwrap();
424 upsert(dir.path(), entry("main", "/r/a/.team", &["x"], "T0")).unwrap();
425 let leftovers: Vec<_> = fs::read_dir(dir.path())
426 .unwrap()
427 .filter_map(|e| e.ok())
428 .map(|e| e.file_name().to_string_lossy().into_owned())
429 .collect();
430 assert!(leftovers.contains(&"teams.json".to_string()));
431 assert!(
432 !leftovers.iter().any(|n| n.ends_with(".tmp")),
433 "no temp file should survive a successful rename: {leftovers:?}"
434 );
435 }
436
437 #[test]
438 fn load_corrupt_file_degrades_to_empty() {
439 let dir = tempfile::tempdir().unwrap();
440 fs::create_dir_all(dir.path()).unwrap();
441 fs::write(dir.path().join(FILE), b"{ this is not json").unwrap();
442 let reg = load(dir.path()).unwrap();
443 assert!(
444 reg.teams.is_empty(),
445 "corrupt store reads as empty, not an error"
446 );
447 }
448
449 #[test]
450 fn is_orphan_tracks_presence_of_compose() {
451 let live = entry("main", "/r/live/.team", &["x"], "T0");
452 let gone = entry("main", "/r/gone/.team", &["x"], "T0");
453 let extant: HashSet<PathBuf> = [PathBuf::from("/r/live/.team/team-compose.yaml")].into();
455 let exists = |p: &Path| extant.contains(p);
456 assert!(!is_orphan(&live, &exists), "live root keeps its compose");
457 assert!(is_orphan(&gone, &exists), "missing compose ⇒ orphan");
458 }
459
460 #[test]
461 fn is_orphan_falls_back_to_bare_root_layout_for_sessions_parity() {
462 let e = entry("main", "/r/proj", &["x"], "T0");
467 let extant: HashSet<PathBuf> = [PathBuf::from("/r/proj/.team/team-compose.yaml")].into();
468 let exists = |p: &Path| extant.contains(p);
469 assert!(!is_orphan(&e, &exists));
470 }
471
472 fn rentry(project: &str, agent: &str, session: &str) -> RosterEntry {
473 RosterEntry {
474 project_id: project.into(),
475 agent: agent.into(),
476 tmux_session: session.into(),
477 }
478 }
479
480 #[test]
481 fn roster_for_root_rebuilds_sessions_from_recorded_prefix() {
482 let mut reg = Registry::default();
485 reg.teams
486 .push(entry("main", "/r/a/.team", &["compass", "scout"], "T0"));
487 reg.teams.push(entry("ops", "/r/b/.team", &["otto"], "T0"));
488 assert_eq!(
489 reg.roster_for_root(Path::new("/r/a/.team")),
490 vec![
491 rentry("main", "compass", "t-main-compass"),
492 rentry("main", "scout", "t-main-scout"),
493 ]
494 );
495 }
496
497 #[test]
498 fn reap_targets_drops_removed_keeps_current() {
499 let roster = vec![
502 rentry("main", "compass", "t-main-compass"),
503 rentry("main", "scout", "t-main-scout"),
504 ];
505 let desired: HashSet<String> = ["main:compass".to_string()].into_iter().collect();
506 assert_eq!(
507 reap_targets(&roster, &desired, None, false),
508 vec![rentry("main", "scout", "t-main-scout")]
509 );
510 }
511
512 #[test]
513 fn reap_targets_skips_entirely_on_per_agent_teardown() {
514 let roster = vec![rentry("main", "scout", "t-main-scout")];
517 assert!(reap_targets(&roster, &HashSet::new(), None, true).is_empty());
518 }
519
520 #[test]
521 fn reap_targets_honors_project_scope() {
522 let roster = vec![
525 rentry("main", "scout", "t-main-scout"),
526 rentry("ops", "otto", "t-ops-otto"),
527 ];
528 assert_eq!(
529 reap_targets(&roster, &HashSet::new(), Some("main"), false),
530 vec![rentry("main", "scout", "t-main-scout")]
531 );
532 }
533
534 #[test]
535 fn orphans_for_root_loads_and_diffs_against_desired() {
536 let dir = tempfile::tempdir().unwrap();
541 upsert(
542 dir.path(),
543 entry("main", "/r/a/.team", &["compass", "scout"], "T0"),
544 )
545 .unwrap();
546 let desired: HashSet<String> = ["main:compass".to_string()].into_iter().collect();
547 assert_eq!(
548 orphans_for_root(dir.path(), Path::new("/r/a/.team"), &desired, None, false).unwrap(),
549 vec![rentry("main", "scout", "t-main-scout")]
550 );
551
552 let empty = tempfile::tempdir().unwrap();
555 assert!(
556 orphans_for_root(empty.path(), Path::new("/r/a/.team"), &desired, None, false)
557 .unwrap()
558 .is_empty()
559 );
560 }
561
562 #[test]
563 fn same_name_other_root_flags_only_a_live_different_folder() {
564 let mut reg = Registry::default();
566 reg.teams.push(entry("main", "/r/a/.team", &["x"], "T0"));
567 let a_live = |p: &Path| p == Path::new("/r/a/.team/team-compose.yaml");
568
569 assert_eq!(
572 same_name_other_root(®, "main", Path::new("/r/b/.team"), &a_live),
573 Some(PathBuf::from("/r/a/.team"))
574 );
575 assert_eq!(
577 same_name_other_root(®, "main", Path::new("/r/a/.team"), &a_live),
578 None
579 );
580 assert_eq!(
582 same_name_other_root(®, "ops", Path::new("/r/b/.team"), &a_live),
583 None
584 );
585 let none_live = |_: &Path| false;
587 assert_eq!(
588 same_name_other_root(®, "main", Path::new("/r/b/.team"), &none_live),
589 None
590 );
591 }
592}