Skip to main content

run/
aliases.rs

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
16/// Path to the user alias file (`~/.config/run-kit/aliases.toml` on most platforms).
17pub 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", &registry).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", &registry).unwrap_err();
152            assert!(err.to_string().contains("reserved"));
153        });
154    }
155}