Skip to main content

prim_fmt/
classify.rs

1//! File classification (FR-2.4/2.5): decide whether prim owns a file, and what
2//! kind it is, from its name/extension alone — never by sniffing content.
3
4use std::path::Path;
5
6/// The kind of file prim recognises. Parsed formats receive structured
7/// canonicalisation plus whitespace hygiene; `Orphan` files (the un-owned text
8/// allowlist) only ever receive whitespace hygiene.
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum FileKind {
11    Markdown,
12    Json,
13    Jsonc,
14    Yaml,
15    Toml,
16    /// An un-owned text file on the curated allowlist (e.g. `.gitignore`).
17    Orphan,
18}
19
20/// Classify `path` by its final component. Returns `None` for anything prim
21/// does not own (source code, unknown types, binaries) — those are left
22/// byte-for-byte unchanged.
23pub fn classify(path: &Path) -> Option<FileKind> {
24    let name = path.file_name()?.to_str()?;
25
26    // Parsed formats and the extension-based orphan patterns (*.txt, *.text).
27    if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
28        match ext.to_ascii_lowercase().as_str() {
29            "md" | "markdown" => return Some(FileKind::Markdown),
30            "json" => return Some(FileKind::Json),
31            "jsonc" => return Some(FileKind::Jsonc),
32            "yaml" | "yml" => return Some(FileKind::Yaml),
33            "toml" => return Some(FileKind::Toml),
34            "txt" | "text" => return Some(FileKind::Orphan),
35            _ => {}
36        }
37    }
38
39    is_orphan(name).then_some(FileKind::Orphan)
40}
41
42/// Whether `name` is on the curated orphan allowlist (documented in
43/// `docs/USAGE.md`). `.env` files are deliberately excluded: their values are
44/// data and may be whitespace-sensitive.
45fn is_orphan(name: &str) -> bool {
46    const EXACT: &[&str] = &[
47        ".gitignore",
48        ".gitattributes",
49        ".dockerignore",
50        ".npmignore",
51        ".eslintignore",
52        ".prettierignore",
53        ".primignore",
54        ".helmignore",
55        ".editorconfig",
56        ".containerignore",
57        ".mailmap",
58        "CODEOWNERS",
59        "Dockerfile",
60        "Containerfile",
61        "AUTHORS",
62        "CONTRIBUTORS",
63        "NOTICE",
64        "COPYING",
65    ];
66
67    EXACT.contains(&name)
68        || name.starts_with("Dockerfile.") // Dockerfile.*
69        || name.starts_with("LICENSE") // LICENSE*
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    fn k(p: &str) -> Option<FileKind> {
77        classify(Path::new(p))
78    }
79
80    #[test]
81    fn parsed_formats_by_extension() {
82        assert_eq!(k("a.md"), Some(FileKind::Markdown));
83        assert_eq!(k("a.markdown"), Some(FileKind::Markdown));
84        assert_eq!(k("a.json"), Some(FileKind::Json));
85        assert_eq!(k("a.jsonc"), Some(FileKind::Jsonc));
86        assert_eq!(k("a.yaml"), Some(FileKind::Yaml));
87        assert_eq!(k("a.yml"), Some(FileKind::Yaml));
88        assert_eq!(k("a.toml"), Some(FileKind::Toml));
89    }
90
91    #[test]
92    fn orphan_allowlist_dotfiles() {
93        for name in [
94            ".gitignore",
95            ".gitattributes",
96            ".dockerignore",
97            ".npmignore",
98            ".eslintignore",
99            ".prettierignore",
100            ".primignore",
101            ".helmignore",
102            ".editorconfig",
103            ".containerignore",
104            ".mailmap",
105        ] {
106            assert_eq!(k(name), Some(FileKind::Orphan), "{name}");
107        }
108    }
109
110    #[test]
111    fn orphan_allowlist_patterns_and_names() {
112        assert_eq!(k("Dockerfile"), Some(FileKind::Orphan));
113        assert_eq!(k("Dockerfile.dev"), Some(FileKind::Orphan));
114        assert_eq!(k("Containerfile"), Some(FileKind::Orphan));
115        assert_eq!(k("CODEOWNERS"), Some(FileKind::Orphan));
116        assert_eq!(k("LICENSE"), Some(FileKind::Orphan));
117        assert_eq!(k("LICENSE.txt"), Some(FileKind::Orphan));
118        assert_eq!(k("AUTHORS"), Some(FileKind::Orphan));
119        assert_eq!(k("CONTRIBUTORS"), Some(FileKind::Orphan));
120        assert_eq!(k("NOTICE"), Some(FileKind::Orphan));
121        assert_eq!(k("COPYING"), Some(FileKind::Orphan));
122        assert_eq!(k("notes.txt"), Some(FileKind::Orphan));
123        assert_eq!(k("readme.text"), Some(FileKind::Orphan));
124    }
125
126    #[test]
127    fn non_owned_returns_none() {
128        assert_eq!(k("main.rs"), None);
129        assert_eq!(k("script.py"), None);
130        assert_eq!(k("logo.png"), None);
131        assert_eq!(k(".env"), None); // data values, not metadata — excluded.
132        assert_eq!(k(".env.local"), None);
133        assert_eq!(k("Makefile"), None); // Make is out of v1 scope.
134        assert_eq!(k("run.sh"), None); // Shell is deferred to Phase 2.
135        assert_eq!(k("noext"), None);
136    }
137
138    #[test]
139    fn classifies_by_final_component_of_a_path() {
140        assert_eq!(k("src/docs/guide.md"), Some(FileKind::Markdown));
141        assert_eq!(k("/etc/project/.gitignore"), Some(FileKind::Orphan));
142    }
143}