1use std::collections::HashMap;
10use std::path::Path;
11
12use file_format::FileFormat;
13use serde::Deserialize;
14
15use crate::{Error, Result};
16
17pub const UNKNOWN_FORMAT: &str = "unknown";
20
21#[derive(Debug, Clone, Deserialize)]
23#[serde(deny_unknown_fields)]
24pub struct FormatDef {
25 pub id: String,
27 pub name: String,
29 pub extensions: Vec<String>,
31}
32
33#[derive(Debug, Deserialize)]
34#[serde(deny_unknown_fields)]
35struct RawFormatDef {
36 id: String,
37 name: String,
38 extensions: Vec<String>,
39 #[serde(default)]
42 refines: Option<String>,
43 #[serde(default)]
47 basenames: Vec<String>,
48}
49
50#[derive(Debug, Deserialize)]
51#[serde(deny_unknown_fields)]
52struct RawTable {
53 version: String,
54 formats: Vec<RawFormatDef>,
55}
56
57pub(crate) const RESERVED_FORMAT_IDS: [&str; 2] = [UNKNOWN_FORMAT, crate::pipeline::SYMLINK_FORMAT];
62
63#[derive(Debug)]
65pub struct FormatTable {
66 version: String,
67 formats: Vec<FormatDef>,
68 by_extension: HashMap<String, String>,
69 by_basename: HashMap<String, String>,
70 refines_by_id: HashMap<String, String>,
71}
72
73impl FormatTable {
74 pub fn builtin() -> Result<Self> {
76 Self::parse(include_str!("../rules/formats.toml"), "formats.toml")
77 }
78
79 pub fn parse(text: &str, name: &str) -> Result<Self> {
81 let raw: RawTable = toml::from_str(text).map_err(|e| Error::Rules {
82 name: name.to_string(),
83 message: e.to_string(),
84 })?;
85 let rules_error = |message: String| Error::Rules {
86 name: name.to_string(),
87 message,
88 };
89 let mut by_extension = HashMap::new();
90 let mut by_basename = HashMap::new();
91 let mut ids = HashMap::new();
92 let mut refines_by_id = HashMap::new();
93 for format in &raw.formats {
94 if RESERVED_FORMAT_IDS.contains(&format.id.as_str()) {
95 return Err(rules_error(format!(
96 "format id {:?} is reserved",
97 format.id
98 )));
99 }
100 if ids.insert(format.id.clone(), ()).is_some() {
101 return Err(rules_error(format!("duplicate format id {:?}", format.id)));
102 }
103 for extension in &format.extensions {
104 if by_extension
105 .insert(extension.clone(), format.id.clone())
106 .is_some()
107 {
108 return Err(rules_error(format!(
109 "extension {extension:?} claimed twice"
110 )));
111 }
112 }
113 for basename in &format.basenames {
114 if basename.is_empty()
115 || by_basename
116 .insert(basename.clone(), format.id.clone())
117 .is_some()
118 {
119 return Err(rules_error(format!("basename {basename:?} claimed twice")));
120 }
121 }
122 if let Some(target) = &format.refines {
123 refines_by_id.insert(format.id.clone(), target.clone());
124 }
125 }
126 for (id, target) in &refines_by_id {
130 if target == id {
131 return Err(rules_error(format!("format {id:?} refines itself")));
132 }
133 if !ids.contains_key(target) {
134 return Err(rules_error(format!(
135 "format {id:?} refines unknown format {target:?}"
136 )));
137 }
138 if refines_by_id.contains_key(target) {
139 return Err(rules_error(format!(
140 "format {id:?} refines {target:?}, which refines another format itself"
141 )));
142 }
143 }
144 let formats = raw
145 .formats
146 .into_iter()
147 .map(|format| FormatDef {
148 id: format.id,
149 name: format.name,
150 extensions: format.extensions,
151 })
152 .collect();
153 Ok(FormatTable {
154 version: raw.version,
155 formats,
156 by_extension,
157 by_basename,
158 refines_by_id,
159 })
160 }
161
162 pub fn version(&self) -> &str {
164 &self.version
165 }
166
167 pub fn formats(&self) -> &[FormatDef] {
169 &self.formats
170 }
171
172 pub fn id_for_extension(&self, extension: &str) -> Option<&str> {
174 self.by_extension.get(extension).map(String::as_str)
175 }
176
177 fn refined_parent(&self, id: &str) -> Option<&str> {
180 self.refines_by_id.get(id).map(String::as_str)
181 }
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct Detection {
187 pub declared: Option<String>,
189 pub detected: String,
191 pub mismatch: bool,
193}
194
195pub fn declared_format(path: &Path, table: &FormatTable) -> Option<String> {
201 if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
202 return table
203 .id_for_extension(&extension.to_ascii_lowercase())
204 .map(String::from);
205 }
206 path.file_name()
207 .and_then(|n| n.to_str())
208 .and_then(|n| table.by_basename.get(n))
209 .cloned()
210}
211
212pub fn detect_file(path: &Path, table: &FormatTable) -> Result<Detection> {
217 Ok(detect_file_with_warnings(path, table)?.0)
218}
219
220pub fn detect_file_with_warnings(
233 path: &Path,
234 table: &FormatTable,
235) -> Result<(Detection, Vec<String>)> {
236 let magic = FileFormat::from_file(path).map_err(|e| Error::io("detect", path, e))?;
237 Ok(resolve_detection(path, magic, table))
238}
239
240pub fn detect_bytes_with_warnings(
248 path_hint: &Path,
249 bytes: &[u8],
250 table: &FormatTable,
251) -> (Detection, Vec<String>) {
252 let magic = FileFormat::from_bytes(bytes);
253 resolve_detection(path_hint, magic, table)
254}
255
256fn resolve_detection(
257 path: &Path,
258 magic: FileFormat,
259 table: &FormatTable,
260) -> (Detection, Vec<String>) {
261 let declared = declared_format(path, table);
262 let magic_known = magic != FileFormat::default() && magic != FileFormat::Empty;
263 let magic_id = if magic_known {
264 table.id_for_extension(magic.extension()).map(String::from)
265 } else {
266 None
267 };
268 let mut warnings = Vec::new();
269 let (detected, mismatch) = match (&magic_id, &declared) {
270 (Some(magic), Some(declared)) => {
271 if magic != declared && table.refined_parent(declared) == Some(magic.as_str()) {
272 (declared.clone(), false)
273 } else {
274 (magic.clone(), magic != declared)
275 }
276 }
277 (Some(magic), None) => (magic.clone(), false),
278 (None, Some(declared)) => {
279 if magic_known {
280 warnings.push(format!("magic-format-outside-table: {}", magic.extension()));
281 }
282 (declared.clone(), false)
283 }
284 (None, None) => (UNKNOWN_FORMAT.to_string(), false),
285 };
286 (
287 Detection {
288 declared,
289 detected,
290 mismatch,
291 },
292 warnings,
293 )
294}
295
296#[cfg(test)]
297mod tests {
298 use super::*;
299 use std::fs;
300
301 const PNG_MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
302
303 #[test]
304 fn builtin_table_parses() {
305 let table = FormatTable::builtin().unwrap();
306 assert_eq!(table.version(), "7");
307 assert_eq!(table.id_for_extension("txt"), Some("text"));
308 assert_eq!(table.id_for_extension("docx"), Some("docx"));
309 assert_eq!(table.id_for_extension("json"), Some("json"));
310 assert_eq!(table.id_for_extension("yml"), Some("yaml"));
311 assert_eq!(table.id_for_extension("py"), Some("python"));
312 assert_eq!(table.id_for_extension("pkl"), Some("pickle"));
313 assert_eq!(table.id_for_extension("psb"), Some("psd"));
314 }
315
316 #[test]
317 fn extension_decides_when_magic_is_inconclusive() {
318 let dir = tempfile::tempdir().unwrap();
319 let path = dir.path().join("notes.md");
320 fs::write(&path, b"# heading\n").unwrap();
321 let table = FormatTable::builtin().unwrap();
322 let detection = detect_file(&path, &table).unwrap();
323 assert_eq!(detection.declared.as_deref(), Some("markdown"));
324 assert_eq!(detection.detected, "markdown");
325 assert!(!detection.mismatch);
326 }
327
328 #[test]
329 fn magic_wins_and_flags_a_mismatch() {
330 let dir = tempfile::tempdir().unwrap();
331 let path = dir.path().join("fake.txt");
332 fs::write(&path, PNG_MAGIC).unwrap();
333 let table = FormatTable::builtin().unwrap();
334 let detection = detect_file(&path, &table).unwrap();
335 assert_eq!(detection.declared.as_deref(), Some("text"));
336 assert_eq!(detection.detected, "png");
337 assert!(detection.mismatch);
338 }
339
340 #[test]
341 fn out_of_table_magic_is_a_warning_not_a_same_id_mismatch() {
342 let dir = tempfile::tempdir().unwrap();
343 let path = dir.path().join("pic.png");
344 std::fs::write(&path, b"GIF89a\x01\x00\x01\x00").unwrap();
345 let table = FormatTable::builtin().unwrap();
346 let (detection, warnings) = detect_file_with_warnings(&path, &table).unwrap();
347 assert_eq!(detection.declared.as_deref(), Some("png"));
348 assert_eq!(detection.detected, "png");
349 assert!(!detection.mismatch);
350 assert_eq!(
351 warnings,
352 vec!["magic-format-outside-table: gif".to_string()]
353 );
354 }
355
356 #[test]
357 fn cfb_magic_under_a_matching_extension_is_not_a_mismatch() {
358 let dir = tempfile::tempdir().unwrap();
359 let path = dir.path().join("locked.docx");
360 let mut bytes = b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1".to_vec();
361 bytes.extend_from_slice(&[0u8; 24]);
362 std::fs::write(&path, &bytes).unwrap();
363 let table = FormatTable::builtin().unwrap();
364 let (detection, warnings) = detect_file_with_warnings(&path, &table).unwrap();
365 assert_eq!(detection.declared.as_deref(), Some("docx"));
366 assert_eq!(detection.detected, "docx");
367 assert!(!detection.mismatch);
368 assert_eq!(
369 warnings,
370 vec!["magic-format-outside-table: cfb".to_string()]
371 );
372 }
373
374 const XML_PROLOG_SVG: &[u8] =
375 b"<?xml version=\"1.0\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\"/>\n";
376
377 #[test]
378 fn a_declared_refinement_of_the_magic_format_is_not_a_mismatch() {
379 let dir = tempfile::tempdir().unwrap();
380 let table = FormatTable::builtin().unwrap();
381 for (file, id) in [
382 ("pic.svg", "svg"),
383 ("dash.twb", "tableau-workbook"),
384 ("src.tds", "tableau-datasource"),
385 ] {
386 let path = dir.path().join(file);
387 fs::write(&path, XML_PROLOG_SVG).unwrap();
388 let (detection, warnings) = detect_file_with_warnings(&path, &table).unwrap();
389 assert_eq!(detection.declared.as_deref(), Some(id), "{file}");
390 assert_eq!(detection.detected, id, "{file}");
391 assert!(!detection.mismatch, "{file}");
392 assert!(warnings.is_empty(), "{file}: {warnings:?}");
393 }
394 }
395
396 #[test]
397 fn a_plain_xml_file_stays_same_id_without_a_flag() {
398 let dir = tempfile::tempdir().unwrap();
399 let path = dir.path().join("feed.xml");
400 fs::write(&path, b"<?xml version=\"1.0\"?>\n<feed/>\n").unwrap();
401 let table = FormatTable::builtin().unwrap();
402 let detection = detect_file(&path, &table).unwrap();
403 assert_eq!(detection.declared.as_deref(), Some("xml"));
404 assert_eq!(detection.detected, "xml");
405 assert!(!detection.mismatch);
406 }
407
408 #[test]
409 fn a_sibling_disagreement_still_flags_a_mismatch() {
410 let dir = tempfile::tempdir().unwrap();
413 let path = dir.path().join("tool.py");
414 fs::write(&path, b"#!/bin/sh\necho hi\n").unwrap();
415 let table = FormatTable::builtin().unwrap();
416 let detection = detect_file(&path, &table).unwrap();
417 assert_eq!(detection.declared.as_deref(), Some("python"));
418 assert_eq!(detection.detected, "shell");
419 assert!(detection.mismatch);
420 }
421
422 fn table_with(entries: &str) -> Result<FormatTable> {
423 FormatTable::parse(&format!("version = \"1\"\n{entries}"), "formats.toml")
424 }
425
426 #[test]
427 fn parse_refuses_a_refine_target_missing_from_the_table() {
428 let err = table_with(
429 "[[formats]]\nid = \"a\"\nname = \"A\"\nextensions = [\"a\"]\nrefines = \"b\"\n",
430 )
431 .unwrap_err();
432 assert!(err.to_string().contains("unknown format"), "{err}");
433 }
434
435 #[test]
436 fn parse_refuses_a_self_refining_entry() {
437 let err = table_with(
438 "[[formats]]\nid = \"a\"\nname = \"A\"\nextensions = [\"a\"]\nrefines = \"a\"\n",
439 )
440 .unwrap_err();
441 assert!(err.to_string().contains("refines itself"), "{err}");
442 }
443
444 #[test]
445 fn parse_refuses_a_refinement_chain() {
446 let err = table_with(concat!(
447 "[[formats]]\nid = \"a\"\nname = \"A\"\nextensions = [\"a\"]\nrefines = \"b\"\n",
448 "[[formats]]\nid = \"b\"\nname = \"B\"\nextensions = [\"b\"]\nrefines = \"c\"\n",
449 "[[formats]]\nid = \"c\"\nname = \"C\"\nextensions = [\"c\"]\n",
450 ))
451 .unwrap_err();
452 assert!(err.to_string().contains("refines another format"), "{err}");
453 }
454
455 #[test]
456 fn a_bare_dotfile_declares_its_format_by_basename() {
457 let dir = tempfile::tempdir().unwrap();
458 let path = dir.path().join(".env");
459 fs::write(&path, b"API_URL=http://localhost\n").unwrap();
460 let table = FormatTable::builtin().unwrap();
461 let detection = detect_file(&path, &table).unwrap();
462 assert_eq!(detection.declared.as_deref(), Some("env"));
463 assert_eq!(detection.detected, "env");
464 assert!(!detection.mismatch);
465 }
466
467 #[test]
468 fn parse_refuses_a_basename_claimed_twice() {
469 let err = table_with(concat!(
470 "[[formats]]\nid = \"a\"\nname = \"A\"\nextensions = [\"a\"]\nbasenames = [\".rc\"]\n",
471 "[[formats]]\nid = \"b\"\nname = \"B\"\nextensions = [\"b\"]\nbasenames = [\".rc\"]\n",
472 ))
473 .unwrap_err();
474 assert!(err.to_string().contains("basename"), "{err}");
475 }
476
477 #[test]
478 fn parse_refuses_the_reserved_format_ids() {
479 for id in ["symlink", "unknown"] {
480 let err = table_with(&format!(
481 "[[formats]]\nid = \"{id}\"\nname = \"X\"\nextensions = [\"x\"]\n"
482 ))
483 .unwrap_err();
484 assert!(err.to_string().contains("reserved"), "{id}: {err}");
485 }
486 }
487
488 #[test]
489 fn in_table_magic_against_a_different_extension_still_flags() {
490 let dir = tempfile::tempdir().unwrap();
491 let path = dir.path().join("fake.json");
492 fs::write(&path, PNG_MAGIC).unwrap();
493 let table = FormatTable::builtin().unwrap();
494 let (detection, warnings) = detect_file_with_warnings(&path, &table).unwrap();
495 assert_eq!(detection.declared.as_deref(), Some("json"));
496 assert_eq!(detection.detected, "png");
497 assert!(detection.mismatch);
498 assert!(warnings.is_empty());
499 }
500
501 #[test]
502 fn empty_file_is_not_a_mismatch() {
503 let dir = tempfile::tempdir().unwrap();
504 let path = dir.path().join("empty.txt");
505 std::fs::write(&path, b"").unwrap();
506 let table = FormatTable::builtin().unwrap();
507 let detection = detect_file(&path, &table).unwrap();
508 assert_eq!(detection.detected, "text");
509 assert!(!detection.mismatch);
510 }
511
512 #[test]
513 fn unplaceable_file_is_unknown() {
514 let dir = tempfile::tempdir().unwrap();
515 let path = dir.path().join("blob.xyz");
516 fs::write(&path, b"opaque bytes").unwrap();
517 let table = FormatTable::builtin().unwrap();
518 let detection = detect_file(&path, &table).unwrap();
519 assert_eq!(detection.declared, None);
520 assert_eq!(detection.detected, UNKNOWN_FORMAT);
521 assert!(!detection.mismatch);
522 }
523}