use crate::{sessions, UnifiedMessage, UNKNOWN_WORKSPACE_GROUP_KEY, UNKNOWN_WORKSPACE_LABEL};
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize)]
pub enum WorktreeRollup {
#[default]
Separate,
MergeIntoRepo,
}
#[derive(Default)]
pub struct WorkspaceLabeler {
labels: HashMap<String, String>,
roots: HashMap<String, Option<String>>,
paths: HashMap<String, Option<String>>,
decoded: HashMap<String, Option<String>>,
resolved_roots: HashMap<String, Option<String>>,
}
impl WorkspaceLabeler {
pub fn label(&mut self, key: &str) -> String {
if let Some(cached) = self.labels.get(key) {
return cached.clone();
}
let decoded = self.decoded(key);
let label = sessions::workspace_display_label_for_decoded_key(key, decoded.as_deref())
.unwrap_or_else(|| UNKNOWN_WORKSPACE_LABEL.to_string());
self.labels.insert(key.to_string(), label.clone());
label
}
pub fn path(&mut self, key: &str) -> Option<String> {
if let Some(cached) = self.paths.get(key) {
return cached.clone();
}
let decoded = self.decoded(key);
let path = sessions::workspace_path_for_decoded_key(key, decoded.as_deref());
self.paths.insert(key.to_string(), path.clone());
path
}
fn decoded(&mut self, key: &str) -> Option<String> {
if let Some(cached) = self.decoded.get(key) {
return cached.clone();
}
let decoded = sessions::decode_claude_project_slug(key);
self.decoded.insert(key.to_string(), decoded.clone());
decoded
}
#[cfg(test)]
pub(crate) fn decoded_key_count(&self) -> usize {
self.decoded.len()
}
pub fn repo_root_of_path(&mut self, path: &str) -> Option<String> {
if let Some(cached) = self.resolved_roots.get(path) {
return cached.clone();
}
let root = sessions::workspace_repo_root_resolved(path);
self.resolved_roots.insert(path.to_string(), root.clone());
root
}
pub fn repo_root(&mut self, key: &str) -> Option<String> {
if let Some(cached) = self.roots.get(key) {
return cached.clone();
}
let decoded = self.decoded(key);
let path = decoded.clone().unwrap_or_else(|| key.to_string());
let root = self
.repo_root_of_path(&path)
.or_else(|| decoded.clone())
.or_else(|| sessions::workspace_repo_root_from_slug(key));
self.roots.insert(key.to_string(), root.clone());
root
}
}
pub fn workspace_bucket(
msg: &UnifiedMessage,
rollup: WorktreeRollup,
labeler: &mut WorkspaceLabeler,
) -> (String, Option<String>, String) {
let Some(key) = msg.workspace_key.as_deref() else {
return (
UNKNOWN_WORKSPACE_GROUP_KEY.to_string(),
None,
UNKNOWN_WORKSPACE_LABEL.to_string(),
);
};
if rollup == WorktreeRollup::MergeIntoRepo {
if let Some(root) = labeler.repo_root(key) {
let label = labeler.label(&root);
return (root.clone(), Some(root), label);
}
}
let label = match msg.workspace_label.as_deref() {
Some(label) if Some(label.to_string()) != sessions::workspace_label_from_key(key) => {
label.to_string()
}
_ => labeler.label(key),
};
(key.to_string(), Some(key.to_string()), label)
}
pub fn workspace_label_overrides<'a>(
messages: impl IntoIterator<Item = &'a UnifiedMessage>,
rollup: WorktreeRollup,
labeler: &mut WorkspaceLabeler,
) -> HashMap<String, String> {
let mut seen: HashSet<&str> = HashSet::new();
let mut base: BTreeMap<String, String> = BTreeMap::new();
for msg in messages {
let Some(key) = msg.workspace_key.as_deref() else {
continue;
};
if !seen.insert(key) {
continue;
}
let (group_key, _, label) = workspace_bucket(msg, rollup, labeler);
base.entry(group_key).or_insert(label);
}
disambiguate_workspace_labels(
base.iter()
.map(|(key, label)| (key.as_str(), label.as_str())),
labeler,
)
}
fn disambiguate_workspace_labels<'a>(
labeled: impl IntoIterator<Item = (&'a str, &'a str)>,
labeler: &mut WorkspaceLabeler,
) -> HashMap<String, String> {
let mut by_label: BTreeMap<&str, BTreeSet<&str>> = BTreeMap::new();
for (key, label) in labeled {
by_label.entry(label).or_default().insert(key);
}
let mut resolved: BTreeMap<String, String> = BTreeMap::new();
for (label, keys) in by_label {
if keys.len() == 1 {
for key in keys {
resolved.insert(key.to_string(), label.to_string());
}
continue;
}
let keys: Vec<&str> = keys.into_iter().collect();
let parents: Vec<Vec<String>> = keys
.iter()
.map(|key| workspace_parent_segments(labeler, key))
.collect();
let max_depth = parents.iter().map(Vec::len).max().unwrap_or(0);
let mut depth = 0;
let mut separated = 0;
for candidate in 0..=max_depth {
let candidates: HashSet<String> = parents
.iter()
.map(|parents| qualify_workspace_label(label, parents, candidate))
.collect();
if candidates.len() > separated {
separated = candidates.len();
depth = candidate;
}
if separated == keys.len() {
break;
}
}
for (key, parents) in keys.iter().zip(&parents) {
resolved.insert(
(*key).to_string(),
qualify_workspace_label(label, parents, depth),
);
}
}
let mut duplicates: BTreeMap<&str, usize> = BTreeMap::new();
for label in resolved.values() {
*duplicates.entry(label.as_str()).or_default() += 1;
}
let ambiguous: HashSet<String> = duplicates
.into_iter()
.filter(|(_, count)| *count > 1)
.map(|(label, _)| label.to_string())
.collect();
resolved
.into_iter()
.map(|(key, label)| {
if ambiguous.contains(&label) {
let qualified = format!("{label} ({key})");
(key, qualified)
} else {
(key, label)
}
})
.collect()
}
fn workspace_parent_segments(labeler: &mut WorkspaceLabeler, key: &str) -> Vec<String> {
let Some(path) = labeler.path(key) else {
return Vec::new();
};
let anchor = labeler.repo_root_of_path(&path).unwrap_or(path);
let mut segments: Vec<String> = anchor
.split('/')
.filter(|segment| !segment.is_empty())
.map(str::to_string)
.collect();
segments.pop();
segments.reverse();
segments
}
fn qualify_workspace_label(label: &str, parents: &[String], depth: usize) -> String {
let taken = depth.min(parents.len());
if taken == 0 {
return label.to_string();
}
let mut prefix: Vec<&str> = parents[..taken].iter().map(String::as_str).collect();
prefix.reverse();
format!("{}/{label}", prefix.join("/"))
}