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}
31
32pub 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
53pub 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
65pub 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
108pub 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
123pub 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
137fn 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
150fn 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
176fn 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 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 assert_eq!(command_for(&entries, "video/mpeg"), None);
253 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}