1use 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
16pub 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
36pub 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
61pub 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
81pub 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
113pub 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
149pub 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", "ed)
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
185pub 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 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 assert_eq!(query("echo dummy; echo %s", "a'b"), vec!["a'b".to_string()]);
233 assert!(query("false", "x").is_empty());
234 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}