Skip to main content

triblespace_core/import/
ntriples.rs

1//! N-Triples → TribleSpace importer.
2//!
3//! Each N-Triples line maps directly to a triblespace trible. Subjects and
4//! object URIs are derived deterministically into entity ids via
5//! [`crate::import::rdf_uri`] — the same URI always maps to the same
6//! triblespace `Id` across processes, so repeated imports converge.
7//!
8//! Predicate URIs become attribute ids by wrapping the IRI handle in
9//! [`entity!`] under [`metadata::iri`] and [`metadata::value_encoding`],
10//! then taking the resulting fragment's root via
11//! [`Attribute::<S>::from`]. The inline encoding is chosen from the
12//! object's XSD datatype:
13//!
14//! - `xsd:integer` / `xsd:long` / `xsd:int` / `xsd:short` / `xsd:byte`
15//!   / `xsd:negativeInteger` / `xsd:nonPositiveInteger` → `I256BE`
16//! - `xsd:nonNegativeInteger` / `xsd:positiveInteger` / `xsd:unsignedInt`
17//!   / `xsd:unsignedLong` / `xsd:unsignedShort` / `xsd:unsignedByte` → `U256BE`
18//! - `xsd:decimal` → `R256BE` (exact rational)
19//! - `xsd:float` / `xsd:double` → `F64`
20//! - `xsd:boolean` → `Boolean`
21//! - `xsd:string`, untyped → `Handle<LongString>`
22//! - URI objects (and `xsd:anyURI` literals) → `GenId`
23//! - `xsd:dateTime` → `NsTAIInterval` as `[t, t]` (degenerate instant)
24//! - `xsd:date` → `NsTAIInterval` (whole day, inclusive bounds)
25//! - `xsd:gYear` / `xsd:gYearMonth` → `NsTAIInterval` (year / month)
26//! - `xsd:duration` / `xsd:dayTimeDuration` → `NsDuration`
27//!   (year/month-only durations fall through to text since their
28//!   ns count depends on context)
29//! - `xsd:hexBinary` / `xsd:base64Binary` → `Handle<RawBytes>`
30//!
31//! Language-tagged literals (`"text"@lang`) are reified into a small
32//! entity carrying [`rdf_lang`](crate::import::rdf_lang) and
33//! [`rdf_text`](crate::import::rdf_text). The owning predicate then
34//! holds a `GenId` pointing at that entity, so language handling falls
35//! out of normal joins instead of needing a `lang()` builtin.
36//!
37//! Blank nodes are resolved via the same content-address path the
38//! `entity!` macro uses: a bnode's id is the Blake3 of its sorted
39//! `(attribute, value)` pairs. Two bnodes with the same outgoing facts
40//! collapse to a single entity automatically — the bnode IS the entity
41//! that has these facts. Orphan bnodes (referenced but never appear as
42//! subject) get a per-import salt so they're distinct existentials
43//! across separate ingest calls. Cyclic blank-node graphs return
44//! [`IngestError::BnodeCycle`] — there's no fixed-point id assignment
45//! without symmetry-breaking, and we'd rather refuse than guess.
46//!
47//! ## API
48//!
49//! [`import_bytes`] is the core entry point — it parses a `Bytes`
50//! buffer (e.g. a memory-mapped file, an over-the-wire payload, or a
51//! `String::into_bytes`'d test fixture) without copying string slices.
52//! [`import_blob`] is a convenience over `Blob<LongString>`, mirroring
53//! [`crate::import::json::JsonObjectImporter::import_blob`].
54//! [`ingest_ntriples`] adapts a `BufRead` by slurping it into a
55//! `Bytes`. [`ingest_ntriples_file`] opens a path and forwards.
56//!
57//! Inside, the parser is winnow-driven over `anybytes::Bytes`. URI and
58//! bnode label slices come back as `View<str>` — Arc-shared into the
59//! input buffer, so storing them in the bnode-resolution buffer costs
60//! nothing. Literal lexical forms come back as `Bytes`: zero-copy on
61//! the no-escape fast path, freshly-allocated only when ECHAR / UCHAR
62//! escapes forced decoding. ECHAR `\b`, `\f`, `\'`, `\n`, `\r`, `\t`,
63//! `\"`, `\\` and UCHAR `\uXXXX` / `\UXXXXXXXX` are all supported in
64//! both string literals and IRIs.
65
66use std::collections::{HashMap, HashSet, VecDeque};
67use std::fmt;
68use std::io::{BufRead, Read};
69use std::path::Path;
70
71use anybytes::{Bytes, View};
72use base64::engine::general_purpose::STANDARD as BASE64;
73use base64::Engine as _;
74use blake3::Hasher;
75use hifitime::prelude::*;
76use num_rational::Ratio;
77use winnow::error::InputError;
78use winnow::stream::Stream;
79use winnow::token::{take, take_while};
80use winnow::Parser;
81
82use crate::blob::encodings::longstring::LongString;
83use crate::blob::encodings::rawbytes::RawBytes;
84use crate::blob::{Blob, IntoBlob};
85use crate::id::{ExclusiveId, Id, ID_LEN};
86use crate::macros::entity;
87use crate::prelude::inlineencodings;
88use crate::trible::{Fragment, Trible, TribleSet};
89use crate::inline::encodings::genid::GenId;
90use crate::inline::encodings::hash::Handle;
91use crate::inline::encodings::shortstring::ShortString;
92use crate::inline::encodings::time::{i128_to_ordered_be, NsDuration, NsTAIInterval};
93use crate::inline::encodings::UnknownInline;
94use crate::inline::encodings::boolean::Boolean;
95use crate::inline::encodings::f64::F64;
96use crate::inline::{RawInline, IntoInline, TryToInline, Inline};
97
98const XSD: &str = "http://www.w3.org/2001/XMLSchema#";
99
100// ── Errors ──────────────────────────────────────────────────────────
101
102/// Error returned by [`ingest_ntriples`] when the input cannot be
103/// completed without compromising semantics.
104#[derive(Debug, Clone)]
105pub enum IngestError {
106    /// A blank-node cycle was detected. Each label's intrinsic id depends
107    /// on its neighbors' ids, so a cycle has no fixed-point assignment
108    /// without a more elaborate symmetry-breaking scheme (see RDF-Canon's
109    /// gossip-path algorithm). We refuse rather than emit something
110    /// arbitrary.
111    BnodeCycle {
112        /// The bnode labels that participate in the unresolved cycle.
113        labels: Vec<String>,
114    },
115    /// The underlying reader returned an I/O error.
116    Io(String),
117}
118
119impl fmt::Display for IngestError {
120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
121        match self {
122            Self::BnodeCycle { labels } => {
123                write!(f, "blank-node cycle in input: {}", labels.join(", "))
124            }
125            Self::Io(msg) => write!(f, "i/o error reading n-triples: {msg}"),
126        }
127    }
128}
129
130impl std::error::Error for IngestError {}
131
132// ── Blank-node buffering ────────────────────────────────────────────
133//
134// Bnode identity in RDF is existential ("some thing with these
135// properties"). We materialise that by deriving each bnode's
136// triblespace `Id` from the set of its outgoing facts via the same
137// content-hash the `entity!` macro uses — the bnode IS the entity that
138// has these facts. Two bnodes with identical outgoing facts collapse
139// to the same id automatically, which is what RDF semantics promise.
140//
141// For bnodes with no outgoing facts (only referenced from elsewhere),
142// we skolemise with a per-import salt so they're treated as distinct
143// existentials across separate ingest calls. Same call, same label →
144// same id; different calls, same label → different ids.
145//
146// Cycles in the bnode reference graph produce `IngestError::BnodeCycle`
147// — there is no fixed point to assign without arbitrarily breaking
148// symmetry, and we'd rather refuse than emit something wrong.
149
150/// One outgoing edge from a bnode subject.
151enum OutgoingFact {
152    /// Fully-resolved value (URI handle, literal, lang-entity reference).
153    Resolved { attr_id: Id, value_raw: RawInline },
154    /// Bnode-to-bnode edge; both subject and target are deferred. The
155    /// `attr_id` is already the GenId-typed predicate-attribute id.
156    BnodeRef {
157        attr_id: Id,
158        target_label: View<str>,
159    },
160}
161
162/// One triple of the form `<resolved_subject> <predicate> _:target`.
163/// Emitted after `target` is resolved.
164struct IncomingFact {
165    subject_id: Id,
166    attr_id: Id,
167    target_label: View<str>,
168}
169
170/// Per-import buffer for blank-node triples. Keys are `View<str>`
171/// slices into the underlying input `Bytes`, so storing a label costs
172/// nothing — the slice Arc-shares the input buffer.
173struct BnodeBuffer {
174    /// Outgoing facts, keyed by bnode subject label.
175    outgoing: HashMap<View<str>, Vec<OutgoingFact>>,
176    /// Triples whose object is a bnode (subject is already resolved).
177    incoming: Vec<IncomingFact>,
178    /// Per-import salt for orphan-bnode skolemisation.
179    salt: [u8; 16],
180}
181
182impl BnodeBuffer {
183    fn new() -> Self {
184        // Random salt → orphans differ across ingest calls, matching
185        // their "fresh existential" RDF semantics. Within a call, the
186        // salt is constant so the same orphan label always produces
187        // the same id.
188        let mut salt = [0u8; 16];
189        rand::Rng::fill(&mut rand::thread_rng(), &mut salt[..]);
190        Self {
191            outgoing: HashMap::new(),
192            incoming: Vec::new(),
193            salt,
194        }
195    }
196
197    fn is_empty(&self) -> bool {
198        self.outgoing.is_empty() && self.incoming.is_empty()
199    }
200
201    fn push_outgoing(&mut self, label: View<str>, fact: OutgoingFact) {
202        self.outgoing.entry(label).or_default().push(fact);
203    }
204
205    fn push_incoming(&mut self, fact: IncomingFact) {
206        self.incoming.push(fact);
207    }
208
209    /// Resolve every buffered bnode and emit its tribles into `facts`.
210    fn flush(self, facts: &mut TribleSet) -> Result<(), IngestError> {
211        if self.is_empty() {
212            return Ok(());
213        }
214
215        // 1. Build dependency graph: label → labels its outgoing facts reference.
216        //    Also collect every label that appears anywhere (subject or target).
217        let mut deps: HashMap<View<str>, HashSet<View<str>>> = HashMap::new();
218        let mut all_labels: HashSet<View<str>> = HashSet::new();
219        for (label, edges) in &self.outgoing {
220            all_labels.insert(label.clone());
221            let entry = deps.entry(label.clone()).or_default();
222            for edge in edges {
223                if let OutgoingFact::BnodeRef { target_label, .. } = edge {
224                    entry.insert(target_label.clone());
225                    all_labels.insert(target_label.clone());
226                }
227            }
228        }
229        for inc in &self.incoming {
230            all_labels.insert(inc.target_label.clone());
231        }
232
233        // 2. Topo-sort. Cycle → error.
234        let order = topo_sort(&all_labels, &deps).map_err(|labels| {
235            let mut sorted: Vec<String> = labels.iter().map(|v| v.as_ref().to_owned()).collect();
236            sorted.sort();
237            IngestError::BnodeCycle { labels: sorted }
238        })?;
239
240        // 3. Resolve each bnode's id in dependency order. By the time
241        //    we visit a label, all labels its outgoing facts reference
242        //    are already in `resolved`.
243        let mut resolved: HashMap<View<str>, Id> = HashMap::new();
244        for label in order {
245            let id = resolve_bnode_id(&label, &self.outgoing, &resolved, &self.salt);
246            resolved.insert(label, id);
247        }
248
249        // 4. Emit outgoing tribles (bnode-as-subject).
250        for (label, edges) in self.outgoing {
251            let subject_id = resolved[&label];
252            let e = ExclusiveId::force_ref(&subject_id);
253            for edge in edges {
254                let (attr_id, value_raw) = match edge {
255                    OutgoingFact::Resolved { attr_id, value_raw } => (attr_id, value_raw),
256                    OutgoingFact::BnodeRef {
257                        attr_id,
258                        target_label,
259                    } => {
260                        let target_id = resolved[&target_label];
261                        let v: Inline<GenId> = target_id.to_inline();
262                        (attr_id, v.raw)
263                    }
264                };
265                let v: Inline<UnknownInline> = Inline::new(value_raw);
266                facts.insert(&Trible::new(e, &attr_id, &v));
267            }
268        }
269
270        // 5. Emit incoming tribles (bnode-as-object, subject already known).
271        for inc in self.incoming {
272            let target_id = resolved[&inc.target_label];
273            let e = ExclusiveId::force_ref(&inc.subject_id);
274            let g: Inline<GenId> = target_id.to_inline();
275            let v: Inline<UnknownInline> = Inline::new(g.raw);
276            facts.insert(&Trible::new(e, &inc.attr_id, &v));
277        }
278
279        Ok(())
280    }
281}
282
283/// Compute the intrinsic id for a single bnode given its outgoing facts.
284/// Mirrors the `entity!` macro's derivation: sort `(attr_id, value_raw)`
285/// pairs, dedupe consecutive duplicates, hash with Blake3, and take the
286/// last 16 bytes as the id.
287///
288/// For orphans (no outgoing facts), falls back to skolemisation:
289/// `Blake3(salt || label)[16..]`.
290fn resolve_bnode_id(
291    label: &View<str>,
292    outgoing: &HashMap<View<str>, Vec<OutgoingFact>>,
293    resolved: &HashMap<View<str>, Id>,
294    salt: &[u8; 16],
295) -> Id {
296    let pairs: Vec<(Id, RawInline)> = outgoing
297        .get(label)
298        .map(|edges| {
299            edges
300                .iter()
301                .map(|edge| match edge {
302                    OutgoingFact::Resolved { attr_id, value_raw } => (*attr_id, *value_raw),
303                    OutgoingFact::BnodeRef {
304                        attr_id,
305                        target_label,
306                    } => {
307                        let target_id = resolved
308                            .get(target_label)
309                            .expect("topo order resolved this target first");
310                        let v: Inline<GenId> = target_id.to_inline();
311                        (*attr_id, v.raw)
312                    }
313                })
314                .collect()
315        })
316        .unwrap_or_default();
317
318    if pairs.is_empty() {
319        // Orphan: skolemise via salt.
320        let mut hasher = Hasher::new();
321        hasher.update(salt);
322        hasher.update(label.as_ref().as_bytes());
323        let digest = hasher.finalize();
324        let mut raw = [0u8; ID_LEN];
325        raw.copy_from_slice(&digest.as_bytes()[digest.as_bytes().len() - ID_LEN..]);
326        return Id::new(raw).expect("non-nil from random salt");
327    }
328
329    let mut pairs = pairs;
330    pairs.sort_unstable();
331    let mut hasher = Hasher::new();
332    let mut last: Option<(Id, RawInline)> = None;
333    for (a, v) in &pairs {
334        if let Some((la, lv)) = last {
335            if *a == la && *v == lv {
336                continue;
337            }
338        }
339        hasher.update(&a[..]);
340        hasher.update(&v[..]);
341        last = Some((*a, *v));
342    }
343    let digest = hasher.finalize();
344    let mut raw = [0u8; ID_LEN];
345    raw.copy_from_slice(&digest.as_bytes()[digest.as_bytes().len() - ID_LEN..]);
346    Id::new(raw).expect("intrinsic id from non-empty pairs")
347}
348
349/// Kahn's topological sort. Returns an ordering where every node comes
350/// after the labels it depends on (its outgoing-fact targets). Returns
351/// the unresolved cycle labels as `Err` if no full ordering exists.
352fn topo_sort(
353    nodes: &HashSet<View<str>>,
354    edges: &HashMap<View<str>, HashSet<View<str>>>,
355) -> Result<Vec<View<str>>, Vec<View<str>>> {
356    let mut in_degree: HashMap<View<str>, usize> =
357        nodes.iter().map(|n| (n.clone(), 0)).collect();
358    for dsts in edges.values() {
359        for dst in dsts {
360            *in_degree.entry(dst.clone()).or_insert(0) += 1;
361        }
362    }
363    let mut queue: VecDeque<View<str>> = in_degree
364        .iter()
365        .filter(|(_, d)| **d == 0)
366        .map(|(n, _)| n.clone())
367        .collect();
368    let mut order: Vec<View<str>> = Vec::with_capacity(nodes.len());
369    while let Some(n) = queue.pop_front() {
370        if let Some(dsts) = edges.get(&n) {
371            for dst in dsts {
372                let d = in_degree.get_mut(dst).expect("dst recorded above");
373                *d -= 1;
374                if *d == 0 {
375                    queue.push_back(dst.clone());
376                }
377            }
378        }
379        order.push(n);
380    }
381    if order.len() < nodes.len() {
382        let cycle: Vec<View<str>> = nodes
383            .iter()
384            .filter(|n| in_degree.get(*n).copied().unwrap_or(0) > 0)
385            .cloned()
386            .collect();
387        Err(cycle)
388    } else {
389        Ok(order)
390    }
391}
392
393// ── Parsing — Bytes/winnow ──────────────────────────────────────────
394//
395// Each parser step takes `&mut Bytes` and returns a `View<str>` (or a
396// `Bytes` for literal lexical forms whose fast/slow path differ). View
397// slices Arc-share the input buffer, so storing them in `BnodeBuffer`
398// across line-equivalent boundaries costs nothing — no copy or alloc.
399// Mirrors `json::parse_string_common`'s shape.
400
401/// What follows a closing `"` on an N-Triples literal:
402/// `^^<datatype>`, `@language`, or nothing.
403enum LiteralSuffix {
404    None,
405    Datatype(View<str>),
406    Language(View<str>),
407}
408
409fn skip_ws_and_comments(bytes: &mut Bytes) {
410    loop {
411        // Eat whitespace bytes. N-Triples grammar permits HT/LF/CR/SP.
412        while matches!(bytes.peek_token(), Some(b) if matches!(b, b' ' | b'\t' | b'\n' | b'\r')) {
413            bytes.pop_front();
414        }
415        // Eat `# ... \n` comments.
416        if bytes.peek_token() == Some(b'#') {
417            while let Some(b) = bytes.pop_front() {
418                if b == b'\n' {
419                    break;
420                }
421            }
422            continue;
423        }
424        break;
425    }
426}
427
428fn skip_inline_ws(bytes: &mut Bytes) {
429    while matches!(bytes.peek_token(), Some(b' ') | Some(b'\t')) {
430        bytes.pop_front();
431    }
432}
433
434/// Take an `<iri>` and return its content as a `View<str>`.
435///
436/// Fast path: scan for `>` with no `\` along the way → return the
437/// slice directly, zero copy. Slow path (any `\u`/`\U` UCHAR escape):
438/// decode into a fresh `Bytes`-backed buffer.
439///
440/// Per the N-Triples grammar, an IRIREF is `'<' (([^#x00-#x20<>"{}|^`\]
441/// | UCHAR))* '>'` — bytes in `0x00..=0x20`, `<`, `>`, `"`, `{`, `}`,
442/// `|`, `^`, `\`` and lone `\` are all rejected literal-form.
443fn take_iri(bytes: &mut Bytes) -> Option<View<str>> {
444    if bytes.peek_token() != Some(b'<') {
445        return None;
446    }
447    bytes.pop_front();
448
449    // Fast path: scan unescaped chars, rejecting any forbidden byte.
450    {
451        let mut tentative = bytes.clone();
452        let mut take = take_while::<_, _, InputError<Bytes>>(0.., |b: u8| {
453            // Accept: anything that isn't terminator (`>`), escape
454            // (`\\`), or one of the spec-forbidden literal chars.
455            b > 0x20 && !matches!(b, b'<' | b'>' | b'"' | b'{' | b'}' | b'|' | b'^' | b'`' | b'\\')
456        });
457        if let Ok(prefix) = take.parse_next(&mut tentative) {
458            if tentative.peek_token() == Some(b'>') {
459                tentative.pop_front();
460                *bytes = tentative;
461                return prefix.view::<str>().ok();
462            }
463        }
464    }
465
466    // Slow path: handle `\uXXXX` / `\UXXXXXXXX` escapes. We arrive
467    // here only if the fast path bailed — either an escape was seen
468    // or a forbidden byte was hit.
469    let mut out: Vec<u8> = Vec::new();
470    while let Some(b) = bytes.peek_token() {
471        match b {
472            b'>' => {
473                bytes.pop_front();
474                return Bytes::from_source(out).view::<str>().ok();
475            }
476            b'\\' => {
477                bytes.pop_front();
478                let kind = bytes.pop_front()?;
479                let decoded = match kind {
480                    b'u' => parse_uchar(bytes, 4)?,
481                    b'U' => parse_uchar(bytes, 8)?,
482                    _ => return None, // IRIs allow only UCHAR escapes
483                };
484                out.extend_from_slice(&decoded);
485            }
486            // Forbidden literal bytes — reject.
487            0..=0x20 | b'<' | b'"' | b'{' | b'}' | b'|' | b'^' | b'`' => return None,
488            _ => {
489                out.push(b);
490                bytes.pop_front();
491            }
492        }
493    }
494    None
495}
496
497/// Take a `_:label` blank-node label as a `View<str>`. Per BLANK_NODE_LABEL,
498/// labels can contain dots in the middle — we terminate purely on
499/// whitespace, since the triple-ending `.` is always preceded by it.
500fn take_bnode(bytes: &mut Bytes) -> Option<View<str>> {
501    if bytes.peek_token() != Some(b'_') {
502        return None;
503    }
504    let mut tentative = bytes.clone();
505    let mut prefix = take::<_, _, InputError<Bytes>>(2usize);
506    let head = prefix.parse_next(&mut tentative).ok()?;
507    if head.as_ref() != b"_:" {
508        return None;
509    }
510    let mut take_label = take_while::<_, _, InputError<Bytes>>(1.., |b: u8| {
511        !matches!(b, b' ' | b'\t' | b'\n' | b'\r')
512    });
513    let label = take_label.parse_next(&mut tentative).ok()?;
514    *bytes = tentative;
515
516    // Re-include the `_:` prefix so callers see the literal label form.
517    // We rebuild from `out` rather than try to reconstruct a contiguous
518    // slice — `View<str>` allocation cost is one Vec, comparable to a
519    // `String::from`. (Future optimisation: have anybytes expose a
520    // contiguous-slice constructor that re-merges adjacent Bytes.)
521    let mut combined = Vec::with_capacity(2 + label.len());
522    combined.extend_from_slice(b"_:");
523    combined.extend_from_slice(label.as_ref());
524    Bytes::from_source(combined).view::<str>().ok()
525}
526
527/// Take a `"..."` literal plus optional `^^<datatype>` / `@language`
528/// suffix. Returns the lexical form as `Bytes` (use `.view::<str>()`)
529/// — zero-copy on the no-escape fast path, freshly-allocated only when
530/// escapes forced decoding.
531fn take_literal(bytes: &mut Bytes) -> Option<(Bytes, LiteralSuffix)> {
532    if bytes.peek_token() != Some(b'"') {
533        return None;
534    }
535    bytes.pop_front();
536
537    // Fast path: scan for the closing quote without any `\`.
538    {
539        let mut tentative = bytes.clone();
540        let mut take = take_while::<_, _, InputError<Bytes>>(0.., |b: u8| {
541            b != b'"' && b != b'\\' && b != b'\n' && b != b'\r'
542        });
543        if let Ok(prefix) = take.parse_next(&mut tentative) {
544            if tentative.peek_token() == Some(b'"') {
545                tentative.pop_front();
546                *bytes = tentative;
547                let suffix = parse_literal_suffix(bytes)?;
548                return Some((prefix, suffix));
549            }
550        }
551    }
552
553    // Slow path: full ECHAR + UCHAR decoding.
554    let mut out: Vec<u8> = Vec::new();
555    loop {
556        let b = bytes.peek_token()?;
557        match b {
558            b'"' => {
559                bytes.pop_front();
560                let suffix = parse_literal_suffix(bytes)?;
561                return Some((Bytes::from_source(out), suffix));
562            }
563            b'\\' => {
564                bytes.pop_front();
565                let kind = bytes.pop_front()?;
566                match kind {
567                    b'n' => out.push(b'\n'),
568                    b't' => out.push(b'\t'),
569                    b'r' => out.push(b'\r'),
570                    b'b' => out.push(0x08),
571                    b'f' => out.push(0x0c),
572                    b'"' => out.push(b'"'),
573                    b'\'' => out.push(b'\''),
574                    b'\\' => out.push(b'\\'),
575                    b'u' => {
576                        let decoded = parse_uchar(bytes, 4)?;
577                        out.extend_from_slice(&decoded);
578                    }
579                    b'U' => {
580                        let decoded = parse_uchar(bytes, 8)?;
581                        out.extend_from_slice(&decoded);
582                    }
583                    _ => return None,
584                }
585            }
586            b'\n' | b'\r' => return None,
587            _ => {
588                out.push(b);
589                bytes.pop_front();
590            }
591        }
592    }
593}
594
595/// Decode `\uXXXX` (4 hex digits) or `\UXXXXXXXX` (8) into UTF-8 bytes.
596/// Caller has already consumed the leading `\u` / `\U`.
597fn parse_uchar(bytes: &mut Bytes, hex_digits: usize) -> Option<Vec<u8>> {
598    let mut grab = take::<_, _, InputError<Bytes>>(hex_digits);
599    let hex = grab.parse_next(bytes).ok()?;
600    let mut code: u32 = 0;
601    for h in hex.as_ref() {
602        code = (code << 4)
603            | match h {
604                b'0'..=b'9' => (h - b'0') as u32,
605                b'a'..=b'f' => (h - b'a' + 10) as u32,
606                b'A'..=b'F' => (h - b'A' + 10) as u32,
607                _ => return None,
608            };
609    }
610    let ch = char::from_u32(code)?;
611    let mut buf = [0u8; 4];
612    Some(ch.encode_utf8(&mut buf).as_bytes().to_vec())
613}
614
615/// Match the optional `^^<datatype>` / `@language` suffix.
616fn parse_literal_suffix(bytes: &mut Bytes) -> Option<LiteralSuffix> {
617    match bytes.peek_token() {
618        Some(b'^') => {
619            // expect `^^`
620            bytes.pop_front();
621            if bytes.pop_front() != Some(b'^') {
622                return None;
623            }
624            let dt = take_iri(bytes)?;
625            Some(LiteralSuffix::Datatype(dt))
626        }
627        Some(b'@') => {
628            bytes.pop_front();
629            let mut take = take_while::<_, _, InputError<Bytes>>(1.., |b: u8| {
630                b.is_ascii_alphanumeric() || b == b'-'
631            });
632            let tag = take.parse_next(bytes).ok()?;
633            tag.view::<str>().ok().map(LiteralSuffix::Language)
634        }
635        _ => Some(LiteralSuffix::None),
636    }
637}
638
639/// Parse a decimal string into a `Ratio<i128>`.
640/// Handles `"3.14"` → `314/100`, `"42"` → `42/1`, `"-0.5"` → `-1/2`.
641fn parse_decimal(s: &str) -> Option<Ratio<i128>> {
642    if let Some(dot_pos) = s.find('.') {
643        let decimals = s.len() - dot_pos - 1;
644        let without_dot: String = s.chars().filter(|c| *c != '.').collect();
645        let numerator: i128 = without_dot.parse().ok()?;
646        let denominator: i128 = 10i128.checked_pow(decimals as u32)?;
647        Some(Ratio::new(numerator, denominator))
648    } else {
649        let n: i128 = s.parse().ok()?;
650        Some(Ratio::from_integer(n))
651    }
652}
653
654// ── XSD temporal parsers ────────────────────────────────────────────
655//
656// xsd:dateTime / xsd:date / xsd:gYear* lexical forms are deliberately
657// strict subsets of ISO 8601. We parse the components ourselves and
658// hand them to hifitime's `from_gregorian_utc`, so leap-second handling
659// and pre-Gregorian dates fall out of hifitime's correctness — we just
660// have to be permissive about timezone notation (`Z`, `+HH:MM`,
661// missing — RDF data uses all three).
662
663/// Eat `[-]YYYY` from the front of `s`, returning the signed year and
664/// the remainder. (Year-range overflow handled by hifitime's checked
665/// constructor in `epoch_from_gregorian_with_offset`.)
666fn parse_year(mut s: &str) -> Option<(i32, &str)> {
667    let neg = if let Some(rest) = s.strip_prefix('-') {
668        s = rest;
669        true
670    } else {
671        false
672    };
673    let digits_end = s
674        .as_bytes()
675        .iter()
676        .position(|b| !b.is_ascii_digit())
677        .unwrap_or(s.len());
678    if digits_end < 4 {
679        return None;
680    }
681    let year_abs: i64 = s[..digits_end].parse().ok()?;
682    let year: i32 = if neg {
683        i32::try_from(-year_abs).ok()?
684    } else {
685        i32::try_from(year_abs).ok()?
686    };
687    Some((year, &s[digits_end..]))
688}
689
690/// Strip an `xsd` timezone suffix (`Z` or `±HH:MM`) and return the
691/// offset in seconds. Missing timezone → 0 (UTC convention for RDF).
692fn parse_timezone_offset(s: &str) -> Option<i64> {
693    if s.is_empty() {
694        return Some(0);
695    }
696    if s == "Z" {
697        return Some(0);
698    }
699    let bytes = s.as_bytes();
700    let sign = match bytes.first()? {
701        b'+' => 1i64,
702        b'-' => -1i64,
703        _ => return None,
704    };
705    if bytes.len() != 6 || bytes[3] != b':' {
706        return None;
707    }
708    let hh: i64 = std::str::from_utf8(&bytes[1..3]).ok()?.parse().ok()?;
709    let mm: i64 = std::str::from_utf8(&bytes[4..6]).ok()?.parse().ok()?;
710    Some(sign * (hh * 3600 + mm * 60))
711}
712
713/// Build an [`Epoch`] (UTC) from Gregorian fields and a timezone offset
714/// in seconds. The offset is *subtracted* — `12:00 +05:00` is `07:00 UTC`.
715fn epoch_from_gregorian_with_offset(
716    year: i32,
717    month: u8,
718    day: u8,
719    hh: u8,
720    mm: u8,
721    ss: u8,
722    ns: u32,
723    offset_secs: i64,
724) -> Option<Epoch> {
725    // hifitime panics on overflow when out of its representable range
726    // (Wikidata has dateTime values like year 1e9 that hifitime can't
727    // hold). Use the checked variant so we fall through to text
728    // storage instead of crashing the importer.
729    let local = Epoch::maybe_from_gregorian_utc(year, month, day, hh, mm, ss, ns).ok()?;
730    Some(local - Duration::from_seconds(offset_secs as f64))
731}
732
733/// xsd:dateTime — `[-]YYYY-MM-DDThh:mm:ss[.f][Z|±HH:MM]`.
734fn parse_xsd_datetime(s: &str) -> Option<i128> {
735    let (year, rest) = parse_year(s)?;
736    let mut chars = rest.as_bytes();
737    if chars.first() != Some(&b'-') {
738        return None;
739    }
740    let month: u8 = std::str::from_utf8(chars.get(1..3)?).ok()?.parse().ok()?;
741    if chars.get(3) != Some(&b'-') {
742        return None;
743    }
744    let day: u8 = std::str::from_utf8(chars.get(4..6)?).ok()?.parse().ok()?;
745    if chars.get(6) != Some(&b'T') {
746        return None;
747    }
748    let hh: u8 = std::str::from_utf8(chars.get(7..9)?).ok()?.parse().ok()?;
749    if chars.get(9) != Some(&b':') {
750        return None;
751    }
752    let mm: u8 = std::str::from_utf8(chars.get(10..12)?).ok()?.parse().ok()?;
753    if chars.get(12) != Some(&b':') {
754        return None;
755    }
756    let ss: u8 = std::str::from_utf8(chars.get(13..15)?).ok()?.parse().ok()?;
757    chars = &chars[15..];
758
759    let mut ns: u32 = 0;
760    if chars.first() == Some(&b'.') {
761        chars = &chars[1..];
762        let frac_end = chars
763            .iter()
764            .position(|b| !b.is_ascii_digit())
765            .unwrap_or(chars.len());
766        // Pad / truncate to 9 digits (nanosecond resolution).
767        let frac_str = std::str::from_utf8(&chars[..frac_end]).ok()?;
768        let mut padded = String::with_capacity(9);
769        padded.push_str(frac_str);
770        while padded.len() < 9 {
771            padded.push('0');
772        }
773        ns = padded[..9].parse().ok()?;
774        chars = &chars[frac_end..];
775    }
776
777    let tz = std::str::from_utf8(chars).ok()?;
778    let offset = parse_timezone_offset(tz)?;
779    let epoch = epoch_from_gregorian_with_offset(year, month, day, hh, mm, ss, ns, offset)?;
780    Some(epoch.to_tai_duration().total_nanoseconds())
781}
782
783/// xsd:date — `[-]YYYY-MM-DD[Z|±HH:MM]`. Returned as inclusive bounds
784/// `[day_start, day_end]`.
785fn parse_xsd_date(s: &str) -> Option<(i128, i128)> {
786    let (year, rest) = parse_year(s)?;
787    let bytes = rest.as_bytes();
788    if bytes.first() != Some(&b'-') {
789        return None;
790    }
791    let month: u8 = std::str::from_utf8(bytes.get(1..3)?).ok()?.parse().ok()?;
792    if bytes.get(3) != Some(&b'-') {
793        return None;
794    }
795    let day: u8 = std::str::from_utf8(bytes.get(4..6)?).ok()?.parse().ok()?;
796    let tz = std::str::from_utf8(&bytes[6..]).ok()?;
797    let offset = parse_timezone_offset(tz)?;
798    let lower = epoch_from_gregorian_with_offset(year, month, day, 0, 0, 0, 0, offset)?
799        .to_tai_duration()
800        .total_nanoseconds();
801    // Day end: lower + 86_400 s - 1 ns. (Inclusive upper.)
802    let upper = lower.checked_add(86_400_000_000_000i128 - 1)?;
803    Some((lower, upper))
804}
805
806/// xsd:gYear — `[-]YYYY[Z|±HH:MM]`. Returned as the whole year as an
807/// inclusive interval.
808fn parse_xsd_gyear(s: &str) -> Option<(i128, i128)> {
809    let (year, rest) = parse_year(s)?;
810    let offset = parse_timezone_offset(rest)?;
811    let lower = epoch_from_gregorian_with_offset(year, 1, 1, 0, 0, 0, 0, offset)?
812        .to_tai_duration()
813        .total_nanoseconds();
814    let next_year = year.checked_add(1)?;
815    let upper_excl = epoch_from_gregorian_with_offset(next_year, 1, 1, 0, 0, 0, 0, offset)?
816        .to_tai_duration()
817        .total_nanoseconds();
818    Some((lower, upper_excl.checked_sub(1)?))
819}
820
821/// xsd:gYearMonth — `[-]YYYY-MM[Z|±HH:MM]`. Whole month, inclusive.
822fn parse_xsd_gyearmonth(s: &str) -> Option<(i128, i128)> {
823    let (year, rest) = parse_year(s)?;
824    let bytes = rest.as_bytes();
825    if bytes.first() != Some(&b'-') {
826        return None;
827    }
828    let month: u8 = std::str::from_utf8(bytes.get(1..3)?).ok()?.parse().ok()?;
829    if !(1..=12).contains(&month) {
830        return None;
831    }
832    let tz = std::str::from_utf8(&bytes[3..]).ok()?;
833    let offset = parse_timezone_offset(tz)?;
834    let lower = epoch_from_gregorian_with_offset(year, month, 1, 0, 0, 0, 0, offset)?
835        .to_tai_duration()
836        .total_nanoseconds();
837    let (next_year, next_month) = if month == 12 {
838        (year.checked_add(1)?, 1u8)
839    } else {
840        (year, month + 1)
841    };
842    let upper_excl = epoch_from_gregorian_with_offset(next_year, next_month, 1, 0, 0, 0, 0, offset)?
843        .to_tai_duration()
844        .total_nanoseconds();
845    Some((lower, upper_excl.checked_sub(1)?))
846}
847
848/// xsd:duration — `[-]P[nY][nM][nD][T[nH][nM][nS]]`. We reject mixed
849/// year/month durations (their second-count depends on context); pure
850/// dayTime durations (`PnDTnHnMnS`) convert to a single ns count.
851fn parse_xsd_duration(s: &str) -> Option<i128> {
852    let mut s = s;
853    let neg = if let Some(rest) = s.strip_prefix('-') {
854        s = rest;
855        true
856    } else {
857        false
858    };
859    let mut s = s.strip_prefix('P')?;
860    let mut total_ns: i128 = 0;
861
862    let mut in_time = false;
863    while !s.is_empty() {
864        if let Some(rest) = s.strip_prefix('T') {
865            in_time = true;
866            s = rest;
867            continue;
868        }
869        let num_end = s
870            .as_bytes()
871            .iter()
872            .position(|b| !(b.is_ascii_digit() || *b == b'.'))?;
873        let num_str = &s[..num_end];
874        let unit = s.as_bytes().get(num_end).copied()?;
875        s = &s[num_end + 1..];
876        let value: f64 = num_str.parse().ok()?;
877        match (in_time, unit) {
878            (false, b'Y') | (false, b'M') => {
879                // Years and months can't be expressed in fixed ns —
880                // their second count depends on which year/month.
881                return None;
882            }
883            (false, b'D') => total_ns = total_ns.checked_add((value * 86_400e9) as i128)?,
884            (true, b'H') => total_ns = total_ns.checked_add((value * 3_600e9) as i128)?,
885            (true, b'M') => total_ns = total_ns.checked_add((value * 60e9) as i128)?,
886            (true, b'S') => total_ns = total_ns.checked_add((value * 1e9) as i128)?,
887            _ => return None,
888        }
889    }
890    Some(if neg { -total_ns } else { total_ns })
891}
892
893// ── URI → Id ────────────────────────────────────────────────────────
894
895/// Map an RDF URI to a triblespace [`Id`] deterministically by routing
896/// it through an `rdf_uri` fragment, without storing anything. The
897/// same URI always produces the same `Id` — across processes,
898/// machines, and repeated imports — so callers can derive ids for
899/// query constants that match what [`import_bytes`] inserts.
900pub fn uri_to_id_pure(uri: &str) -> Id {
901    let handle: Inline<Handle<LongString>> =
902        uri.to_owned().to_blob().get_handle();
903    let fragment = entity! { crate::import::rdf_uri: handle };
904    fragment.root().expect("intrinsic URI entity")
905}
906
907/// Record `uri` into the meta fragment — the URI-string blob plus the
908/// `rdf_uri` annotation trible referencing it — and return the URI's
909/// intrinsic entity id. One blob hash derives the handle, the
910/// annotation entity, and the id; idempotent under content
911/// addressing, so repeated mentions of the same URI cost a hash and
912/// two no-op inserts.
913fn record_uri(meta: &mut Fragment, uri: impl IntoBlob<LongString>) -> Id {
914    let handle: Inline<Handle<LongString>> = meta.put(uri);
915    let annotation = entity! { crate::import::rdf_uri: handle };
916    let id = annotation.root().expect("intrinsic URI entity");
917    *meta += annotation.into_facts();
918    id
919}
920
921// ── Ingestion ───────────────────────────────────────────────────────
922
923/// An imported N-Triples document, split into the graph itself and
924/// the import's provenance exhaust. Both halves are self-contained
925/// [`Fragment`]s — facts plus the blobs those facts reference — which
926/// is what lets the importer be a pure function of the input bytes:
927/// no workspace is touched until the caller decides where the result
928/// should live.
929///
930/// `facts` is the faithful translation of the source document — one
931/// trible per source triple, carrying the literal blobs (string
932/// values, hex/base64 payloads) its tribles reference by handle.
933/// Queries over `facts` see exactly the rows a SPARQL engine over
934/// the same document would.
935///
936/// `meta` is the import's full self-description, with the blobs it
937/// references embedded:
938///
939/// - one `rdf_uri` annotation entity per distinct entity IRI — the
940///   URI↔id inverse mapping that makes intrinsic entity ids
941///   recoverable back to their source URIs;
942/// - one describing entity per distinct `(predicate IRI, value
943///   schema)` pair — `metadata::iri` + `metadata::value_encoding`
944///   facts that make attribute ids recoverable back to their
945///   predicate URIs and schemas.
946///
947/// It is import *metadata*, not part of the imported graph: when
948/// persisting an import, store it in the commit's metadata slot
949/// (`ws.commit_with_metadata(import.facts, import.meta, msg)`), or
950/// union it into `facts` (`import.facts + import.meta`) if the
951/// merged view is genuinely wanted.
952#[derive(Debug)]
953pub struct NtImport {
954    /// The imported graph — one trible per source triple, with the
955    /// literal blobs those tribles reference embedded.
956    pub facts: Fragment,
957    /// Import self-description: `rdf_uri` annotations for entity
958    /// URIs, describing entities for predicate attributes, and the
959    /// URI-string blobs both reference.
960    pub meta: Fragment,
961    /// Number of triples parsed.
962    pub triples: usize,
963}
964
965/// Import an N-Triples document already loaded as `Bytes`. This is the
966/// core entry point — every other adapter funnels here. Pure: the
967/// result fragments carry their own blobs, so no workspace or blob
968/// store is needed (or touched) during parsing.
969pub fn import_bytes(mut bytes: Bytes) -> Result<NtImport, IngestError> {
970    let mut facts = Fragment::empty();
971    let mut meta = Fragment::empty();
972    let mut bnodes = BnodeBuffer::new();
973    let mut count = 0;
974    let mut attr_cache = NTriplesAttrCache::default();
975
976    loop {
977        skip_ws_and_comments(&mut bytes);
978        if bytes.peek_token().is_none() {
979            break;
980        }
981        if parse_triple(
982            &mut facts,
983            &mut meta,
984            &mut bnodes,
985            &mut bytes,
986            &mut attr_cache,
987        ) {
988            count += 1;
989        } else {
990            // Malformed triple — skip to next newline so a single bad
991            // line doesn't abort the import. Mirrors the line-skip
992            // tolerance the BufRead version had.
993            while let Some(b) = bytes.pop_front() {
994                if b == b'\n' {
995                    break;
996                }
997            }
998        }
999    }
1000
1001    bnodes.flush(facts.facts_mut())?;
1002    Ok(NtImport {
1003        facts,
1004        meta,
1005        triples: count,
1006    })
1007}
1008
1009/// Convenience wrapper around [`import_bytes`] for a `Blob<LongString>`
1010/// — the on-disk / on-wire representation N-Triples shows up as.
1011pub fn import_blob(blob: Blob<LongString>) -> Result<NtImport, IngestError> {
1012    import_bytes(blob.bytes)
1013}
1014
1015/// `BufRead` adapter — slurps the reader into a `Bytes` and forwards
1016/// to [`import_bytes`].
1017pub fn ingest_ntriples(mut reader: impl BufRead) -> Result<NtImport, IngestError> {
1018    let mut buf = Vec::new();
1019    reader
1020        .read_to_end(&mut buf)
1021        .map_err(|e| IngestError::Io(e.to_string()))?;
1022    import_bytes(Bytes::from_source(buf))
1023}
1024
1025/// Parse one triple from the front of `bytes` and emit its facts.
1026/// Plain triples emit directly into `facts`; triples touching a blank
1027/// node go into `bnodes` for deferred resolution. Returns `true` on
1028/// success, `false` on malformed input (caller skips to next line).
1029/// Per-import cache of predicate-IRI → attribute-id, one slot per value
1030/// schema the parser dispatches to. Resolution computes
1031/// `entity!{ metadata::iri:, metadata::value_encoding: }.root()` —
1032/// `<S as MetaDescribe>::id()` plus a content-address per call, both
1033/// nontrivial — so caching by (S, IRI) avoids redoing that work for
1034/// every trible sharing a predicate.
1035///
1036/// First resolution per (S, IRI) also records the attribute into the
1037/// `meta` fragment: the describing entity (`metadata::iri` +
1038/// `metadata::value_encoding` facts) and the IRI-string blob it
1039/// references. Together with the `rdf_uri` annotations this makes an
1040/// import fully self-describing — entity URIs *and* predicate URIs
1041/// (with their value schemas) are recoverable from `meta` alone.
1042#[derive(Default)]
1043struct NTriplesAttrCache {
1044    genid: HashMap<String, Id>,
1045    longstring: HashMap<String, Id>,
1046    rawbytes: HashMap<String, Id>,
1047    i256be: HashMap<String, Id>,
1048    u256be: HashMap<String, Id>,
1049    r256be: HashMap<String, Id>,
1050    f64: HashMap<String, Id>,
1051    boolean: HashMap<String, Id>,
1052    nsduration: HashMap<String, Id>,
1053    nstai: HashMap<String, Id>,
1054}
1055
1056impl NTriplesAttrCache {
1057    /// Shared resolver: derive (and memoise) the attribute id for
1058    /// `(S, iri)`, emitting the attribute's describing entity and the
1059    /// IRI-string blob into `meta` on first encounter.
1060    fn resolve<S: crate::metadata::MetaDescribe>(
1061        map: &mut HashMap<String, Id>,
1062        meta: &mut Fragment,
1063        iri: &str,
1064    ) -> Id {
1065        if let Some(id) = map.get(iri) {
1066            return *id;
1067        }
1068        let h: Inline<Handle<LongString>> = meta.put(String::from(iri));
1069        let describe = entity! {
1070            crate::metadata::iri:            h,
1071            crate::metadata::value_encoding: <S as crate::metadata::MetaDescribe>::id(),
1072        };
1073        let id = describe.root().expect("intrinsic attribute entity");
1074        // Facts-only merge — see record_uri.
1075        *meta += describe.into_facts();
1076        map.insert(iri.to_string(), id);
1077        id
1078    }
1079
1080    fn genid(&mut self, meta: &mut Fragment, iri: &str) -> Id {
1081        Self::resolve::<inlineencodings::GenId>(&mut self.genid, meta, iri)
1082    }
1083    fn longstring(&mut self, meta: &mut Fragment, iri: &str) -> Id {
1084        Self::resolve::<Handle<LongString>>(&mut self.longstring, meta, iri)
1085    }
1086    fn rawbytes(&mut self, meta: &mut Fragment, iri: &str) -> Id {
1087        Self::resolve::<Handle<RawBytes>>(&mut self.rawbytes, meta, iri)
1088    }
1089    fn i256be(&mut self, meta: &mut Fragment, iri: &str) -> Id {
1090        Self::resolve::<inlineencodings::I256BE>(&mut self.i256be, meta, iri)
1091    }
1092    fn u256be(&mut self, meta: &mut Fragment, iri: &str) -> Id {
1093        Self::resolve::<inlineencodings::U256BE>(&mut self.u256be, meta, iri)
1094    }
1095    fn r256be(&mut self, meta: &mut Fragment, iri: &str) -> Id {
1096        Self::resolve::<inlineencodings::R256BE>(&mut self.r256be, meta, iri)
1097    }
1098    fn f64(&mut self, meta: &mut Fragment, iri: &str) -> Id {
1099        Self::resolve::<inlineencodings::F64>(&mut self.f64, meta, iri)
1100    }
1101    fn boolean(&mut self, meta: &mut Fragment, iri: &str) -> Id {
1102        Self::resolve::<inlineencodings::Boolean>(&mut self.boolean, meta, iri)
1103    }
1104    fn nsduration(&mut self, meta: &mut Fragment, iri: &str) -> Id {
1105        Self::resolve::<NsDuration>(&mut self.nsduration, meta, iri)
1106    }
1107    fn nstai(&mut self, meta: &mut Fragment, iri: &str) -> Id {
1108        Self::resolve::<NsTAIInterval>(&mut self.nstai, meta, iri)
1109    }
1110}
1111
1112fn parse_triple(
1113    facts: &mut Fragment,
1114    meta: &mut Fragment,
1115    bnodes: &mut BnodeBuffer,
1116    bytes: &mut Bytes,
1117    attr_cache: &mut NTriplesAttrCache,
1118) -> bool {
1119    // Subject — IRI or bnode label.
1120    let (subject_iri, subject_label): (Option<View<str>>, Option<View<str>>) =
1121        match bytes.peek_token() {
1122            Some(b'<') => match take_iri(bytes) {
1123                Some(uri) => (Some(uri), None),
1124                None => return false,
1125            },
1126            Some(b'_') => match take_bnode(bytes) {
1127                Some(label) => (None, Some(label)),
1128                None => return false,
1129            },
1130            _ => return false,
1131        };
1132    skip_inline_ws(bytes);
1133
1134    let Some(predicate) = take_iri(bytes) else {
1135        return false;
1136    };
1137    skip_inline_ws(bytes);
1138
1139    // Anchor the IRI subject up front so its rdf_uri annotation lands
1140    // before any emission. Bnode subjects resolve in `flush`.
1141    let iri_subject_anchor: Option<Id> = subject_iri
1142        .as_ref()
1143        .map(|uri| record_uri(meta, uri.clone()));
1144
1145    // Object — IRI, bnode, or literal.
1146    let outcome = match bytes.peek_token() {
1147        Some(b'<') => {
1148            let Some(obj_uri) = take_iri(bytes) else {
1149                return false;
1150            };
1151            emit_object_iri(
1152                facts,
1153                meta,
1154                bnodes,
1155                iri_subject_anchor,
1156                subject_label,
1157                predicate.as_ref(),
1158                obj_uri,
1159                attr_cache,
1160            );
1161            true
1162        }
1163        Some(b'_') => {
1164            let Some(target_label) = take_bnode(bytes) else {
1165                return false;
1166            };
1167            let attr_id = attr_cache.genid(meta, predicate.as_ref());
1168            match (iri_subject_anchor, subject_label) {
1169                (Some(s_id), None) => {
1170                    bnodes.push_incoming(IncomingFact {
1171                        subject_id: s_id,
1172                        attr_id,
1173                        target_label,
1174                    });
1175                }
1176                (None, Some(s_label)) => {
1177                    bnodes.push_outgoing(
1178                        s_label,
1179                        OutgoingFact::BnodeRef {
1180                            attr_id,
1181                            target_label,
1182                        },
1183                    );
1184                }
1185                _ => return false,
1186            }
1187            true
1188        }
1189        Some(b'"') => {
1190            let Some((text_bytes, suffix)) = take_literal(bytes) else {
1191                return false;
1192            };
1193            let Ok(text) = text_bytes.view::<str>() else {
1194                return false;
1195            };
1196            match (iri_subject_anchor, subject_label) {
1197                (Some(s_id), None) => {
1198                    let e = ExclusiveId::force_ref(&s_id);
1199                    match suffix {
1200                        LiteralSuffix::None => {
1201                            emit_text_literal(facts, meta, e, predicate.as_ref(), text, attr_cache)
1202                        }
1203                        LiteralSuffix::Datatype(dt) => emit_typed_literal(
1204                            facts,
1205                            meta,
1206                            e,
1207                            predicate.as_ref(),
1208                            text,
1209                            dt.as_ref(),
1210                            attr_cache,
1211                        ),
1212                        LiteralSuffix::Language(lang) => emit_lang_literal(
1213                            facts,
1214                            meta,
1215                            e,
1216                            predicate.as_ref(),
1217                            lang.as_ref(),
1218                            text,
1219                            attr_cache,
1220                        ),
1221                    }
1222                }
1223                (None, Some(s_label)) => {
1224                    if let Some(fact) = build_resolved_outgoing(
1225                        facts,
1226                        meta,
1227                        predicate.as_ref(),
1228                        text,
1229                        suffix,
1230                        attr_cache,
1231                    ) {
1232                        bnodes.push_outgoing(s_label, fact);
1233                    }
1234                }
1235                _ => return false,
1236            }
1237            true
1238        }
1239        _ => false,
1240    };
1241
1242    if outcome {
1243        skip_inline_ws(bytes);
1244        // The trailing `.` terminator. Tolerant: missing-dot gets the
1245        // line skipped by the outer loop.
1246        if bytes.peek_token() != Some(b'.') {
1247            return false;
1248        }
1249        bytes.pop_front();
1250    }
1251    outcome
1252}
1253
1254fn emit_object_iri(
1255    facts: &mut Fragment,
1256    meta: &mut Fragment,
1257    bnodes: &mut BnodeBuffer,
1258    iri_subject_anchor: Option<Id>,
1259    subject_label: Option<View<str>>,
1260    predicate: &str,
1261    obj_uri: View<str>,
1262    attr_cache: &mut NTriplesAttrCache,
1263) {
1264    match (iri_subject_anchor, subject_label) {
1265        (Some(s_id), None) => {
1266            emit_uri_object(
1267                facts,
1268                meta,
1269                &ExclusiveId::force_ref(&s_id),
1270                predicate,
1271                obj_uri.as_ref(),
1272                attr_cache,
1273            );
1274        }
1275        (None, Some(s_label)) => {
1276            let attr_id = attr_cache.genid(meta, predicate);
1277            let obj_id = record_uri(meta, obj_uri);
1278            let g: Inline<GenId> = obj_id.to_inline();
1279            bnodes.push_outgoing(
1280                s_label,
1281                OutgoingFact::Resolved {
1282                    attr_id,
1283                    value_raw: g.raw,
1284                },
1285            );
1286        }
1287        _ => {}
1288    }
1289}
1290
1291/// Materialise a literal-valued bnode-outgoing fact: blob writes /
1292/// reified-language entities happen now, and we hand back the
1293/// (attr_id, value_raw) pair to be inserted once the bnode subject id
1294/// is resolved.
1295fn build_resolved_outgoing(
1296    facts: &mut Fragment,
1297    meta: &mut Fragment,
1298    predicate: &str,
1299    text: View<str>,
1300    suffix: LiteralSuffix,
1301    attr_cache: &mut NTriplesAttrCache,
1302) -> Option<OutgoingFact> {
1303    match suffix {
1304        LiteralSuffix::None => {
1305            let attr_id = attr_cache.longstring(meta, predicate);
1306            let handle: Inline<Handle<LongString>> = facts.put(text);
1307            Some(OutgoingFact::Resolved {
1308                attr_id,
1309                value_raw: handle.raw,
1310            })
1311        }
1312        LiteralSuffix::Datatype(dt) => {
1313            // Build a temporary scratch fragment to reuse the existing
1314            // emit_typed_literal logic, then steal the (attr, value)
1315            // back out of it. Cheaper than re-implementing per-type.
1316            let mut scratch = Fragment::empty();
1317            let scratch_id = Id::new([0xFF; ID_LEN]).expect("non-nil scratch id");
1318            let scratch_e = ExclusiveId::force_ref(&scratch_id);
1319            // The scratch fragment only captures the (attr, value)
1320            // pair; rdf_uri annotations (the anyURI path) go to the
1321            // real `meta` fragment — they're valid import provenance
1322            // regardless of how the parent bnode resolves.
1323            emit_typed_literal(
1324                &mut scratch,
1325                meta,
1326                scratch_e,
1327                predicate,
1328                text,
1329                dt.as_ref(),
1330                attr_cache,
1331            );
1332            // Any blobs the typed emit produced (hex/base64 payloads)
1333            // are real content the stolen value handle references —
1334            // move them to `facts` before dropping the scratch facts.
1335            let (scratch_facts, scratch_blobs) = scratch.into_facts_and_blobs();
1336            facts.blobs_mut().union(scratch_blobs);
1337            let pair = scratch_facts
1338                .iter()
1339                .next()
1340                .map(|t| (*t.a(), t.v::<UnknownInline>().raw));
1341            pair.map(|(attr_id, value_raw)| OutgoingFact::Resolved { attr_id, value_raw })
1342        }
1343        LiteralSuffix::Language(lang) => {
1344            // Reify into the @lang entity now; the parent bnode's
1345            // outgoing fact carries a GenId reference to it. Side
1346            // effects (the lang-entity tribles) land in `facts`
1347            // immediately since they don't depend on the parent id.
1348            let Ok(lang_value): Result<Inline<ShortString>, _> = lang.as_ref().try_to_inline() else {
1349                return None;
1350            };
1351            let text_handle: Inline<Handle<LongString>> = facts.put(text);
1352            let label_fragment = entity! {
1353                crate::import::rdf_lang: lang_value,
1354                crate::import::rdf_text: text_handle,
1355            };
1356            let label_id = label_fragment
1357                .root()
1358                .expect("intrinsic id from rdf_lang+rdf_text");
1359            // Facts-only merge — see emit_lang_literal.
1360            *facts += label_fragment.into_facts();
1361            let attr_id = attr_cache.genid(meta, predicate);
1362            let g: Inline<GenId> = label_id.to_inline();
1363            Some(OutgoingFact::Resolved {
1364                attr_id,
1365                value_raw: g.raw,
1366            })
1367        }
1368    }
1369}
1370
1371fn emit_uri_object(
1372    facts: &mut Fragment,
1373    meta: &mut Fragment,
1374    e: &ExclusiveId,
1375    predicate: &str,
1376    obj_uri: &str,
1377    attr_cache: &mut NTriplesAttrCache,
1378) {
1379    let attr_id = attr_cache.genid(meta, predicate);
1380    let obj_id = record_uri(meta, obj_uri.to_owned());
1381    let g: Inline<GenId> = obj_id.to_inline();
1382    facts.facts_mut().insert(&Trible::new(e, &attr_id, &g));
1383}
1384
1385fn emit_text_literal(
1386    facts: &mut Fragment,
1387    meta: &mut Fragment,
1388    e: &ExclusiveId,
1389    predicate: &str,
1390    text: View<str>,
1391    attr_cache: &mut NTriplesAttrCache,
1392) {
1393    let attr_id = attr_cache.longstring(meta, predicate);
1394    let handle: Inline<Handle<LongString>> = facts.put(text);
1395    facts.facts_mut().insert(&Trible::new(e, &attr_id, &handle));
1396}
1397
1398fn emit_typed_literal(
1399    facts: &mut Fragment,
1400    meta: &mut Fragment,
1401    e: &ExclusiveId,
1402    predicate: &str,
1403    text: View<str>,
1404    datatype: &str,
1405    attr_cache: &mut NTriplesAttrCache,
1406) {
1407    if let Some(local) = datatype.strip_prefix(XSD) {
1408        match local {
1409            "integer" | "int" | "long" | "short" | "byte" | "negativeInteger"
1410            | "nonPositiveInteger" => {
1411                if let Ok(val) = text.parse::<i128>() {
1412                    let attr_id = attr_cache.i256be(meta, predicate);
1413                    let v: Inline<inlineencodings::I256BE> = val.to_inline();
1414                    facts.facts_mut().insert(&Trible::new(e, &attr_id, &v));
1415                    return;
1416                }
1417            }
1418            "nonNegativeInteger" | "positiveInteger" | "unsignedInt" | "unsignedLong"
1419            | "unsignedShort" | "unsignedByte" => {
1420                if let Ok(val) = text.parse::<u128>() {
1421                    let attr_id = attr_cache.u256be(meta, predicate);
1422                    let v: Inline<inlineencodings::U256BE> = val.to_inline();
1423                    facts.facts_mut().insert(&Trible::new(e, &attr_id, &v));
1424                    return;
1425                }
1426            }
1427            "decimal" => {
1428                if let Some(val) = parse_decimal(text.as_ref()) {
1429                    let attr_id = attr_cache.r256be(meta, predicate);
1430                    let v: Inline<inlineencodings::R256BE> = val.to_inline();
1431                    facts.facts_mut().insert(&Trible::new(e, &attr_id, &v));
1432                    return;
1433                }
1434            }
1435            "float" | "double" => {
1436                if let Ok(val) = text.parse::<f64>() {
1437                    let attr_id = attr_cache.f64(meta, predicate);
1438                    let v: Inline<F64> = val.to_inline();
1439                    facts.facts_mut().insert(&Trible::new(e, &attr_id, &v));
1440                    return;
1441                }
1442            }
1443            "boolean" => match text.as_ref() {
1444                "true" | "1" => {
1445                    let attr_id = attr_cache.boolean(meta, predicate);
1446                    let v: Inline<Boolean> = true.to_inline();
1447                    facts.facts_mut().insert(&Trible::new(e, &attr_id, &v));
1448                    return;
1449                }
1450                "false" | "0" => {
1451                    let attr_id = attr_cache.boolean(meta, predicate);
1452                    let v: Inline<Boolean> = false.to_inline();
1453                    facts.facts_mut().insert(&Trible::new(e, &attr_id, &v));
1454                    return;
1455                }
1456                _ => {}
1457            },
1458            "dateTime" => {
1459                if let Some(ns) = parse_xsd_datetime(text.as_ref()) {
1460                    emit_interval(facts.facts_mut(), meta, e, predicate, ns, ns, attr_cache);
1461                    return;
1462                }
1463            }
1464            "date" => {
1465                if let Some((lo, hi)) = parse_xsd_date(text.as_ref()) {
1466                    emit_interval(facts.facts_mut(), meta, e, predicate, lo, hi, attr_cache);
1467                    return;
1468                }
1469            }
1470            "gYear" => {
1471                if let Some((lo, hi)) = parse_xsd_gyear(text.as_ref()) {
1472                    emit_interval(facts.facts_mut(), meta, e, predicate, lo, hi, attr_cache);
1473                    return;
1474                }
1475            }
1476            "gYearMonth" => {
1477                if let Some((lo, hi)) = parse_xsd_gyearmonth(text.as_ref()) {
1478                    emit_interval(facts.facts_mut(), meta, e, predicate, lo, hi, attr_cache);
1479                    return;
1480                }
1481            }
1482            "duration" | "dayTimeDuration" => {
1483                if let Some(ns) = parse_xsd_duration(text.as_ref()) {
1484                    let attr_id = attr_cache.nsduration(meta, predicate);
1485                    let v: Inline<NsDuration> = ns.to_inline();
1486                    facts.facts_mut().insert(&Trible::new(e, &attr_id, &v));
1487                    return;
1488                }
1489            }
1490            "hexBinary" => {
1491                if let Ok(bytes) = hex::decode(text.as_ref()) {
1492                    let attr_id = attr_cache.rawbytes(meta, predicate);
1493                    let handle: Inline<Handle<RawBytes>> = facts.put(bytes);
1494                    facts.facts_mut().insert(&Trible::new(e, &attr_id, &handle));
1495                    return;
1496                }
1497            }
1498            "base64Binary" => {
1499                if let Ok(bytes) = BASE64.decode(text.as_ref()) {
1500                    let attr_id = attr_cache.rawbytes(meta, predicate);
1501                    let handle: Inline<Handle<RawBytes>> = facts.put(bytes);
1502                    facts.facts_mut().insert(&Trible::new(e, &attr_id, &handle));
1503                    return;
1504                }
1505            }
1506            "anyURI" => {
1507                // Treat the literal as an IRI reference — same path as
1508                // bracketed `<...>` objects, so `"http://x"^^xsd:anyURI`
1509                // and `<http://x>` collapse to the same entity id.
1510                emit_uri_object(facts, meta, e, predicate, text.as_ref(), attr_cache);
1511                return;
1512            }
1513            _ => {}
1514        }
1515    }
1516    // Unknown / unparseable typed literal: fall back to text storage.
1517    emit_text_literal(facts, meta, e, predicate, text, attr_cache);
1518}
1519
1520/// Helper to emit an `[lo, hi]` interval trible.
1521fn emit_interval(
1522    facts: &mut TribleSet,
1523    meta: &mut Fragment,
1524    e: &ExclusiveId,
1525    predicate: &str,
1526    lo: i128,
1527    hi: i128,
1528    attr_cache: &mut NTriplesAttrCache,
1529) {
1530    let attr_id = attr_cache.nstai(meta, predicate);
1531    let mut raw = [0u8; 32];
1532    raw[0..16].copy_from_slice(&i128_to_ordered_be(lo));
1533    raw[16..32].copy_from_slice(&i128_to_ordered_be(hi));
1534    let v: Inline<NsTAIInterval> = Inline::new(raw);
1535    facts.insert(&Trible::new(e, &attr_id, &v));
1536}
1537
1538fn emit_lang_literal(
1539    facts: &mut Fragment,
1540    meta: &mut Fragment,
1541    e: &ExclusiveId,
1542    predicate: &str,
1543    lang: &str,
1544    text: View<str>,
1545    attr_cache: &mut NTriplesAttrCache,
1546) {
1547    // Reify `"text"@lang` into a small entity carrying `rdf_lang` and
1548    // `rdf_text`. The intrinsic id derived from those facts dedupes
1549    // `(lang, text)` pairs across the whole import.
1550    let Ok(lang_value): Result<Inline<ShortString>, _> = lang.try_to_inline() else {
1551        return; // tag too long; BCP-47 caps subtags at 8 chars
1552    };
1553    let text_handle: Inline<Handle<LongString>> = facts.put(text);
1554    let label_fragment = entity! {
1555        crate::import::rdf_lang: lang_value,
1556        crate::import::rdf_text: text_handle,
1557    };
1558    let label_id = label_fragment
1559        .root()
1560        .expect("intrinsic id from rdf_lang+rdf_text");
1561    // Facts-only merge: accumulating one export per label entity
1562    // would bloat the import fragment's export set for no consumer.
1563    *facts += label_fragment.into_facts();
1564    let attr_id = attr_cache.genid(meta, predicate);
1565    let v: Inline<GenId> = label_id.to_inline();
1566    facts.facts_mut().insert(&Trible::new(e, &attr_id, &v));
1567}
1568
1569/// Convenience wrapper around [`import_bytes`] that opens a file at
1570/// `path`, slurps it into a `Bytes`, and ingests.
1571pub fn ingest_ntriples_file(path: &Path) -> Result<NtImport, IngestError> {
1572    let file = std::fs::File::open(path).map_err(|e| IngestError::Io(e.to_string()))?;
1573    let mut reader = std::io::BufReader::new(file);
1574    let mut buf = Vec::new();
1575    reader
1576        .read_to_end(&mut buf)
1577        .map_err(|e| IngestError::Io(e.to_string()))?;
1578    import_bytes(Bytes::from_source(buf))
1579}
1580
1581// ── Tests ───────────────────────────────────────────────────────────
1582
1583#[cfg(test)]
1584mod tests {
1585    use super::*;
1586
1587    fn bytes_of(s: &str) -> Bytes {
1588        Bytes::from_source(s.as_bytes().to_vec())
1589    }
1590
1591    #[test]
1592    fn take_iri_consumes_brackets() {
1593        let mut input = bytes_of("<http://example.org/s> rest");
1594        let iri = take_iri(&mut input).unwrap();
1595        assert_eq!(iri.as_ref(), "http://example.org/s");
1596        // Remaining bytes should start with " rest".
1597        let remaining: Vec<u8> = (0..)
1598            .scan(input.clone(), |b, _| b.pop_front())
1599            .collect();
1600        assert_eq!(&remaining[..5], b" rest");
1601    }
1602
1603    #[test]
1604    fn take_bnode_includes_prefix() {
1605        let mut input = bytes_of("_:bf55954f96378f65ddb1da9836e2eb87 .");
1606        let label = take_bnode(&mut input).unwrap();
1607        assert_eq!(label.as_ref(), "_:bf55954f96378f65ddb1da9836e2eb87");
1608    }
1609
1610    #[test]
1611    fn take_bnode_allows_internal_dot() {
1612        // BLANK_NODE_LABEL grammar permits dots in the middle of labels.
1613        // The trailing triple-`.` is always preceded by whitespace, so
1614        // whitespace-only termination handles both.
1615        let mut input = bytes_of("_:foo.bar .");
1616        let label = take_bnode(&mut input).unwrap();
1617        assert_eq!(label.as_ref(), "_:foo.bar");
1618    }
1619
1620    #[test]
1621    fn take_literal_unescaped() {
1622        let mut input = bytes_of(r#""hello" ."#);
1623        let (text, suffix) = take_literal(&mut input).unwrap();
1624        assert_eq!(text.view::<str>().unwrap().as_ref(), "hello");
1625        assert!(matches!(suffix, LiteralSuffix::None));
1626    }
1627
1628    #[test]
1629    fn take_literal_with_datatype_suffix() {
1630        let mut input = bytes_of(r#""42"^^<http://www.w3.org/2001/XMLSchema#integer> ."#);
1631        let (text, suffix) = take_literal(&mut input).unwrap();
1632        assert_eq!(text.view::<str>().unwrap().as_ref(), "42");
1633        assert!(matches!(
1634            suffix,
1635            LiteralSuffix::Datatype(ref dt)
1636                if dt.as_ref() == "http://www.w3.org/2001/XMLSchema#integer"
1637        ));
1638    }
1639
1640    #[test]
1641    fn take_literal_with_lang_tag() {
1642        let mut input = bytes_of(r#""hello"@en ."#);
1643        let (text, suffix) = take_literal(&mut input).unwrap();
1644        assert_eq!(text.view::<str>().unwrap().as_ref(), "hello");
1645        assert!(matches!(
1646            suffix,
1647            LiteralSuffix::Language(ref tag) if tag.as_ref() == "en"
1648        ));
1649    }
1650
1651    #[test]
1652    fn take_literal_with_lang_region() {
1653        let mut input = bytes_of(r#""labor"@en-US ."#);
1654        let (text, suffix) = take_literal(&mut input).unwrap();
1655        assert_eq!(text.view::<str>().unwrap().as_ref(), "labor");
1656        assert!(matches!(
1657            suffix,
1658            LiteralSuffix::Language(ref tag) if tag.as_ref() == "en-US"
1659        ));
1660    }
1661
1662    #[test]
1663    fn take_literal_with_basic_escapes() {
1664        let mut input = bytes_of(r#""line\nbreak" ."#);
1665        let (text, _) = take_literal(&mut input).unwrap();
1666        assert_eq!(text.view::<str>().unwrap().as_ref(), "line\nbreak");
1667    }
1668
1669    #[test]
1670    fn take_literal_with_extended_echar() {
1671        // \b, \f, \' are valid N-Triples ECHAR but were previously unsupported.
1672        let mut input = bytes_of(r#""a\bb\fc\'d" ."#);
1673        let (text, _) = take_literal(&mut input).unwrap();
1674        assert_eq!(
1675            text.view::<str>().unwrap().as_ref(),
1676            "a\u{0008}b\u{000c}c'd"
1677        );
1678    }
1679
1680    #[test]
1681    fn take_literal_with_unicode_escape_4() {
1682        let mut input = bytes_of(r#""smile ☺ here" ."#);
1683        let (text, _) = take_literal(&mut input).unwrap();
1684        assert_eq!(text.view::<str>().unwrap().as_ref(), "smile ☺ here");
1685    }
1686
1687    #[test]
1688    fn take_literal_with_unicode_escape_8() {
1689        // \U with 8 hex digits — N-Triples-only (JSON has no \U).
1690        let mut input = bytes_of(r#""grin \U0001F600 here" ."#);
1691        let (text, _) = take_literal(&mut input).unwrap();
1692        assert_eq!(text.view::<str>().unwrap().as_ref(), "grin 😀 here");
1693    }
1694
1695    #[test]
1696    fn take_iri_with_unicode_escape() {
1697        // IRIs may carry \u escapes for non-ASCII path components.
1698        let mut input = bytes_of(r#"<http://ex/é> rest"#);
1699        let iri = take_iri(&mut input).unwrap();
1700        assert_eq!(iri.as_ref(), "http://ex/é");
1701    }
1702
1703    #[test]
1704    fn decimal_parse_helper() {
1705        let r = parse_decimal("3.14").unwrap();
1706        assert_eq!(*r.numer(), 157);
1707        assert_eq!(*r.denom(), 50);
1708
1709        let r = parse_decimal("42").unwrap();
1710        assert_eq!(*r.numer(), 42);
1711        assert_eq!(*r.denom(), 1);
1712
1713        let r = parse_decimal("-0.5").unwrap();
1714        assert_eq!(*r.numer(), -1);
1715        assert_eq!(*r.denom(), 2);
1716    }
1717
1718    #[test]
1719    fn xsd_datetime_z_and_offset() {
1720        // The two strings should parse to the same instant.
1721        let utc = parse_xsd_datetime("2020-01-01T12:00:00Z").unwrap();
1722        let plus5 = parse_xsd_datetime("2020-01-01T17:00:00+05:00").unwrap();
1723        assert_eq!(utc, plus5);
1724    }
1725
1726    #[test]
1727    fn xsd_datetime_with_fractional_seconds() {
1728        let no_frac = parse_xsd_datetime("2020-01-01T00:00:00Z").unwrap();
1729        let with_frac = parse_xsd_datetime("2020-01-01T00:00:00.5Z").unwrap();
1730        assert_eq!(with_frac - no_frac, 500_000_000);
1731    }
1732
1733    #[test]
1734    fn xsd_datetime_bce_year() {
1735        // Negative year → year before 1 CE in proleptic Gregorian.
1736        // Just check it parses (round-trip semantics is hifitime's problem).
1737        assert!(parse_xsd_datetime("-0500-01-01T00:00:00Z").is_some());
1738    }
1739
1740    #[test]
1741    fn xsd_date_spans_one_day() {
1742        let (lo, hi) = parse_xsd_date("2020-01-01").unwrap();
1743        // 86400 seconds in nanoseconds, minus 1 for inclusive upper.
1744        assert_eq!(hi - lo, 86_400_000_000_000 - 1);
1745    }
1746
1747    #[test]
1748    fn xsd_gyear_spans_full_year() {
1749        let (lo_2020, hi_2020) = parse_xsd_gyear("2020").unwrap();
1750        let (lo_2021, _) = parse_xsd_gyear("2021").unwrap();
1751        // 2020 was a leap year — 366 days.
1752        assert_eq!(hi_2020 - lo_2020, 366 * 86_400_000_000_000 - 1);
1753        // 2020 immediately precedes 2021.
1754        assert_eq!(hi_2020 + 1, lo_2021);
1755    }
1756
1757    #[test]
1758    fn xsd_gyearmonth_spans_one_month() {
1759        let (lo_jan, hi_jan) = parse_xsd_gyearmonth("2020-01").unwrap();
1760        // January has 31 days.
1761        assert_eq!(hi_jan - lo_jan, 31 * 86_400_000_000_000 - 1);
1762
1763        let (_, hi_feb) = parse_xsd_gyearmonth("2020-02").unwrap();
1764        let (lo_mar, _) = parse_xsd_gyearmonth("2020-03").unwrap();
1765        assert_eq!(hi_feb + 1, lo_mar);
1766    }
1767
1768    #[test]
1769    fn xsd_duration_daytime_only() {
1770        // P1DT2H3M4.5S = 1 day + 2h + 3m + 4.5s
1771        let ns = parse_xsd_duration("P1DT2H3M4.5S").unwrap();
1772        let expected = 86_400_000_000_000i128
1773            + 2 * 3_600_000_000_000
1774            + 3 * 60_000_000_000
1775            + 4_500_000_000;
1776        assert_eq!(ns, expected);
1777    }
1778
1779    #[test]
1780    fn xsd_duration_negative() {
1781        let ns = parse_xsd_duration("-PT5S").unwrap();
1782        assert_eq!(ns, -5_000_000_000);
1783    }
1784
1785    #[test]
1786    fn xsd_duration_rejects_year_month() {
1787        // Year/month durations don't have a fixed ns count.
1788        assert!(parse_xsd_duration("P1Y").is_none());
1789        assert!(parse_xsd_duration("P1M").is_none());
1790        assert!(parse_xsd_duration("P1Y2M").is_none());
1791    }
1792}