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}
31
32/// The mailcap files to consult, in order.
33pub fn paths() -> Vec<PathBuf> {
34    if let Ok(list) = std::env::var("MAILCAPS")
35        && !list.trim().is_empty()
36    {
37        return list
38            .split(':')
39            .filter(|p| !p.is_empty())
40            .map(PathBuf::from)
41            .collect();
42    }
43    let mut out = Vec::new();
44    if let Ok(home) = std::env::var("HOME") {
45        out.push(PathBuf::from(home).join(".mailcap"));
46    }
47    out.push(PathBuf::from("/etc/mailcap"));
48    out.push(PathBuf::from("/usr/etc/mailcap"));
49    out.push(PathBuf::from("/usr/local/etc/mailcap"));
50    out
51}
52
53/// Every entry from every readable mailcap file, in search order. A
54/// missing file is not an error, like mutt.
55pub fn load() -> Vec<Entry> {
56    let mut out = Vec::new();
57    for path in paths() {
58        if let Ok(text) = std::fs::read_to_string(&path) {
59            out.extend(parse(&text));
60        }
61    }
62    out
63}
64
65/// Parse one mailcap file.
66pub fn parse(text: &str) -> Vec<Entry> {
67    let mut out = Vec::new();
68    for line in logical_lines(text) {
69        let line = line.trim();
70        if line.is_empty() || line.starts_with('#') {
71            continue;
72        }
73        let mut fields = split_fields(line);
74        if fields.len() < 2 {
75            continue;
76        }
77        let rest = fields.split_off(2);
78        let command = fields.pop().unwrap_or_default().trim().to_string();
79        let mimetype = fields.pop().unwrap_or_default().trim().to_lowercase();
80        if mimetype.is_empty() || command.is_empty() || !mimetype.contains('/') {
81            continue;
82        }
83        let mut entry = Entry {
84            mimetype,
85            command,
86            copiousoutput: false,
87            needsterminal: false,
88            test: None,
89        };
90        for field in rest {
91            let field = field.trim();
92            let (key, value) = match field.split_once('=') {
93                Some((k, v)) => (k.trim().to_lowercase(), Some(v.trim().to_string())),
94                None => (field.to_lowercase(), None),
95            };
96            match (key.as_str(), value) {
97                ("copiousoutput", _) => entry.copiousoutput = true,
98                ("needsterminal", _) => entry.needsterminal = true,
99                ("test", Some(v)) => entry.test = Some(v),
100                _ => {}
101            }
102        }
103        out.push(entry);
104    }
105    out
106}
107
108/// The command that renders `mimetype` inline, or None when no entry
109/// can: mutt takes the first matching `copiousoutput` entry whose
110/// `test` passes. An entry wanting the terminal, or one interpolating
111/// a `%{parameter}` rmut does not have, is passed over.
112pub fn command_for(entries: &[Entry], mimetype: &str) -> Option<String> {
113    let want = mimetype.trim().to_lowercase();
114    let main = want.split('/').next().unwrap_or_default();
115    entries
116        .iter()
117        .filter(|e| e.mimetype == want || e.mimetype == format!("{main}/*"))
118        .filter(|e| e.copiousoutput && !e.needsterminal && !e.command.contains("%{"))
119        .find(|e| e.test.as_deref().is_none_or(test_passes))
120        .map(|e| e.command.clone())
121}
122
123/// The viewer for `mimetype`, mutt's view-mailcap: the first matching
124/// entry whose `test` passes, terminal-wanting or not, with whether
125/// it writes to stdout (`copiousoutput`) rather than the terminal.
126pub fn viewer_for(entries: &[Entry], mimetype: &str) -> Option<(String, bool)> {
127    let want = mimetype.trim().to_lowercase();
128    let main = want.split('/').next().unwrap_or_default();
129    entries
130        .iter()
131        .filter(|e| e.mimetype == want || e.mimetype == format!("{main}/*"))
132        .filter(|e| !e.command.contains("%{"))
133        .find(|e| e.test.as_deref().is_none_or(test_passes))
134        .map(|e| (e.command.clone(), e.copiousoutput))
135}
136
137/// Run a `test=` field: success is exit status zero, and a test that
138/// cannot even start counts as failed.
139fn test_passes(command: &str) -> bool {
140    std::process::Command::new("sh")
141        .arg("-c")
142        .arg(command)
143        .stdin(std::process::Stdio::null())
144        .stdout(std::process::Stdio::null())
145        .stderr(std::process::Stdio::null())
146        .status()
147        .is_ok_and(|s| s.success())
148}
149
150/// Fold mailcap's backslash continuations into one line each.
151fn logical_lines(text: &str) -> Vec<String> {
152    let mut out: Vec<String> = Vec::new();
153    let mut pending: Option<String> = None;
154    for line in text.lines() {
155        let continues = trailing_backslashes(line) % 2 == 1;
156        let piece = match continues {
157            true => &line[..line.len() - 1],
158            false => line,
159        };
160        match &mut pending {
161            Some(buf) => buf.push_str(piece),
162            None => pending = Some(piece.to_string()),
163        }
164        if !continues && let Some(done) = pending.take() {
165            out.push(done);
166        }
167    }
168    out.extend(pending);
169    out
170}
171
172fn trailing_backslashes(line: &str) -> usize {
173    line.chars().rev().take_while(|c| *c == '\\').count()
174}
175
176/// Split on unescaped semicolons, unescaping `\;` and `\\` as we go.
177fn split_fields(line: &str) -> Vec<String> {
178    let mut out = Vec::new();
179    let mut cur = String::new();
180    let mut chars = line.chars();
181    while let Some(c) = chars.next() {
182        match c {
183            '\\' => match chars.next() {
184                Some(next @ (';' | '\\')) => cur.push(next),
185                Some(next) => {
186                    cur.push('\\');
187                    cur.push(next);
188                }
189                None => cur.push('\\'),
190            },
191            ';' => out.push(std::mem::take(&mut cur)),
192            _ => cur.push(c),
193        }
194    }
195    out.push(cur);
196    out
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202
203    const SAMPLE: &str = "\
204# a comment
205text/html; lynx -dump %s; copiousoutput; nametemplate=%s.html
206text/html; sensible-browser %s; test=test -n \"$DISPLAY\"
207application/pdf; \\
208  pdftotext -layout %s -; copiousoutput
209text/x-patch; colordiff; copiousoutput
210application/zip; unzip -l %s ; copiousoutput
211video/mpeg; mpv %s; needsterminal
212image/*; catimg %s; copiousoutput
213broken line without a command
214";
215
216    #[test]
217    fn parses_types_commands_and_flags() {
218        let entries = parse(SAMPLE);
219        let types: Vec<&str> = entries.iter().map(|e| e.mimetype.as_str()).collect();
220        assert_eq!(
221            types,
222            [
223                "text/html",
224                "text/html",
225                "application/pdf",
226                "text/x-patch",
227                "application/zip",
228                "video/mpeg",
229                "image/*",
230            ]
231        );
232        assert!(entries[0].copiousoutput);
233        assert!(!entries[1].copiousoutput);
234        assert_eq!(entries[1].test.as_deref(), Some("test -n \"$DISPLAY\""));
235        // The continuation line is folded into the command.
236        assert_eq!(entries[2].command, "pdftotext -layout %s -");
237        assert!(entries[5].needsterminal);
238    }
239
240    #[test]
241    fn command_for_takes_the_first_copiousoutput_entry() {
242        let entries = parse(SAMPLE);
243        assert_eq!(
244            command_for(&entries, "text/html").as_deref(),
245            Some("lynx -dump %s")
246        );
247        assert_eq!(
248            command_for(&entries, "TEXT/X-Patch").as_deref(),
249            Some("colordiff")
250        );
251        // needsterminal cannot render into the pager.
252        assert_eq!(command_for(&entries, "video/mpeg"), None);
253        // A type/* entry answers for the whole main type.
254        assert_eq!(
255            command_for(&entries, "image/png").as_deref(),
256            Some("catimg %s")
257        );
258        assert_eq!(command_for(&entries, "application/msword"), None);
259    }
260
261    #[test]
262    fn a_failing_test_field_skips_the_entry() {
263        let entries = parse(
264            "text/html; never-run; copiousoutput; test=false\n\
265             text/html; w3m -dump -T text/html; copiousoutput; test=true\n",
266        );
267        assert_eq!(
268            command_for(&entries, "text/html").as_deref(),
269            Some("w3m -dump -T text/html")
270        );
271    }
272
273    #[test]
274    fn escaped_semicolons_stay_in_the_command() {
275        let entries = parse("text/plain; sed 's/a/b/'\\; cat; copiousoutput\n");
276        assert_eq!(entries[0].command, "sed 's/a/b/'; cat");
277        assert!(entries[0].copiousoutput);
278    }
279}