use anyhow::{Context, Result};
use serde::Serialize;
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum DiscoverySource {
CargoPackageName,
CargoBinaryName,
FirstLetterAbbrev,
GitRemote,
}
impl DiscoverySource {
pub fn as_str(&self) -> &'static str {
match self {
Self::CargoPackageName => "cargo_package_name",
Self::CargoBinaryName => "cargo_binary_name",
Self::FirstLetterAbbrev => "first_letter_abbrev",
Self::GitRemote => "git_remote",
}
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AliasDiscovery {
pub short: String,
pub full: String,
pub source: DiscoverySource,
}
pub async fn discover_project_aliases(project_root: &Path) -> Result<Vec<AliasDiscovery>> {
let root = project_root.to_path_buf();
tokio::task::spawn_blocking(move || discover_blocking(&root))
.await
.context("join discover_project_aliases")?
}
fn discover_blocking(project_root: &Path) -> Result<Vec<AliasDiscovery>> {
let mut discoveries: Vec<AliasDiscovery> = Vec::new();
let mut seen_pairs: HashSet<(String, String)> = HashSet::new();
let mut packages: Vec<(String, String)> = Vec::new();
let root_manifest = project_root.join("Cargo.toml");
if root_manifest.is_file() {
match std::fs::read_to_string(&root_manifest)
.context("read root Cargo.toml")
.and_then(|s| toml::from_str::<toml::Value>(&s).context("parse root Cargo.toml"))
{
Ok(root_toml) => {
let members = workspace_members(&root_toml);
if !members.is_empty() {
for member in expand_members(project_root, &members) {
scan_member(&member, &mut discoveries, &mut seen_pairs, &mut packages);
}
} else if root_toml.get("package").is_some() {
scan_member(
project_root,
&mut discoveries,
&mut seen_pairs,
&mut packages,
);
}
}
Err(e) => {
tracing::warn!("discovery: skipping root Cargo.toml: {e:#}");
}
}
}
add_first_letter_abbreviations(&packages, &mut discoveries, &mut seen_pairs);
if let Some(d) = discover_git_remote(project_root) {
push_unique(&mut discoveries, &mut seen_pairs, d);
}
Ok(discoveries)
}
fn workspace_members(root_toml: &toml::Value) -> Vec<String> {
root_toml
.get("workspace")
.and_then(|w| w.get("members"))
.and_then(|m| m.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default()
}
fn expand_members(root: &Path, patterns: &[String]) -> Vec<PathBuf> {
let mut out = Vec::new();
for pattern in patterns {
if let Some(prefix) = pattern.strip_suffix("/*") {
let dir = root.join(prefix);
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() && path.join("Cargo.toml").is_file() {
out.push(path);
}
}
} else {
let path = root.join(pattern);
if path.is_dir() && path.join("Cargo.toml").is_file() {
out.push(path);
}
}
}
out
}
fn scan_member(
member_dir: &Path,
discoveries: &mut Vec<AliasDiscovery>,
seen_pairs: &mut HashSet<(String, String)>,
packages: &mut Vec<(String, String)>,
) {
let manifest = member_dir.join("Cargo.toml");
let Ok(raw) = std::fs::read_to_string(&manifest) else {
return;
};
let Ok(parsed) = toml::from_str::<toml::Value>(&raw) else {
tracing::warn!("discovery: failed to parse {}", manifest.display());
return;
};
let dir_name = member_dir
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
if dir_name.is_empty() {
return;
}
let package_name = parsed
.get("package")
.and_then(|p| p.get("name"))
.and_then(|n| n.as_str())
.map(|s| s.to_string());
if let Some(ref pkg) = package_name {
packages.push((pkg.clone(), dir_name.clone()));
if pkg != &dir_name {
push_unique(
discoveries,
seen_pairs,
AliasDiscovery {
short: pkg.clone(),
full: dir_name.clone(),
source: DiscoverySource::CargoPackageName,
},
);
}
}
if let Some(bins) = parsed.get("bin").and_then(|b| b.as_array()) {
let pkg_for_bin = package_name.as_deref().unwrap_or(&dir_name).to_string();
for bin in bins {
if let Some(bin_name) = bin.get("name").and_then(|n| n.as_str()) {
if bin_name != pkg_for_bin {
push_unique(
discoveries,
seen_pairs,
AliasDiscovery {
short: bin_name.to_string(),
full: pkg_for_bin.clone(),
source: DiscoverySource::CargoBinaryName,
},
);
}
}
}
}
}
fn add_first_letter_abbreviations(
packages: &[(String, String)],
discoveries: &mut Vec<AliasDiscovery>,
seen_pairs: &mut HashSet<(String, String)>,
) {
let package_name_set: HashSet<&str> = packages.iter().map(|(p, _)| p.as_str()).collect();
let mut groups: HashMap<String, Vec<&str>> = HashMap::new();
for (pkg, _dir) in packages {
if !pkg.contains('-') {
continue;
}
let abbrev: String = pkg
.split('-')
.filter_map(|seg| seg.chars().next())
.collect();
if abbrev.len() < 2 {
continue;
}
groups.entry(abbrev).or_default().push(pkg.as_str());
}
for (abbrev, fulls) in groups {
if fulls.len() != 1 {
continue;
}
let full = fulls[0];
if abbrev == full {
continue;
}
if package_name_set.contains(abbrev.as_str()) {
continue;
}
push_unique(
discoveries,
seen_pairs,
AliasDiscovery {
short: abbrev,
full: full.to_string(),
source: DiscoverySource::FirstLetterAbbrev,
},
);
}
}
fn discover_git_remote(project_root: &Path) -> Option<AliasDiscovery> {
let config_path = project_root.join(".git").join("config");
let raw = std::fs::read_to_string(&config_path).ok()?;
let url = extract_origin_url(&raw)?;
let short = short_repo_name(&url)?;
let dir_name = project_root
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
if dir_name.is_empty() || short == dir_name {
return None;
}
Some(AliasDiscovery {
short,
full: dir_name,
source: DiscoverySource::GitRemote,
})
}
fn extract_origin_url(config: &str) -> Option<String> {
let mut in_origin = false;
for line in config.lines() {
let trimmed = line.trim();
if trimmed.starts_with('[') {
in_origin = trimmed == "[remote \"origin\"]";
continue;
}
if in_origin {
if let Some(rest) = trimmed.strip_prefix("url") {
let rest = rest.trim_start();
if let Some(rest) = rest.strip_prefix('=') {
return Some(rest.trim().to_string());
}
}
}
}
None
}
fn short_repo_name(url: &str) -> Option<String> {
let last = url
.rsplit(|c: char| c == '/' || c == ':')
.next()
.unwrap_or("");
let stripped = last.strip_suffix(".git").unwrap_or(last).trim();
if stripped.is_empty() {
None
} else {
Some(stripped.to_string())
}
}
fn push_unique(
discoveries: &mut Vec<AliasDiscovery>,
seen_subjects: &mut HashSet<(String, String)>,
d: AliasDiscovery,
) {
let key = (d.short.clone(), String::new());
if seen_subjects.insert(key) {
discoveries.push(d);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn discovers_trusty_git_analytics_alias() {
let root = workspace_root();
let discoveries = discover_blocking(&root).expect("discover");
let hit = discoveries
.iter()
.find(|d| d.short == "tga" && d.full == "trusty-git-analytics");
assert!(
hit.is_some(),
"expected tga→trusty-git-analytics in discoveries; got: {discoveries:?}"
);
assert_eq!(hit.unwrap().source, DiscoverySource::CargoPackageName);
}
#[test]
fn first_letter_abbrev_emits_unique_workspace_initials() {
let root = workspace_root();
let discoveries = discover_blocking(&root).expect("discover");
let hit = discoveries.iter().find(|d| {
d.short == "tc"
&& d.full == "trusty-common"
&& d.source == DiscoverySource::FirstLetterAbbrev
});
assert!(
hit.is_some(),
"expected tc→trusty-common first-letter abbrev; got: {discoveries:?}"
);
}
#[test]
fn first_letter_abbrev_tm_unique_when_only_trusty_memory() {
let packages = vec![
("trusty-memory".to_string(), "trusty-memory".to_string()),
("trusty-common".to_string(), "trusty-common".to_string()),
("trusty-mpm-cli".to_string(), "trusty-mpm-cli".to_string()),
];
let mut discoveries = Vec::new();
let mut seen = HashSet::new();
add_first_letter_abbreviations(&packages, &mut discoveries, &mut seen);
let tm = discoveries
.iter()
.find(|d| d.short == "tm" && d.source == DiscoverySource::FirstLetterAbbrev);
assert_eq!(
tm.map(|d| d.full.as_str()),
Some("trusty-memory"),
"tm must abbreviate trusty-memory in this fixture; got: {discoveries:?}"
);
}
#[tokio::test]
async fn no_duplicate_short_names_in_results() {
let root = workspace_root();
let a = discover_project_aliases(&root).await.expect("discover a");
let b = discover_project_aliases(&root).await.expect("discover b");
assert_eq!(a.len(), b.len(), "two calls must yield equal counts");
let mut seen = HashSet::new();
for d in &a {
assert!(
seen.insert((d.short.clone(), d.full.clone())),
"duplicate discovery: {} → {} ({:?})",
d.short,
d.full,
d.source,
);
}
}
#[test]
fn first_letter_abbrev_skips_ambiguous() {
let packages = vec![
("trusty-memory".to_string(), "trusty-memory".to_string()),
("trusty-monitor".to_string(), "trusty-monitor".to_string()),
];
let mut discoveries = Vec::new();
let mut seen = HashSet::new();
add_first_letter_abbreviations(&packages, &mut discoveries, &mut seen);
let tm = discoveries
.iter()
.find(|d| d.short == "tm" && d.source == DiscoverySource::FirstLetterAbbrev);
assert!(
tm.is_none(),
"ambiguous tm must not produce an abbrev discovery; got: {discoveries:?}"
);
}
#[test]
fn extract_origin_url_handles_typical_config() {
let cfg = "\
[core]
\trepositoryformatversion = 0
[remote \"origin\"]
\turl = git@github.com:bobmatnyc/trusty-tools.git
\tfetch = +refs/heads/*:refs/remotes/origin/*
[branch \"main\"]
\tremote = origin
";
assert_eq!(
extract_origin_url(cfg),
Some("git@github.com:bobmatnyc/trusty-tools.git".to_string())
);
}
#[test]
fn short_repo_name_strips_git_suffix_and_path() {
assert_eq!(
short_repo_name("git@github.com:bobmatnyc/trusty-tools.git").as_deref(),
Some("trusty-tools")
);
assert_eq!(
short_repo_name("https://github.com/bobmatnyc/trusty-tools.git").as_deref(),
Some("trusty-tools")
);
assert_eq!(
short_repo_name("https://github.com/bobmatnyc/trusty-tools").as_deref(),
Some("trusty-tools")
);
assert_eq!(short_repo_name("").as_deref(), None);
}
#[test]
fn scan_member_emits_package_and_binary_aliases() {
let tmp = tempfile::tempdir().expect("tempdir");
let member = tmp.path().join("trusty-git-analytics");
std::fs::create_dir_all(&member).expect("mkdir");
std::fs::write(
member.join("Cargo.toml"),
r#"
[package]
name = "tga"
version = "0.1.0"
[[bin]]
name = "tga_bench"
path = "src/bench.rs"
[[bin]]
name = "tga"
path = "src/main.rs"
"#,
)
.expect("write Cargo.toml");
let mut discoveries = Vec::new();
let mut seen = HashSet::new();
let mut packages = Vec::new();
scan_member(&member, &mut discoveries, &mut seen, &mut packages);
let pkg_disc = discoveries
.iter()
.find(|d| d.source == DiscoverySource::CargoPackageName)
.expect("package alias");
assert_eq!(pkg_disc.short, "tga");
assert_eq!(pkg_disc.full, "trusty-git-analytics");
let bin_disc = discoveries
.iter()
.find(|d| d.source == DiscoverySource::CargoBinaryName)
.expect("binary alias");
assert_eq!(bin_disc.short, "tga_bench");
assert_eq!(bin_disc.full, "tga");
assert_eq!(
discoveries
.iter()
.filter(|d| d.source == DiscoverySource::CargoBinaryName)
.count(),
1
);
}
fn workspace_root() -> PathBuf {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest_dir
.parent() .and_then(|p| p.parent()) .expect("workspace root")
.to_path_buf()
}
}