Skip to main content

oxirs_ttl/
compact_serializer.rs

1//! Compact Turtle serialization.
2//!
3//! Produces human-readable Turtle output with subject grouping, predicate
4//! grouping (semicolons), object list abbreviation (commas), prefix
5//! optimization, blank node inlining, collection syntax `(...)`,
6//! `rdf:type` → `a` shorthand, and configurable indentation / line width.
7
8use std::collections::{BTreeMap, HashMap};
9use std::fmt::Write as FmtWrite;
10
11// ── RDF constants ────────────────────────────────────────────────────────────
12
13const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
14const RDF_FIRST: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#first";
15const RDF_REST: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#rest";
16const RDF_NIL: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#nil";
17
18// ── Public types ─────────────────────────────────────────────────────────────
19
20/// A term in an RDF triple.
21#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
22pub enum RdfTerm {
23    /// A named IRI.
24    Iri(String),
25    /// A plain or typed literal.
26    Literal {
27        /// Lexical value.
28        value: String,
29        /// Optional datatype IRI.
30        datatype: Option<String>,
31        /// Optional language tag.
32        language: Option<String>,
33    },
34    /// A blank node identifier.
35    Blank(String),
36}
37
38/// An RDF triple for serialization.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct RdfTriple {
41    /// Subject.
42    pub subject: RdfTerm,
43    /// Predicate.
44    pub predicate: RdfTerm,
45    /// Object.
46    pub object: RdfTerm,
47}
48
49impl RdfTriple {
50    /// Create a new triple.
51    pub fn new(subject: RdfTerm, predicate: RdfTerm, object: RdfTerm) -> Self {
52        Self {
53            subject,
54            predicate,
55            object,
56        }
57    }
58}
59
60/// Configuration for compact serialization.
61#[derive(Debug, Clone)]
62pub struct CompactConfig {
63    /// Number of spaces per indentation level (default 2).
64    pub indent_size: usize,
65    /// Target maximum line width before wrapping (default 80).
66    pub max_line_width: usize,
67    /// Use `a` shorthand for `rdf:type` (default true).
68    pub use_a_shorthand: bool,
69    /// Inline anonymous blank nodes as `[...]` when they are the object of
70    /// exactly one triple (default true).
71    pub inline_blank_nodes: bool,
72    /// Use collection syntax `(...)` for rdf:List structures (default true).
73    pub use_collection_syntax: bool,
74    /// Sort subjects alphabetically (default true).
75    pub sort_subjects: bool,
76}
77
78impl Default for CompactConfig {
79    fn default() -> Self {
80        Self {
81            indent_size: 2,
82            max_line_width: 80,
83            use_a_shorthand: true,
84            inline_blank_nodes: true,
85            use_collection_syntax: true,
86            sort_subjects: true,
87        }
88    }
89}
90
91/// Serialization statistics.
92#[derive(Debug, Clone, Default)]
93pub struct SerializerStats {
94    /// Number of triples serialized.
95    pub triples_count: usize,
96    /// Number of distinct subjects.
97    pub subjects_count: usize,
98    /// Number of prefix declarations emitted.
99    pub prefix_count: usize,
100    /// Number of `rdf:type` → `a` shortenings.
101    pub a_shorthand_count: usize,
102    /// Number of blank nodes inlined as `[...]`.
103    pub inlined_blanks: usize,
104}
105
106// ── CompactSerializer ────────────────────────────────────────────────────────
107
108/// Compact Turtle serializer.
109pub struct CompactSerializer {
110    /// Prefix map: short name → IRI namespace.
111    prefixes: BTreeMap<String, String>,
112    /// Configuration.
113    config: CompactConfig,
114}
115
116impl CompactSerializer {
117    /// Create a serializer with default configuration and no prefixes.
118    pub fn new() -> Self {
119        Self {
120            prefixes: BTreeMap::new(),
121            config: CompactConfig::default(),
122        }
123    }
124
125    /// Create a serializer with the given configuration.
126    pub fn with_config(config: CompactConfig) -> Self {
127        Self {
128            prefixes: BTreeMap::new(),
129            config,
130        }
131    }
132
133    /// Register a prefix.
134    pub fn add_prefix(&mut self, prefix: impl Into<String>, namespace: impl Into<String>) {
135        self.prefixes.insert(prefix.into(), namespace.into());
136    }
137
138    /// Serialize a set of triples to compact Turtle.
139    pub fn serialize(&self, triples: &[RdfTriple]) -> (String, SerializerStats) {
140        let mut stats = SerializerStats {
141            triples_count: triples.len(),
142            ..Default::default()
143        };
144
145        let mut output = String::new();
146
147        // 1. Emit prefix declarations.
148        for (prefix, ns) in &self.prefixes {
149            let _ = writeln!(output, "@prefix {prefix}: <{ns}> .");
150            stats.prefix_count += 1;
151        }
152        if !self.prefixes.is_empty() {
153            output.push('\n');
154        }
155
156        // 2. Group triples by subject.
157        let grouped = self.group_by_subject(triples);
158        stats.subjects_count = grouped.len();
159
160        // 3. Determine blank node usage for inlining.
161        let blank_usage = self.blank_node_object_count(triples);
162
163        // 4. Collect inlineable blank nodes (occur as object of exactly 1 triple
164        //    and themselves appear as subject of some triples).
165        let inlineable: std::collections::HashSet<String> = if self.config.inline_blank_nodes {
166            blank_usage
167                .iter()
168                .filter(|&(id, &count)| {
169                    count == 1 && grouped.contains_key(&RdfTerm::Blank(id.clone()))
170                })
171                .map(|(id, _)| id.clone())
172                .collect()
173        } else {
174            std::collections::HashSet::new()
175        };
176
177        // 5. Serialize each subject group.
178        let mut subjects: Vec<&RdfTerm> = grouped.keys().collect();
179        if self.config.sort_subjects {
180            subjects.sort();
181        }
182
183        let indent = " ".repeat(self.config.indent_size);
184
185        for (idx, subject) in subjects.iter().enumerate() {
186            // Skip subjects that will be inlined.
187            if let RdfTerm::Blank(id) = subject {
188                if inlineable.contains(id.as_str()) {
189                    continue;
190                }
191            }
192
193            let pred_obj_list = grouped.get(subject).map(|v| v.as_slice()).unwrap_or(&[]);
194
195            let subj_str = self.format_term(subject);
196            let _ = write!(output, "{subj_str}");
197
198            // Group by predicate.
199            let pred_groups = self.group_by_predicate(pred_obj_list);
200            let preds: Vec<&RdfTerm> = {
201                let mut v: Vec<&RdfTerm> = pred_groups.keys().collect();
202                v.sort();
203                v
204            };
205
206            for (pi, pred) in preds.iter().enumerate() {
207                let pred_str = self.format_predicate(pred, &mut stats);
208
209                if pi == 0 {
210                    let _ = write!(output, " {pred_str}");
211                } else {
212                    let _ = write!(output, " ;\n{indent}{pred_str}");
213                }
214
215                let objects = pred_groups.get(pred).map(|v| v.as_slice()).unwrap_or(&[]);
216                for (oi, obj) in objects.iter().enumerate() {
217                    let obj_str =
218                        self.format_object(obj, &grouped, &inlineable, &indent, &mut stats);
219                    if oi == 0 {
220                        let _ = write!(output, " {obj_str}");
221                    } else {
222                        let _ = write!(output, " ,\n{indent}{indent}{obj_str}");
223                    }
224                }
225            }
226
227            let _ = writeln!(output, " .");
228            if idx + 1 < subjects.len() {
229                output.push('\n');
230            }
231        }
232
233        (output, stats)
234    }
235
236    // ── Grouping ─────────────────────────────────────────────────────────────
237
238    fn group_by_subject<'a>(
239        &self,
240        triples: &'a [RdfTriple],
241    ) -> BTreeMap<RdfTerm, Vec<(&'a RdfTerm, &'a RdfTerm)>> {
242        let mut map: BTreeMap<RdfTerm, Vec<(&'a RdfTerm, &'a RdfTerm)>> = BTreeMap::new();
243        for t in triples {
244            map.entry(t.subject.clone())
245                .or_default()
246                .push((&t.predicate, &t.object));
247        }
248        map
249    }
250
251    fn group_by_predicate<'a>(
252        &self,
253        pred_obj_list: &[(&'a RdfTerm, &'a RdfTerm)],
254    ) -> BTreeMap<RdfTerm, Vec<&'a RdfTerm>> {
255        let mut map: BTreeMap<RdfTerm, Vec<&'a RdfTerm>> = BTreeMap::new();
256        for &(pred, obj) in pred_obj_list {
257            map.entry(pred.clone()).or_default().push(obj);
258        }
259        map
260    }
261
262    // ── Blank node counting ──────────────────────────────────────────────────
263
264    fn blank_node_object_count(&self, triples: &[RdfTriple]) -> HashMap<String, usize> {
265        let mut counts: HashMap<String, usize> = HashMap::new();
266        for t in triples {
267            if let RdfTerm::Blank(id) = &t.object {
268                *counts.entry(id.clone()).or_insert(0) += 1;
269            }
270        }
271        counts
272    }
273
274    // ── Formatting ───────────────────────────────────────────────────────────
275
276    fn format_term(&self, term: &RdfTerm) -> String {
277        match term {
278            RdfTerm::Iri(iri) => self.compress_iri(iri),
279            RdfTerm::Literal {
280                value,
281                datatype,
282                language,
283            } => {
284                let mut s = format!("\"{}\"", Self::escape_turtle(value));
285                if let Some(lang) = language {
286                    let _ = write!(s, "@{lang}");
287                } else if let Some(dt) = datatype {
288                    let compressed = self.compress_iri(dt);
289                    let _ = write!(s, "^^{compressed}");
290                }
291                s
292            }
293            RdfTerm::Blank(id) => format!("_:{id}"),
294        }
295    }
296
297    fn format_predicate(&self, pred: &RdfTerm, stats: &mut SerializerStats) -> String {
298        if let RdfTerm::Iri(iri) = pred {
299            if self.config.use_a_shorthand && iri == RDF_TYPE {
300                stats.a_shorthand_count += 1;
301                return "a".to_string();
302            }
303        }
304        self.format_term(pred)
305    }
306
307    fn format_object(
308        &self,
309        obj: &RdfTerm,
310        grouped: &BTreeMap<RdfTerm, Vec<(&RdfTerm, &RdfTerm)>>,
311        inlineable: &std::collections::HashSet<String>,
312        indent: &str,
313        stats: &mut SerializerStats,
314    ) -> String {
315        if let RdfTerm::Blank(id) = obj {
316            if self.config.inline_blank_nodes && inlineable.contains(id.as_str()) {
317                if let Some(pred_obj_list) = grouped.get(&RdfTerm::Blank(id.clone())) {
318                    stats.inlined_blanks += 1;
319                    return self.format_inline_blank(pred_obj_list, indent, stats);
320                }
321            }
322        }
323        self.format_term(obj)
324    }
325
326    fn format_inline_blank(
327        &self,
328        pred_obj_list: &[(&RdfTerm, &RdfTerm)],
329        indent: &str,
330        stats: &mut SerializerStats,
331    ) -> String {
332        if pred_obj_list.is_empty() {
333            return "[]".to_string();
334        }
335        let inner_indent = format!("{indent}  ");
336        let mut s = String::from("[\n");
337        for (i, &(pred, obj)) in pred_obj_list.iter().enumerate() {
338            let pred_str = self.format_predicate(pred, stats);
339            let obj_str = self.format_term(obj);
340            let sep = if i + 1 < pred_obj_list.len() {
341                " ;"
342            } else {
343                ""
344            };
345            let _ = writeln!(s, "{inner_indent}{pred_str} {obj_str}{sep}");
346        }
347        let _ = write!(s, "{indent}]");
348        s
349    }
350
351    // ── IRI compression ──────────────────────────────────────────────────────
352
353    fn compress_iri(&self, iri: &str) -> String {
354        // Try to find the longest matching prefix.
355        let mut best: Option<(&str, &str)> = None;
356        for (prefix, ns) in &self.prefixes {
357            if iri.starts_with(ns.as_str())
358                && (best.is_none() || ns.len() > best.map(|(_, n)| n.len()).unwrap_or(0))
359            {
360                best = Some((prefix.as_str(), ns.as_str()));
361            }
362        }
363        if let Some((prefix, ns)) = best {
364            let local = &iri[ns.len()..];
365            format!("{prefix}:{local}")
366        } else {
367            format!("<{iri}>")
368        }
369    }
370
371    // ── Turtle escaping ──────────────────────────────────────────────────────
372
373    fn escape_turtle(s: &str) -> String {
374        let mut out = String::with_capacity(s.len());
375        for c in s.chars() {
376            match c {
377                '\\' => out.push_str("\\\\"),
378                '"' => out.push_str("\\\""),
379                '\n' => out.push_str("\\n"),
380                '\r' => out.push_str("\\r"),
381                '\t' => out.push_str("\\t"),
382                _ => out.push(c),
383            }
384        }
385        out
386    }
387
388    // ── Collection detection ─────────────────────────────────────────────────
389
390    /// Detect rdf:List chains starting from the given blank node.
391    ///
392    /// Returns the ordered list of `rdf:first` values if the blank node is the
393    /// head of a well-formed list terminating with `rdf:nil`, or `None` otherwise.
394    pub fn detect_list(&self, head: &str, triples: &[RdfTriple]) -> Option<Vec<RdfTerm>> {
395        let mut by_subject: HashMap<String, Vec<(&RdfTerm, &RdfTerm)>> = HashMap::new();
396        for t in triples {
397            if let RdfTerm::Blank(id) = &t.subject {
398                by_subject
399                    .entry(id.clone())
400                    .or_default()
401                    .push((&t.predicate, &t.object));
402            }
403        }
404
405        let mut items = Vec::new();
406        let mut current = head.to_string();
407
408        for _ in 0..10_000 {
409            let po = by_subject.get(&current)?;
410
411            let first = po.iter().find_map(|(p, o)| {
412                if let RdfTerm::Iri(iri) = p {
413                    if iri == RDF_FIRST {
414                        return Some((*o).clone());
415                    }
416                }
417                None
418            })?;
419
420            let rest = po.iter().find_map(|(p, o)| {
421                if let RdfTerm::Iri(iri) = p {
422                    if iri == RDF_REST {
423                        return Some((*o).clone());
424                    }
425                }
426                None
427            })?;
428
429            items.push(first);
430
431            match &rest {
432                RdfTerm::Iri(iri) if iri == RDF_NIL => return Some(items),
433                RdfTerm::Blank(next_id) => {
434                    current = next_id.clone();
435                }
436                _ => return None,
437            }
438        }
439
440        None
441    }
442
443    /// Format an rdf:List collection in `(...)` syntax.
444    pub fn format_collection(&self, items: &[RdfTerm]) -> String {
445        let parts: Vec<String> = items.iter().map(|t| self.format_term(t)).collect();
446        format!("( {} )", parts.join(" "))
447    }
448
449    // ── Prefix optimization ──────────────────────────────────────────────────
450
451    /// Suggest optimal prefixes from a set of triples.
452    ///
453    /// Scans all IRIs, extracts namespace candidates, and returns the most
454    /// frequently used ones.
455    pub fn suggest_prefixes(
456        triples: &[RdfTriple],
457        max_prefixes: usize,
458    ) -> BTreeMap<String, String> {
459        let mut ns_count: HashMap<String, usize> = HashMap::new();
460
461        for t in triples {
462            for term in [&t.subject, &t.predicate, &t.object] {
463                if let RdfTerm::Iri(iri) = term {
464                    if let Some(ns) = Self::extract_namespace(iri) {
465                        *ns_count.entry(ns).or_insert(0) += 1;
466                    }
467                }
468            }
469        }
470
471        let mut sorted: Vec<(String, usize)> = ns_count.into_iter().collect();
472        sorted.sort_by_key(|b| std::cmp::Reverse(b.1));
473
474        let mut result = BTreeMap::new();
475        for (i, (ns, _)) in sorted.into_iter().take(max_prefixes).enumerate() {
476            let prefix = Self::derive_prefix_name(&ns, i);
477            result.insert(prefix, ns);
478        }
479        result
480    }
481
482    fn extract_namespace(iri: &str) -> Option<String> {
483        if let Some(pos) = iri.rfind('#') {
484            Some(iri[..=pos].to_string())
485        } else {
486            iri.rfind('/').map(|pos| iri[..=pos].to_string())
487        }
488    }
489
490    fn derive_prefix_name(ns: &str, idx: usize) -> String {
491        // Try to extract a meaningful short name from the namespace.
492        let stripped = ns.trim_end_matches('#').trim_end_matches('/');
493        if let Some(pos) = stripped.rfind('/') {
494            let candidate = &stripped[pos + 1..];
495            if !candidate.is_empty() && candidate.len() <= 10 {
496                return candidate.to_lowercase();
497            }
498        }
499        format!("ns{idx}")
500    }
501}
502
503impl Default for CompactSerializer {
504    fn default() -> Self {
505        Self::new()
506    }
507}
508
509// ═══════════════════════════════════════════════════════════════════════════════
510// Tests
511// ═══════════════════════════════════════════════════════════════════════════════
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    // ── Helpers ──────────────────────────────────────────────────────────────
518
519    fn iri(s: &str) -> RdfTerm {
520        RdfTerm::Iri(s.to_string())
521    }
522
523    fn lit(s: &str) -> RdfTerm {
524        RdfTerm::Literal {
525            value: s.to_string(),
526            datatype: None,
527            language: None,
528        }
529    }
530
531    fn lit_lang(s: &str, lang: &str) -> RdfTerm {
532        RdfTerm::Literal {
533            value: s.to_string(),
534            datatype: None,
535            language: Some(lang.to_string()),
536        }
537    }
538
539    fn lit_typed(s: &str, dt: &str) -> RdfTerm {
540        RdfTerm::Literal {
541            value: s.to_string(),
542            datatype: Some(dt.to_string()),
543            language: None,
544        }
545    }
546
547    fn blank(s: &str) -> RdfTerm {
548        RdfTerm::Blank(s.to_string())
549    }
550
551    fn triple(s: RdfTerm, p: RdfTerm, o: RdfTerm) -> RdfTriple {
552        RdfTriple::new(s, p, o)
553    }
554
555    // ── Subject grouping ─────────────────────────────────────────────────────
556
557    #[test]
558    fn test_subject_grouping() {
559        let triples = vec![
560            triple(iri("http://ex.org/s"), iri("http://ex.org/p1"), lit("a")),
561            triple(iri("http://ex.org/s"), iri("http://ex.org/p2"), lit("b")),
562        ];
563        let ser = CompactSerializer::new();
564        let (output, stats) = ser.serialize(&triples);
565        assert_eq!(stats.subjects_count, 1);
566        // One `.` for one subject group
567        assert_eq!(output.matches(" .").count(), 1);
568    }
569
570    #[test]
571    fn test_multiple_subjects() {
572        let triples = vec![
573            triple(iri("http://ex.org/s1"), iri("http://ex.org/p"), lit("a")),
574            triple(iri("http://ex.org/s2"), iri("http://ex.org/p"), lit("b")),
575        ];
576        let ser = CompactSerializer::new();
577        let (output, stats) = ser.serialize(&triples);
578        assert_eq!(stats.subjects_count, 2);
579        assert_eq!(output.matches(" .").count(), 2);
580    }
581
582    // ── Predicate grouping (semicolons) ──────────────────────────────────────
583
584    #[test]
585    fn test_predicate_grouping_semicolons() {
586        let triples = vec![
587            triple(iri("http://ex.org/s"), iri("http://ex.org/p1"), lit("a")),
588            triple(iri("http://ex.org/s"), iri("http://ex.org/p2"), lit("b")),
589        ];
590        let ser = CompactSerializer::new();
591        let (output, _) = ser.serialize(&triples);
592        assert!(output.contains(";"));
593    }
594
595    // ── Object list abbreviation (commas) ────────────────────────────────────
596
597    #[test]
598    fn test_object_list_commas() {
599        let triples = vec![
600            triple(iri("http://ex.org/s"), iri("http://ex.org/p"), lit("a")),
601            triple(iri("http://ex.org/s"), iri("http://ex.org/p"), lit("b")),
602        ];
603        let ser = CompactSerializer::new();
604        let (output, _) = ser.serialize(&triples);
605        assert!(output.contains(","));
606    }
607
608    // ── Prefix optimization ──────────────────────────────────────────────────
609
610    #[test]
611    fn test_prefix_compression() {
612        let mut ser = CompactSerializer::new();
613        ser.add_prefix("ex", "http://example.org/");
614        let triples = vec![triple(
615            iri("http://example.org/s"),
616            iri("http://example.org/p"),
617            lit("v"),
618        )];
619        let (output, _) = ser.serialize(&triples);
620        assert!(output.contains("@prefix ex: <http://example.org/> ."));
621        assert!(output.contains("ex:s"));
622        assert!(output.contains("ex:p"));
623    }
624
625    #[test]
626    fn test_no_prefix_full_iri() {
627        let ser = CompactSerializer::new();
628        let triples = vec![triple(
629            iri("http://example.org/s"),
630            iri("http://example.org/p"),
631            lit("v"),
632        )];
633        let (output, _) = ser.serialize(&triples);
634        assert!(output.contains("<http://example.org/s>"));
635    }
636
637    #[test]
638    fn test_suggest_prefixes() {
639        let triples = vec![
640            triple(
641                iri("http://example.org/s1"),
642                iri("http://example.org/p"),
643                lit("a"),
644            ),
645            triple(
646                iri("http://example.org/s2"),
647                iri("http://other.org/q"),
648                lit("b"),
649            ),
650        ];
651        let suggested = CompactSerializer::suggest_prefixes(&triples, 5);
652        assert!(!suggested.is_empty());
653    }
654
655    // ── rdf:type → "a" shorthand ─────────────────────────────────────────────
656
657    #[test]
658    fn test_a_shorthand() {
659        let ser = CompactSerializer::new();
660        let triples = vec![triple(
661            iri("http://example.org/s"),
662            iri(RDF_TYPE),
663            iri("http://example.org/MyClass"),
664        )];
665        let (output, stats) = ser.serialize(&triples);
666        assert!(output.contains(" a "));
667        assert_eq!(stats.a_shorthand_count, 1);
668    }
669
670    #[test]
671    fn test_no_a_shorthand_when_disabled() {
672        let config = CompactConfig {
673            use_a_shorthand: false,
674            ..Default::default()
675        };
676        let ser = CompactSerializer::with_config(config);
677        let triples = vec![triple(
678            iri("http://example.org/s"),
679            iri(RDF_TYPE),
680            iri("http://example.org/MyClass"),
681        )];
682        let (output, stats) = ser.serialize(&triples);
683        assert!(!output.contains(" a "));
684        assert_eq!(stats.a_shorthand_count, 0);
685    }
686
687    // ── Blank node inlining ──────────────────────────────────────────────────
688
689    #[test]
690    fn test_blank_node_inline() {
691        let triples = vec![
692            triple(iri("http://ex.org/s"), iri("http://ex.org/p"), blank("b0")),
693            triple(blank("b0"), iri("http://ex.org/name"), lit("Alice")),
694        ];
695        let ser = CompactSerializer::new();
696        let (output, stats) = ser.serialize(&triples);
697        assert!(output.contains("["), "Should contain inline blank node");
698        assert!(stats.inlined_blanks >= 1);
699    }
700
701    #[test]
702    fn test_blank_node_not_inlined_multiple_refs() {
703        let triples = vec![
704            triple(iri("http://ex.org/s1"), iri("http://ex.org/p"), blank("b0")),
705            triple(iri("http://ex.org/s2"), iri("http://ex.org/p"), blank("b0")),
706            triple(blank("b0"), iri("http://ex.org/name"), lit("Alice")),
707        ];
708        let ser = CompactSerializer::new();
709        let (output, stats) = ser.serialize(&triples);
710        // Blank node referenced twice → not inlined
711        assert_eq!(stats.inlined_blanks, 0);
712        assert!(output.contains("_:b0"));
713    }
714
715    // ── Collection syntax ────────────────────────────────────────────────────
716
717    #[test]
718    fn test_detect_list() {
719        let triples = vec![
720            triple(blank("l0"), iri(RDF_FIRST), lit("a")),
721            triple(blank("l0"), iri(RDF_REST), blank("l1")),
722            triple(blank("l1"), iri(RDF_FIRST), lit("b")),
723            triple(blank("l1"), iri(RDF_REST), iri(RDF_NIL)),
724        ];
725        let ser = CompactSerializer::new();
726        let list = ser.detect_list("l0", &triples);
727        assert!(list.is_some());
728        let items = list.expect("list should exist");
729        assert_eq!(items.len(), 2);
730        assert_eq!(items[0], lit("a"));
731        assert_eq!(items[1], lit("b"));
732    }
733
734    #[test]
735    fn test_detect_list_not_a_list() {
736        let triples = vec![triple(
737            blank("x"),
738            iri("http://ex.org/p"),
739            lit("not a list"),
740        )];
741        let ser = CompactSerializer::new();
742        let list = ser.detect_list("x", &triples);
743        assert!(list.is_none());
744    }
745
746    #[test]
747    fn test_format_collection() {
748        let ser = CompactSerializer::new();
749        let items = vec![lit("a"), lit("b"), lit("c")];
750        let output = ser.format_collection(&items);
751        assert_eq!(output, "( \"a\" \"b\" \"c\" )");
752    }
753
754    // ── Pretty-print indentation ─────────────────────────────────────────────
755
756    #[test]
757    fn test_custom_indent() {
758        let config = CompactConfig {
759            indent_size: 4,
760            ..Default::default()
761        };
762        let ser = CompactSerializer::with_config(config);
763        let triples = vec![
764            triple(iri("http://ex.org/s"), iri("http://ex.org/p1"), lit("a")),
765            triple(iri("http://ex.org/s"), iri("http://ex.org/p2"), lit("b")),
766        ];
767        let (output, _) = ser.serialize(&triples);
768        // Second predicate should be indented with 4 spaces.
769        assert!(output.contains("    "));
770    }
771
772    // ── Literal escaping ─────────────────────────────────────────────────────
773
774    #[test]
775    fn test_escape_special_chars() {
776        let ser = CompactSerializer::new();
777        let triples = vec![triple(
778            iri("http://ex.org/s"),
779            iri("http://ex.org/p"),
780            lit("hello\n\"world\\"),
781        )];
782        let (output, _) = ser.serialize(&triples);
783        assert!(output.contains("\\n"));
784        assert!(output.contains("\\\""));
785        assert!(output.contains("\\\\"));
786    }
787
788    // ── Language-tagged literals ──────────────────────────────────────────────
789
790    #[test]
791    fn test_lang_literal() {
792        let ser = CompactSerializer::new();
793        let triples = vec![triple(
794            iri("http://ex.org/s"),
795            iri("http://ex.org/p"),
796            lit_lang("hello", "en"),
797        )];
798        let (output, _) = ser.serialize(&triples);
799        assert!(output.contains("\"hello\"@en"));
800    }
801
802    // ── Typed literals ───────────────────────────────────────────────────────
803
804    #[test]
805    fn test_typed_literal() {
806        let mut ser = CompactSerializer::new();
807        ser.add_prefix("xsd", "http://www.w3.org/2001/XMLSchema#");
808        let triples = vec![triple(
809            iri("http://ex.org/s"),
810            iri("http://ex.org/p"),
811            lit_typed("42", "http://www.w3.org/2001/XMLSchema#integer"),
812        )];
813        let (output, _) = ser.serialize(&triples);
814        assert!(output.contains("^^xsd:integer"));
815    }
816
817    // ── Empty graph ──────────────────────────────────────────────────────────
818
819    #[test]
820    fn test_empty_graph() {
821        let ser = CompactSerializer::new();
822        let (output, stats) = ser.serialize(&[]);
823        assert!(output.is_empty() || output.trim().is_empty());
824        assert_eq!(stats.triples_count, 0);
825    }
826
827    // ── Config defaults ──────────────────────────────────────────────────────
828
829    #[test]
830    fn test_default_config() {
831        let c = CompactConfig::default();
832        assert_eq!(c.indent_size, 2);
833        assert_eq!(c.max_line_width, 80);
834        assert!(c.use_a_shorthand);
835        assert!(c.inline_blank_nodes);
836        assert!(c.use_collection_syntax);
837        assert!(c.sort_subjects);
838    }
839
840    // ── Stats ────────────────────────────────────────────────────────────────
841
842    #[test]
843    fn test_stats_triples_count() {
844        let ser = CompactSerializer::new();
845        let triples = vec![
846            triple(iri("http://ex.org/s1"), iri("http://ex.org/p"), lit("a")),
847            triple(iri("http://ex.org/s2"), iri("http://ex.org/p"), lit("b")),
848            triple(iri("http://ex.org/s3"), iri("http://ex.org/p"), lit("c")),
849        ];
850        let (_, stats) = ser.serialize(&triples);
851        assert_eq!(stats.triples_count, 3);
852        assert_eq!(stats.subjects_count, 3);
853    }
854
855    #[test]
856    fn test_stats_prefix_count() {
857        let mut ser = CompactSerializer::new();
858        ser.add_prefix("ex", "http://example.org/");
859        ser.add_prefix("foaf", "http://xmlns.com/foaf/0.1/");
860        let (_, stats) = ser.serialize(&[]);
861        assert_eq!(stats.prefix_count, 2);
862    }
863
864    // ── Prefix suggestion ────────────────────────────────────────────────────
865
866    #[test]
867    fn test_suggest_prefixes_respects_max() {
868        let triples = vec![
869            triple(iri("http://a.org/s"), iri("http://b.org/p"), lit("v")),
870            triple(iri("http://c.org/s"), iri("http://d.org/p"), lit("v")),
871        ];
872        let suggested = CompactSerializer::suggest_prefixes(&triples, 2);
873        assert!(suggested.len() <= 2);
874    }
875
876    #[test]
877    fn test_suggest_prefixes_empty() {
878        let suggested = CompactSerializer::suggest_prefixes(&[], 5);
879        assert!(suggested.is_empty());
880    }
881
882    // ── Namespace extraction ─────────────────────────────────────────────────
883
884    #[test]
885    fn test_extract_namespace_hash() {
886        let ns = CompactSerializer::extract_namespace("http://ex.org/ns#term");
887        assert_eq!(ns, Some("http://ex.org/ns#".to_string()));
888    }
889
890    #[test]
891    fn test_extract_namespace_slash() {
892        let ns = CompactSerializer::extract_namespace("http://ex.org/ns/term");
893        assert_eq!(ns, Some("http://ex.org/ns/".to_string()));
894    }
895
896    #[test]
897    fn test_extract_namespace_none() {
898        let ns = CompactSerializer::extract_namespace("urn:simple");
899        // No '#' or '/' separator, so namespace cannot be extracted.
900        assert!(ns.is_none());
901    }
902
903    // ── Sort subjects ────────────────────────────────────────────────────────
904
905    #[test]
906    fn test_sort_subjects_enabled() {
907        let triples = vec![
908            triple(iri("http://z.org/s"), iri("http://ex.org/p"), lit("z")),
909            triple(iri("http://a.org/s"), iri("http://ex.org/p"), lit("a")),
910        ];
911        let ser = CompactSerializer::new();
912        let (output, _) = ser.serialize(&triples);
913        let a_pos = output.find("<http://a.org/s>").unwrap_or(usize::MAX);
914        let z_pos = output.find("<http://z.org/s>").unwrap_or(usize::MAX);
915        assert!(a_pos < z_pos, "Subjects should be sorted");
916    }
917
918    #[test]
919    fn test_sort_subjects_disabled() {
920        let config = CompactConfig {
921            sort_subjects: false,
922            ..Default::default()
923        };
924        let ser = CompactSerializer::with_config(config);
925        let triples = vec![triple(
926            iri("http://z.org/s"),
927            iri("http://ex.org/p"),
928            lit("z"),
929        )];
930        let (output, _) = ser.serialize(&triples);
931        assert!(output.contains("<http://z.org/s>"));
932    }
933
934    // ── Default serializer ───────────────────────────────────────────────────
935
936    #[test]
937    fn test_default_serializer() {
938        let ser = CompactSerializer::default();
939        let (output, stats) = ser.serialize(&[triple(
940            iri("http://ex.org/s"),
941            iri("http://ex.org/p"),
942            lit("v"),
943        )]);
944        assert_eq!(stats.triples_count, 1);
945        assert!(!output.is_empty());
946    }
947
948    // ── IRI compression longest match ────────────────────────────────────────
949
950    #[test]
951    fn test_longest_prefix_match() {
952        let mut ser = CompactSerializer::new();
953        ser.add_prefix("short", "http://ex.org/");
954        ser.add_prefix("long", "http://ex.org/ns/");
955        let triples = vec![triple(
956            iri("http://ex.org/ns/term"),
957            iri("http://ex.org/p"),
958            lit("v"),
959        )];
960        let (output, _) = ser.serialize(&triples);
961        assert!(output.contains("long:term"));
962    }
963
964    // ── Blank node as subject ────────────────────────────────────────────────
965
966    #[test]
967    fn test_blank_node_subject() {
968        let ser = CompactSerializer::new();
969        let triples = vec![triple(blank("b0"), iri("http://ex.org/p"), lit("v"))];
970        let (output, _) = ser.serialize(&triples);
971        assert!(output.contains("_:b0"));
972    }
973
974    // ── Additional tests for coverage ────────────────────────────────────────
975
976    #[test]
977    fn test_multiple_prefixes() {
978        let mut ser = CompactSerializer::new();
979        ser.add_prefix("ex", "http://example.org/");
980        ser.add_prefix("foaf", "http://xmlns.com/foaf/0.1/");
981        ser.add_prefix("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#");
982        let triples = vec![triple(
983            iri("http://example.org/alice"),
984            iri("http://xmlns.com/foaf/0.1/name"),
985            lit("Alice"),
986        )];
987        let (output, _) = ser.serialize(&triples);
988        assert!(output.contains("ex:alice"));
989        assert!(output.contains("foaf:name"));
990    }
991
992    #[test]
993    fn test_three_predicates_same_subject() {
994        let ser = CompactSerializer::new();
995        let triples = vec![
996            triple(iri("http://ex.org/s"), iri("http://ex.org/p1"), lit("a")),
997            triple(iri("http://ex.org/s"), iri("http://ex.org/p2"), lit("b")),
998            triple(iri("http://ex.org/s"), iri("http://ex.org/p3"), lit("c")),
999        ];
1000        let (output, stats) = ser.serialize(&triples);
1001        assert_eq!(stats.subjects_count, 1);
1002        assert_eq!(output.matches(';').count(), 2);
1003    }
1004
1005    #[test]
1006    fn test_three_objects_same_predicate() {
1007        let ser = CompactSerializer::new();
1008        let triples = vec![
1009            triple(iri("http://ex.org/s"), iri("http://ex.org/p"), lit("a")),
1010            triple(iri("http://ex.org/s"), iri("http://ex.org/p"), lit("b")),
1011            triple(iri("http://ex.org/s"), iri("http://ex.org/p"), lit("c")),
1012        ];
1013        let (output, _) = ser.serialize(&triples);
1014        assert_eq!(output.matches(',').count(), 2);
1015    }
1016
1017    #[test]
1018    fn test_rdf_type_with_prefix() {
1019        let mut ser = CompactSerializer::new();
1020        ser.add_prefix("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#");
1021        let triples = vec![triple(
1022            iri("http://ex.org/s"),
1023            iri(RDF_TYPE),
1024            iri("http://ex.org/Class"),
1025        )];
1026        let (output, stats) = ser.serialize(&triples);
1027        assert!(output.contains(" a "));
1028        assert_eq!(stats.a_shorthand_count, 1);
1029    }
1030
1031    #[test]
1032    fn test_escape_tab_and_return() {
1033        let ser = CompactSerializer::new();
1034        let triples = vec![triple(
1035            iri("http://ex.org/s"),
1036            iri("http://ex.org/p"),
1037            lit("line1\tline2\rline3"),
1038        )];
1039        let (output, _) = ser.serialize(&triples);
1040        assert!(output.contains("\\t"));
1041        assert!(output.contains("\\r"));
1042    }
1043
1044    #[test]
1045    fn test_single_element_list() {
1046        let triples = vec![
1047            triple(blank("l0"), iri(RDF_FIRST), lit("only")),
1048            triple(blank("l0"), iri(RDF_REST), iri(RDF_NIL)),
1049        ];
1050        let ser = CompactSerializer::new();
1051        let list = ser.detect_list("l0", &triples);
1052        assert!(list.is_some());
1053        let items = list.expect("single item list");
1054        assert_eq!(items.len(), 1);
1055        assert_eq!(items[0], lit("only"));
1056    }
1057
1058    #[test]
1059    fn test_detect_list_missing_first() {
1060        let triples = vec![triple(blank("l0"), iri(RDF_REST), iri(RDF_NIL))];
1061        let ser = CompactSerializer::new();
1062        let list = ser.detect_list("l0", &triples);
1063        assert!(list.is_none());
1064    }
1065
1066    #[test]
1067    fn test_detect_list_missing_rest() {
1068        let triples = vec![triple(blank("l0"), iri(RDF_FIRST), lit("a"))];
1069        let ser = CompactSerializer::new();
1070        let list = ser.detect_list("l0", &triples);
1071        assert!(list.is_none());
1072    }
1073
1074    #[test]
1075    fn test_format_collection_single() {
1076        let ser = CompactSerializer::new();
1077        let items = vec![lit("x")];
1078        let output = ser.format_collection(&items);
1079        assert_eq!(output, "( \"x\" )");
1080    }
1081
1082    #[test]
1083    fn test_format_collection_empty() {
1084        let ser = CompactSerializer::new();
1085        let items: Vec<RdfTerm> = vec![];
1086        let output = ser.format_collection(&items);
1087        assert_eq!(output, "(  )");
1088    }
1089
1090    #[test]
1091    fn test_inline_blank_disabled() {
1092        let config = CompactConfig {
1093            inline_blank_nodes: false,
1094            ..Default::default()
1095        };
1096        let ser = CompactSerializer::with_config(config);
1097        let triples = vec![
1098            triple(iri("http://ex.org/s"), iri("http://ex.org/p"), blank("b0")),
1099            triple(blank("b0"), iri("http://ex.org/name"), lit("Alice")),
1100        ];
1101        let (output, stats) = ser.serialize(&triples);
1102        assert!(!output.contains('['));
1103        assert_eq!(stats.inlined_blanks, 0);
1104    }
1105
1106    #[test]
1107    fn test_suggest_prefixes_frequent_wins() {
1108        let triples = vec![
1109            triple(iri("http://ex.org/s1"), iri("http://ex.org/p1"), lit("a")),
1110            triple(iri("http://ex.org/s2"), iri("http://ex.org/p2"), lit("b")),
1111            triple(iri("http://ex.org/s3"), iri("http://other.org/q"), lit("c")),
1112        ];
1113        let suggested = CompactSerializer::suggest_prefixes(&triples, 1);
1114        assert_eq!(suggested.len(), 1);
1115        // ex.org should win (5 occurrences vs 1)
1116        let (_, ns) = suggested.iter().next().expect("one prefix");
1117        assert!(ns.contains("ex.org"));
1118    }
1119}