use std::collections::{HashMap, HashSet};
use crate::core::changeid;
#[derive(Debug, Clone, Hash, Eq, PartialEq)]
pub enum Entity {
Unstaged,
Branch(String),
Commit {
oid: git2::Oid,
change_id: Option<String>,
},
File(String),
}
#[cfg(test)]
impl Entity {
pub fn commit(oid: git2::Oid) -> Self {
Entity::Commit {
oid,
change_id: None,
}
}
}
struct CommitId {
id: String,
letters: Option<String>,
}
pub struct IdAllocator {
map: HashMap<Entity, String>,
commits: HashMap<git2::Oid, CommitId>,
}
pub fn commit_file_id(sid: &str, index: usize) -> String {
format!("{sid}:{index}")
}
impl IdAllocator {
pub fn new(entities: Vec<Entity>) -> Self {
let map = resolve_collisions(entities);
let commits = map
.iter()
.filter_map(|(entity, id)| match entity {
Entity::Commit { oid, change_id } => Some((
*oid,
CommitId {
id: id.clone(),
letters: change_id.as_deref().map(changeid::to_letters),
},
)),
_ => None,
})
.collect();
IdAllocator { map, commits }
}
pub fn get_unstaged(&self) -> &str {
self.map
.get(&Entity::Unstaged)
.map(|s| s.as_str())
.unwrap_or("zz")
}
pub fn get_branch(&self, name: &str) -> &str {
self.map
.get(&Entity::Branch(name.to_string()))
.map(|s| s.as_str())
.unwrap_or("")
}
pub fn get_commit(&self, oid: git2::Oid) -> &str {
self.commits.get(&oid).map(|c| c.id.as_str()).unwrap_or("")
}
pub fn get_file(&self, path: &str) -> &str {
self.map
.get(&Entity::File(path.to_string()))
.map(|s| s.as_str())
.unwrap_or("")
}
pub fn find_persistent(&self, arg: &str) -> Vec<git2::Oid> {
let needle = match changeid::normalize(arg) {
Some(id) => changeid::to_letters(&id),
None if arg.len() >= changeid::MIN_LEN && changeid::is_letters(arg) => arg.to_string(),
None => return Vec::new(),
};
self.commits
.iter()
.filter(|(_, c)| c.letters.as_deref().is_some_and(|l| l.starts_with(&needle)))
.map(|(oid, _)| *oid)
.collect()
}
}
fn generate_candidates(entity: &Entity) -> Vec<String> {
let candidates = match entity {
Entity::Unstaged => return vec!["zz".to_string()],
Entity::Commit { oid, .. } => {
let hex = oid.to_string();
let chars: Vec<char> = hex.chars().collect();
(2..=chars.len())
.map(|n| chars[..n].iter().collect())
.collect()
}
Entity::Branch(name) => word_candidates(name),
Entity::File(path) => {
let filename = std::path::Path::new(path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(path);
let stem = std::path::Path::new(filename)
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or(filename);
word_candidates(stem)
}
};
let candidates: Vec<String> = candidates
.into_iter()
.map(|c| c.to_lowercase())
.filter(|c| c != "zz")
.collect();
if candidates.is_empty() {
vec!["zz1".to_string()]
} else {
candidates
}
}
fn persistent_candidates(entities: &[Entity]) -> HashMap<git2::Oid, Vec<String>> {
let mut owners: HashMap<&str, Vec<git2::Oid>> = HashMap::new();
for entity in entities {
if let Entity::Commit {
oid,
change_id: Some(change_id),
} = entity
{
owners.entry(change_id).or_default().push(*oid);
}
}
let unique: Vec<(git2::Oid, Vec<char>)> = owners
.iter()
.filter(|(_, oids)| oids.len() == 1)
.map(|(change_id, oids)| (oids[0], changeid::to_letters(change_id).chars().collect()))
.collect();
unique
.iter()
.map(|(oid, letters)| {
let mut n = changeid::MIN_LEN.min(letters.len());
while n < letters.len()
&& unique
.iter()
.any(|(other, l)| other != oid && l[..n] == letters[..n])
{
n += 1;
}
let candidates = (n..=letters.len())
.map(|n| letters[..n].iter().collect())
.collect();
(*oid, candidates)
})
.collect()
}
fn word_candidates(name: &str) -> Vec<String> {
let words: Vec<Vec<char>> = name
.split(['-', '_', '/'])
.filter(|w| !w.is_empty())
.map(|w| w.chars().collect())
.collect();
if words.len() >= 2 {
multi_word_candidates(&words)
} else {
single_word_candidates(name)
}
}
fn multi_word_candidates(words: &[Vec<char>]) -> Vec<String> {
let mut candidates = Vec::new();
let word1 = &words[0];
let word2 = if words.len() >= 2 {
&words[1]
} else {
&words[0]
};
for &ch1 in word1 {
for &ch2 in word2 {
let candidate: String = [ch1, ch2].iter().collect();
if !candidates.contains(&candidate) {
candidates.push(candidate);
}
}
}
for n in 3..=word1.len().max(word2.len()).max(5) {
let prefix: String = format!(
"{}{}",
word1.iter().take(n).collect::<String>(),
word2.iter().take(n).collect::<String>()
)
.chars()
.take(n)
.collect();
if !candidates.contains(&prefix) {
candidates.push(prefix);
}
}
candidates
}
fn single_word_candidates(word: &str) -> Vec<String> {
let chars: Vec<char> = word.chars().collect();
let mut candidates = Vec::new();
if chars.is_empty() {
return candidates;
}
if chars.len() == 1 {
candidates.push(format!("{}{}", chars[0], chars[0]));
return candidates;
}
for i in 0..chars.len() {
for j in (i + 1)..chars.len() {
let candidate: String = [chars[i], chars[j]].iter().collect();
if !candidates.contains(&candidate) {
candidates.push(candidate);
}
}
}
for n in 3..=chars.len() {
let prefix: String = chars[..n].iter().collect();
if !candidates.contains(&prefix) {
candidates.push(prefix);
}
}
candidates
}
fn entity_priority(entity: &Entity) -> u8 {
match entity {
Entity::Unstaged => 0,
Entity::Commit { .. } => 1,
Entity::Branch(_) | Entity::File(_) => 2,
}
}
fn resolve_collisions(entities: Vec<Entity>) -> HashMap<Entity, String> {
let mut persistent = persistent_candidates(&entities);
let mut items: Vec<(Entity, Vec<String>)> = entities
.into_iter()
.map(|e| {
let cands = match &e {
Entity::Commit { oid, .. } => persistent.remove(oid),
_ => None,
}
.unwrap_or_else(|| generate_candidates(&e));
(e, cands)
})
.collect();
items.sort_by_key(|(e, _)| entity_priority(e));
let mut used: HashSet<String> = HashSet::new();
let mut result: HashMap<Entity, String> = HashMap::new();
for (entity, candidates) in items {
let id = candidates
.iter()
.find(|c| !used.contains(*c))
.cloned()
.unwrap_or_else(|| {
let base = candidates.first().map(|s| s.as_str()).unwrap_or("??");
let mut n = 1;
loop {
let suffixed = format!("{}{}", base, n);
if !used.contains(&suffixed) {
break suffixed;
}
n += 1;
if n > 10000 {
break format!("{}_{}", base, n);
}
}
});
used.insert(id.clone());
result.insert(entity, id);
}
result
}
#[cfg(test)]
#[path = "shortid_test.rs"]
mod tests;