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 RawTable {
36 version: String,
37 formats: Vec<FormatDef>,
38}
39
40#[derive(Debug)]
42pub struct FormatTable {
43 version: String,
44 formats: Vec<FormatDef>,
45 by_extension: HashMap<String, String>,
46}
47
48impl FormatTable {
49 pub fn builtin() -> Result<Self> {
51 Self::parse(include_str!("../rules/formats.toml"), "formats.toml")
52 }
53
54 pub fn parse(text: &str, name: &str) -> Result<Self> {
56 let raw: RawTable = toml::from_str(text).map_err(|e| Error::Rules {
57 name: name.to_string(),
58 message: e.to_string(),
59 })?;
60 let mut by_extension = HashMap::new();
61 let mut ids = HashMap::new();
62 for format in &raw.formats {
63 if ids.insert(format.id.clone(), ()).is_some() {
64 return Err(Error::Rules {
65 name: name.to_string(),
66 message: format!("duplicate format id {:?}", format.id),
67 });
68 }
69 for extension in &format.extensions {
70 if by_extension
71 .insert(extension.clone(), format.id.clone())
72 .is_some()
73 {
74 return Err(Error::Rules {
75 name: name.to_string(),
76 message: format!("extension {extension:?} claimed twice"),
77 });
78 }
79 }
80 }
81 Ok(FormatTable {
82 version: raw.version,
83 formats: raw.formats,
84 by_extension,
85 })
86 }
87
88 pub fn version(&self) -> &str {
90 &self.version
91 }
92
93 pub fn formats(&self) -> &[FormatDef] {
95 &self.formats
96 }
97
98 pub fn id_for_extension(&self, extension: &str) -> Option<&str> {
100 self.by_extension.get(extension).map(String::as_str)
101 }
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct Detection {
107 pub declared: Option<String>,
109 pub detected: String,
111 pub mismatch: bool,
113}
114
115pub fn declared_format(path: &Path, table: &FormatTable) -> Option<String> {
119 path.extension()
120 .and_then(|e| e.to_str())
121 .and_then(|e| table.id_for_extension(&e.to_ascii_lowercase()))
122 .map(String::from)
123}
124
125pub fn detect_file(path: &Path, table: &FormatTable) -> Result<Detection> {
132 let declared = declared_format(path, table);
133 let magic = FileFormat::from_file(path).map_err(|e| Error::io("detect", path, e))?;
134 let magic_known = magic != FileFormat::default() && magic != FileFormat::Empty;
135 let magic_id = if magic_known {
136 table.id_for_extension(magic.extension()).map(String::from)
137 } else {
138 None
139 };
140 let (detected, mismatch) = match (&magic_id, &declared) {
141 (Some(magic), Some(declared)) => (magic.clone(), magic != declared),
142 (Some(magic), None) => (magic.clone(), false),
143 (None, Some(declared)) => (declared.clone(), magic_known),
144 (None, None) => (UNKNOWN_FORMAT.to_string(), false),
145 };
146 Ok(Detection {
147 declared,
148 detected,
149 mismatch,
150 })
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use std::fs;
157
158 const PNG_MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
159
160 #[test]
161 fn builtin_table_parses() {
162 let table = FormatTable::builtin().unwrap();
163 assert_eq!(table.version(), "3");
164 assert_eq!(table.id_for_extension("txt"), Some("text"));
165 assert_eq!(table.id_for_extension("docx"), Some("docx"));
166 }
167
168 #[test]
169 fn extension_decides_when_magic_is_inconclusive() {
170 let dir = tempfile::tempdir().unwrap();
171 let path = dir.path().join("notes.md");
172 fs::write(&path, b"# heading\n").unwrap();
173 let table = FormatTable::builtin().unwrap();
174 let detection = detect_file(&path, &table).unwrap();
175 assert_eq!(detection.declared.as_deref(), Some("markdown"));
176 assert_eq!(detection.detected, "markdown");
177 assert!(!detection.mismatch);
178 }
179
180 #[test]
181 fn magic_wins_and_flags_a_mismatch() {
182 let dir = tempfile::tempdir().unwrap();
183 let path = dir.path().join("fake.txt");
184 fs::write(&path, PNG_MAGIC).unwrap();
185 let table = FormatTable::builtin().unwrap();
186 let detection = detect_file(&path, &table).unwrap();
187 assert_eq!(detection.declared.as_deref(), Some("text"));
188 assert_eq!(detection.detected, "png");
189 assert!(detection.mismatch);
190 }
191
192 #[test]
193 fn out_of_table_magic_still_flags_a_mismatch() {
194 let dir = tempfile::tempdir().unwrap();
195 let path = dir.path().join("pic.png");
196 std::fs::write(&path, b"GIF89a\x01\x00\x01\x00").unwrap();
197 let table = FormatTable::builtin().unwrap();
198 let detection = detect_file(&path, &table).unwrap();
199 assert_eq!(detection.declared.as_deref(), Some("png"));
200 assert_eq!(detection.detected, "png");
201 assert!(detection.mismatch);
202 }
203
204 #[test]
205 fn empty_file_is_not_a_mismatch() {
206 let dir = tempfile::tempdir().unwrap();
207 let path = dir.path().join("empty.txt");
208 std::fs::write(&path, b"").unwrap();
209 let table = FormatTable::builtin().unwrap();
210 let detection = detect_file(&path, &table).unwrap();
211 assert_eq!(detection.detected, "text");
212 assert!(!detection.mismatch);
213 }
214
215 #[test]
216 fn unplaceable_file_is_unknown() {
217 let dir = tempfile::tempdir().unwrap();
218 let path = dir.path().join("blob.xyz");
219 fs::write(&path, b"opaque bytes").unwrap();
220 let table = FormatTable::builtin().unwrap();
221 let detection = detect_file(&path, &table).unwrap();
222 assert_eq!(detection.declared, None);
223 assert_eq!(detection.detected, UNKNOWN_FORMAT);
224 assert!(!detection.mismatch);
225 }
226}