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
123fn 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
136fn 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
162fn 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 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 assert_eq!(command_for(&entries, "video/mpeg"), None);
239 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}