use std::collections::HashMap;
use std::collections::HashSet;
use crate::project::AbsolutePath;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(super) struct TargetDirMember {
pub(super) project_root: AbsolutePath,
}
#[derive(Debug, Default)]
pub(crate) struct TargetDirIndex {
by_target_dir: HashMap<AbsolutePath, Vec<TargetDirMember>>,
by_project: HashMap<AbsolutePath, AbsolutePath>,
}
impl TargetDirIndex {
pub fn new() -> Self { Self::default() }
pub(super) fn upsert(&mut self, member: TargetDirMember, target_dir: AbsolutePath) {
let project_root = member.project_root.clone();
if let Some(previous_dir) = self.by_project.get(&project_root).cloned() {
if previous_dir == target_dir {
if let Some(bucket) = self.by_target_dir.get_mut(&target_dir) {
if bucket.iter().any(|m| m.project_root == project_root) {
return;
}
bucket.push(member);
return;
}
} else {
self.evict_from_bucket(&previous_dir, &project_root);
}
}
self.by_project.insert(project_root, target_dir.clone());
self.by_target_dir
.entry(target_dir)
.or_default()
.push(member);
}
pub fn siblings<'a>(
&'a self,
target_dir: &AbsolutePath,
exclude: &[AbsolutePath],
) -> Vec<&'a AbsolutePath> {
let excluded: HashSet<&AbsolutePath> = exclude.iter().collect();
self.by_target_dir
.get(target_dir)
.map(|members| {
members
.iter()
.filter(|m| !excluded.contains(&m.project_root))
.map(|m| &m.project_root)
.collect()
})
.unwrap_or_default()
}
fn evict_from_bucket(&mut self, target_dir: &AbsolutePath, project_root: &AbsolutePath) {
if let Some(bucket) = self.by_target_dir.get_mut(target_dir) {
bucket.retain(|m| m.project_root != *project_root);
if bucket.is_empty() {
self.by_target_dir.remove(target_dir);
}
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum CleanSelection {
Project {
root: AbsolutePath,
},
WorktreeGroup {
primary: AbsolutePath,
linked: Vec<AbsolutePath>,
},
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::*;
fn dir(s: &str) -> AbsolutePath { AbsolutePath::from(PathBuf::from(s)) }
fn project(root: &str) -> TargetDirMember {
TargetDirMember {
project_root: dir(root),
}
}
#[test]
fn upsert_inserts_into_the_forward_and_reverse_maps() {
let mut index = TargetDirIndex::new();
index.upsert(project("/ws/a"), dir("/ws/a/target"));
let siblings = index.siblings(&dir("/ws/a/target"), &[]);
assert_eq!(siblings.len(), 1);
assert_eq!(*siblings[0], dir("/ws/a"));
}
#[test]
fn upsert_evicts_stale_bucket_entry_when_target_dir_changes() {
let mut index = TargetDirIndex::new();
index.upsert(project("/ws/a"), dir("/ws/a/target"));
index.upsert(project("/ws/a"), dir("/tmp/custom"));
assert!(
index.siblings(&dir("/ws/a/target"), &[]).is_empty(),
"stale bucket is empty after the target dir moved"
);
let new = index.siblings(&dir("/tmp/custom"), &[]);
assert_eq!(new.len(), 1);
assert_eq!(*new[0], dir("/ws/a"));
}
#[test]
fn upsert_is_idempotent_when_target_dir_is_unchanged() {
let mut index = TargetDirIndex::new();
index.upsert(project("/ws/a"), dir("/t"));
index.upsert(project("/ws/a"), dir("/t"));
let siblings = index.siblings(&dir("/t"), &[]);
assert_eq!(siblings.len(), 1, "no duplicate rows for the same project");
}
#[test]
fn siblings_excludes_members_named_in_the_exclude_list() {
let mut index = TargetDirIndex::new();
index.upsert(project("/ws/a"), dir("/shared"));
index.upsert(project("/ws/b"), dir("/shared"));
index.upsert(project("/ws/c"), dir("/shared"));
let siblings = index.siblings(&dir("/shared"), &[dir("/ws/a"), dir("/ws/b")]);
assert_eq!(siblings.len(), 1);
assert_eq!(*siblings[0], dir("/ws/c"));
}
#[test]
fn siblings_returns_empty_for_unknown_target_dir() {
let index = TargetDirIndex::new();
assert!(index.siblings(&dir("/nowhere"), &[]).is_empty());
}
}