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 nametemplate: Option<String>,
137}
138
139pub 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
156pub 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
185fn 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
198fn 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
224fn 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 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 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 assert_eq!(command_for(&entries, "video/mpeg"), None);
329 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}