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/// Run a `test=` field: success is exit status zero, and a test that
124/// cannot even start counts as failed.
125fn test_passes(command: &str) -> bool {
126    std::process::Command::new("sh")
127        .arg("-c")
128        .arg(command)
129        .stdin(std::process::Stdio::null())
130        .stdout(std::process::Stdio::null())
131        .stderr(std::process::Stdio::null())
132        .status()
133        .is_ok_and(|s| s.success())
134}
135
136/// Fold mailcap's backslash continuations into one line each.
137fn logical_lines(text: &str) -> Vec<String> {
138    let mut out: Vec<String> = Vec::new();
139    let mut pending: Option<String> = None;
140    for line in text.lines() {
141        let continues = trailing_backslashes(line) % 2 == 1;
142        let piece = match continues {
143            true => &line[..line.len() - 1],
144            false => line,
145        };
146        match &mut pending {
147            Some(buf) => buf.push_str(piece),
148            None => pending = Some(piece.to_string()),
149        }
150        if !continues && let Some(done) = pending.take() {
151            out.push(done);
152        }
153    }
154    out.extend(pending);
155    out
156}
157
158fn trailing_backslashes(line: &str) -> usize {
159    line.chars().rev().take_while(|c| *c == '\\').count()
160}
161
162/// Split on unescaped semicolons, unescaping `\;` and `\\` as we go.
163fn split_fields(line: &str) -> Vec<String> {
164    let mut out = Vec::new();
165    let mut cur = String::new();
166    let mut chars = line.chars();
167    while let Some(c) = chars.next() {
168        match c {
169            '\\' => match chars.next() {
170                Some(next @ (';' | '\\')) => cur.push(next),
171                Some(next) => {
172                    cur.push('\\');
173                    cur.push(next);
174                }
175                None => cur.push('\\'),
176            },
177            ';' => out.push(std::mem::take(&mut cur)),
178            _ => cur.push(c),
179        }
180    }
181    out.push(cur);
182    out
183}
184
185#[cfg(test)]
186mod tests {
187    use super::*;
188
189    const SAMPLE: &str = "\
190# a comment
191text/html; lynx -dump %s; copiousoutput; nametemplate=%s.html
192text/html; sensible-browser %s; test=test -n \"$DISPLAY\"
193application/pdf; \\
194  pdftotext -layout %s -; copiousoutput
195text/x-patch; colordiff; copiousoutput
196application/zip; unzip -l %s ; copiousoutput
197video/mpeg; mpv %s; needsterminal
198image/*; catimg %s; copiousoutput
199broken line without a command
200";
201
202    #[test]
203    fn parses_types_commands_and_flags() {
204        let entries = parse(SAMPLE);
205        let types: Vec<&str> = entries.iter().map(|e| e.mimetype.as_str()).collect();
206        assert_eq!(
207            types,
208            [
209                "text/html",
210                "text/html",
211                "application/pdf",
212                "text/x-patch",
213                "application/zip",
214                "video/mpeg",
215                "image/*",
216            ]
217        );
218        assert!(entries[0].copiousoutput);
219        assert!(!entries[1].copiousoutput);
220        assert_eq!(entries[1].test.as_deref(), Some("test -n \"$DISPLAY\""));
221        // The continuation line is folded into the command.
222        assert_eq!(entries[2].command, "pdftotext -layout %s -");
223        assert!(entries[5].needsterminal);
224    }
225
226    #[test]
227    fn command_for_takes_the_first_copiousoutput_entry() {
228        let entries = parse(SAMPLE);
229        assert_eq!(
230            command_for(&entries, "text/html").as_deref(),
231            Some("lynx -dump %s")
232        );
233        assert_eq!(
234            command_for(&entries, "TEXT/X-Patch").as_deref(),
235            Some("colordiff")
236        );
237        // needsterminal cannot render into the pager.
238        assert_eq!(command_for(&entries, "video/mpeg"), None);
239        // A type/* entry answers for the whole main type.
240        assert_eq!(
241            command_for(&entries, "image/png").as_deref(),
242            Some("catimg %s")
243        );
244        assert_eq!(command_for(&entries, "application/msword"), None);
245    }
246
247    #[test]
248    fn a_failing_test_field_skips_the_entry() {
249        let entries = parse(
250            "text/html; never-run; copiousoutput; test=false\n\
251             text/html; w3m -dump -T text/html; copiousoutput; test=true\n",
252        );
253        assert_eq!(
254            command_for(&entries, "text/html").as_deref(),
255            Some("w3m -dump -T text/html")
256        );
257    }
258
259    #[test]
260    fn escaped_semicolons_stay_in_the_command() {
261        let entries = parse("text/plain; sed 's/a/b/'\\; cat; copiousoutput\n");
262        assert_eq!(entries[0].command, "sed 's/a/b/'; cat");
263        assert!(entries[0].copiousoutput);
264    }
265}