use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::Context as _;
use serde::Deserialize;
const BUILTINS: &[(&str, &str)] = &[
("b", "build"),
("c", "check"),
("d", "doc"),
("t", "test"),
("r", "run"),
("rm", "remove"),
];
const MAX_EXPANSION_DEPTH: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExtractedAlias {
pub name: String,
pub expansion: Vec<String>,
}
impl ExtractedAlias {
pub(crate) fn display_command(&self) -> String {
shlex::try_join(self.expansion.iter().map(String::as_str))
.unwrap_or_else(|_| self.expansion.join(" "))
}
}
pub(crate) fn find_configs(start: &Path) -> Vec<PathBuf> {
let mut configs = Vec::new();
for ancestor in start.ancestors() {
if let Some(path) = pick_config_in(&ancestor.join(".cargo")) {
configs.push(path);
}
}
if let Some(home) = cargo_home()
&& let Some(path) = pick_config_in(&home)
{
if !configs.iter().any(|existing| existing == &path) {
configs.push(path);
}
}
configs
}
fn pick_config_in(dir: &Path) -> Option<PathBuf> {
let no_ext = dir.join("config");
if no_ext.is_file() {
return Some(no_ext);
}
let with_ext = dir.join("config.toml");
with_ext.is_file().then_some(with_ext)
}
fn cargo_home() -> Option<PathBuf> {
if let Ok(value) = std::env::var("CARGO_HOME")
&& !value.is_empty()
{
return Some(PathBuf::from(value));
}
home_dir().map(|home| home.join(".cargo"))
}
fn home_dir() -> Option<PathBuf> {
#[cfg(unix)]
let var = "HOME";
#[cfg(windows)]
let var = "USERPROFILE";
std::env::var_os(var)
.map(PathBuf::from)
.filter(|p| !p.as_os_str().is_empty())
}
pub(crate) fn extract_tasks(dir: &Path) -> anyhow::Result<Vec<ExtractedAlias>> {
let configs = find_configs(dir);
let raw = merge_alias_tables(&configs)?;
Ok(expand_all(&raw))
}
pub(crate) fn find_anchor(root: &Path) -> Option<PathBuf> {
find_configs(root).into_iter().next().or_else(|| {
let cargo_toml = root.join("Cargo.toml");
cargo_toml.is_file().then_some(cargo_toml)
})
}
pub(crate) fn run_cmd(task: &str, args: &[String]) -> Command {
let mut c = super::program::command("cargo");
c.arg(task).args(args);
c
}
fn merge_alias_tables(paths: &[PathBuf]) -> anyhow::Result<HashMap<String, Vec<String>>> {
let mut merged: HashMap<String, Vec<String>> = HashMap::new();
for path in paths.iter().rev() {
let aliases =
read_alias_table(path).with_context(|| format!("reading {}", path.display()))?;
for (name, value) in aliases {
let Some(tokens) = tokenize(&value) else {
anyhow::bail!(
"cargo alias `{name}` in {} is unparseable or empty",
path.display()
);
};
merged.insert(name, tokens);
}
}
for (name, expansion) in BUILTINS {
merged.insert((*name).to_string(), vec![(*expansion).to_string()]);
}
Ok(merged)
}
#[derive(Deserialize)]
struct ConfigDoc {
#[serde(default)]
alias: HashMap<String, AliasValue>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum AliasValue {
Str(String),
Arr(Vec<String>),
}
fn read_alias_table(path: &Path) -> anyhow::Result<HashMap<String, AliasValue>> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("failed to read {}", path.display()))?;
let doc: ConfigDoc =
toml::from_str(&content).with_context(|| format!("failed to parse {}", path.display()))?;
Ok(doc.alias)
}
fn tokenize(value: &AliasValue) -> Option<Vec<String>> {
match value {
AliasValue::Arr(tokens) => (!tokens.is_empty()).then(|| tokens.clone()),
AliasValue::Str(raw) => {
let split = shlex::split(raw)?;
(!split.is_empty()).then_some(split)
}
}
}
fn expand_all(map: &HashMap<String, Vec<String>>) -> Vec<ExtractedAlias> {
let mut out: Vec<ExtractedAlias> = map
.iter()
.map(|(name, tokens)| ExtractedAlias {
name: name.clone(),
expansion: expand_chain(tokens.clone(), map),
})
.collect();
out.sort_by(|a, b| a.name.cmp(&b.name));
out
}
fn expand_chain(mut tokens: Vec<String>, map: &HashMap<String, Vec<String>>) -> Vec<String> {
let mut visited: HashSet<String> = HashSet::new();
for _ in 0..MAX_EXPANSION_DEPTH {
let Some(head) = tokens.first().cloned() else {
return tokens;
};
let Some(expansion) = map.get(&head) else {
return tokens;
};
if !visited.insert(head) {
return tokens;
}
let mut next = expansion.clone();
next.extend(tokens.drain(1..));
tokens = next;
}
tokens
}
#[cfg(test)]
mod tests {
use std::fs;
use super::{
AliasValue, BUILTINS, ExtractedAlias, expand_chain, extract_tasks, find_configs,
merge_alias_tables, pick_config_in, tokenize,
};
use crate::tool::test_support::TempDir;
#[test]
fn pick_config_prefers_no_extension_when_both_present() {
let dir = TempDir::new("cargo-aliases-pick");
fs::create_dir_all(dir.path().join(".cargo")).unwrap();
fs::write(
dir.path().join(".cargo").join("config"),
"[alias]\nx = \"build\"\n",
)
.unwrap();
fs::write(
dir.path().join(".cargo").join("config.toml"),
"[alias]\ny = \"build\"\n",
)
.unwrap();
let picked = pick_config_in(&dir.path().join(".cargo")).expect("config should resolve");
assert!(picked.ends_with("config"));
assert!(!picked.to_string_lossy().ends_with(".toml"));
}
#[test]
fn find_configs_walks_up_from_nested_dir() {
let dir = TempDir::new("cargo-aliases-walk");
let nested = dir.path().join("a").join("b");
fs::create_dir_all(&nested).unwrap();
fs::create_dir_all(dir.path().join(".cargo")).unwrap();
fs::write(dir.path().join(".cargo").join("config.toml"), "").unwrap();
fs::create_dir_all(nested.join(".cargo")).unwrap();
fs::write(nested.join(".cargo").join("config.toml"), "").unwrap();
let configs = find_configs(&nested);
let names: Vec<_> = configs
.iter()
.map(|p| p.parent().unwrap().parent().unwrap().to_path_buf())
.collect();
assert!(names[0].ends_with("b"));
assert!(names.iter().any(|p| p == dir.path()));
}
#[test]
fn tokenize_string_form_handles_quoted_args() {
let tokens = tokenize(&AliasValue::Str("run -- \"a b\"".into())).unwrap();
assert_eq!(tokens, ["run", "--", "a b"]);
}
#[test]
fn tokenize_array_form_passes_through_verbatim() {
let tokens = tokenize(&AliasValue::Arr(vec!["run".into(), "--release".into()])).unwrap();
assert_eq!(tokens, ["run", "--release"]);
}
#[test]
fn tokenize_rejects_empty_values() {
assert!(tokenize(&AliasValue::Str(String::new())).is_none());
assert!(tokenize(&AliasValue::Arr(Vec::new())).is_none());
}
#[test]
fn merge_overlays_builtins_over_user_redefinitions() {
let dir = TempDir::new("cargo-aliases-builtin-override");
fs::create_dir_all(dir.path().join(".cargo")).unwrap();
fs::write(
dir.path().join(".cargo").join("config.toml"),
"[alias]\nb = \"check\"\n",
)
.unwrap();
let merged = merge_alias_tables(&[dir.path().join(".cargo").join("config.toml")]).unwrap();
assert_eq!(merged.get("b").unwrap(), &vec!["build".to_string()]);
}
#[test]
fn merge_deeper_config_wins_over_ancestor() {
let dir = TempDir::new("cargo-aliases-deep-wins");
let nested = dir.path().join("crate");
fs::create_dir_all(nested.join(".cargo")).unwrap();
fs::create_dir_all(dir.path().join(".cargo")).unwrap();
fs::write(
dir.path().join(".cargo").join("config.toml"),
"[alias]\nl = \"clippy\"\n",
)
.unwrap();
fs::write(
nested.join(".cargo").join("config.toml"),
"[alias]\nl = \"clippy --all-targets\"\n",
)
.unwrap();
let configs = vec![
nested.join(".cargo").join("config.toml"),
dir.path().join(".cargo").join("config.toml"),
];
let merged = merge_alias_tables(&configs).unwrap();
assert_eq!(merged.get("l").unwrap(), &vec!["clippy", "--all-targets"]);
}
#[test]
fn expand_chain_resolves_recursive_aliases() {
let mut map = std::collections::HashMap::new();
map.insert("rr".into(), vec!["run".into(), "--release".into()]);
map.insert(
"recursive_example".into(),
vec!["rr".into(), "--example".into(), "recursions".into()],
);
let expanded = expand_chain(map["recursive_example"].clone(), &map);
assert_eq!(expanded, ["run", "--release", "--example", "recursions"]);
}
#[test]
fn expand_chain_breaks_self_referential_cycles() {
let mut map = std::collections::HashMap::new();
map.insert("loop".into(), vec!["loop".into(), "--flag".into()]);
let expanded = expand_chain(map["loop"].clone(), &map);
assert!(expanded.iter().any(|t| t == "--flag"));
}
#[test]
fn extract_tasks_surfaces_builtins_even_without_user_aliases() {
let dir = TempDir::new("cargo-aliases-builtins-only");
fs::create_dir_all(dir.path().join(".cargo")).unwrap();
fs::write(dir.path().join(".cargo").join("config.toml"), "").unwrap();
let tasks = extract_tasks(dir.path()).unwrap();
for (name, expansion) in BUILTINS {
let task = tasks
.iter()
.find(|t| t.name == *name)
.unwrap_or_else(|| panic!("built-in {name} should be surfaced"));
assert_eq!(task.expansion, vec![(*expansion).to_string()]);
}
}
#[test]
fn display_command_renders_tokens_space_separated() {
let alias = ExtractedAlias {
name: "l".into(),
expansion: vec![
"clippy".into(),
"--all-targets".into(),
"-D".into(),
"warnings".into(),
],
};
assert_eq!(alias.display_command(), "clippy --all-targets -D warnings");
}
#[test]
fn display_command_round_trips_whitespace_tokens() {
let alias = ExtractedAlias {
name: "x".into(),
expansion: vec!["run".into(), "--".into(), "a b".into()],
};
let rendered = alias.display_command();
let reparsed = tokenize(&AliasValue::Str(rendered.clone())).unwrap();
assert_eq!(reparsed, alias.expansion, "rendered: {rendered}");
}
}