Skip to main content

rmut_core/
alias.rs

1//! Mutt-style address aliases: lines of `alias nick expansion...` read
2//! from $RMUT_ALIASES or ~/.config/rmut/aliases.
3
4use std::collections::HashMap;
5use std::path::PathBuf;
6
7pub fn default_path() -> Option<PathBuf> {
8    if let Ok(p) = std::env::var("RMUT_ALIASES") {
9        return Some(PathBuf::from(p));
10    }
11    std::env::var("HOME")
12        .ok()
13        .map(|h| PathBuf::from(h).join(".config/rmut/aliases"))
14}
15
16/// The alias file: what the config names (mutt's $alias_file), else
17/// $RMUT_ALIASES, else ~/.config/rmut/aliases. A leading `~` is the
18/// home directory, as it is everywhere else.
19pub fn path_for(configured: Option<&str>) -> Option<PathBuf> {
20    match configured.map(str::trim).filter(|p| !p.is_empty()) {
21        Some(path) => Some(expand_home(path)),
22        None => default_path(),
23    }
24}
25
26fn expand_home(path: &str) -> PathBuf {
27    match path.strip_prefix("~/") {
28        Some(rest) => match std::env::var("HOME") {
29            Ok(home) => PathBuf::from(home).join(rest),
30            Err(_) => PathBuf::from(path),
31        },
32        None => PathBuf::from(path),
33    }
34}
35
36/// The aliases in the file the config names, or the default one.
37pub fn load(configured: Option<&str>) -> HashMap<String, String> {
38    path_for(configured)
39        .and_then(|p| std::fs::read_to_string(p).ok())
40        .map(|t| parse(&t))
41        .unwrap_or_default()
42}
43
44pub fn parse(text: &str) -> HashMap<String, String> {
45    let mut map = HashMap::new();
46    for line in text.lines() {
47        let line = line.trim();
48        if line.starts_with('#') {
49            continue;
50        }
51        if let Some(rest) = line.strip_prefix("alias ") {
52            let mut parts = rest.trim().splitn(2, char::is_whitespace);
53            if let (Some(nick), Some(expansion)) = (parts.next(), parts.next()) {
54                map.insert(nick.to_string(), expansion.trim().to_string());
55            }
56        }
57    }
58    map
59}
60
61/// Append `alias nick expansion` to the alias file (create-alias),
62/// creating the file if needed. A repeated nick wins by coming later.
63/// Returns the path written.
64/// The same, into the file the config names.
65pub fn append_to(configured: Option<&str>, nick: &str, expansion: &str) -> anyhow::Result<PathBuf> {
66    use anyhow::Context;
67    use std::io::Write;
68    let path = path_for(configured).context("no alias file path ($HOME unset)")?;
69    if let Some(parent) = path.parent() {
70        std::fs::create_dir_all(parent)?;
71    }
72    let mut file = std::fs::OpenOptions::new()
73        .create(true)
74        .append(true)
75        .open(&path)
76        .with_context(|| format!("opening {}", path.display()))?;
77    writeln!(file, "alias {nick} {expansion}")?;
78    Ok(path)
79}
80
81/// mutt's unalias: drop these nicks (or every alias, for `*`) from
82/// the alias file, rewriting it without them. How many lines went.
83pub fn remove_from(configured: Option<&str>, nicks: &[String]) -> anyhow::Result<usize> {
84    use anyhow::Context;
85    let path = path_for(configured).context("no alias file path ($HOME unset)")?;
86    let Ok(text) = std::fs::read_to_string(&path) else {
87        return Ok(0);
88    };
89    let all = nicks.iter().any(|n| n == "*");
90    let mut removed = 0;
91    let kept: Vec<&str> = text
92        .lines()
93        .filter(|line| {
94            let nick = line
95                .trim()
96                .strip_prefix("alias ")
97                .and_then(|rest| rest.split_whitespace().next());
98            let goes = nick.is_some_and(|n| all || nicks.iter().any(|w| w == n));
99            removed += usize::from(goes);
100            !goes
101        })
102        .collect();
103    if removed > 0 {
104        let mut out = kept.join("\n");
105        if !out.is_empty() {
106            out.push('\n');
107        }
108        std::fs::write(&path, out).with_context(|| format!("writing {}", path.display()))?;
109    }
110    Ok(removed)
111}
112
113/// Completion candidates for a partial address: expansions of every
114/// alias whose nick starts with `word` (case-insensitive), then
115/// query_command results, sorted and deduplicated. `sort` is mutt's
116/// $sort_alias: "address" (by the expansion; the default), "alias"
117/// (by nick; "unsorted" reads the same, the file being a map), with
118/// "reverse-" flipping either.
119pub fn complete(
120    word: &str,
121    aliases: &HashMap<String, String>,
122    query_command: Option<&str>,
123    sort: Option<&str>,
124) -> Vec<String> {
125    let lower = word.to_lowercase();
126    let mut hits: Vec<(&String, &String)> = aliases
127        .iter()
128        .filter(|(nick, _)| nick.to_lowercase().starts_with(&lower))
129        .collect();
130    let (reverse, key) = match sort.unwrap_or("address").strip_prefix("reverse-") {
131        Some(key) => (true, key),
132        None => (false, sort.unwrap_or("address")),
133    };
134    match key {
135        "alias" | "unsorted" => hits.sort_by(|a, b| a.0.cmp(b.0)),
136        _ => hits.sort_by(|a, b| a.1.cmp(b.1)),
137    }
138    if reverse {
139        hits.reverse();
140    }
141    let mut out: Vec<String> = hits.into_iter().map(|(_, e)| e.clone()).collect();
142    if let Some(command) = query_command {
143        out.extend(query(command, word));
144    }
145    out.dedup();
146    out
147}
148
149/// Run mutt's query_command (`%s` = the search word, appended when the
150/// command has no `%s`) and parse its output: the first line is a
151/// human message, then one `address<TAB>name[<TAB>extra]` per line.
152pub fn query(command: &str, word: &str) -> Vec<String> {
153    let quoted = format!("'{}'", word.replace('\'', r"'\''"));
154    let command = if command.contains("%s") {
155        command.replace("%s", &quoted)
156    } else {
157        format!("{command} {quoted}")
158    };
159    let Ok(out) = std::process::Command::new("sh")
160        .arg("-c")
161        .arg(&command)
162        .output()
163    else {
164        return Vec::new();
165    };
166    String::from_utf8_lossy(&out.stdout)
167        .lines()
168        .skip(1)
169        .filter_map(|line| {
170            let mut fields = line.split('\t');
171            let addr = fields.next()?.trim();
172            if addr.is_empty() {
173                return None;
174            }
175            Some(
176                match fields.next().map(str::trim).filter(|n| !n.is_empty()) {
177                    Some(name) => format!("{name} <{addr}>"),
178                    None => addr.to_string(),
179                },
180            )
181        })
182        .collect()
183}
184
185/// Expand comma-separated recipients; a bare token exactly matching an
186/// alias nick is replaced by its expansion.
187pub fn expand(input: &str, aliases: &HashMap<String, String>) -> String {
188    input
189        .split(',')
190        .map(|token| {
191            let t = token.trim();
192            aliases.get(t).cloned().unwrap_or_else(|| t.to_string())
193        })
194        .filter(|s| !s.is_empty())
195        .collect::<Vec<_>>()
196        .join(", ")
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    #[test]
204    fn complete_matches_nick_prefixes() {
205        let map = parse(
206            "alias petr Petr Novak <petr@example.com>\nalias pete pete@example.org\nalias jane jane@example.com\n",
207        );
208        assert_eq!(
209            complete("PE", &map, None, None),
210            vec![
211                "Petr Novak <petr@example.com>".to_string(),
212                "pete@example.org".into(),
213            ]
214        );
215        assert_eq!(complete("jane", &map, None, None).len(), 1);
216        assert!(complete("zz", &map, None, None).is_empty());
217    }
218
219    #[test]
220    fn query_parses_mutt_output() {
221        // First line is a message; addr\tname\textra lines follow.
222        let cmd =
223            "printf 'Searching %s...\\nzdenka@example.com\\tZdenka Q\\tnote\\nbare@example.com\\n'";
224        assert_eq!(
225            query(cmd, "zd"),
226            vec![
227                "Zdenka Q <zdenka@example.com>".to_string(),
228                "bare@example.com".into(),
229            ]
230        );
231        // The word reaches the command shell-quoted, quotes included.
232        assert_eq!(query("echo dummy; echo %s", "a'b"), vec!["a'b".to_string()]);
233        assert!(query("false", "x").is_empty());
234        // Query results merge behind alias matches in complete().
235        let map = parse("alias zdeno zdeno@example.net\n");
236        let all = complete(
237            "zd",
238            &map,
239            Some("printf 'found\\nzdenka@example.com\\tZdenka Q\\n'"),
240            None,
241        );
242        assert_eq!(
243            all,
244            vec![
245                "zdeno@example.net".to_string(),
246                "Zdenka Q <zdenka@example.com>".into(),
247            ]
248        );
249    }
250
251    #[test]
252    fn parse_and_expand() {
253        let map = parse(
254            "# my aliases\nalias jane Jane Doe <jane@example.com>\nalias team a@x, b@y\nnot an alias line\n",
255        );
256        assert_eq!(map.len(), 2);
257        assert_eq!(
258            expand("jane, chief@corp", &map),
259            "Jane Doe <jane@example.com>, chief@corp"
260        );
261        assert_eq!(expand("team", &map), "a@x, b@y");
262        assert_eq!(expand("nobody", &HashMap::new()), "nobody");
263    }
264
265    #[test]
266    fn unalias_rewrites_the_file_without_the_nicks() {
267        let dir = tempfile::tempdir().unwrap();
268        let path = dir.path().join("aliases");
269        std::fs::write(
270            &path,
271            "# mine\nalias jane Jane <jane@example.com>\nalias bob bob@example.com\nalias al Al <al@example.com>\n",
272        )
273        .unwrap();
274        let configured = path.to_string_lossy().to_string();
275        assert_eq!(
276            remove_from(Some(&configured), &["bob".to_string()]).unwrap(),
277            1
278        );
279        let text = std::fs::read_to_string(&path).unwrap();
280        assert!(!text.contains("bob") && text.contains("jane") && text.starts_with("# mine"));
281        assert_eq!(
282            remove_from(Some(&configured), &["nobody".to_string()]).unwrap(),
283            0
284        );
285        assert_eq!(
286            remove_from(Some(&configured), &["*".to_string()]).unwrap(),
287            2
288        );
289        assert_eq!(std::fs::read_to_string(&path).unwrap(), "# mine\n");
290    }
291
292    #[test]
293    fn completion_order_follows_sort_alias() {
294        let aliases: HashMap<String, String> = [
295            ("zed".to_string(), "Aaron <aaron@example.com>".to_string()),
296            ("amy".to_string(), "Zoe <zoe@example.com>".to_string()),
297        ]
298        .into_iter()
299        .collect();
300        let by_address = complete("", &aliases, None, None);
301        assert!(by_address[0].starts_with("Aaron"), "{by_address:?}");
302        let by_alias = complete("", &aliases, None, Some("alias"));
303        assert!(
304            by_alias[0].starts_with("Zoe"),
305            "amy before zed: {by_alias:?}"
306        );
307        let reversed = complete("", &aliases, None, Some("reverse-alias"));
308        assert!(reversed[0].starts_with("Aaron"), "{reversed:?}");
309    }
310}