1use std::collections::BTreeMap;
2use std::path::PathBuf;
3
4use anyhow::{Context, Result, bail};
5use serde::{Deserialize, Serialize};
6
7use crate::engine::LanguageRegistry;
8use crate::language::builtin_alias_lookup;
9
10#[derive(Debug, Default, Serialize, Deserialize)]
11struct AliasFile {
12 #[serde(default)]
13 aliases: BTreeMap<String, String>,
14}
15
16pub fn config_path() -> PathBuf {
18 dirs::config_dir()
19 .unwrap_or_else(std::env::temp_dir)
20 .join("run-kit")
21 .join("aliases.toml")
22}
23
24pub fn normalize_token(token: &str) -> String {
25 token.trim().to_ascii_lowercase()
26}
27
28pub fn load_custom_aliases() -> BTreeMap<String, String> {
29 let path = config_path();
30 if !path.is_file() {
31 return BTreeMap::new();
32 }
33 match std::fs::read_to_string(&path) {
34 Ok(content) => toml::from_str::<AliasFile>(&content)
35 .map(|file| file.aliases)
36 .unwrap_or_default(),
37 Err(_) => BTreeMap::new(),
38 }
39}
40
41pub fn custom_alias_lookup(token: &str) -> Option<String> {
42 let key = normalize_token(token);
43 load_custom_aliases().get(&key).cloned()
44}
45
46pub fn save_custom_aliases(aliases: &BTreeMap<String, String>) -> Result<()> {
47 let path = config_path();
48 if let Some(parent) = path.parent() {
49 std::fs::create_dir_all(parent)
50 .with_context(|| format!("create config dir {}", parent.display()))?;
51 }
52 let file = AliasFile {
53 aliases: aliases.clone(),
54 };
55 let content = toml::to_string_pretty(&file).context("serialize aliases.toml")?;
56 std::fs::write(&path, content).with_context(|| format!("write {}", path.display()))?;
57 Ok(())
58}
59
60fn validate_alias_name(alias: &str) -> Result<String> {
61 let normalized = normalize_token(alias);
62 if normalized.is_empty() {
63 bail!("alias name cannot be empty");
64 }
65 if normalized.contains(char::is_whitespace) {
66 bail!("alias name cannot contain whitespace");
67 }
68 if builtin_alias_lookup(&normalized).is_some() {
69 bail!(
70 "alias '{normalized}' is reserved by a built-in alias; choose a different name"
71 );
72 }
73 Ok(normalized)
74}
75
76fn validate_language_target(language: &str, registry: &LanguageRegistry) -> Result<String> {
77 let spec = crate::language::LanguageSpec::new(language.to_string());
78 let Some(engine) = registry.resolve(&spec) else {
79 bail!(
80 "unknown language '{language}'. Run `run doctor` to see supported languages."
81 );
82 };
83 Ok(engine.id().to_string())
84}
85
86pub fn add_alias(alias: &str, language: &str, registry: &LanguageRegistry) -> Result<()> {
87 let alias = validate_alias_name(alias)?;
88 let language = validate_language_target(language, registry)?;
89
90 let mut aliases = load_custom_aliases();
91 aliases.insert(alias.clone(), language.clone());
92 save_custom_aliases(&aliases)?;
93 println!("Added alias '{alias}' -> '{language}'");
94 Ok(())
95}
96
97pub fn remove_alias(alias: &str) -> Result<()> {
98 let alias = normalize_token(alias);
99 if alias.is_empty() {
100 bail!("alias name cannot be empty");
101 }
102
103 let mut aliases = load_custom_aliases();
104 if aliases.remove(&alias).is_none() {
105 bail!("custom alias '{alias}' not found");
106 }
107 save_custom_aliases(&aliases)?;
108 println!("Removed alias '{alias}'");
109 Ok(())
110}
111
112pub fn list_custom_aliases() -> Vec<(String, String)> {
113 load_custom_aliases()
114 .into_iter()
115 .collect::<Vec<_>>()
116}
117
118#[cfg(test)]
119mod tests {
120 use super::*;
121
122 fn with_temp_config<F: FnOnce(&PathBuf)>(f: F) {
123 let temp = tempfile::tempdir().expect("tempdir");
124 let config_root = temp.path().join("config");
125 std::fs::create_dir_all(&config_root).expect("mkdir");
126 unsafe { std::env::set_var("XDG_CONFIG_HOME", &config_root) };
127 f(&config_path());
128 unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
129 }
130
131 #[test]
132 fn add_and_remove_round_trip() {
133 with_temp_config(|path| {
134 let registry = LanguageRegistry::bootstrap();
135 add_alias("p", "python", ®istry).expect("add");
136 assert!(path.is_file());
137 assert_eq!(
138 custom_alias_lookup("p").as_deref(),
139 Some("python")
140 );
141
142 remove_alias("p").expect("remove");
143 assert!(custom_alias_lookup("p").is_none());
144 });
145 }
146
147 #[test]
148 fn rejects_builtin_alias_name() {
149 with_temp_config(|_| {
150 let registry = LanguageRegistry::bootstrap();
151 let err = add_alias("py", "python", ®istry).unwrap_err();
152 assert!(err.to_string().contains("reserved"));
153 });
154 }
155}