use std::collections::BTreeMap;
use cargoless_cas::sha256_hex;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct WorkspaceConfig {
pub cargo_toml: Option<String>,
pub cargo_lock: Option<String>,
pub rust_toolchain: Option<String>,
pub cargo_config: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WorkspaceConfigHash(String);
impl WorkspaceConfigHash {
pub fn as_str(&self) -> &str {
&self.0
}
}
const WSCONFIG_DOMAIN: &str = "cargoless/wsconfig/v1";
impl WorkspaceConfig {
pub fn new(
cargo_toml: Option<String>,
cargo_lock: Option<String>,
rust_toolchain: Option<String>,
cargo_config: Option<String>,
) -> Self {
Self {
cargo_toml,
cargo_lock,
rust_toolchain,
cargo_config,
}
}
pub fn hash(&self) -> WorkspaceConfigHash {
fn token(f: &Option<String>) -> String {
match f {
Some(content) => format!("P{}", sha256_hex(content.as_bytes())),
None => "A".to_string(),
}
}
let canonical = format!(
"{WSCONFIG_DOMAIN}\n{}\n{}\n{}\n{}",
token(&self.cargo_toml),
token(&self.cargo_lock),
token(&self.rust_toolchain),
token(&self.cargo_config),
);
WorkspaceConfigHash(sha256_hex(canonical.as_bytes()))
}
}
pub fn cluster_worktrees<I, S>(entries: I) -> BTreeMap<WorkspaceConfigHash, Vec<String>>
where
I: IntoIterator<Item = (S, WorkspaceConfig)>,
S: Into<String>,
{
let mut clusters: BTreeMap<WorkspaceConfigHash, Vec<String>> = BTreeMap::new();
for (wt, cfg) in entries {
clusters.entry(cfg.hash()).or_default().push(wt.into());
}
for wts in clusters.values_mut() {
wts.sort();
wts.dedup();
}
clusters
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(
ct: Option<&str>,
cl: Option<&str>,
rt: Option<&str>,
cc: Option<&str>,
) -> WorkspaceConfig {
WorkspaceConfig::new(
ct.map(str::to_string),
cl.map(str::to_string),
rt.map(str::to_string),
cc.map(str::to_string),
)
}
#[test]
fn identical_config_same_hash() {
let a = cfg(Some("[workspace]"), Some("lock-v1"), Some("1.85"), None);
let b = cfg(Some("[workspace]"), Some("lock-v1"), Some("1.85"), None);
assert_eq!(a.hash(), b.hash(), "byte-identical config ⇒ same cluster");
assert_eq!(a.hash().as_str().len(), 64, "64-hex digest");
}
#[test]
fn any_single_file_difference_splits_cluster() {
let base = cfg(
Some("[workspace]"),
Some("lockA"),
Some("1.85"),
Some("cfgA"),
);
let diff_toml = cfg(
Some("[workspace]\nx=1"),
Some("lockA"),
Some("1.85"),
Some("cfgA"),
);
let diff_lock = cfg(
Some("[workspace]"),
Some("lockB"),
Some("1.85"),
Some("cfgA"),
);
let diff_tc = cfg(
Some("[workspace]"),
Some("lockA"),
Some("1.86"),
Some("cfgA"),
);
let diff_cc = cfg(
Some("[workspace]"),
Some("lockA"),
Some("1.85"),
Some("cfgB"),
);
for v in [&diff_toml, &diff_lock, &diff_tc, &diff_cc] {
assert_ne!(
base.hash(),
v.hash(),
"a difference in ANY of the 4 files must split the cluster (under-cluster = wrong verdict)"
);
}
}
#[test]
fn absent_differs_from_present_empty_bias_to_split() {
let absent = cfg(Some("[workspace]"), Some("L"), None, None);
let present_empty = cfg(Some("[workspace]"), Some("L"), None, Some(""));
assert_ne!(
absent.hash(),
present_empty.hash(),
"absent ≠ present-but-empty (bias-to-split: never assume cargo semantics here)"
);
}
#[test]
fn no_cross_file_boundary_collision() {
let a = cfg(Some("x"), Some(""), None, None);
let b = cfg(Some(""), Some("x"), None, None);
assert_ne!(
a.hash(),
b.hash(),
"tokenized encoding must be length/boundary unambiguous"
);
}
#[test]
fn tf_mv_common_case_one_cluster() {
let shared = || {
cfg(
Some("[workspace]\nmembers=[]"),
Some("lock"),
Some("1.85"),
None,
)
};
let clusters = cluster_worktrees([
("/repo/.claude/worktrees/a", shared()),
("/repo/.claude/worktrees/b", shared()),
("/repo", shared()),
("/repo-sibling", shared()),
]);
assert_eq!(clusters.len(), 1, "all-shared ⇒ single cluster");
let (_, members) = clusters.iter().next().unwrap();
assert_eq!(
members,
&vec![
"/repo".to_string(),
"/repo-sibling".to_string(),
"/repo/.claude/worktrees/a".to_string(),
"/repo/.claude/worktrees/b".to_string(),
],
"sorted, deduped membership"
);
}
#[test]
fn divergent_dep_experiment_forms_own_cluster() {
let base = || cfg(Some("[workspace]"), Some("lock-v1"), None, None);
let experiment = cfg(
Some("[workspace]"),
Some("lock-v2-bumped-serde"),
None,
None,
);
let clusters = cluster_worktrees([
("/repo/.claude/worktrees/a", base()),
("/repo/.claude/worktrees/b", base()),
("/repo/.claude/worktrees/dep-exp", experiment),
]);
assert_eq!(clusters.len(), 2, "the dep-experiment WT splits off");
let sizes: Vec<usize> = clusters.values().map(Vec::len).collect();
let mut sorted = sizes.clone();
sorted.sort();
assert_eq!(
sorted,
vec![1, 2],
"2-member base cluster + 1-member experiment"
);
}
#[test]
fn grouping_is_input_order_independent() {
let a = || cfg(Some("A"), None, None, None);
let b = || cfg(Some("B"), None, None, None);
let fwd = cluster_worktrees([("w1", a()), ("w2", b()), ("w3", a())]);
let rev = cluster_worktrees([("w3", a()), ("w2", b()), ("w1", a())]);
assert_eq!(
fwd, rev,
"discovery order must not change clustering (no RA churn from enumeration order)"
);
assert_eq!(fwd.len(), 2);
assert_eq!(fwd[&a().hash()], vec!["w1".to_string(), "w3".to_string()]);
assert_eq!(fwd[&b().hash()], vec!["w2".to_string()]);
}
#[test]
fn all_absent_is_a_valid_stable_cluster() {
let none = WorkspaceConfig::default();
assert_eq!(none.hash(), WorkspaceConfig::default().hash());
let clusters = cluster_worktrees([("w1", none.clone()), ("w2", none)]);
assert_eq!(clusters.len(), 1);
}
}