Skip to main content

rmut_core/
mailcap.rs

1//! RFC 1524 mailcap, read the way mutt reads it for `auto_view`: a
2//! `[filters]` entry with an empty command means "the command is in
3//! my mailcap", and only a `copiousoutput` entry can answer, since
4//! that is the one promising plain text on stdout rather than a
5//! window of its own.
6//!
7//! The files are `$MAILCAPS` (colon-separated) when it is set, and
8//! otherwise mutt's default list: `~/.mailcap`, `/etc/mailcap`,
9//! `/usr/etc/mailcap`, `/usr/local/etc/mailcap`, in that order. The
10//! first entry that matches a type wins, `text/*` wildcards included.
11
12use std::path::PathBuf;
13
14/// One mailcap line: the type it handles, the view command, and the
15/// flags rmut cares about.
16#[derive(Debug, Clone)]
17pub struct Entry {
18    /// Lowercased `type/subtype`, possibly `type/*`.
19    pub mimetype: String,
20    pub command: String,
21    /// The command writes plain text to stdout: mutt's requirement
22    /// for auto_view, and ours.
23    pub copiousoutput: bool,
24    /// The command wants the terminal, so it cannot render into the
25    /// pager.
26    pub needsterminal: bool,
27    /// `test=`: a shell command that must succeed for the entry to
28    /// apply (this is how a mailcap gates on `$DISPLAY`).
29    pub test: Option<String>,
30    /// `nametemplate=%s.html`: the shape the viewer wants the temp
31    /// file's name in - a browser sniffs an extensionless file as
32    /// text and shows source.
33    pub nametemplate: Option<String>,
34}
35
36/// The mailcap files to consult, in order.
37pub 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
57/// Every entry from every readable mailcap file, in search order. A
58/// missing file is not an error, like mutt.
59pub 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
69/// Parse one mailcap file.
70pub 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
114/// The command that renders `mimetype` inline, or None when no entry
115/// can: mutt takes the first matching `copiousoutput` entry whose
116/// `test` passes. An entry wanting the terminal, or one interpolating
117/// a `%{parameter}` rmut does not have, is passed over.
118pub 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/// What view-mailcap needs of the entry it picked.
130#[derive(Debug, Clone)]
131pub struct Viewer {
132    pub command: String,
133    /// Writes plain text to stdout rather than taking the terminal.
134    pub copious: bool,
135    /// Wants the terminal (`needsterminal`): a curses viewer, not a
136    /// windowed one.
137    pub needsterminal: bool,
138    /// The temp file name shape the viewer wants, when it says.
139    pub nametemplate: Option<String>,
140}
141
142/// The viewer for `mimetype`, mutt's view-mailcap: the first matching
143/// entry whose `test` passes, terminal-wanting or not.
144pub 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
160/// A part's temp-file name through the entry's `nametemplate`,
161/// mutt's rfc1524_expand_filename: basenames only; around the
162/// template's `%s`, each side of the name that already matches is
163/// kept, and each side that does not is added - so `page.html`
164/// through `%s.html` stays itself, and `part-2` becomes
165/// `part-2.html`. A template with no `%s` names the file outright.
166pub 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
189/// Run a `test=` field: success is exit status zero, and a test that
190/// cannot even start counts as failed.
191fn 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
202/// Fold mailcap's backslash continuations into one line each.
203fn 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
228/// Split on unescaped semicolons, unescaping `\;` and `\\` as we go.
229fn 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        // The continuation line is folded into the command.
288        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        // mutt's rfc1524_expand_filename: a side of the name that
298        // already matches the template is kept, one that does not is
299        // added; no template keeps the name; basenames only.
300        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        // needsterminal cannot render into the pager.
332        assert_eq!(command_for(&entries, "video/mpeg"), None);
333        // A type/* entry answers for the whole main type.
334        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}