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    /// The temp file name shape the viewer wants, when it says.
136    pub nametemplate: Option<String>,
137}
138
139/// The viewer for `mimetype`, mutt's view-mailcap: the first matching
140/// entry whose `test` passes, terminal-wanting or not.
141pub fn viewer_for(entries: &[Entry], mimetype: &str) -> Option<Viewer> {
142    let want = mimetype.trim().to_lowercase();
143    let main = want.split('/').next().unwrap_or_default();
144    entries
145        .iter()
146        .filter(|e| e.mimetype == want || e.mimetype == format!("{main}/*"))
147        .filter(|e| !e.command.contains("%{"))
148        .find(|e| e.test.as_deref().is_none_or(test_passes))
149        .map(|e| Viewer {
150            command: e.command.clone(),
151            copious: e.copiousoutput,
152            nametemplate: e.nametemplate.clone(),
153        })
154}
155
156/// A part's temp-file name through the entry's `nametemplate`,
157/// mutt's rfc1524_expand_filename: basenames only; around the
158/// template's `%s`, each side of the name that already matches is
159/// kept, and each side that does not is added - so `page.html`
160/// through `%s.html` stays itself, and `part-2` becomes
161/// `part-2.html`. A template with no `%s` names the file outright.
162pub fn apply_nametemplate(template: Option<&str>, name: &str) -> String {
163    let base = |s: &str| s.rsplit('/').next().unwrap_or(s).to_string();
164    let name = base(name);
165    let Some(template) = template else {
166        return name;
167    };
168    let template = base(template);
169    match template.split_once("%s") {
170        None => template,
171        Some((pre, suf)) => {
172            let mut out = String::new();
173            if !name.starts_with(pre) {
174                out.push_str(pre);
175            }
176            out.push_str(&name);
177            if !name.ends_with(suf) {
178                out.push_str(suf);
179            }
180            out
181        }
182    }
183}
184
185/// Run a `test=` field: success is exit status zero, and a test that
186/// cannot even start counts as failed.
187fn test_passes(command: &str) -> bool {
188    std::process::Command::new("sh")
189        .arg("-c")
190        .arg(command)
191        .stdin(std::process::Stdio::null())
192        .stdout(std::process::Stdio::null())
193        .stderr(std::process::Stdio::null())
194        .status()
195        .is_ok_and(|s| s.success())
196}
197
198/// Fold mailcap's backslash continuations into one line each.
199fn logical_lines(text: &str) -> Vec<String> {
200    let mut out: Vec<String> = Vec::new();
201    let mut pending: Option<String> = None;
202    for line in text.lines() {
203        let continues = trailing_backslashes(line) % 2 == 1;
204        let piece = match continues {
205            true => &line[..line.len() - 1],
206            false => line,
207        };
208        match &mut pending {
209            Some(buf) => buf.push_str(piece),
210            None => pending = Some(piece.to_string()),
211        }
212        if !continues && let Some(done) = pending.take() {
213            out.push(done);
214        }
215    }
216    out.extend(pending);
217    out
218}
219
220fn trailing_backslashes(line: &str) -> usize {
221    line.chars().rev().take_while(|c| *c == '\\').count()
222}
223
224/// Split on unescaped semicolons, unescaping `\;` and `\\` as we go.
225fn split_fields(line: &str) -> Vec<String> {
226    let mut out = Vec::new();
227    let mut cur = String::new();
228    let mut chars = line.chars();
229    while let Some(c) = chars.next() {
230        match c {
231            '\\' => match chars.next() {
232                Some(next @ (';' | '\\')) => cur.push(next),
233                Some(next) => {
234                    cur.push('\\');
235                    cur.push(next);
236                }
237                None => cur.push('\\'),
238            },
239            ';' => out.push(std::mem::take(&mut cur)),
240            _ => cur.push(c),
241        }
242    }
243    out.push(cur);
244    out
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    const SAMPLE: &str = "\
252# a comment
253text/html; lynx -dump %s; copiousoutput; nametemplate=%s.html
254text/html; sensible-browser %s; test=test -n \"$DISPLAY\"
255application/pdf; \\
256  pdftotext -layout %s -; copiousoutput
257text/x-patch; colordiff; copiousoutput
258application/zip; unzip -l %s ; copiousoutput
259video/mpeg; mpv %s; needsterminal
260image/*; catimg %s; copiousoutput
261broken line without a command
262";
263
264    #[test]
265    fn parses_types_commands_and_flags() {
266        let entries = parse(SAMPLE);
267        let types: Vec<&str> = entries.iter().map(|e| e.mimetype.as_str()).collect();
268        assert_eq!(
269            types,
270            [
271                "text/html",
272                "text/html",
273                "application/pdf",
274                "text/x-patch",
275                "application/zip",
276                "video/mpeg",
277                "image/*",
278            ]
279        );
280        assert!(entries[0].copiousoutput);
281        assert!(!entries[1].copiousoutput);
282        assert_eq!(entries[1].test.as_deref(), Some("test -n \"$DISPLAY\""));
283        // The continuation line is folded into the command.
284        assert_eq!(entries[2].command, "pdftotext -layout %s -");
285        assert!(entries[5].needsterminal);
286    }
287
288    #[test]
289    fn nametemplate_is_read_and_applied_like_mutt() {
290        let entries = parse(SAMPLE);
291        let viewer = viewer_for(&entries, "text/html").expect("html has a viewer");
292        assert_eq!(viewer.nametemplate.as_deref(), Some("%s.html"));
293        // mutt's rfc1524_expand_filename: a side of the name that
294        // already matches the template is kept, one that does not is
295        // added; no template keeps the name; basenames only.
296        assert_eq!(apply_nametemplate(Some("%s.html"), "part-2"), "part-2.html");
297        assert_eq!(
298            apply_nametemplate(Some("%s.html"), "page.html"),
299            "page.html"
300        );
301        assert_eq!(
302            apply_nametemplate(Some("mutt-%s.html"), "page.html"),
303            "mutt-page.html"
304        );
305        assert_eq!(apply_nametemplate(None, "blob.bin"), "blob.bin");
306        assert_eq!(
307            apply_nametemplate(Some("fixed.pdf"), "whatever"),
308            "fixed.pdf"
309        );
310        assert_eq!(
311            apply_nametemplate(Some("/tmp/%s.html"), "../../etc/passwd"),
312            "passwd.html"
313        );
314    }
315
316    #[test]
317    fn command_for_takes_the_first_copiousoutput_entry() {
318        let entries = parse(SAMPLE);
319        assert_eq!(
320            command_for(&entries, "text/html").as_deref(),
321            Some("lynx -dump %s")
322        );
323        assert_eq!(
324            command_for(&entries, "TEXT/X-Patch").as_deref(),
325            Some("colordiff")
326        );
327        // needsterminal cannot render into the pager.
328        assert_eq!(command_for(&entries, "video/mpeg"), None);
329        // A type/* entry answers for the whole main type.
330        assert_eq!(
331            command_for(&entries, "image/png").as_deref(),
332            Some("catimg %s")
333        );
334        assert_eq!(command_for(&entries, "application/msword"), None);
335    }
336
337    #[test]
338    fn a_failing_test_field_skips_the_entry() {
339        let entries = parse(
340            "text/html; never-run; copiousoutput; test=false\n\
341             text/html; w3m -dump -T text/html; copiousoutput; test=true\n",
342        );
343        assert_eq!(
344            command_for(&entries, "text/html").as_deref(),
345            Some("w3m -dump -T text/html")
346        );
347    }
348
349    #[test]
350    fn escaped_semicolons_stay_in_the_command() {
351        let entries = parse("text/plain; sed 's/a/b/'\\; cat; copiousoutput\n");
352        assert_eq!(entries[0].command, "sed 's/a/b/'; cat");
353        assert!(entries[0].copiousoutput);
354    }
355}