use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::cache_layout::{CacheLayout, OverlayHash};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct WorktreeId(String);
impl WorktreeId {
pub fn new(id: impl Into<String>) -> Self {
Self(id.into())
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CorunVerdict {
Green,
Red,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Provenance {
CombinedGreen,
SoloGreen,
SoloRed,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attribution {
pub worktree: WorktreeId,
pub verdict: CorunVerdict,
pub provenance: Provenance,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CorunPolicy {
#[default]
Corun,
NoCorun,
}
pub trait OverlaySetChecker {
fn check(&self, overlays: &[OverlayHash]) -> CorunVerdict;
}
pub trait VerdictCache {
fn get(&self, key: &Path) -> Option<CorunVerdict>;
fn put(&self, key: &Path, verdict: CorunVerdict);
}
#[derive(Debug, Default)]
pub struct MemVerdictCache {
map: Mutex<BTreeMap<PathBuf, CorunVerdict>>,
}
impl MemVerdictCache {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn len(&self) -> usize {
self.map.lock().expect("cache mutex").len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
#[must_use]
pub fn contains(&self, key: &Path) -> bool {
self.map.lock().expect("cache mutex").contains_key(key)
}
}
impl VerdictCache for MemVerdictCache {
fn get(&self, key: &Path) -> Option<CorunVerdict> {
self.map.lock().expect("cache mutex").get(key).copied()
}
fn put(&self, key: &Path, verdict: CorunVerdict) {
self.map
.lock()
.expect("cache mutex")
.insert(key.to_path_buf(), verdict);
}
}
fn solo_attribution(
checker: &dyn OverlaySetChecker,
cache: &dyn VerdictCache,
layout: &CacheLayout,
wt: &WorktreeId,
hw: &OverlayHash,
) -> Attribution {
let key = layout.solo_entry(hw);
let verdict = match cache.get(&key) {
Some(v) => v,
None => {
let v = checker.check(std::slice::from_ref(hw));
cache.put(&key, v);
v
}
};
Attribution {
worktree: wt.clone(),
verdict,
provenance: match verdict {
CorunVerdict::Green => Provenance::SoloGreen,
CorunVerdict::Red => Provenance::SoloRed,
},
}
}
pub fn corun(
checker: &dyn OverlaySetChecker,
cache: &dyn VerdictCache,
layout: &CacheLayout,
batch: &[(WorktreeId, OverlayHash)],
policy: CorunPolicy,
) -> Vec<Attribution> {
if batch.is_empty() {
return Vec::new();
}
if policy == CorunPolicy::NoCorun || batch.len() == 1 {
return batch
.iter()
.map(|(wt, hw)| solo_attribution(checker, cache, layout, wt, hw))
.collect();
}
let overlays: Vec<OverlayHash> = batch.iter().map(|(_, hw)| hw.clone()).collect();
let ckey = layout.combined_entry(&overlays);
let combined = match cache.get(&ckey) {
Some(v) => v,
None => {
let v = checker.check(&overlays);
cache.put(&ckey, v);
v
}
};
match combined {
CorunVerdict::Green => batch
.iter()
.map(|(wt, _)| Attribution {
worktree: wt.clone(),
verdict: CorunVerdict::Green,
provenance: Provenance::CombinedGreen,
})
.collect(),
CorunVerdict::Red => batch
.iter()
.map(|(wt, hw)| solo_attribution(checker, cache, layout, wt, hw))
.collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::RefCell;
struct MockChecker {
red_overlays: Vec<String>,
calls: RefCell<Vec<Vec<String>>>,
}
impl MockChecker {
fn new(red: &[&str]) -> Self {
Self {
red_overlays: red.iter().map(|s| s.to_string()).collect(),
calls: RefCell::new(Vec::new()),
}
}
fn call_count(&self) -> usize {
self.calls.borrow().len()
}
}
impl OverlaySetChecker for MockChecker {
fn check(&self, overlays: &[OverlayHash]) -> CorunVerdict {
self.calls
.borrow_mut()
.push(overlays.iter().map(|h| h.as_str().to_string()).collect());
if overlays
.iter()
.any(|h| self.red_overlays.iter().any(|r| r == h.as_str()))
{
CorunVerdict::Red
} else {
CorunVerdict::Green
}
}
}
fn layout() -> CacheLayout {
let mut p = std::env::temp_dir();
p.push(format!("cargoless-corun-{}", std::process::id()));
CacheLayout::for_repo(p, crate::cache_layout::TF_STATE_DIR_REL)
}
fn wt(id: &str, hw: &str) -> (WorktreeId, OverlayHash) {
(WorktreeId::new(id), OverlayHash::new(hw))
}
#[test]
fn corun_empty_batch_is_empty() {
let c = MockChecker::new(&[]);
let cache = MemVerdictCache::new();
assert!(corun(&c, &cache, &layout(), &[], CorunPolicy::Corun).is_empty());
assert_eq!(c.call_count(), 0);
}
#[test]
fn corun_single_wt_is_solo_not_combined() {
let c = MockChecker::new(&[]);
let cache = MemVerdictCache::new();
let l = layout();
let out = corun(&c, &cache, &l, &[wt("A", "hwA")], CorunPolicy::Corun);
assert_eq!(out.len(), 1);
assert_eq!(out[0].provenance, Provenance::SoloGreen);
assert!(cache.contains(&l.solo_entry(&OverlayHash::new("hwA"))));
assert_eq!(cache.len(), 1);
}
#[test]
fn corun_combined_green_attributes_green_to_all_with_combined_provenance() {
let c = MockChecker::new(&[]); let cache = MemVerdictCache::new();
let l = layout();
let batch = [wt("A", "hwA"), wt("B", "hwB"), wt("C", "hwC")];
let out = corun(&c, &cache, &l, &batch, CorunPolicy::Corun);
assert_eq!(out.len(), 3);
for a in &out {
assert_eq!(a.verdict, CorunVerdict::Green);
assert_eq!(
a.provenance,
Provenance::CombinedGreen,
"batch-green must be flagged optimistic, never SoloGreen"
);
}
assert_eq!(
c.call_count(),
1,
"combined green = exactly one check for N WTs"
);
}
#[test]
fn corun_combined_green_does_not_forge_solo_cache_entries() {
let c = MockChecker::new(&[]);
let cache = MemVerdictCache::new();
let l = layout();
let batch = [wt("A", "hwA"), wt("B", "hwB")];
corun(&c, &cache, &l, &batch, CorunPolicy::Corun);
assert!(!cache.contains(&l.solo_entry(&OverlayHash::new("hwA"))));
assert!(!cache.contains(&l.solo_entry(&OverlayHash::new("hwB"))));
assert_eq!(cache.len(), 1);
assert!(
cache.contains(&l.combined_entry(&[OverlayHash::new("hwA"), OverlayHash::new("hwB")]))
);
}
#[test]
fn corun_combined_red_falls_back_to_solo_attribution() {
let c = MockChecker::new(&["hwB"]);
let cache = MemVerdictCache::new();
let l = layout();
let batch = [wt("A", "hwA"), wt("B", "hwB"), wt("C", "hwC")];
let out = corun(&c, &cache, &l, &batch, CorunPolicy::Corun);
let by = |id: &str| out.iter().find(|a| a.worktree.as_str() == id).unwrap();
assert_eq!(by("A").verdict, CorunVerdict::Green);
assert_eq!(by("A").provenance, Provenance::SoloGreen);
assert_eq!(by("B").verdict, CorunVerdict::Red);
assert_eq!(by("B").provenance, Provenance::SoloRed);
assert_eq!(by("C").verdict, CorunVerdict::Green);
assert_eq!(by("C").provenance, Provenance::SoloGreen);
assert_eq!(c.call_count(), 4);
}
#[test]
fn corun_no_corun_policy_forces_solo_path() {
let c = MockChecker::new(&[]);
let cache = MemVerdictCache::new();
let l = layout();
let batch = [wt("A", "hwA"), wt("B", "hwB")];
let out = corun(&c, &cache, &l, &batch, CorunPolicy::NoCorun);
assert!(out.iter().all(|a| a.provenance == Provenance::SoloGreen));
assert!(
!cache.contains(&l.combined_entry(&[OverlayHash::new("hwA"), OverlayHash::new("hwB")]))
);
assert_eq!(c.call_count(), 2, "--no-corun = one check per worktree");
}
#[test]
fn corun_combined_cache_hit_skips_recheck() {
let c = MockChecker::new(&[]);
let cache = MemVerdictCache::new();
let l = layout();
let batch = [wt("A", "hwA"), wt("B", "hwB")];
corun(&c, &cache, &l, &batch, CorunPolicy::Corun);
assert_eq!(c.call_count(), 1);
corun(&c, &cache, &l, &batch, CorunPolicy::Corun);
assert_eq!(
c.call_count(),
1,
"content-addressed combined hit, no recheck"
);
}
#[test]
fn corun_solo_cache_hit_skips_recheck() {
let c = MockChecker::new(&[]);
let cache = MemVerdictCache::new();
let l = layout();
corun(&c, &cache, &l, &[wt("A", "hwA")], CorunPolicy::Corun);
corun(&c, &cache, &l, &[wt("A", "hwA")], CorunPolicy::Corun);
assert_eq!(c.call_count(), 1, "solo content-addressed hit, no recheck");
}
#[test]
fn corun_provenance_distinguishes_combined_from_solo_green() {
let c = MockChecker::new(&[]);
let cache1 = MemVerdictCache::new();
let l = layout();
let combined = corun(
&c,
&cache1,
&l,
&[wt("A", "hwA"), wt("B", "hwB")],
CorunPolicy::Corun,
);
let cache2 = MemVerdictCache::new();
let solo = corun(&c, &cache2, &l, &[wt("A", "hwA")], CorunPolicy::NoCorun);
assert_eq!(combined[0].verdict, solo[0].verdict); assert_ne!(
combined[0].provenance, solo[0].provenance,
"combined-green and solo-green must be type-distinguishable"
);
}
#[test]
fn corun_cross_dep_combined_green_can_mask_solo_red_is_visible_in_provenance() {
struct CrossDep;
impl OverlaySetChecker for CrossDep {
fn check(&self, ov: &[OverlayHash]) -> CorunVerdict {
let set: Vec<&str> = ov.iter().map(|h| h.as_str()).collect();
if set == ["hwB"] {
CorunVerdict::Red
} else {
CorunVerdict::Green
}
}
}
let cache = MemVerdictCache::new();
let l = layout();
let out = corun(
&CrossDep,
&cache,
&l,
&[wt("A", "hwA"), wt("B", "hwB")],
CorunPolicy::Corun,
);
assert!(out.iter().all(|a| a.verdict == CorunVerdict::Green));
assert!(
out.iter()
.all(|a| a.provenance == Provenance::CombinedGreen)
);
}
}