use std::collections::{BTreeMap, BTreeSet};
use std::io;
use std::path::Path;
use crate::cluster::{WorkspaceConfig, WorkspaceConfigHash};
fn read_one(path: &Path) -> io::Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(s) => Ok(Some(s)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e),
}
}
fn read_first(root: &Path, candidates: &[&str]) -> io::Result<Option<String>> {
for rel in candidates {
if let Some(content) = read_one(&root.join(rel))? {
return Ok(Some(content));
}
}
Ok(None)
}
pub fn read_workspace_config(root: &Path) -> io::Result<WorkspaceConfig> {
Ok(WorkspaceConfig::new(
read_first(root, &["Cargo.toml"])?,
read_first(root, &["Cargo.lock"])?,
read_first(root, &["rust-toolchain.toml", "rust-toolchain"])?,
read_first(root, &[".cargo/config.toml", ".cargo/config"])?,
))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LifecycleAction {
SpawnRa(WorkspaceConfigHash),
TeardownRa(WorkspaceConfigHash),
NoOp,
}
#[derive(Debug, Clone, Default)]
pub struct ClusterLifecycle {
members: BTreeMap<WorkspaceConfigHash, BTreeSet<String>>,
wt_cluster: BTreeMap<String, WorkspaceConfigHash>,
}
impl ClusterLifecycle {
pub fn new() -> Self {
Self::default()
}
pub fn activate(
&mut self,
wt: impl Into<String>,
hash: WorkspaceConfigHash,
) -> LifecycleAction {
let wt = wt.into();
if self.wt_cluster.get(&wt) == Some(&hash) {
return LifecycleAction::NoOp;
}
self.detach(&wt);
let set = self.members.entry(hash.clone()).or_default();
let was_empty = set.is_empty();
set.insert(wt.clone());
self.wt_cluster.insert(wt, hash.clone());
if was_empty {
LifecycleAction::SpawnRa(hash)
} else {
LifecycleAction::NoOp
}
}
pub fn deactivate(&mut self, wt: impl Into<String>) -> LifecycleAction {
let wt = wt.into();
match self.detach(&wt) {
Some(hash) if !self.members.contains_key(&hash) => {
LifecycleAction::TeardownRa(hash)
}
_ => LifecycleAction::NoOp,
}
}
pub fn reroute(
&mut self,
wt: impl Into<String>,
new_hash: WorkspaceConfigHash,
) -> (LifecycleAction, LifecycleAction) {
let wt = wt.into();
if self.wt_cluster.get(&wt) == Some(&new_hash) {
return (LifecycleAction::NoOp, LifecycleAction::NoOp);
}
let leaving = self.deactivate(wt.clone());
let joining = self.activate(wt, new_hash);
(leaving, joining)
}
pub fn live_cluster_count(&self) -> usize {
self.members.len()
}
pub fn members_of(&self, hash: &WorkspaceConfigHash) -> Vec<String> {
self.members
.get(hash)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default()
}
fn detach(&mut self, wt: &str) -> Option<WorkspaceConfigHash> {
let hash = self.wt_cluster.remove(wt)?;
if let Some(set) = self.members.get_mut(&hash) {
set.remove(wt);
if set.is_empty() {
self.members.remove(&hash);
}
}
Some(hash)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn h(cfg: &WorkspaceConfig) -> WorkspaceConfigHash {
cfg.hash()
}
#[test]
fn reads_present_files_and_distinguishes_absent() {
let dir = std::env::temp_dir().join(format!("cl-cm-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join(".cargo")).unwrap();
fs::write(dir.join("Cargo.toml"), "[package]\nname='x'\n").unwrap();
fs::write(dir.join("Cargo.lock"), "# lock\n").unwrap();
fs::write(dir.join("rust-toolchain"), "1.85.0\n").unwrap();
fs::write(dir.join(".cargo/config.toml"), "[build]\n").unwrap();
let cfg = read_workspace_config(&dir).unwrap();
assert_eq!(cfg.cargo_toml.as_deref(), Some("[package]\nname='x'\n"));
assert_eq!(cfg.cargo_lock.as_deref(), Some("# lock\n"));
assert_eq!(
cfg.rust_toolchain.as_deref(),
Some("1.85.0\n"),
"legacy bare `rust-toolchain` must be detected (else under-cluster)"
);
assert_eq!(cfg.cargo_config.as_deref(), Some("[build]\n"));
let empty = dir.join("empty");
fs::create_dir_all(&empty).unwrap();
let none = read_workspace_config(&empty).unwrap();
assert_eq!(none, WorkspaceConfig::default());
assert_eq!(
none.hash(),
WorkspaceConfig::default().hash(),
"all-absent hashes to the canonical empty cluster"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn canonical_name_wins_over_legacy_when_both_present() {
let dir = std::env::temp_dir().join(format!("cl-cm2-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join(".cargo")).unwrap();
fs::write(dir.join("rust-toolchain.toml"), "CANON\n").unwrap();
fs::write(dir.join("rust-toolchain"), "LEGACY\n").unwrap();
fs::write(dir.join(".cargo/config.toml"), "CANON\n").unwrap();
fs::write(dir.join(".cargo/config"), "LEGACY\n").unwrap();
let cfg = read_workspace_config(&dir).unwrap();
assert_eq!(cfg.rust_toolchain.as_deref(), Some("CANON\n"));
assert_eq!(cfg.cargo_config.as_deref(), Some("CANON\n"));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn present_empty_file_is_some_not_none() {
let dir = std::env::temp_dir().join(format!("cl-cm3-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("Cargo.toml"), "").unwrap();
let cfg = read_workspace_config(&dir).unwrap();
assert_eq!(cfg.cargo_toml.as_deref(), Some(""));
assert_ne!(
cfg.hash(),
WorkspaceConfig::default().hash(),
"present-but-empty Cargo.toml must NOT cluster with all-absent"
);
let _ = fs::remove_dir_all(&dir);
}
fn cfg_a() -> WorkspaceConfig {
WorkspaceConfig::new(Some("A".into()), None, None, None)
}
fn cfg_b() -> WorkspaceConfig {
WorkspaceConfig::new(Some("B".into()), None, None, None)
}
#[test]
fn spawn_only_on_0_to_1_teardown_only_on_1_to_0() {
let mut lc = ClusterLifecycle::new();
let a = h(&cfg_a());
assert_eq!(
lc.activate("wt1", a.clone()),
LifecycleAction::SpawnRa(a.clone())
);
assert_eq!(lc.activate("wt2", a.clone()), LifecycleAction::NoOp);
assert_eq!(lc.live_cluster_count(), 1);
assert_eq!(lc.deactivate("wt1"), LifecycleAction::NoOp);
assert_eq!(lc.members_of(&a), vec!["wt2".to_string()]);
assert_eq!(lc.deactivate("wt2"), LifecycleAction::TeardownRa(a.clone()));
assert_eq!(lc.live_cluster_count(), 0);
assert!(lc.members_of(&a).is_empty());
}
#[test]
fn idempotent_double_activate_and_double_deactivate() {
let mut lc = ClusterLifecycle::new();
let a = h(&cfg_a());
assert_eq!(
lc.activate("wt1", a.clone()),
LifecycleAction::SpawnRa(a.clone())
);
assert_eq!(lc.activate("wt1", a.clone()), LifecycleAction::NoOp);
assert_eq!(lc.members_of(&a), vec!["wt1".to_string()]);
assert_eq!(lc.deactivate("wt1"), LifecycleAction::TeardownRa(a.clone()));
assert_eq!(lc.deactivate("wt1"), LifecycleAction::NoOp);
assert_eq!(lc.deactivate("ghost"), LifecycleAction::NoOp);
}
#[test]
fn distinct_configs_get_distinct_ras() {
let mut lc = ClusterLifecycle::new();
let a = h(&cfg_a());
let b = h(&cfg_b());
assert_ne!(
a, b,
"different Cargo.toml ⇒ different cluster (bias-to-split)"
);
assert_eq!(
lc.activate("wA", a.clone()),
LifecycleAction::SpawnRa(a.clone())
);
assert_eq!(
lc.activate("wB", b.clone()),
LifecycleAction::SpawnRa(b.clone())
);
assert_eq!(
lc.live_cluster_count(),
2,
"two divergent configs ⇒ two RAs"
);
assert_eq!(lc.deactivate("wA"), LifecycleAction::TeardownRa(a));
assert_eq!(lc.live_cluster_count(), 1);
assert_eq!(lc.deactivate("wB"), LifecycleAction::TeardownRa(b));
assert_eq!(lc.live_cluster_count(), 0);
}
#[test]
fn reroute_tears_old_and_spawns_new_only_at_boundaries() {
let mut lc = ClusterLifecycle::new();
let a = h(&cfg_a());
let b = h(&cfg_b());
lc.activate("wt1", a.clone());
lc.activate("wt2", a.clone());
let (leaving, joining) = lc.reroute("wt1", b.clone());
assert_eq!(leaving, LifecycleAction::NoOp, "A still has wt2");
assert_eq!(joining, LifecycleAction::SpawnRa(b.clone()), "B is new");
assert_eq!(lc.members_of(&a), vec!["wt2".to_string()]);
assert_eq!(lc.members_of(&b), vec!["wt1".to_string()]);
let (leaving, joining) = lc.reroute("wt2", b.clone());
assert_eq!(leaving, LifecycleAction::TeardownRa(a.clone()));
assert_eq!(joining, LifecycleAction::NoOp, "B already live");
assert_eq!(lc.live_cluster_count(), 1);
assert_eq!(
lc.reroute("wt1", b.clone()),
(LifecycleAction::NoOp, LifecycleAction::NoOp)
);
}
#[test]
fn activate_moving_across_clusters_detaches_old_membership() {
let mut lc = ClusterLifecycle::new();
let a = h(&cfg_a());
let b = h(&cfg_b());
lc.activate("wt1", a.clone());
assert_eq!(lc.live_cluster_count(), 1);
assert_eq!(
lc.activate("wt1", b.clone()),
LifecycleAction::SpawnRa(b.clone())
);
assert!(
lc.members_of(&a).is_empty(),
"old cluster A must have no stale membership for wt1"
);
assert_eq!(lc.members_of(&b), vec!["wt1".to_string()]);
assert_eq!(
lc.live_cluster_count(),
1,
"A pruned at 1→0, B live ⇒ exactly one RA"
);
}
}