Skip to main content

qualia_cli/ingest/
detect.rs

1use std::fs::File;
2use std::io::Read;
3use std::path::Path;
4
5/// Serialization formats supported by the ingest pipeline.
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum SemanticFormat {
8    NTriples,
9    NTriplesStar,
10    NQuads,
11    NQuadsStar,
12    Turtle,
13    TurtleStar,
14    TriG,
15    TriGStar,
16    N3,
17    RdfXml,
18    JsonLd,
19    JsonLdStar,
20    CborLd,
21    Kml,
22    Chk,
23    Q42,
24    AgentIntentJsonl,
25    /// 3D mesh asset (Wavefront OBJ, STL, or binary glTF) — geometry → semantic NQuins.
26    Mesh,
27}
28
29impl SemanticFormat {
30    pub fn label(self) -> &'static str {
31        match self {
32            SemanticFormat::NTriples => "N-Triples",
33            SemanticFormat::NTriplesStar => "N-Triples-Star",
34            SemanticFormat::NQuads => "N-Quads",
35            SemanticFormat::NQuadsStar => "N-Quads-Star",
36            SemanticFormat::Turtle => "Turtle",
37            SemanticFormat::TurtleStar => "Turtle-Star",
38            SemanticFormat::TriG => "TriG",
39            SemanticFormat::TriGStar => "TriG-Star",
40            SemanticFormat::N3 => "N3",
41            SemanticFormat::RdfXml => "RDF/XML",
42            SemanticFormat::JsonLd => "JSON-LD",
43            SemanticFormat::JsonLdStar => "JSON-LD-Star",
44            SemanticFormat::CborLd => "CBOR-LD",
45            SemanticFormat::Kml => "KML",
46            SemanticFormat::Chk => "CHK",
47            SemanticFormat::Q42 => "Q42",
48            SemanticFormat::AgentIntentJsonl => "Agent-Intent-JSONL",
49            SemanticFormat::Mesh => "Mesh (OBJ/STL/GLB)",
50        }
51    }
52}
53
54/// Detect the serialization format of a file by inspecting its extension and
55/// the first 16 bytes of content (magic bytes).
56///
57/// Extension check runs first (O(1)); magic-byte read is a fallback disambiguation
58/// step that opens the file for at most 16 bytes.  Returns `None` only when
59/// neither heuristic yields a conclusive result.
60pub fn detect_format(path: &Path) -> Option<SemanticFormat> {
61    let ext = path
62        .extension()
63        .and_then(|e| e.to_str())
64        .map(|s| s.to_ascii_lowercase());
65
66    // Read up to 16 magic bytes without allocating more than that.
67    let mut magic = [0u8; 16];
68    let magic_len = File::open(path)
69        .ok()
70        .and_then(|mut f| f.read(&mut magic).ok())
71        .unwrap_or(0);
72    let magic = &magic[..magic_len];
73
74    // ── Magic-byte checks (format-definitive) ────────────────────────────
75
76    // Q42 binary vault
77    if magic.len() >= 3 && &magic[..3] == b"Q42" {
78        return Some(SemanticFormat::Q42);
79    }
80    // QCHK / CHK profile blob
81    if magic.len() >= 4 && &magic[..4] == b"QCHK" {
82        return Some(SemanticFormat::Chk);
83    }
84    // GLB (binary glTF) — "glTF" magic.
85    if magic.starts_with(b"glTF") {
86        return Some(SemanticFormat::Mesh);
87    }
88    // CBOR-LD: standard CBOR self-describe tag 0xd9 0xd9 0xf7
89    if magic.len() >= 3 && magic[0] == 0xd9 && magic[1] == 0xd9 && magic[2] == 0xf7 {
90        return Some(SemanticFormat::CborLd);
91    }
92    // CBOR definite-length map (0xa0–0xb7) — only commit if extension confirms
93    if magic.len() >= 1 && (0xa0..=0xb7).contains(&magic[0]) {
94        if matches!(ext.as_deref(), Some("cbor") | Some("cborld")) {
95            return Some(SemanticFormat::CborLd);
96        }
97    }
98
99    // XML envelope — distinguish KML from RDF/XML by extension
100    let starts_xml =
101        magic.starts_with(b"<?xml") || magic.starts_with(b"<rdf:") || magic.starts_with(b"<RDF:");
102    if starts_xml {
103        return match ext.as_deref() {
104            Some("kml") => Some(SemanticFormat::Kml),
105            _ => Some(SemanticFormat::RdfXml),
106        };
107    }
108
109    // JSON envelope: { or [ — use extension to pick LD vs LD-Star
110    if magic.first().copied() == Some(b'{') || magic.first().copied() == Some(b'[') {
111        return match ext.as_deref() {
112            Some("jsonld-star") | Some("json-ld-star") => Some(SemanticFormat::JsonLdStar),
113            Some("jsonl") => Some(SemanticFormat::AgentIntentJsonl),
114            _ => Some(SemanticFormat::JsonLd),
115        };
116    }
117
118    // ── Extension fallback ────────────────────────────────────────────────
119    match ext.as_deref() {
120        Some("nt") => Some(SemanticFormat::NTriples),
121        Some("nts") | Some("nt-star") => Some(SemanticFormat::NTriplesStar),
122        Some("nq") => Some(SemanticFormat::NQuads),
123        Some("nqs") | Some("nq-star") => Some(SemanticFormat::NQuadsStar),
124        Some("ttl") => Some(SemanticFormat::Turtle),
125        Some("ttls") | Some("ttl-star") => Some(SemanticFormat::TurtleStar),
126        Some("trig") => Some(SemanticFormat::TriG),
127        Some("trigs") | Some("trig-star") => Some(SemanticFormat::TriGStar),
128        Some("n3") => Some(SemanticFormat::N3),
129        Some("rdf") | Some("owl") => Some(SemanticFormat::RdfXml),
130        Some("xml") => Some(SemanticFormat::RdfXml),
131        Some("jsonld") | Some("json-ld") | Some("json") => Some(SemanticFormat::JsonLd),
132        Some("jsonl") => Some(SemanticFormat::AgentIntentJsonl),
133        Some("cbor") | Some("cborld") => Some(SemanticFormat::CborLd),
134        Some("kml") => Some(SemanticFormat::Kml),
135        Some("chk") | Some("qchk") => Some(SemanticFormat::Chk),
136        Some("q42") => Some(SemanticFormat::Q42),
137        Some("obj") | Some("stl") | Some("glb") | Some("gltf") => Some(SemanticFormat::Mesh),
138        _ => None,
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145    use std::io::Write;
146
147    fn tmp_file(name: &str, content: &[u8]) -> std::path::PathBuf {
148        let path = std::env::temp_dir().join(name);
149        let mut f = std::fs::File::create(&path).expect("create tmp file");
150        f.write_all(content).expect("write tmp file");
151        path
152    }
153
154    #[test]
155    fn detect_by_extension_nt() {
156        let p = tmp_file("test_detect.nt", b"<s> <p> <o> .\n");
157        assert_eq!(detect_format(&p), Some(SemanticFormat::NTriples));
158    }
159
160    #[test]
161    fn detect_by_extension_ttl() {
162        let p = tmp_file("test_detect.ttl", b"@prefix ex: <http://ex.org/> .\n");
163        assert_eq!(detect_format(&p), Some(SemanticFormat::Turtle));
164    }
165
166    #[test]
167    fn detect_by_extension_jsonld() {
168        let p = tmp_file("test_detect.jsonld", b"{\"@context\": {}}");
169        assert_eq!(detect_format(&p), Some(SemanticFormat::JsonLd));
170    }
171
172    #[test]
173    fn detect_by_magic_q42() {
174        let p = tmp_file("test_detect_magic.bin", b"Q42V\x00\x00\x00\x00");
175        assert_eq!(detect_format(&p), Some(SemanticFormat::Q42));
176    }
177
178    #[test]
179    fn detect_by_magic_xml_rdf() {
180        let p = tmp_file("test_detect_magic.rdf", b"<?xml version=\"1.0\"?><rdf:RDF");
181        assert_eq!(detect_format(&p), Some(SemanticFormat::RdfXml));
182    }
183
184    #[test]
185    fn detect_by_magic_kml() {
186        let p = tmp_file("test_detect_magic.kml", b"<?xml version=\"1.0\"?><kml>");
187        assert_eq!(detect_format(&p), Some(SemanticFormat::Kml));
188    }
189
190    #[test]
191    fn detect_by_magic_cbor_ld() {
192        let p = tmp_file("test_detect_magic.cbor", b"\xd9\xd9\xf7\xa1");
193        assert_eq!(detect_format(&p), Some(SemanticFormat::CborLd));
194    }
195
196    #[test]
197    fn detect_json_content_without_ld_extension() {
198        let p = tmp_file(
199            "test_detect_json.json",
200            b"{\"@context\": {}, \"@id\": \"x\"}",
201        );
202        assert_eq!(detect_format(&p), Some(SemanticFormat::JsonLd));
203    }
204
205    #[test]
206    fn detect_unknown_returns_none() {
207        let p = tmp_file("test_detect.xyz", b"some unknown data");
208        assert_eq!(detect_format(&p), None);
209    }
210
211    #[test]
212    fn label_round_trips() {
213        assert_eq!(SemanticFormat::NTriples.label(), "N-Triples");
214        assert_eq!(SemanticFormat::TurtleStar.label(), "Turtle-Star");
215        assert_eq!(SemanticFormat::JsonLdStar.label(), "JSON-LD-Star");
216    }
217}