use std::collections::{BTreeMap, HashSet};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub(crate) struct MapKey {
pub(crate) repo: String,
pub(crate) prefix: Option<String>,
}
pub(crate) fn parse_key(
raw: &str,
configured: &HashSet<&str>,
) -> std::result::Result<MapKey, String> {
let (repo, prefix) = match raw.split_once(':') {
Some((repo, prefix)) => (repo, Some(prefix)),
None => (raw, None),
};
if repo.is_empty() {
return Err("empty repository name".into());
}
if repo.contains('/') && !configured.contains(repo) {
return Err(format!(
"a '/' in the repository name, and no repositories[].name is '{repo}'; \
a path prefix follows a ':'"
));
}
let prefix = match prefix {
None => None,
Some(p) => {
let p = p.trim_start_matches("./").trim_matches('/');
if p.is_empty() {
return Err("empty path prefix after ':'".into());
}
Some(p.to_string())
}
};
Ok(MapKey {
repo: repo.to_string(),
prefix,
})
}
pub(crate) fn is_under(path: &str, prefix: &str) -> bool {
path.strip_prefix(prefix)
.is_some_and(|rest| rest.is_empty() || rest.starts_with('/'))
}
#[derive(Debug, Clone, Default)]
pub(crate) struct RepoKeys {
bare: Option<String>,
prefixes: Vec<(String, String)>,
}
struct Tally<'a> {
votes: usize,
specificity: usize,
prefix: Option<&'a str>,
}
impl RepoKeys {
pub(crate) fn insert(&mut self, prefix: Option<String>, category: String) {
match prefix {
None => self.bare = Some(category),
Some(p) => {
self.prefixes.push((p, category));
self.prefixes
.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then_with(|| a.0.cmp(&b.0)));
}
}
}
pub(crate) fn has_prefixes(&self) -> bool {
!self.prefixes.is_empty()
}
pub(crate) fn resolve(&self, paths: &[String]) -> Option<(Option<&str>, &str)> {
if self.prefixes.is_empty() || paths.is_empty() {
return self.bare.as_deref().map(|c| (None, c));
}
let mut by_category: BTreeMap<&str, Tally<'_>> = BTreeMap::new();
let mut unmapped = 0_usize;
for path in paths {
let hit = self.prefixes.iter().find(|(p, _)| is_under(path, p));
let (prefix, category, specificity) = match (hit, self.bare.as_deref()) {
(Some((p, c)), _) => (Some(p.as_str()), c.as_str(), p.len() + 1),
(None, Some(c)) => (None, c, 0),
(None, None) => {
unmapped += 1;
continue;
}
};
let t = by_category.entry(category).or_insert(Tally {
votes: 0,
specificity,
prefix,
});
t.votes += 1;
if (specificity, std::cmp::Reverse(prefix))
> (t.specificity, std::cmp::Reverse(t.prefix))
{
t.specificity = specificity;
t.prefix = prefix;
}
}
let mut best: Option<(&str, &Tally<'_>)> = None;
for (category, t) in &by_category {
if best.is_none_or(|(_, b)| (t.votes, t.specificity) > (b.votes, b.specificity)) {
best = Some((*category, t));
}
}
best.filter(|(_, t)| t.votes >= unmapped)
.map(|(category, t)| (t.prefix, category))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn equal_length_keys_of_one_category_break_on_the_prefix() {
let mut keys = RepoKeys::default();
keys.insert(Some("bb".into()), "qa".into());
keys.insert(Some("aa".into()), "qa".into());
for order in [["bb/1", "aa/1"], ["aa/1", "bb/1"]] {
let paths: Vec<String> = order.iter().map(|p| p.to_string()).collect();
assert_eq!(keys.resolve(&paths), Some((Some("aa"), "qa")), "{order:?}");
}
}
}