use std::fs;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
const FILE: &str = "teams.json";
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TeamEntry {
#[serde(default)]
pub project_id: String,
#[serde(default)]
pub root: PathBuf,
#[serde(default)]
pub tmux_prefix: String,
#[serde(default)]
pub agents: Vec<String>,
#[serde(default)]
pub started_at: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Registry {
#[serde(default)]
pub teams: Vec<TeamEntry>,
}
pub fn config_dir() -> Option<PathBuf> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(|home| PathBuf::from(home).join(".config/teamctl"))
}
pub fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
pub fn load(dir: &Path) -> Result<Registry> {
let path = dir.join(FILE);
if !path.exists() {
return Ok(Registry::default());
}
let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
Ok(serde_json::from_str(&raw).unwrap_or_default())
}
pub fn upsert(dir: &Path, entry: TeamEntry) -> Result<()> {
upsert_many(dir, vec![entry])
}
pub fn upsert_many(dir: &Path, entries: Vec<TeamEntry>) -> Result<()> {
if entries.is_empty() {
return Ok(());
}
let mut reg = load(dir)?;
for mut entry in entries {
if let Some(existing) = reg
.teams
.iter_mut()
.find(|t| t.project_id == entry.project_id && t.root == entry.root)
{
if !existing.started_at.is_empty() {
entry.started_at = existing.started_at.clone();
}
*existing = entry;
} else {
reg.teams.push(entry);
}
}
reg.teams.sort_by(|a, b| {
a.root
.cmp(&b.root)
.then_with(|| a.project_id.cmp(&b.project_id))
});
save(dir, ®)
}
pub fn clear(dir: &Path, root: &Path, project: Option<&str>) -> Result<()> {
let mut reg = load(dir)?;
let before = reg.teams.len();
reg.teams
.retain(|t| t.root != root || project.is_some_and(|p| t.project_id != p));
if reg.teams.len() != before {
save(dir, ®)?;
}
Ok(())
}
pub fn is_orphan(entry: &TeamEntry, path_exists: &impl Fn(&Path) -> bool) -> bool {
!(path_exists(&entry.root.join("team-compose.yaml"))
|| path_exists(&entry.root.join(".team").join("team-compose.yaml")))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RosterEntry {
pub project_id: String,
pub agent: String,
pub tmux_session: String,
}
impl RosterEntry {
pub fn id(&self) -> String {
format!("{}:{}", self.project_id, self.agent)
}
}
impl Registry {
pub fn roster_for_root(&self, root: &Path) -> Vec<RosterEntry> {
self.teams
.iter()
.filter(|t| t.root == root)
.flat_map(|t| {
t.agents.iter().map(move |agent| RosterEntry {
project_id: t.project_id.clone(),
agent: agent.clone(),
tmux_session: format!("{}{}-{}", t.tmux_prefix, t.project_id, agent),
})
})
.collect()
}
}
pub fn reap_targets(
roster: &[RosterEntry],
desired: &std::collections::HashSet<String>,
scoped: Option<&str>,
per_agent: bool,
) -> Vec<RosterEntry> {
if per_agent {
return Vec::new();
}
roster
.iter()
.filter(|e| scoped.is_none_or(|p| e.project_id == p))
.filter(|e| !desired.contains(&e.id()))
.cloned()
.collect()
}
pub fn orphans_for_root(
dir: &Path,
root: &Path,
desired: &std::collections::HashSet<String>,
scoped: Option<&str>,
per_agent: bool,
) -> Result<Vec<RosterEntry>> {
let reg = load(dir)?;
Ok(reap_targets(
®.roster_for_root(root),
desired,
scoped,
per_agent,
))
}
pub fn same_name_other_root(
reg: &Registry,
project_id: &str,
root: &Path,
path_exists: &impl Fn(&Path) -> bool,
) -> Option<PathBuf> {
reg.teams
.iter()
.find(|t| t.project_id == project_id && t.root != root && !is_orphan(t, path_exists))
.map(|t| t.root.clone())
}
fn save(dir: &Path, reg: &Registry) -> Result<()> {
fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
let path = dir.join(FILE);
let tmp = dir.join(format!("{FILE}.{}.tmp", std::process::id()));
let body = serde_json::to_string_pretty(reg)?;
fs::write(&tmp, body).with_context(|| format!("write {}", tmp.display()))?;
fs::rename(&tmp, &path).with_context(|| format!("rename into {}", path.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashSet;
fn entry(project: &str, root: &str, agents: &[&str], started: &str) -> TeamEntry {
TeamEntry {
project_id: project.into(),
root: PathBuf::from(root),
tmux_prefix: "t-".into(),
agents: agents.iter().map(|s| (*s).to_string()).collect(),
started_at: started.into(),
}
}
#[test]
fn load_missing_file_is_empty() {
let dir = tempfile::tempdir().unwrap();
let reg = load(dir.path()).unwrap();
assert!(reg.teams.is_empty());
}
#[test]
fn upsert_then_load_roundtrips() {
let dir = tempfile::tempdir().unwrap();
upsert(
dir.path(),
entry(
"main",
"/r/a/.team",
&["compass", "scout"],
"2026-06-13T00:00:00Z",
),
)
.unwrap();
let reg = load(dir.path()).unwrap();
assert_eq!(reg.teams.len(), 1);
let t = ®.teams[0];
assert_eq!(t.project_id, "main");
assert_eq!(t.root, PathBuf::from("/r/a/.team"));
assert_eq!(t.tmux_prefix, "t-");
assert_eq!(t.agents, vec!["compass", "scout"]);
assert_eq!(t.started_at, "2026-06-13T00:00:00Z");
}
#[test]
fn upsert_same_key_replaces_and_preserves_started_at() {
let dir = tempfile::tempdir().unwrap();
upsert(dir.path(), entry("main", "/r/a/.team", &["compass"], "T0")).unwrap();
upsert(
dir.path(),
entry("main", "/r/a/.team", &["compass", "scribe"], "T1"),
)
.unwrap();
let reg = load(dir.path()).unwrap();
assert_eq!(reg.teams.len(), 1, "same (project,root) is one row");
assert_eq!(
reg.teams[0].agents,
vec!["compass", "scribe"],
"roster refreshed"
);
assert_eq!(
reg.teams[0].started_at, "T0",
"uptime preserved across re-up"
);
}
#[test]
fn distinct_keys_coexist_and_sort() {
let dir = tempfile::tempdir().unwrap();
upsert_many(
dir.path(),
vec![
entry("main", "/r/b/.team", &["x"], "T0"),
entry("main", "/r/a/.team", &["x"], "T0"),
entry("ops", "/r/a/.team", &["y"], "T0"),
],
)
.unwrap();
let reg = load(dir.path()).unwrap();
assert_eq!(reg.teams.len(), 3);
assert_eq!(
reg.teams
.iter()
.map(|t| (t.root.to_string_lossy().into_owned(), t.project_id.clone()))
.collect::<Vec<_>>(),
vec![
("/r/a/.team".into(), "main".into()),
("/r/a/.team".into(), "ops".into()),
("/r/b/.team".into(), "main".into()),
]
);
}
#[test]
fn clear_whole_root_drops_all_its_entries() {
let dir = tempfile::tempdir().unwrap();
upsert_many(
dir.path(),
vec![
entry("main", "/r/a/.team", &["x"], "T0"),
entry("ops", "/r/a/.team", &["y"], "T0"),
entry("main", "/r/b/.team", &["z"], "T0"),
],
)
.unwrap();
clear(dir.path(), Path::new("/r/a/.team"), None).unwrap();
let reg = load(dir.path()).unwrap();
assert_eq!(reg.teams.len(), 1);
assert_eq!(reg.teams[0].root, PathBuf::from("/r/b/.team"));
}
#[test]
fn clear_scoped_project_keeps_sibling_at_same_root() {
let dir = tempfile::tempdir().unwrap();
upsert_many(
dir.path(),
vec![
entry("main", "/r/a/.team", &["x"], "T0"),
entry("ops", "/r/a/.team", &["y"], "T0"),
],
)
.unwrap();
clear(dir.path(), Path::new("/r/a/.team"), Some("main")).unwrap();
let reg = load(dir.path()).unwrap();
assert_eq!(reg.teams.len(), 1, "only the scoped project is dropped");
assert_eq!(reg.teams[0].project_id, "ops");
}
#[test]
fn save_is_atomic_and_leaves_no_temp() {
let dir = tempfile::tempdir().unwrap();
upsert(dir.path(), entry("main", "/r/a/.team", &["x"], "T0")).unwrap();
let leftovers: Vec<_> = fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
assert!(leftovers.contains(&"teams.json".to_string()));
assert!(
!leftovers.iter().any(|n| n.ends_with(".tmp")),
"no temp file should survive a successful rename: {leftovers:?}"
);
}
#[test]
fn load_corrupt_file_degrades_to_empty() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path()).unwrap();
fs::write(dir.path().join(FILE), b"{ this is not json").unwrap();
let reg = load(dir.path()).unwrap();
assert!(
reg.teams.is_empty(),
"corrupt store reads as empty, not an error"
);
}
#[test]
fn is_orphan_tracks_presence_of_compose() {
let live = entry("main", "/r/live/.team", &["x"], "T0");
let gone = entry("main", "/r/gone/.team", &["x"], "T0");
let extant: HashSet<PathBuf> = [PathBuf::from("/r/live/.team/team-compose.yaml")].into();
let exists = |p: &Path| extant.contains(p);
assert!(!is_orphan(&live, &exists), "live root keeps its compose");
assert!(is_orphan(&gone, &exists), "missing compose ⇒ orphan");
}
#[test]
fn is_orphan_falls_back_to_bare_root_layout_for_sessions_parity() {
let e = entry("main", "/r/proj", &["x"], "T0");
let extant: HashSet<PathBuf> = [PathBuf::from("/r/proj/.team/team-compose.yaml")].into();
let exists = |p: &Path| extant.contains(p);
assert!(!is_orphan(&e, &exists));
}
fn rentry(project: &str, agent: &str, session: &str) -> RosterEntry {
RosterEntry {
project_id: project.into(),
agent: agent.into(),
tmux_session: session.into(),
}
}
#[test]
fn roster_for_root_rebuilds_sessions_from_recorded_prefix() {
let mut reg = Registry::default();
reg.teams
.push(entry("main", "/r/a/.team", &["compass", "scout"], "T0"));
reg.teams.push(entry("ops", "/r/b/.team", &["otto"], "T0"));
assert_eq!(
reg.roster_for_root(Path::new("/r/a/.team")),
vec![
rentry("main", "compass", "t-main-compass"),
rentry("main", "scout", "t-main-scout"),
]
);
}
#[test]
fn reap_targets_drops_removed_keeps_current() {
let roster = vec![
rentry("main", "compass", "t-main-compass"),
rentry("main", "scout", "t-main-scout"),
];
let desired: HashSet<String> = ["main:compass".to_string()].into_iter().collect();
assert_eq!(
reap_targets(&roster, &desired, None, false),
vec![rentry("main", "scout", "t-main-scout")]
);
}
#[test]
fn reap_targets_skips_entirely_on_per_agent_teardown() {
let roster = vec![rentry("main", "scout", "t-main-scout")];
assert!(reap_targets(&roster, &HashSet::new(), None, true).is_empty());
}
#[test]
fn reap_targets_honors_project_scope() {
let roster = vec![
rentry("main", "scout", "t-main-scout"),
rentry("ops", "otto", "t-ops-otto"),
];
assert_eq!(
reap_targets(&roster, &HashSet::new(), Some("main"), false),
vec![rentry("main", "scout", "t-main-scout")]
);
}
#[test]
fn orphans_for_root_loads_and_diffs_against_desired() {
let dir = tempfile::tempdir().unwrap();
upsert(
dir.path(),
entry("main", "/r/a/.team", &["compass", "scout"], "T0"),
)
.unwrap();
let desired: HashSet<String> = ["main:compass".to_string()].into_iter().collect();
assert_eq!(
orphans_for_root(dir.path(), Path::new("/r/a/.team"), &desired, None, false).unwrap(),
vec![rentry("main", "scout", "t-main-scout")]
);
let empty = tempfile::tempdir().unwrap();
assert!(
orphans_for_root(empty.path(), Path::new("/r/a/.team"), &desired, None, false)
.unwrap()
.is_empty()
);
}
#[test]
fn same_name_other_root_flags_only_a_live_different_folder() {
let mut reg = Registry::default();
reg.teams.push(entry("main", "/r/a/.team", &["x"], "T0"));
let a_live = |p: &Path| p == Path::new("/r/a/.team/team-compose.yaml");
assert_eq!(
same_name_other_root(®, "main", Path::new("/r/b/.team"), &a_live),
Some(PathBuf::from("/r/a/.team"))
);
assert_eq!(
same_name_other_root(®, "main", Path::new("/r/a/.team"), &a_live),
None
);
assert_eq!(
same_name_other_root(®, "ops", Path::new("/r/b/.team"), &a_live),
None
);
let none_live = |_: &Path| false;
assert_eq!(
same_name_other_root(®, "main", Path::new("/r/b/.team"), &none_live),
None
);
}
}