use std::borrow::Cow;
use std::collections::HashMap;
use crate::config::Config;
#[derive(Debug, Default, Clone)]
pub(crate) struct AliasMap {
map: HashMap<String, String>,
}
impl AliasMap {
#[cfg(feature = "slash-completion")]
pub(crate) fn names(&self) -> Vec<String> {
self.map.keys().cloned().collect()
}
}
pub(crate) fn build_alias_map(cfg: &Config) -> (AliasMap, Vec<String>) {
let mut map = HashMap::new();
let mut warnings = Vec::new();
let Some(raw) = cfg.slash_aliases.as_ref() else {
return (AliasMap { map }, warnings);
};
for (alias, target) in raw {
let alias_key = alias.trim_start_matches('/').to_string();
let target_cmd = if target.starts_with('/') {
target.clone()
} else {
format!("/{target}")
};
if alias_key.is_empty() {
warnings.push(format!(
"slash_aliases: {:?} -> {:?}: empty alias key is ignored \
(it would make bare \"/\" run {target_cmd})",
alias, target,
));
continue;
}
if !super::is_known_slash_command(&target_cmd) {
warnings.push(format!(
"slash_aliases: {:?} -> {:?}: {:?} is not a known built-in command \
(see /help); it will be passed through but may not resolve",
alias, target, target_cmd,
));
}
if super::is_known_slash_command(&format!("/{alias_key}")) {
warnings.push(format!(
"slash_aliases: {:?} -> {:?}: alias key {:?} shadows the built-in \
/{alias_key}, which can no longer be run by that name",
alias, target, alias_key,
));
}
map.insert(alias_key, target_cmd);
}
(AliasMap { map }, warnings)
}
pub(crate) fn expand_alias<'a>(text: &'a str, aliases: &AliasMap) -> Cow<'a, str> {
let Some(rest) = text.strip_prefix('/') else {
return Cow::Borrowed(text);
};
let (name, args) = match rest.find(char::is_whitespace) {
Some(i) => (&rest[..i], &rest[i..]),
None => (rest, ""),
};
let Some(target) = aliases.map.get(name) else {
return Cow::Borrowed(text);
};
if args.is_empty() {
Cow::Owned(target.clone())
} else {
Cow::Owned(format!("{target}{args}"))
}
}
pub(crate) fn display_entries(cfg: &Config) -> Vec<String> {
let (am, _warnings) = build_alias_map(cfg);
let mut entries: Vec<(String, String)> = am.map.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
entries
.into_iter()
.map(|(alias, target)| format!("/{alias} -> {target}"))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_deserializes_slash_aliases_flat_map() {
let cfg: Config =
serde_json::from_str(r#"{ "slash_aliases": { "exit": "quit" } }"#).unwrap();
let map = cfg.slash_aliases.expect("slash_aliases should deserialize");
assert_eq!(map.get("exit").map(String::as_str), Some("quit"));
let cfg: Config = serde_json::from_str(r"{}").unwrap();
assert!(cfg.slash_aliases.is_none(), "absent key -> None");
}
#[test]
fn build_alias_map_normalizes_leading_slashes() {
let cfg: Config =
serde_json::from_str(r#"{ "slash_aliases": { "/exit": "quit", "q": "/quit" } }"#)
.unwrap();
let (am, warnings) = build_alias_map(&cfg);
assert_eq!(am.map.get("exit").map(String::as_str), Some("/quit"));
assert_eq!(am.map.get("q").map(String::as_str), Some("/quit"));
assert!(
warnings.is_empty(),
"quit is a known built-in: {warnings:?}"
);
}
#[test]
fn build_alias_map_warns_on_unknown_target() {
let cfg: Config =
serde_json::from_str(r#"{ "slash_aliases": { "exit": "qiut" } }"#).unwrap();
let (am, warnings) = build_alias_map(&cfg);
assert_eq!(am.map.get("exit").map(String::as_str), Some("/qiut"));
assert_eq!(warnings.len(), 1, "exactly one warning: {warnings:?}");
assert!(
warnings[0].contains("/qiut") && warnings[0].contains("not a known built-in"),
"warning should name the target: {}",
warnings[0],
);
}
#[test]
fn build_alias_map_no_warnings_for_known_targets() {
let cfg: Config =
serde_json::from_str(r#"{ "slash_aliases": { "bye": "quit", "cls": "clear" } }"#)
.unwrap();
let (am, warnings) = build_alias_map(&cfg);
assert_eq!(am.map.get("bye").map(String::as_str), Some("/quit"));
assert_eq!(am.map.get("cls").map(String::as_str), Some("/clear"));
assert!(
warnings.is_empty(),
"both targets are known built-ins: {warnings:?}"
);
}
#[test]
fn build_alias_map_warns_when_key_shadows_builtin() {
let cfg: Config =
serde_json::from_str(r#"{ "slash_aliases": { "quit": "clear" } }"#).unwrap();
let (am, warnings) = build_alias_map(&cfg);
assert_eq!(am.map.get("quit").map(String::as_str), Some("/clear"));
assert_eq!(warnings.len(), 1, "exactly one warning: {warnings:?}");
assert!(
warnings[0].contains("shadows") && warnings[0].contains("/quit"),
"warning should name the shadowed built-in: {}",
warnings[0],
);
}
#[test]
fn build_alias_map_absent_config_is_empty() {
let cfg = Config::default();
let (am, warnings) = build_alias_map(&cfg);
assert!(am.map.is_empty());
assert!(warnings.is_empty());
}
#[test]
fn build_alias_map_empty_map_is_empty() {
let cfg: Config = serde_json::from_str(r#"{ "slash_aliases": {} }"#).unwrap();
let (am, warnings) = build_alias_map(&cfg);
assert!(am.map.is_empty());
assert!(warnings.is_empty());
}
fn map_from(pairs: impl IntoIterator<Item = (&'static str, &'static str)>) -> AliasMap {
AliasMap {
map: pairs
.into_iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect(),
}
}
#[test]
fn expand_alias_rewrites_command_name_only() {
let am = map_from([("exit", "/quit")]);
assert_eq!(expand_alias("/exit", &am), "/quit");
assert_eq!(expand_alias("/exit keep recent", &am), "/quit keep recent");
assert_eq!(expand_alias("/exit two", &am), "/quit two");
}
#[test]
fn expand_alias_passes_through_unaliased_commands() {
let am = map_from([("exit", "/quit")]);
assert_eq!(expand_alias("/model gpt", &am), "/model gpt");
assert_eq!(expand_alias("/quit", &am), "/quit");
}
#[test]
fn expand_alias_passes_through_non_slash_and_empty() {
let am = map_from([("exit", "/quit")]);
assert_eq!(expand_alias("hello world", &am), "hello world");
assert_eq!(expand_alias("", &am), "");
}
#[test]
fn expand_alias_empty_map_passes_through() {
let am = AliasMap::default();
assert_eq!(expand_alias("/exit", &am), "/exit");
assert_eq!(expand_alias("/exit x", &am), "/exit x");
}
#[test]
fn build_alias_map_rejects_empty_alias_key() {
let cfg: Config =
serde_json::from_str(r#"{ "slash_aliases": { "": "quit", "exit": "quit" } }"#).unwrap();
let (am, warnings) = build_alias_map(&cfg);
assert!(!am.map.contains_key(""), "empty alias key must be dropped");
assert_eq!(am.map.get("exit").map(String::as_str), Some("/quit"));
assert!(
warnings.iter().any(|w| w.contains("empty")),
"should warn about the dropped empty key: {warnings:?}",
);
}
#[test]
fn display_entries_lists_normalized_aliases_sorted() {
let cfg: Config = serde_json::from_str(
r#"{ "slash_aliases": { "bye": "/quit", "cls": "clear", "": "quit" } }"#,
)
.unwrap();
assert_eq!(
display_entries(&cfg),
vec!["/bye -> /quit", "/cls -> /clear"]
);
}
#[test]
fn display_entries_empty_when_no_aliases() {
assert!(display_entries(&Config::default()).is_empty());
let cfg: Config = serde_json::from_str(r#"{ "slash_aliases": {} }"#).unwrap();
assert!(display_entries(&cfg).is_empty());
}
}