Skip to main content

oxirs_core/format/
format.rs

1//! RDF Format Enumeration and Detection
2//!
3//! Extracted and adapted from OxiGraph oxrdfio with OxiRS enhancements.
4//! Based on W3C RDF specifications and IANA media type registry.
5
6use serde::{Deserialize, Serialize};
7use std::fmt;
8
9/// JSON-LD profile for enhanced features
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
11pub enum JsonLdProfile {
12    /// Standard JSON-LD processing
13    Standard,
14    /// Expanded JSON-LD (no compaction)
15    Expanded,
16    /// Compacted JSON-LD
17    Compacted,
18    /// Flattened JSON-LD
19    Flattened,
20    /// Streaming JSON-LD
21    Streaming,
22}
23
24impl JsonLdProfile {
25    /// Get profile from IRI
26    pub fn from_iri(iri: &str) -> Option<Self> {
27        match iri {
28            "http://www.w3.org/ns/json-ld#expanded" => Some(Self::Expanded),
29            "http://www.w3.org/ns/json-ld#compacted" => Some(Self::Compacted),
30            "http://www.w3.org/ns/json-ld#flattened" => Some(Self::Flattened),
31            "http://www.w3.org/ns/json-ld#streaming" => Some(Self::Streaming),
32            _ => None,
33        }
34    }
35
36    /// Get IRI for profile
37    pub fn iri(&self) -> &'static str {
38        match self {
39            Self::Standard => "http://www.w3.org/ns/json-ld#standard",
40            Self::Expanded => "http://www.w3.org/ns/json-ld#expanded",
41            Self::Compacted => "http://www.w3.org/ns/json-ld#compacted",
42            Self::Flattened => "http://www.w3.org/ns/json-ld#flattened",
43            Self::Streaming => "http://www.w3.org/ns/json-ld#streaming",
44        }
45    }
46}
47
48/// Set of JSON-LD profiles
49#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
50pub struct JsonLdProfileSet {
51    profiles: Vec<JsonLdProfile>,
52}
53
54impl JsonLdProfileSet {
55    /// Create empty profile set
56    pub const fn empty() -> Self {
57        Self {
58            profiles: Vec::new(),
59        }
60    }
61
62    /// Create from single profile
63    pub fn from_profile(profile: JsonLdProfile) -> Self {
64        Self {
65            profiles: vec![profile],
66        }
67    }
68
69    /// Check if contains profile
70    pub fn contains(&self, profile: JsonLdProfile) -> bool {
71        self.profiles.contains(&profile)
72    }
73
74    /// Add profile to set
75    pub fn insert(&mut self, profile: JsonLdProfile) {
76        if !self.contains(profile) {
77            self.profiles.push(profile);
78        }
79    }
80
81    /// Get all profiles
82    pub fn profiles(&self) -> &[JsonLdProfile] {
83        &self.profiles
84    }
85
86    /// Convert to jsonld module's JsonLdProfileSet (bitfield-based)
87    ///
88    /// Maps format::JsonLdProfile to jsonld::JsonLdProfile for compatibility.
89    pub fn to_jsonld_profile_set(&self) -> crate::jsonld::JsonLdProfileSet {
90        use crate::jsonld;
91        let mut set = jsonld::JsonLdProfileSet::empty();
92
93        for profile in &self.profiles {
94            let jsonld_profile = match profile {
95                JsonLdProfile::Standard => continue, // No direct equivalent, skip
96                JsonLdProfile::Expanded => jsonld::JsonLdProfile::Expanded,
97                JsonLdProfile::Compacted => jsonld::JsonLdProfile::Compacted,
98                JsonLdProfile::Flattened => jsonld::JsonLdProfile::Flattened,
99                JsonLdProfile::Streaming => jsonld::JsonLdProfile::Streaming,
100            };
101            set |= jsonld_profile;
102        }
103
104        set
105    }
106
107    /// Create from jsonld module's JsonLdProfileSet
108    ///
109    /// Converts jsonld::JsonLdProfileSet to format::JsonLdProfileSet.
110    pub fn from_jsonld_profile_set(jsonld_set: crate::jsonld::JsonLdProfileSet) -> Self {
111        use crate::jsonld;
112        let mut profiles = Vec::new();
113
114        for jsonld_profile in jsonld_set {
115            let profile = match jsonld_profile {
116                jsonld::JsonLdProfile::Expanded => JsonLdProfile::Expanded,
117                jsonld::JsonLdProfile::Compacted => JsonLdProfile::Compacted,
118                jsonld::JsonLdProfile::Flattened => JsonLdProfile::Flattened,
119                jsonld::JsonLdProfile::Streaming => JsonLdProfile::Streaming,
120                // Context, Frame, Framed don't have direct equivalents
121                jsonld::JsonLdProfile::Context => continue,
122                jsonld::JsonLdProfile::Frame => continue,
123                jsonld::JsonLdProfile::Framed => continue,
124            };
125            profiles.push(profile);
126        }
127
128        Self { profiles }
129    }
130}
131
132impl From<JsonLdProfile> for JsonLdProfileSet {
133    fn from(profile: JsonLdProfile) -> Self {
134        Self::from_profile(profile)
135    }
136}
137
138impl std::ops::BitOr for JsonLdProfile {
139    type Output = JsonLdProfileSet;
140
141    fn bitor(self, rhs: Self) -> Self::Output {
142        let mut set = JsonLdProfileSet::from_profile(self);
143        set.insert(rhs);
144        set
145    }
146}
147
148impl std::ops::BitOrAssign<JsonLdProfile> for JsonLdProfileSet {
149    fn bitor_assign(&mut self, rhs: JsonLdProfile) {
150        self.insert(rhs);
151    }
152}
153
154/// RDF serialization formats
155///
156/// This enumeration covers all major RDF serialization formats supported by OxiRS.
157/// Based on W3C specifications and community standards.
158#[derive(Eq, PartialEq, Debug, Clone, Hash, Serialize, Deserialize, Default)]
159#[non_exhaustive]
160pub enum RdfFormat {
161    /// [N3](https://w3c.github.io/N3/spec/)
162    N3,
163    /// [N-Quads](https://www.w3.org/TR/n-quads/)
164    NQuads,
165    /// [N-Triples](https://www.w3.org/TR/n-triples/)
166    NTriples,
167    /// [RDF/XML](https://www.w3.org/TR/rdf-syntax-grammar/)
168    RdfXml,
169    /// [TriG](https://www.w3.org/TR/trig/)
170    TriG,
171    /// [Turtle](https://www.w3.org/TR/turtle/)
172    #[default]
173    Turtle,
174    /// [JSON-LD](https://www.w3.org/TR/json-ld/) with optional profiles
175    JsonLd { profile: JsonLdProfileSet },
176}
177
178impl RdfFormat {
179    /// The format canonical IRI according to the [Unique URIs for file formats registry](https://www.w3.org/ns/formats/).
180    ///
181    /// ```
182    /// use oxirs_core::format::RdfFormat;
183    ///
184    /// assert_eq!(
185    ///     RdfFormat::NTriples.iri(),
186    ///     "http://www.w3.org/ns/formats/N-Triples"
187    /// )
188    /// ```
189    pub const fn iri(&self) -> &'static str {
190        match self {
191            Self::JsonLd { .. } => "https://www.w3.org/ns/formats/data/JSON-LD",
192            Self::N3 => "http://www.w3.org/ns/formats/N3",
193            Self::NQuads => "http://www.w3.org/ns/formats/N-Quads",
194            Self::NTriples => "http://www.w3.org/ns/formats/N-Triples",
195            Self::RdfXml => "http://www.w3.org/ns/formats/RDF_XML",
196            Self::TriG => "http://www.w3.org/ns/formats/TriG",
197            Self::Turtle => "http://www.w3.org/ns/formats/Turtle",
198        }
199    }
200
201    /// The format [IANA media type](https://tools.ietf.org/html/rfc2046).
202    ///
203    /// ```
204    /// use oxirs_core::format::RdfFormat;
205    ///
206    /// assert_eq!(RdfFormat::NTriples.media_type(), "application/n-triples")
207    /// ```
208    pub fn media_type(&self) -> &'static str {
209        match self {
210            Self::JsonLd { profile } => {
211                if profile.contains(JsonLdProfile::Streaming) {
212                    "application/ld+json;profile=http://www.w3.org/ns/json-ld#streaming"
213                } else {
214                    "application/ld+json"
215                }
216            }
217            Self::N3 => "text/n3",
218            Self::NQuads => "application/n-quads",
219            Self::NTriples => "application/n-triples",
220            Self::RdfXml => "application/rdf+xml",
221            Self::TriG => "application/trig",
222            Self::Turtle => "text/turtle",
223        }
224    }
225
226    /// The format [IANA-registered](https://tools.ietf.org/html/rfc2046) file extension.
227    ///
228    /// ```
229    /// use oxirs_core::format::RdfFormat;
230    ///
231    /// assert_eq!(RdfFormat::NTriples.file_extension(), "nt")
232    /// ```
233    pub const fn file_extension(&self) -> &'static str {
234        match self {
235            Self::JsonLd { .. } => "jsonld",
236            Self::N3 => "n3",
237            Self::NQuads => "nq",
238            Self::NTriples => "nt",
239            Self::RdfXml => "rdf",
240            Self::TriG => "trig",
241            Self::Turtle => "ttl",
242        }
243    }
244
245    /// The format name.
246    ///
247    /// ```
248    /// use oxirs_core::format::RdfFormat;
249    ///
250    /// assert_eq!(RdfFormat::NTriples.name(), "N-Triples")
251    /// ```
252    pub fn name(&self) -> &'static str {
253        match self {
254            Self::JsonLd { profile } => {
255                if profile.contains(JsonLdProfile::Streaming) {
256                    "Streaming JSON-LD"
257                } else {
258                    "JSON-LD"
259                }
260            }
261            Self::N3 => "N3",
262            Self::NQuads => "N-Quads",
263            Self::NTriples => "N-Triples",
264            Self::RdfXml => "RDF/XML",
265            Self::TriG => "TriG",
266            Self::Turtle => "Turtle",
267        }
268    }
269
270    /// Checks if the format supports [RDF datasets](https://www.w3.org/TR/rdf11-concepts/#dfn-rdf-dataset) and not only [RDF graphs](https://www.w3.org/TR/rdf11-concepts/#dfn-rdf-graph).
271    ///
272    /// ```
273    /// use oxirs_core::format::RdfFormat;
274    ///
275    /// assert_eq!(RdfFormat::NTriples.supports_datasets(), false);
276    /// assert_eq!(RdfFormat::NQuads.supports_datasets(), true);
277    /// ```
278    pub const fn supports_datasets(&self) -> bool {
279        matches!(self, Self::JsonLd { .. } | Self::NQuads | Self::TriG)
280    }
281
282    /// Checks if the format supports RDF-star (quoted triples).
283    ///
284    /// ```
285    /// use oxirs_core::format::RdfFormat;
286    ///
287    /// assert_eq!(RdfFormat::Turtle.supports_rdf_star(), true);
288    /// assert_eq!(RdfFormat::RdfXml.supports_rdf_star(), false);
289    /// ```
290    pub const fn supports_rdf_star(&self) -> bool {
291        matches!(
292            self,
293            Self::NTriples | Self::NQuads | Self::Turtle | Self::TriG
294        )
295    }
296
297    /// Looks for a known format from a media type.
298    ///
299    /// It supports some media type aliases.
300    /// For example, "application/xml" is going to return `RdfFormat::RdfXml` even if it is not its canonical media type.
301    ///
302    /// Example:
303    /// ```
304    /// use oxirs_core::format::{RdfFormat, JsonLdProfile};
305    ///
306    /// assert_eq!(
307    ///     RdfFormat::from_media_type("text/turtle; charset=utf-8"),
308    ///     Some(RdfFormat::Turtle)
309    /// );
310    /// assert_eq!(
311    ///     RdfFormat::from_media_type(
312    ///         "application/ld+json ; profile = http://www.w3.org/ns/json-ld#streaming"
313    ///     ),
314    ///     Some(RdfFormat::JsonLd {
315    ///         profile: JsonLdProfile::Streaming.into()
316    ///     })
317    /// )
318    /// ```
319    pub fn from_media_type(media_type: &str) -> Option<Self> {
320        const MEDIA_SUBTYPES: [(&str, RdfFormat); 14] = [
321            (
322                "activity+json",
323                RdfFormat::JsonLd {
324                    profile: JsonLdProfileSet::empty(),
325                },
326            ),
327            (
328                "json",
329                RdfFormat::JsonLd {
330                    profile: JsonLdProfileSet::empty(),
331                },
332            ),
333            (
334                "ld+json",
335                RdfFormat::JsonLd {
336                    profile: JsonLdProfileSet::empty(),
337                },
338            ),
339            (
340                "jsonld",
341                RdfFormat::JsonLd {
342                    profile: JsonLdProfileSet::empty(),
343                },
344            ),
345            ("n-quads", RdfFormat::NQuads),
346            ("n-triples", RdfFormat::NTriples),
347            ("n3", RdfFormat::N3),
348            ("nquads", RdfFormat::NQuads),
349            ("ntriples", RdfFormat::NTriples),
350            ("plain", RdfFormat::NTriples),
351            ("rdf+xml", RdfFormat::RdfXml),
352            ("trig", RdfFormat::TriG),
353            ("turtle", RdfFormat::Turtle),
354            ("xml", RdfFormat::RdfXml),
355        ];
356        const UTF8_CHARSETS: [&str; 3] = ["ascii", "utf8", "utf-8"];
357
358        let (type_subtype, parameters) = media_type.split_once(';').unwrap_or((media_type, ""));
359
360        let (r#type, subtype) = type_subtype.split_once('/')?;
361        let r#type = r#type.trim();
362        if !r#type.eq_ignore_ascii_case("application") && !r#type.eq_ignore_ascii_case("text") {
363            return None;
364        }
365        let subtype = subtype.trim();
366        let subtype = subtype.strip_prefix("x-").unwrap_or(subtype);
367
368        let parameters = parameters.trim();
369        let parameters = if parameters.is_empty() {
370            Vec::new()
371        } else {
372            parameters
373                .split(';')
374                .map(|p| {
375                    let (key, value) = p.split_once('=')?;
376                    Some((key.trim(), value.trim()))
377                })
378                .collect::<Option<Vec<_>>>()?
379        };
380
381        for (candidate_subtype, mut candidate_id) in MEDIA_SUBTYPES {
382            if candidate_subtype.eq_ignore_ascii_case(subtype) {
383                // We have a look at parameters
384                for (key, mut value) in parameters {
385                    match key {
386                        "charset"
387                            if !UTF8_CHARSETS.iter().any(|c| c.eq_ignore_ascii_case(value)) =>
388                        {
389                            return None; // No other charset than UTF-8 is supported
390                        }
391                        "profile" => {
392                            // We remove enclosing double quotes
393                            if value.starts_with('"') && value.ends_with('"') {
394                                value = &value[1..value.len() - 1];
395                            }
396                            if let RdfFormat::JsonLd { profile } = &mut candidate_id {
397                                for value in value.split(' ') {
398                                    if let Some(value) = JsonLdProfile::from_iri(value.trim()) {
399                                        profile.insert(value);
400                                    }
401                                }
402                            }
403                        }
404                        _ => (), // We ignore
405                    }
406                }
407                return Some(candidate_id);
408            }
409        }
410        None
411    }
412
413    /// Looks for a known format from an extension.
414    ///
415    /// It supports some aliases.
416    ///
417    /// Example:
418    /// ```
419    /// use oxirs_core::format::RdfFormat;
420    ///
421    /// assert_eq!(RdfFormat::from_extension("nt"), Some(RdfFormat::NTriples))
422    /// ```
423    pub fn from_extension(extension: &str) -> Option<Self> {
424        const EXTENSIONS: [(&str, RdfFormat); 10] = [
425            (
426                "json",
427                RdfFormat::JsonLd {
428                    profile: JsonLdProfileSet::empty(),
429                },
430            ),
431            (
432                "jsonld",
433                RdfFormat::JsonLd {
434                    profile: JsonLdProfileSet::empty(),
435                },
436            ),
437            ("n3", RdfFormat::N3),
438            ("nq", RdfFormat::NQuads),
439            ("nt", RdfFormat::NTriples),
440            ("rdf", RdfFormat::RdfXml),
441            ("trig", RdfFormat::TriG),
442            ("ttl", RdfFormat::Turtle),
443            ("txt", RdfFormat::NTriples),
444            ("xml", RdfFormat::RdfXml),
445        ];
446        for (candidate_extension, candidate_id) in EXTENSIONS {
447            if candidate_extension.eq_ignore_ascii_case(extension) {
448                return Some(candidate_id);
449            }
450        }
451        None
452    }
453}
454
455impl fmt::Display for RdfFormat {
456    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
457        f.write_str(self.name())
458    }
459}
460
461#[cfg(test)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn test_from_media_type() {
467        assert_eq!(RdfFormat::from_media_type("foo/bar"), None);
468        assert_eq!(RdfFormat::from_media_type("text/csv"), None);
469        assert_eq!(
470            RdfFormat::from_media_type("text/turtle"),
471            Some(RdfFormat::Turtle)
472        );
473        assert_eq!(
474            RdfFormat::from_media_type("application/x-turtle"),
475            Some(RdfFormat::Turtle)
476        );
477        assert_eq!(
478            RdfFormat::from_media_type("application/ld+json"),
479            Some(RdfFormat::JsonLd {
480                profile: JsonLdProfileSet::empty()
481            })
482        );
483        assert_eq!(
484            RdfFormat::from_media_type("application/ld+json;profile=foo"),
485            Some(RdfFormat::JsonLd {
486                profile: JsonLdProfileSet::empty()
487            })
488        );
489        assert_eq!(
490            RdfFormat::from_media_type(
491                "application/ld+json;profile=http://www.w3.org/ns/json-ld#streaming"
492            ),
493            Some(RdfFormat::JsonLd {
494                profile: JsonLdProfile::Streaming.into()
495            })
496        );
497    }
498
499    #[test]
500    fn test_from_extension() {
501        assert_eq!(RdfFormat::from_extension("ttl"), Some(RdfFormat::Turtle));
502        assert_eq!(RdfFormat::from_extension("nt"), Some(RdfFormat::NTriples));
503        assert_eq!(RdfFormat::from_extension("nq"), Some(RdfFormat::NQuads));
504        assert_eq!(RdfFormat::from_extension("rdf"), Some(RdfFormat::RdfXml));
505        assert_eq!(
506            RdfFormat::from_extension("jsonld"),
507            Some(RdfFormat::JsonLd {
508                profile: JsonLdProfileSet::empty()
509            })
510        );
511        assert_eq!(RdfFormat::from_extension("unknown"), None);
512    }
513
514    #[test]
515    fn test_format_properties() {
516        assert!(RdfFormat::NQuads.supports_datasets());
517        assert!(!RdfFormat::NTriples.supports_datasets());
518
519        assert!(RdfFormat::Turtle.supports_rdf_star());
520        assert!(!RdfFormat::RdfXml.supports_rdf_star());
521
522        assert_eq!(RdfFormat::Turtle.file_extension(), "ttl");
523        assert_eq!(RdfFormat::NTriples.media_type(), "application/n-triples");
524        assert_eq!(RdfFormat::Turtle.name(), "Turtle");
525    }
526
527    #[test]
528    fn test_jsonld_profiles() {
529        let mut profile_set = JsonLdProfileSet::empty();
530        assert!(!profile_set.contains(JsonLdProfile::Streaming));
531
532        profile_set.insert(JsonLdProfile::Streaming);
533        assert!(profile_set.contains(JsonLdProfile::Streaming));
534
535        let combined = JsonLdProfile::Streaming | JsonLdProfile::Expanded;
536        assert!(combined.contains(JsonLdProfile::Streaming));
537        assert!(combined.contains(JsonLdProfile::Expanded));
538    }
539}