use std::collections::HashMap;
use std::path::Path;
use file_format::FileFormat;
use serde::Deserialize;
use crate::{Error, Result};
pub const UNKNOWN_FORMAT: &str = "unknown";
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FormatDef {
pub id: String,
pub name: String,
pub extensions: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawTable {
version: String,
formats: Vec<FormatDef>,
}
#[derive(Debug)]
pub struct FormatTable {
version: String,
formats: Vec<FormatDef>,
by_extension: HashMap<String, String>,
}
impl FormatTable {
pub fn builtin() -> Result<Self> {
Self::parse(include_str!("../rules/formats.toml"), "formats.toml")
}
pub fn parse(text: &str, name: &str) -> Result<Self> {
let raw: RawTable = toml::from_str(text).map_err(|e| Error::Rules {
name: name.to_string(),
message: e.to_string(),
})?;
let mut by_extension = HashMap::new();
let mut ids = HashMap::new();
for format in &raw.formats {
if ids.insert(format.id.clone(), ()).is_some() {
return Err(Error::Rules {
name: name.to_string(),
message: format!("duplicate format id {:?}", format.id),
});
}
for extension in &format.extensions {
if by_extension
.insert(extension.clone(), format.id.clone())
.is_some()
{
return Err(Error::Rules {
name: name.to_string(),
message: format!("extension {extension:?} claimed twice"),
});
}
}
}
Ok(FormatTable {
version: raw.version,
formats: raw.formats,
by_extension,
})
}
pub fn version(&self) -> &str {
&self.version
}
pub fn formats(&self) -> &[FormatDef] {
&self.formats
}
pub fn id_for_extension(&self, extension: &str) -> Option<&str> {
self.by_extension.get(extension).map(String::as_str)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Detection {
pub declared: Option<String>,
pub detected: String,
pub mismatch: bool,
}
pub fn declared_format(path: &Path, table: &FormatTable) -> Option<String> {
path.extension()
.and_then(|e| e.to_str())
.and_then(|e| table.id_for_extension(&e.to_ascii_lowercase()))
.map(String::from)
}
pub fn detect_file(path: &Path, table: &FormatTable) -> Result<Detection> {
let declared = declared_format(path, table);
let magic = FileFormat::from_file(path).map_err(|e| Error::io("detect", path, e))?;
let magic_known = magic != FileFormat::default() && magic != FileFormat::Empty;
let magic_id = if magic_known {
table.id_for_extension(magic.extension()).map(String::from)
} else {
None
};
let (detected, mismatch) = match (&magic_id, &declared) {
(Some(magic), Some(declared)) => (magic.clone(), magic != declared),
(Some(magic), None) => (magic.clone(), false),
(None, Some(declared)) => (declared.clone(), magic_known),
(None, None) => (UNKNOWN_FORMAT.to_string(), false),
};
Ok(Detection {
declared,
detected,
mismatch,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
const PNG_MAGIC: &[u8] = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR";
#[test]
fn builtin_table_parses() {
let table = FormatTable::builtin().unwrap();
assert_eq!(table.version(), "3");
assert_eq!(table.id_for_extension("txt"), Some("text"));
assert_eq!(table.id_for_extension("docx"), Some("docx"));
}
#[test]
fn extension_decides_when_magic_is_inconclusive() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("notes.md");
fs::write(&path, b"# heading\n").unwrap();
let table = FormatTable::builtin().unwrap();
let detection = detect_file(&path, &table).unwrap();
assert_eq!(detection.declared.as_deref(), Some("markdown"));
assert_eq!(detection.detected, "markdown");
assert!(!detection.mismatch);
}
#[test]
fn magic_wins_and_flags_a_mismatch() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("fake.txt");
fs::write(&path, PNG_MAGIC).unwrap();
let table = FormatTable::builtin().unwrap();
let detection = detect_file(&path, &table).unwrap();
assert_eq!(detection.declared.as_deref(), Some("text"));
assert_eq!(detection.detected, "png");
assert!(detection.mismatch);
}
#[test]
fn out_of_table_magic_still_flags_a_mismatch() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pic.png");
std::fs::write(&path, b"GIF89a\x01\x00\x01\x00").unwrap();
let table = FormatTable::builtin().unwrap();
let detection = detect_file(&path, &table).unwrap();
assert_eq!(detection.declared.as_deref(), Some("png"));
assert_eq!(detection.detected, "png");
assert!(detection.mismatch);
}
#[test]
fn empty_file_is_not_a_mismatch() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.txt");
std::fs::write(&path, b"").unwrap();
let table = FormatTable::builtin().unwrap();
let detection = detect_file(&path, &table).unwrap();
assert_eq!(detection.detected, "text");
assert!(!detection.mismatch);
}
#[test]
fn unplaceable_file_is_unknown() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("blob.xyz");
fs::write(&path, b"opaque bytes").unwrap();
let table = FormatTable::builtin().unwrap();
let detection = detect_file(&path, &table).unwrap();
assert_eq!(detection.declared, None);
assert_eq!(detection.detected, UNKNOWN_FORMAT);
assert!(!detection.mismatch);
}
}