1use std::path::PathBuf;
13
14#[derive(Debug, Clone)]
17pub struct Entry {
18 pub mimetype: String,
20 pub command: String,
21 pub copiousoutput: bool,
24 pub needsterminal: bool,
27 pub test: Option<String>,
30 pub nametemplate: Option<String>,
34}
35
36pub fn paths() -> Vec<PathBuf> {
38 if let Ok(list) = std::env::var("MAILCAPS")
39 && !list.trim().is_empty()
40 {
41 return list
42 .split(':')
43 .filter(|p| !p.is_empty())
44 .map(PathBuf::from)
45 .collect();
46 }
47 let mut out = Vec::new();
48 if let Ok(home) = std::env::var("HOME") {
49 out.push(PathBuf::from(home).join(".mailcap"));
50 }
51 out.push(PathBuf::from("/etc/mailcap"));
52 out.push(PathBuf::from("/usr/etc/mailcap"));
53 out.push(PathBuf::from("/usr/local/etc/mailcap"));
54 out
55}
56
57pub fn load() -> Vec<Entry> {
60 let mut out = Vec::new();
61 for path in paths() {
62 if let Ok(text) = std::fs::read_to_string(&path) {
63 out.extend(parse(&text));
64 }
65 }
66 out
67}
68
69pub fn parse(text: &str) -> Vec<Entry> {
71 let mut out = Vec::new();
72 for line in logical_lines(text) {
73 let line = line.trim();
74 if line.is_empty() || line.starts_with('#') {
75 continue;
76 }
77 let mut fields = split_fields(line);
78 if fields.len() < 2 {
79 continue;
80 }
81 let rest = fields.split_off(2);
82 let command = fields.pop().unwrap_or_default().trim().to_string();
83 let mimetype = fields.pop().unwrap_or_default().trim().to_lowercase();
84 if mimetype.is_empty() || command.is_empty() || !mimetype.contains('/') {
85 continue;
86 }
87 let mut entry = Entry {
88 mimetype,
89 command,
90 copiousoutput: false,
91 needsterminal: false,
92 test: None,
93 nametemplate: None,
94 };
95 for field in rest {
96 let field = field.trim();
97 let (key, value) = match field.split_once('=') {
98 Some((k, v)) => (k.trim().to_lowercase(), Some(v.trim().to_string())),
99 None => (field.to_lowercase(), None),
100 };
101 match (key.as_str(), value) {
102 ("copiousoutput", _) => entry.copiousoutput = true,
103 ("needsterminal", _) => entry.needsterminal = true,
104 ("test", Some(v)) => entry.test = Some(v),
105 ("nametemplate", Some(v)) => entry.nametemplate = Some(v),
106 _ => {}
107 }
108 }
109 out.push(entry);
110 }
111 out
112}
113
114pub fn command_for(entries: &[Entry], mimetype: &str) -> Option<String> {
119 let want = mimetype.trim().to_lowercase();
120 let main = want.split('/').next().unwrap_or_default();
121 entries
122 .iter()
123 .filter(|e| e.mimetype == want || e.mimetype == format!("{main}/*"))
124 .filter(|e| e.copiousoutput && !e.needsterminal && !e.command.contains("%{"))
125 .find(|e| e.test.as_deref().is_none_or(test_passes))
126 .map(|e| e.command.clone())
127}
128
129#[derive(Debug, Clone)]
131pub struct Viewer {
132 pub command: String,
133 pub copious: bool,
135 pub needsterminal: bool,
138 pub nametemplate: Option<String>,
140}
141
142pub fn viewer_for(entries: &[Entry], mimetype: &str) -> Option<Viewer> {
145 let want = mimetype.trim().to_lowercase();
146 let main = want.split('/').next().unwrap_or_default();
147 entries
148 .iter()
149 .filter(|e| e.mimetype == want || e.mimetype == format!("{main}/*"))
150 .filter(|e| !e.command.contains("%{"))
151 .find(|e| e.test.as_deref().is_none_or(test_passes))
152 .map(|e| Viewer {
153 command: e.command.clone(),
154 copious: e.copiousoutput,
155 needsterminal: e.needsterminal,
156 nametemplate: e.nametemplate.clone(),
157 })
158}
159
160pub fn apply_nametemplate(template: Option<&str>, name: &str) -> String {
167 let base = |s: &str| s.rsplit('/').next().unwrap_or(s).to_string();
168 let name = base(name);
169 let Some(template) = template else {
170 return name;
171 };
172 let template = base(template);
173 match template.split_once("%s") {
174 None => template,
175 Some((pre, suf)) => {
176 let mut out = String::new();
177 if !name.starts_with(pre) {
178 out.push_str(pre);
179 }
180 out.push_str(&name);
181 if !name.ends_with(suf) {
182 out.push_str(suf);
183 }
184 out
185 }
186 }
187}
188
189fn test_passes(command: &str) -> bool {
192 std::process::Command::new("sh")
193 .arg("-c")
194 .arg(command)
195 .stdin(std::process::Stdio::null())
196 .stdout(std::process::Stdio::null())
197 .stderr(std::process::Stdio::null())
198 .status()
199 .is_ok_and(|s| s.success())
200}
201
202fn logical_lines(text: &str) -> Vec<String> {
204 let mut out: Vec<String> = Vec::new();
205 let mut pending: Option<String> = None;
206 for line in text.lines() {
207 let continues = trailing_backslashes(line) % 2 == 1;
208 let piece = match continues {
209 true => &line[..line.len() - 1],
210 false => line,
211 };
212 match &mut pending {
213 Some(buf) => buf.push_str(piece),
214 None => pending = Some(piece.to_string()),
215 }
216 if !continues && let Some(done) = pending.take() {
217 out.push(done);
218 }
219 }
220 out.extend(pending);
221 out
222}
223
224fn trailing_backslashes(line: &str) -> usize {
225 line.chars().rev().take_while(|c| *c == '\\').count()
226}
227
228fn split_fields(line: &str) -> Vec<String> {
230 let mut out = Vec::new();
231 let mut cur = String::new();
232 let mut chars = line.chars();
233 while let Some(c) = chars.next() {
234 match c {
235 '\\' => match chars.next() {
236 Some(next @ (';' | '\\')) => cur.push(next),
237 Some(next) => {
238 cur.push('\\');
239 cur.push(next);
240 }
241 None => cur.push('\\'),
242 },
243 ';' => out.push(std::mem::take(&mut cur)),
244 _ => cur.push(c),
245 }
246 }
247 out.push(cur);
248 out
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 const SAMPLE: &str = "\
256# a comment
257text/html; lynx -dump %s; copiousoutput; nametemplate=%s.html
258text/html; sensible-browser %s; test=test -n \"$DISPLAY\"
259application/pdf; \\
260 pdftotext -layout %s -; copiousoutput
261text/x-patch; colordiff; copiousoutput
262application/zip; unzip -l %s ; copiousoutput
263video/mpeg; mpv %s; needsterminal
264image/*; catimg %s; copiousoutput
265broken line without a command
266";
267
268 #[test]
269 fn parses_types_commands_and_flags() {
270 let entries = parse(SAMPLE);
271 let types: Vec<&str> = entries.iter().map(|e| e.mimetype.as_str()).collect();
272 assert_eq!(
273 types,
274 [
275 "text/html",
276 "text/html",
277 "application/pdf",
278 "text/x-patch",
279 "application/zip",
280 "video/mpeg",
281 "image/*",
282 ]
283 );
284 assert!(entries[0].copiousoutput);
285 assert!(!entries[1].copiousoutput);
286 assert_eq!(entries[1].test.as_deref(), Some("test -n \"$DISPLAY\""));
287 assert_eq!(entries[2].command, "pdftotext -layout %s -");
289 assert!(entries[5].needsterminal);
290 }
291
292 #[test]
293 fn nametemplate_is_read_and_applied_like_mutt() {
294 let entries = parse(SAMPLE);
295 let viewer = viewer_for(&entries, "text/html").expect("html has a viewer");
296 assert_eq!(viewer.nametemplate.as_deref(), Some("%s.html"));
297 assert_eq!(apply_nametemplate(Some("%s.html"), "part-2"), "part-2.html");
301 assert_eq!(
302 apply_nametemplate(Some("%s.html"), "page.html"),
303 "page.html"
304 );
305 assert_eq!(
306 apply_nametemplate(Some("mutt-%s.html"), "page.html"),
307 "mutt-page.html"
308 );
309 assert_eq!(apply_nametemplate(None, "blob.bin"), "blob.bin");
310 assert_eq!(
311 apply_nametemplate(Some("fixed.pdf"), "whatever"),
312 "fixed.pdf"
313 );
314 assert_eq!(
315 apply_nametemplate(Some("/tmp/%s.html"), "../../etc/passwd"),
316 "passwd.html"
317 );
318 }
319
320 #[test]
321 fn command_for_takes_the_first_copiousoutput_entry() {
322 let entries = parse(SAMPLE);
323 assert_eq!(
324 command_for(&entries, "text/html").as_deref(),
325 Some("lynx -dump %s")
326 );
327 assert_eq!(
328 command_for(&entries, "TEXT/X-Patch").as_deref(),
329 Some("colordiff")
330 );
331 assert_eq!(command_for(&entries, "video/mpeg"), None);
333 assert_eq!(
335 command_for(&entries, "image/png").as_deref(),
336 Some("catimg %s")
337 );
338 assert_eq!(command_for(&entries, "application/msword"), None);
339 }
340
341 #[test]
342 fn a_failing_test_field_skips_the_entry() {
343 let entries = parse(
344 "text/html; never-run; copiousoutput; test=false\n\
345 text/html; w3m -dump -T text/html; copiousoutput; test=true\n",
346 );
347 assert_eq!(
348 command_for(&entries, "text/html").as_deref(),
349 Some("w3m -dump -T text/html")
350 );
351 }
352
353 #[test]
354 fn escaped_semicolons_stay_in_the_command() {
355 let entries = parse("text/plain; sed 's/a/b/'\\; cat; copiousoutput\n");
356 assert_eq!(entries[0].command, "sed 's/a/b/'; cat");
357 assert!(entries[0].copiousoutput);
358 }
359}