use std::collections::BTreeMap;
use std::path::PathBuf;
use toml_edit::{DocumentMut, InlineTable, Item, Table, value};
use crate::BUILTINS;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Scope {
Global,
Profile(String),
}
impl Scope {
pub fn label(&self) -> String {
match self {
Scope::Global => "global".to_string(),
Scope::Profile(name) => format!("profile {name}"),
}
}
}
#[derive(Debug, PartialEq, Eq)]
pub struct Entry {
pub name: String,
pub expansion: String,
pub scope: Scope,
}
#[derive(Debug)]
pub struct Applied {
pub scope: Scope,
pub previous: Option<String>,
pub warning: Option<String>,
}
#[derive(Default)]
pub struct Aliases {
global: BTreeMap<String, String>,
profile: BTreeMap<String, String>,
profile_name: Option<String>,
profile_cache: BTreeMap<String, BTreeMap<String, String>>,
path: Option<PathBuf>,
}
impl Aliases {
pub fn new(
global: BTreeMap<String, String>,
profile: BTreeMap<String, String>,
profile_name: Option<String>,
path: Option<PathBuf>,
) -> Self {
let profile_cache = profile_name
.as_ref()
.map(|name| [(name.clone(), profile.clone())].into_iter().collect())
.unwrap_or_default();
Self {
global,
profile,
profile_name,
profile_cache,
path,
}
}
pub fn lookup(&self, name: &str) -> Option<(&str, Scope)> {
if let Some(expansion) = self.profile.get(name) {
let scope = Scope::Profile(self.profile_name.clone().unwrap_or_default());
return Some((expansion.as_str(), scope));
}
self.global.get(name).map(|e| (e.as_str(), Scope::Global))
}
pub fn entries(&self) -> Vec<Entry> {
let profile_scope = || Scope::Profile(self.profile_name.clone().unwrap_or_default());
let mut out: Vec<Entry> = self
.profile
.iter()
.map(|(name, expansion)| Entry {
name: name.clone(),
expansion: expansion.clone(),
scope: profile_scope(),
})
.collect();
out.extend(
self.global
.iter()
.filter(|(name, _)| !self.profile.contains_key(*name))
.map(|(name, expansion)| Entry {
name: name.clone(),
expansion: expansion.clone(),
scope: Scope::Global,
}),
);
out
}
pub fn select_profile(&mut self, name: Option<String>, aliases: BTreeMap<String, String>) {
if let Some(current) = self.profile_name.as_ref() {
self.profile_cache
.insert(current.clone(), self.profile.clone());
}
self.profile = name
.as_ref()
.and_then(|name| self.profile_cache.get(name).cloned())
.unwrap_or(aliases);
if let Some(name) = name.as_ref() {
self.profile_cache
.insert(name.clone(), self.profile.clone());
}
self.profile_name = name;
}
pub fn expand(&self, line: &str) -> Result<Option<String>, String> {
let mut current = line.to_string();
let mut seen: Vec<String> = Vec::new();
loop {
let trimmed = current.trim_start();
let (first, rest) = match trimmed.find(char::is_whitespace) {
Some(i) => (&trimmed[..i], trimmed[i..].trim_start()),
None => (trimmed, ""),
};
if matches!(first, "tool" | "builtin") {
break;
}
let Some((expansion, _)) = self.lookup(first) else {
break;
};
if seen.iter().any(|s| s == first) {
seen.push(first.to_string());
return Err(format!(
"alias `{}` expands in a cycle ({}); break it with `unalias {}`",
seen[0],
seen.join(" -> "),
seen[0]
));
}
seen.push(first.to_string());
current = if rest.is_empty() {
expansion.to_string()
} else {
format!("{expansion} {rest}")
};
}
Ok((!seen.is_empty()).then_some(current))
}
pub fn define(&mut self, name: &str, expansion: &str, global: bool) -> Result<Applied, String> {
validate(name, expansion)?;
let scope = self.write_scope(global);
let table = self.table_mut(&scope);
let previous = table.insert(name.to_string(), expansion.to_string());
let warning = self
.persist(|doc| set_in_document(doc, &scope, name, expansion))
.err();
Ok(Applied {
scope,
previous,
warning,
})
}
pub fn remove(&mut self, name: &str, global: bool) -> Result<Applied, String> {
let scope = if !global && self.profile.contains_key(name) {
Scope::Profile(self.profile_name.clone().unwrap_or_default())
} else if self.global.contains_key(name) {
Scope::Global
} else if !global && self.profile_name.is_some() {
return Err(format!("no alias named `{name}`"));
} else {
return Err(format!("no global alias named `{name}`"));
};
let previous = self.table_mut(&scope).remove(name);
let warning = self
.persist(|doc| remove_from_document(doc, &scope, name))
.err();
Ok(Applied {
scope,
previous,
warning,
})
}
fn write_scope(&self, global: bool) -> Scope {
match (&self.profile_name, global) {
(Some(name), false) => Scope::Profile(name.clone()),
_ => Scope::Global,
}
}
fn table_mut(&mut self, scope: &Scope) -> &mut BTreeMap<String, String> {
match scope {
Scope::Global => &mut self.global,
Scope::Profile(_) => &mut self.profile,
}
}
fn persist(
&self,
edit: impl FnOnce(&mut DocumentMut) -> Result<(), String>,
) -> Result<(), String> {
let Some(path) = &self.path else {
return Err(
"no platform config directory (pass --config), so the alias applies to \
this session only"
.to_string(),
);
};
let source = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(format!("{}: {e}", path.display())),
};
let mut doc = source
.parse::<DocumentMut>()
.map_err(|e| format!("{}: {e}", path.display()))?;
edit(&mut doc)?;
write_atomic(path, &doc.to_string()).map_err(|e| format!("{}: {e}", path.display()))
}
}
fn validate(name: &str, expansion: &str) -> Result<(), String> {
if name.is_empty() {
return Err("usage: alias <name>=<expansion>".to_string());
}
if name.chars().any(char::is_whitespace) {
return Err(format!("alias name `{name}` cannot contain whitespace"));
}
if name.contains('=') {
return Err(format!("alias name `{name}` cannot contain `=`"));
}
if let Some((builtin, _)) = BUILTINS.iter().find(|(b, _)| *b == name) {
return Err(format!(
"`{builtin}` is a built-in command, so an alias by that name would hide it"
));
}
if expansion.trim().is_empty() {
return Err(format!(
"alias `{name}` needs something to expand to (usage: alias {name}=<expansion>)"
));
}
Ok(())
}
use crate::secure_file::write_atomic;
enum AliasTable<'t> {
Table(&'t mut Table),
Inline(&'t mut InlineTable),
}
impl AliasTable<'_> {
fn set(&mut self, name: &str, expansion: &str) {
match self {
AliasTable::Table(t) => t[name] = value(expansion),
AliasTable::Inline(t) => {
t.insert(name, expansion.into());
}
}
}
fn remove(&mut self, name: &str) -> bool {
match self {
AliasTable::Table(t) => t.remove(name).is_some(),
AliasTable::Inline(t) => t.remove(name).is_some(),
}
}
}
pub fn set_in_document(
doc: &mut DocumentMut,
scope: &Scope,
name: &str,
expansion: &str,
) -> Result<(), String> {
let mut table = aliases_table(doc, scope, true)?
.ok_or_else(|| format!("no `{}` table in the config file", scope.label()))?;
table.set(name, expansion);
Ok(())
}
pub fn remove_from_document(
doc: &mut DocumentMut,
scope: &Scope,
name: &str,
) -> Result<(), String> {
let Some(mut table) = aliases_table(doc, scope, false)? else {
return Ok(());
};
table.remove(name);
Ok(())
}
fn aliases_table<'d>(
doc: &'d mut DocumentMut,
scope: &Scope,
create: bool,
) -> Result<Option<AliasTable<'d>>, String> {
let parent = match scope {
Scope::Global => doc.as_table_mut(),
Scope::Profile(profile) => {
let Some(servers) = child_table(doc.as_table_mut(), "servers")? else {
return Ok(None);
};
match child_table(servers, profile)? {
Some(t) => t,
None => return Ok(None),
}
}
};
if !parent.contains_key("aliases") {
if !create {
return Ok(None);
}
parent.insert("aliases", Item::Table(Table::new()));
}
match parent.get_mut("aliases") {
Some(Item::Table(t)) => Ok(Some(AliasTable::Table(t))),
Some(Item::Value(v)) if v.is_inline_table() => Ok(Some(AliasTable::Inline(
v.as_inline_table_mut().expect("checked"),
))),
Some(_) => Err("`aliases` in the config file is not a table".to_string()),
None => Ok(None),
}
}
fn child_table<'t>(parent: &'t mut Table, key: &str) -> Result<Option<&'t mut Table>, String> {
match parent.get_mut(key) {
Some(Item::Table(t)) => Ok(Some(t)),
Some(_) => Err(format!(
"`{key}` in the config file is not a standalone table, so the alias cannot be written \
into it; add it to the file-level [aliases] table with `alias --global` instead"
)),
None => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::property::{ALIAS_REGRESSIONS, GENERATED_CASES, Generator};
fn map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn aliases(global: &[(&str, &str)], profile: &[(&str, &str)]) -> Aliases {
let name = (!profile.is_empty()).then(|| "cratesio".to_string());
Aliases::new(map(global), map(profile), name, None)
}
#[test]
fn property_alias_expansion_always_terminates_at_a_command_or_cycle() {
for (pairs, line) in ALIAS_REGRESSIONS {
let aliases = aliases(pairs, &[]);
if let Err(error) = aliases.expand(line) {
assert!(error.contains("cycle"), "unexpected alias failure: {error}");
}
}
let mut generator = Generator::new(0x05);
for _ in 0..GENERATED_CASES {
let count = 1 + generator.index(32);
let mut table = BTreeMap::new();
for index in 0..count {
let target = generator.index(count + 2);
let first = if target < count {
format!("a{target}")
} else if target == count {
"tool echo".to_string()
} else {
"echo".to_string()
};
let tail = generator.text(24).replace(char::is_whitespace, "_");
let expansion = if tail.is_empty() {
first
} else {
format!("{first} value={tail}")
};
table.insert(format!("a{index}"), expansion);
}
let aliases = Aliases::new(table.clone(), BTreeMap::new(), None, None);
let line = format!("a{} arg={}", generator.index(count), generator.text(24));
match aliases.expand(&line) {
Ok(Some(expanded)) => {
let first = expanded.split_whitespace().next().unwrap_or_default();
assert!(
matches!(first, "tool" | "builtin") || !table.contains_key(first),
"expansion stopped on alias {first:?}: {expanded:?}"
);
}
Ok(None) => panic!("generated input always starts with an alias: {line:?}"),
Err(error) => assert!(error.contains("cycle"), "unexpected failure: {error}"),
}
}
}
#[test]
fn expansion_substitutes_the_first_word_and_keeps_the_rest() {
let a = aliases(&[("dl", "get_downloads crate")], &[]);
assert_eq!(
a.expand("dl =serde").unwrap().as_deref(),
Some("get_downloads crate =serde")
);
assert_eq!(
a.expand("dl").unwrap().as_deref(),
Some("get_downloads crate")
);
}
#[test]
fn a_line_that_names_no_alias_is_left_alone() {
let a = aliases(&[("t", "tools")], &[]);
assert_eq!(a.expand("tools").unwrap(), None);
assert_eq!(a.expand("").unwrap(), None);
assert_eq!(a.expand("describe t").unwrap(), None);
}
#[test]
fn an_expansion_naming_another_alias_expands_again() {
let a = aliases(&[("t", "tools"), ("lst", "t")], &[]);
assert_eq!(a.expand("lst").unwrap().as_deref(), Some("tools"));
}
#[test]
fn a_cycle_is_reported_rather_than_looped() {
let a = aliases(&[("a", "b"), ("b", "a")], &[]);
let err = a.expand("a x").unwrap_err();
assert!(err.contains("cycle"), "{err}");
assert!(err.contains("a -> b -> a"), "{err}");
assert!(err.contains("unalias a"), "{err}");
}
#[test]
fn a_trailing_ampersand_survives_expansion() {
let a = aliases(&[("sa", "slow_add a=1 b=2 &")], &[]);
assert_eq!(
a.expand("sa").unwrap().as_deref(),
Some("slow_add a=1 b=2 &")
);
let a = aliases(&[("sa", "slow_add a=1 b=2")], &[]);
assert_eq!(
a.expand("sa &").unwrap().as_deref(),
Some("slow_add a=1 b=2 &")
);
}
#[test]
fn a_profile_alias_shadows_the_global_one() {
let a = aliases(&[("t", "tools")], &[("t", "templates")]);
assert_eq!(a.expand("t").unwrap().as_deref(), Some("templates"));
assert_eq!(
a.lookup("t").map(|(e, s)| (e.to_string(), s)),
Some(("templates".to_string(), Scope::Profile("cratesio".into())))
);
let entries = a.entries();
assert_eq!(entries.len(), 1, "{entries:?}");
assert_eq!(entries[0].expansion, "templates");
}
#[test]
fn switching_profiles_keeps_globals_and_replaces_profile_aliases() {
let mut aliases = aliases(&[("g", "help")], &[("p", "tools")]);
aliases.select_profile(Some("other".to_string()), map(&[("q", "prompts")]));
assert!(aliases.lookup("p").is_none());
assert_eq!(aliases.lookup("g").map(|(value, _)| value), Some("help"));
assert_eq!(
aliases.lookup("q"),
Some(("prompts", Scope::Profile("other".to_string())))
);
aliases.define("live", "resources", false).unwrap();
aliases.select_profile(None, BTreeMap::new());
assert!(aliases.lookup("live").is_none());
aliases.select_profile(Some("other".to_string()), BTreeMap::new());
assert_eq!(
aliases.lookup("live").map(|(value, _)| value),
Some("resources")
);
}
#[test]
fn entries_list_profile_aliases_before_global_ones() {
let a = aliases(&[("z", "tools"), ("a", "prompts")], &[("p", "templates")]);
let names: Vec<String> = a.entries().into_iter().map(|e| e.name).collect();
assert_eq!(names, vec!["p", "a", "z"]);
}
#[test]
fn a_name_that_would_hide_a_builtin_is_refused() {
let mut a = aliases(&[], &[]);
for reserved in ["help", "tool", "builtin"] {
let err = a.define(reserved, "tools", false).unwrap_err();
assert!(err.contains("built-in"), "{err}");
}
assert!(a.entries().is_empty());
}
#[test]
fn an_alias_can_expand_to_an_explicit_namespace() {
let a = aliases(&[("w", "tool wait"), ("tool", "tools")], &[]);
assert_eq!(
a.expand("w id=42").unwrap().as_deref(),
Some("tool wait id=42")
);
assert_eq!(a.expand("tool wait id=42").unwrap(), None);
}
#[test]
fn a_malformed_name_or_empty_expansion_is_refused() {
let mut a = aliases(&[], &[]);
assert!(
a.define("two words", "tools", false)
.unwrap_err()
.contains("whitespace")
);
assert!(a.define("a=b", "tools", false).unwrap_err().contains('='));
assert!(
a.define("t", " ", false)
.unwrap_err()
.contains("expand to")
);
}
#[test]
fn define_reports_the_scope_and_what_it_replaced() {
let mut a = aliases(&[], &[("t", "tools")]);
let applied = a.define("t", "templates", false).unwrap();
assert_eq!(applied.scope, Scope::Profile("cratesio".into()));
assert_eq!(applied.previous.as_deref(), Some("tools"));
assert!(applied.warning.is_some());
assert_eq!(a.expand("t").unwrap().as_deref(), Some("templates"));
}
#[test]
fn define_without_a_profile_lands_in_the_global_table() {
let mut a = aliases(&[], &[]);
assert_eq!(a.define("t", "tools", false).unwrap().scope, Scope::Global);
let mut a = aliases(&[], &[("x", "tools")]);
assert_eq!(a.define("t", "tools", true).unwrap().scope, Scope::Global);
}
#[test]
fn unalias_removes_the_definition_in_effect() {
let mut a = aliases(&[("t", "tools")], &[("t", "templates")]);
assert_eq!(
a.remove("t", false).unwrap().scope,
Scope::Profile("cratesio".into())
);
assert_eq!(a.expand("t").unwrap().as_deref(), Some("tools"));
assert_eq!(a.remove("t", false).unwrap().scope, Scope::Global);
assert_eq!(a.expand("t").unwrap(), None);
}
#[test]
fn unalias_global_does_not_reach_a_profile_alias() {
let mut a = aliases(&[], &[("t", "templates")]);
let err = a.remove("t", true).unwrap_err();
assert!(err.contains("no global alias"), "{err}");
assert_eq!(
a.remove("nope", false).unwrap_err(),
"no alias named `nope`"
);
}
fn edited(source: &str, edit: impl FnOnce(&mut DocumentMut)) -> String {
let mut doc = source.parse::<DocumentMut>().unwrap();
edit(&mut doc);
doc.to_string()
}
const WITH_PROFILE: &str = r#"# my servers
[servers.cratesio]
url = "https://cratesio-mcp.fly.dev/" # the public one
"#;
#[test]
fn writing_an_alias_preserves_the_rest_of_the_file() {
let out = edited(WITH_PROFILE, |doc| {
set_in_document(doc, &Scope::Global, "t", "tools").unwrap()
});
assert!(out.contains("# my servers"), "{out}");
assert!(out.contains("# the public one"), "{out}");
assert!(out.contains("[aliases]"), "{out}");
assert!(out.contains(r#"t = "tools""#), "{out}");
out.parse::<DocumentMut>().unwrap();
}
#[test]
fn a_profile_alias_is_written_under_the_profile() {
let out = edited(WITH_PROFILE, |doc| {
set_in_document(
doc,
&Scope::Profile("cratesio".into()),
"dl",
"get_downloads",
)
.unwrap()
});
assert!(out.contains("[servers.cratesio.aliases]"), "{out}");
assert!(out.contains(r#"dl = "get_downloads""#), "{out}");
}
#[test]
fn a_profile_that_is_not_in_the_file_is_an_error_not_a_new_profile() {
let mut doc = WITH_PROFILE.parse::<DocumentMut>().unwrap();
let err =
set_in_document(&mut doc, &Scope::Profile("absent".into()), "t", "tools").unwrap_err();
assert!(err.contains("absent"), "{err}");
}
#[test]
fn redefining_replaces_the_value_in_place() {
let out = edited("[aliases]\nt = \"tools\"\nd = \"describe\"\n", |doc| {
set_in_document(doc, &Scope::Global, "t", "templates").unwrap()
});
assert!(out.contains(r#"t = "templates""#), "{out}");
assert!(out.contains(r#"d = "describe""#), "{out}");
assert!(!out.contains(r#""tools""#), "{out}");
}
#[test]
fn an_inline_aliases_table_is_edited_in_place() {
let out = edited(
"[servers.x]\nurl = \"https://example/mcp\"\naliases = { t = \"tools\" }\n",
|doc| {
set_in_document(doc, &Scope::Profile("x".into()), "d", "describe").unwrap();
},
);
assert!(out.contains("aliases = {"), "{out}");
assert!(out.contains(r#"t = "tools""#), "{out}");
assert!(out.contains(r#"d = "describe""#), "{out}");
}
#[test]
fn removing_the_last_alias_empties_the_table_but_keeps_its_comment() {
let out = edited("# keep me\n[aliases]\nt = \"tools\"\n", |doc| {
remove_from_document(doc, &Scope::Global, "t").unwrap()
});
assert!(!out.contains(r#"t = "tools""#), "{out}");
assert!(out.contains("# keep me"), "{out}");
assert!(
crate::config::Config::parse(&out)
.unwrap()
.aliases
.is_empty(),
"the emptied table should read back as no aliases: {out}"
);
let out = edited("[aliases]\nt = \"tools\"\nd = \"describe\"\n", |doc| {
remove_from_document(doc, &Scope::Global, "t").unwrap()
});
assert!(out.contains("[aliases]"), "{out}");
assert!(out.contains(r#"d = "describe""#), "{out}");
}
#[test]
fn removing_from_a_file_without_the_table_is_not_an_error() {
let out = edited(WITH_PROFILE, |doc| {
remove_from_document(doc, &Scope::Global, "t").unwrap();
remove_from_document(doc, &Scope::Profile("cratesio".into()), "t").unwrap();
remove_from_document(doc, &Scope::Profile("absent".into()), "t").unwrap();
});
assert_eq!(out, WITH_PROFILE);
}
#[test]
fn a_full_round_trip_through_a_real_file() {
let dir = std::env::temp_dir().join(format!("mcp-repl-alias-{}", std::process::id()));
let path = dir.join("config.toml");
let _ = std::fs::remove_dir_all(&dir);
let mut a = Aliases::new(BTreeMap::new(), BTreeMap::new(), None, Some(path.clone()));
let applied = a.define("t", "tools", false).unwrap();
assert!(applied.warning.is_none(), "{:?}", applied.warning);
let written = std::fs::read_to_string(&path).unwrap();
assert!(written.contains("[aliases]"), "{written}");
assert!(written.contains(r#"t = "tools""#), "{written}");
let config = crate::config::Config::parse(&written).unwrap();
assert_eq!(config.aliases.get("t").map(String::as_str), Some("tools"));
a.remove("t", false).unwrap();
let written = std::fs::read_to_string(&path).unwrap();
assert!(
crate::config::Config::parse(&written)
.unwrap()
.aliases
.is_empty(),
"{written}"
);
let _ = std::fs::remove_dir_all(&dir);
}
}