Skip to main content

oxirs_ttl/
turtle_validator.rs

1/// Turtle/TriG document syntax validation.
2///
3/// Provides a lightweight, line-oriented validator that detects common Turtle
4/// syntax problems without performing a full parse.  It is suitable for fast
5/// "lint" checks of Turtle files before handing them to a strict parser.
6use std::collections::HashSet;
7
8// ── Types ─────────────────────────────────────────────────────────────────────
9
10/// A single issue found during validation.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum ValidationIssue {
13    /// Non-fatal issue — document may still be usable.
14    Warning(String),
15    /// Fatal issue — document is invalid.
16    Error(String),
17}
18
19impl ValidationIssue {
20    /// Returns the human-readable message regardless of severity.
21    pub fn message(&self) -> &str {
22        match self {
23            Self::Warning(m) | Self::Error(m) => m,
24        }
25    }
26
27    /// Returns `true` for `Error` variants.
28    pub fn is_error(&self) -> bool {
29        matches!(self, Self::Error(_))
30    }
31}
32
33// ── ValidationReport ─────────────────────────────────────────────────────────
34
35/// The outcome of validating a Turtle document.
36#[derive(Debug, Default)]
37pub struct ValidationReport {
38    /// All validation issues found (warnings and errors).
39    pub issues: Vec<ValidationIssue>,
40    /// Number of non-blank, non-comment lines in the input.
41    pub line_count: usize,
42    /// Approximate number of triple-ending `.` tokens found.
43    pub triple_count: usize,
44}
45
46impl ValidationReport {
47    /// Returns `true` when no `Error` issues were found.
48    pub fn is_valid(&self) -> bool {
49        !self.issues.iter().any(|i| i.is_error())
50    }
51
52    /// Number of `Error` issues.
53    pub fn error_count(&self) -> usize {
54        self.issues.iter().filter(|i| i.is_error()).count()
55    }
56
57    /// Number of `Warning` issues.
58    pub fn warning_count(&self) -> usize {
59        self.issues.iter().filter(|i| !i.is_error()).count()
60    }
61
62    /// Collect the messages of all `Error` issues.
63    pub fn errors(&self) -> Vec<&str> {
64        self.issues
65            .iter()
66            .filter(|i| i.is_error())
67            .map(|i| i.message())
68            .collect()
69    }
70
71    /// Collect the messages of all `Warning` issues.
72    pub fn warnings(&self) -> Vec<&str> {
73        self.issues
74            .iter()
75            .filter(|i| !i.is_error())
76            .map(|i| i.message())
77            .collect()
78    }
79}
80
81// ── TurtleValidator ───────────────────────────────────────────────────────────
82
83/// Line-oriented Turtle syntax validator.
84pub struct TurtleValidator {
85    strict: bool,
86}
87
88impl TurtleValidator {
89    /// Create a new validator in lenient mode.
90    pub fn new() -> Self {
91        Self { strict: false }
92    }
93
94    /// Enable strict mode (additional warnings become errors).
95    pub fn strict(mut self) -> Self {
96        self.strict = true;
97        self
98    }
99
100    /// Validate an entire Turtle document string.
101    pub fn validate(&self, input: &str) -> ValidationReport {
102        let mut report = ValidationReport {
103            triple_count: Self::count_triples_approx(input),
104            ..Default::default()
105        };
106
107        let mut known_prefixes: Vec<String> = vec![
108            "rdf".to_string(),
109            "rdfs".to_string(),
110            "owl".to_string(),
111            "xsd".to_string(),
112        ];
113
114        for (lineno, raw_line) in input.lines().enumerate() {
115            let line = raw_line.trim();
116            // Skip blank lines and comment lines
117            if line.is_empty() || line.starts_with('#') {
118                continue;
119            }
120            report.line_count += 1;
121
122            // @prefix / PREFIX declarations
123            if line.to_lowercase().starts_with("@prefix")
124                || line.to_lowercase().starts_with("prefix")
125            {
126                if let Some(issue) = self.validate_prefix_declaration(line) {
127                    report.issues.push(issue);
128                } else {
129                    // Extract the prefix name and remember it
130                    if let Some(name) = extract_prefix_name(line) {
131                        if !known_prefixes.contains(&name) {
132                            known_prefixes.push(name);
133                        }
134                    }
135                }
136                continue;
137            }
138
139            // Triple lines (non-prefix, non-blank, non-comment)
140            if let Some(issue) = self.validate_triple_line(line, &known_prefixes) {
141                if self.strict {
142                    // Upgrade warnings to errors in strict mode
143                    let upgraded = match issue {
144                        ValidationIssue::Warning(msg) => {
145                            ValidationIssue::Error(format!("[strict] {msg} (line {})", lineno + 1))
146                        }
147                        other => other,
148                    };
149                    report.issues.push(upgraded);
150                } else {
151                    report.issues.push(issue);
152                }
153            }
154        }
155
156        report
157    }
158
159    /// Validate a single `@prefix` or `PREFIX` declaration line.
160    ///
161    /// Returns `Some(Error)` if the line is malformed.
162    pub fn validate_prefix_declaration(&self, line: &str) -> Option<ValidationIssue> {
163        let lc = line.to_lowercase();
164        let rest = if lc.starts_with("@prefix") {
165            line["@prefix".len()..].trim()
166        } else if lc.starts_with("prefix") {
167            line["prefix".len()..].trim()
168        } else {
169            return None;
170        };
171
172        // Must contain a colon in the prefix name part
173        // Expected formats:
174        //   @prefix ex: <http://example.org/> .
175        //   PREFIX ex: <http://example.org/>
176        let colon_pos = match rest.find(':') {
177            Some(pos) => pos,
178            None => {
179                return Some(ValidationIssue::Error(format!(
180                    "prefix declaration missing colon: {line}"
181                )));
182            }
183        };
184
185        let prefix_name = &rest[..colon_pos].trim();
186        if !prefix_name.is_empty() && !self.validate_prefix_name(prefix_name) {
187            return Some(ValidationIssue::Error(format!(
188                "invalid prefix name '{prefix_name}' in: {line}"
189            )));
190        }
191
192        // After the colon there should be whitespace then an IRI in angle brackets
193        let after_colon = rest[colon_pos + 1..].trim();
194        if !after_colon.starts_with('<') {
195            return Some(ValidationIssue::Error(format!(
196                "prefix IRI must be enclosed in <...>: {line}"
197            )));
198        }
199        if !after_colon.contains('>') {
200            return Some(ValidationIssue::Error(format!(
201                "prefix IRI not closed with '>': {line}"
202            )));
203        }
204
205        // Extract the IRI between < >
206        if let Some(iri) = extract_iri(after_colon) {
207            if !self.validate_iri(&iri) {
208                return Some(ValidationIssue::Error(format!(
209                    "invalid IRI in prefix declaration: {iri}"
210                )));
211            }
212        }
213
214        None
215    }
216
217    /// Validate a single non-prefix Turtle line.
218    ///
219    /// Returns `Some(Warning)` for unknown prefixes, `Some(Error)` for
220    /// unclosed angle brackets, etc.
221    pub fn validate_triple_line(
222        &self,
223        line: &str,
224        known_prefixes: &[String],
225    ) -> Option<ValidationIssue> {
226        // Detect unclosed angle brackets in IRI terms
227        let open_angles = line.chars().filter(|&c| c == '<').count();
228        let close_angles = line.chars().filter(|&c| c == '>').count();
229        if open_angles != close_angles {
230            return Some(ValidationIssue::Error(format!(
231                "unbalanced angle brackets in: {line}"
232            )));
233        }
234
235        // Detect unclosed string literals (simple heuristic: odd number of `"`)
236        // We don't count escaped quotes here for simplicity.
237        let double_quote_count = line.chars().filter(|&c| c == '"').count();
238        if double_quote_count % 2 != 0 {
239            return Some(ValidationIssue::Warning(format!(
240                "possible unclosed string literal in: {line}"
241            )));
242        }
243
244        // Warn about `prefixName:localPart` terms with unknown prefix
245        let known: HashSet<&str> = known_prefixes.iter().map(String::as_str).collect();
246        for token in tokenize_turtle_line(line) {
247            if let Some(prefix) = extract_prefix_from_token(token) {
248                if !prefix.is_empty() && !known.contains(prefix) {
249                    return Some(ValidationIssue::Warning(format!(
250                        "unknown prefix '{prefix}' in: {line}"
251                    )));
252                }
253            }
254        }
255
256        None
257    }
258
259    /// Returns `true` when `iri` looks like a valid (absolute) IRI.
260    ///
261    /// This is a heuristic check — it just verifies the IRI contains a scheme
262    /// separator and no bare whitespace.
263    pub fn validate_iri(&self, iri: &str) -> bool {
264        if iri.is_empty() {
265            return false;
266        }
267        if iri.contains(' ') || iri.contains('\t') {
268            return false;
269        }
270        // Relative IRIs (no scheme) are allowed in Turtle if a base is set.
271        // We accept them but flag them as warnings elsewhere.
272        true
273    }
274
275    /// Returns `true` when `prefix` is a valid Turtle prefix name (PN_PREFIX).
276    ///
277    /// A prefix name consists of letters, digits (after the first character),
278    /// underscores, hyphens, and dots (but not as the last character).
279    pub fn validate_prefix_name(&self, prefix: &str) -> bool {
280        if prefix.is_empty() {
281            return true; // empty prefix is allowed
282        }
283        let mut chars = prefix.chars();
284        // First character must be a letter or underscore
285        let first = match chars.next() {
286            Some(c) => c,
287            None => return true,
288        };
289        if !first.is_alphabetic() && first != '_' {
290            return false;
291        }
292        // Remaining characters
293        let remaining: Vec<char> = chars.collect();
294        if let Some(&last) = remaining.last() {
295            if last == '.' || last == '-' {
296                return false;
297            }
298        }
299        remaining
300            .iter()
301            .all(|&c| c.is_alphanumeric() || c == '_' || c == '-' || c == '.')
302    }
303
304    /// Count the approximate number of triples in a Turtle document by counting
305    /// statement-ending `.` tokens on non-comment lines.
306    pub fn count_triples_approx(input: &str) -> usize {
307        let mut count = 0usize;
308        for line in input.lines() {
309            let trimmed = line.trim();
310            if trimmed.is_empty() || trimmed.starts_with('#') {
311                continue;
312            }
313            // Skip prefix declarations
314            let lc = trimmed.to_lowercase();
315            if lc.starts_with("@prefix") || lc.starts_with("prefix") {
316                continue;
317            }
318            // A line that ends in '.' (possibly followed by a comment) is an
319            // approximate statement terminator.
320            // Strip inline comment
321            let without_comment = if let Some(pos) = trimmed.find(" #") {
322                trimmed[..pos].trim()
323            } else {
324                trimmed
325            };
326            if without_comment.ends_with('.') {
327                count += 1;
328            }
329        }
330        count
331    }
332}
333
334impl Default for TurtleValidator {
335    fn default() -> Self {
336        Self::new()
337    }
338}
339
340// ── Private helpers ───────────────────────────────────────────────────────────
341
342/// Extract the prefix name (before the colon) from a `@prefix`/`PREFIX` line.
343fn extract_prefix_name(line: &str) -> Option<String> {
344    let lc = line.to_lowercase();
345    let rest = if lc.starts_with("@prefix") {
346        line["@prefix".len()..].trim()
347    } else if lc.starts_with("prefix") {
348        line["prefix".len()..].trim()
349    } else {
350        return None;
351    };
352    let colon = rest.find(':')?;
353    Some(rest[..colon].trim().to_string())
354}
355
356/// Extract an IRI from a string that starts with `<`.
357fn extract_iri(s: &str) -> Option<String> {
358    let start = s.find('<')? + 1;
359    let end = s[start..].find('>')? + start;
360    Some(s[start..end].to_string())
361}
362
363/// Tokenize a Turtle line into whitespace-separated tokens, skipping string
364/// literals for prefix checking purposes.
365fn tokenize_turtle_line(line: &str) -> Vec<&str> {
366    let mut tokens = Vec::new();
367    let mut in_string = false;
368    let mut in_iri = false;
369    let mut start = 0usize;
370
371    for (i, ch) in line.char_indices() {
372        match ch {
373            '"' => in_string = !in_string,
374            '<' if !in_string => in_iri = true,
375            '>' if in_iri => in_iri = false,
376            ' ' | '\t' if !in_string && !in_iri => {
377                if i > start {
378                    tokens.push(&line[start..i]);
379                }
380                start = i + 1;
381            }
382            _ => {}
383        }
384    }
385    if start < line.len() {
386        tokens.push(&line[start..]);
387    }
388    tokens
389}
390
391/// If `token` looks like `prefix:local`, return the prefix part.
392fn extract_prefix_from_token(token: &str) -> Option<&str> {
393    // Tokens inside < > are IRIs and don't have a prefix notation
394    if token.starts_with('<') || token.starts_with('"') || token.starts_with('_') {
395        return None;
396    }
397    // Strip trailing punctuation (., ;, ,)
398    let cleaned = token.trim_end_matches(['.', ';', ',']);
399    let colon = cleaned.find(':')?;
400    Some(&cleaned[..colon])
401}
402
403// ── Tests ─────────────────────────────────────────────────────────────────────
404
405#[cfg(test)]
406mod tests {
407    use super::*;
408
409    // ── validate ──────────────────────────────────────────────────────────────
410
411    #[test]
412    fn test_valid_turtle_passes() {
413        let ttl = r#"
414@prefix ex: <http://example.org/> .
415ex:Alice ex:knows ex:Bob .
416"#;
417        let v = TurtleValidator::new();
418        let report = v.validate(ttl);
419        assert!(report.is_valid());
420        assert_eq!(report.error_count(), 0);
421    }
422
423    #[test]
424    fn test_empty_input_is_valid() {
425        let v = TurtleValidator::new();
426        let report = v.validate("");
427        assert!(report.is_valid());
428        assert_eq!(report.line_count, 0);
429    }
430
431    #[test]
432    fn test_comments_only_is_valid() {
433        let input = "# This is a comment\n# Another comment\n";
434        let v = TurtleValidator::new();
435        let report = v.validate(input);
436        assert!(report.is_valid());
437        assert_eq!(report.line_count, 0);
438    }
439
440    #[test]
441    fn test_missing_prefix_error() {
442        // Using an unknown prefix without declaring it
443        let input = "unk:Alice unk:knows unk:Bob .\n";
444        let v = TurtleValidator::new();
445        let report = v.validate(input);
446        // Should produce at least one warning about the unknown prefix
447        assert!(!report.issues.is_empty());
448    }
449
450    #[test]
451    fn test_unknown_prefix_produces_warning() {
452        let input = "foo:s foo:p foo:o .\n";
453        let v = TurtleValidator::new();
454        let report = v.validate(input);
455        assert!(report.warning_count() >= 1);
456    }
457
458    // ── validate_prefix_declaration ───────────────────────────────────────────
459
460    #[test]
461    fn test_valid_prefix_declaration() {
462        let v = TurtleValidator::new();
463        let issue = v.validate_prefix_declaration("@prefix ex: <http://example.org/> .");
464        assert!(issue.is_none());
465    }
466
467    #[test]
468    fn test_sparql_prefix_declaration() {
469        let v = TurtleValidator::new();
470        let issue = v.validate_prefix_declaration("PREFIX ex: <http://example.org/>");
471        assert!(issue.is_none());
472    }
473
474    #[test]
475    fn test_prefix_missing_colon() {
476        let v = TurtleValidator::new();
477        let issue = v.validate_prefix_declaration("@prefix ex <http://example.org/> .");
478        assert!(matches!(issue, Some(ValidationIssue::Error(_))));
479    }
480
481    #[test]
482    fn test_prefix_iri_not_in_angle_brackets() {
483        let v = TurtleValidator::new();
484        let issue = v.validate_prefix_declaration("@prefix ex: http://example.org/ .");
485        assert!(matches!(issue, Some(ValidationIssue::Error(_))));
486    }
487
488    #[test]
489    fn test_prefix_iri_not_closed() {
490        let v = TurtleValidator::new();
491        let issue = v.validate_prefix_declaration("@prefix ex: <http://example.org/ .");
492        assert!(matches!(issue, Some(ValidationIssue::Error(_))));
493    }
494
495    #[test]
496    fn test_prefix_invalid_name() {
497        let v = TurtleValidator::new();
498        // Prefix names cannot start with a digit
499        let issue = v.validate_prefix_declaration("@prefix 1bad: <http://example.org/> .");
500        assert!(matches!(issue, Some(ValidationIssue::Error(_))));
501    }
502
503    #[test]
504    fn test_prefix_empty_name_allowed() {
505        let v = TurtleValidator::new();
506        let issue = v.validate_prefix_declaration("@prefix : <http://example.org/> .");
507        assert!(issue.is_none());
508    }
509
510    // ── validate_triple_line ──────────────────────────────────────────────────
511
512    #[test]
513    fn test_valid_triple_line_with_known_prefixes() {
514        let v = TurtleValidator::new();
515        let issue = v.validate_triple_line("ex:Alice ex:knows ex:Bob .", &["ex".to_string()]);
516        assert!(issue.is_none());
517    }
518
519    #[test]
520    fn test_triple_line_unbalanced_angle_brackets() {
521        let v = TurtleValidator::new();
522        let issue = v.validate_triple_line(
523            "<http://example.org/Alice ex:knows ex:Bob .",
524            &["ex".to_string()],
525        );
526        assert!(matches!(issue, Some(ValidationIssue::Error(_))));
527    }
528
529    #[test]
530    fn test_triple_line_unclosed_string_literal() {
531        let v = TurtleValidator::new();
532        let issue = v.validate_triple_line(r#"ex:s ex:p "unclosed ."#, &["ex".to_string()]);
533        assert!(matches!(issue, Some(ValidationIssue::Warning(_))));
534    }
535
536    #[test]
537    fn test_triple_line_unknown_prefix_warning() {
538        let v = TurtleValidator::new();
539        let issue = v.validate_triple_line("unkn:s unkn:p unkn:o .", &["ex".to_string()]);
540        assert!(matches!(issue, Some(ValidationIssue::Warning(_))));
541    }
542
543    // ── validate_iri ──────────────────────────────────────────────────────────
544
545    #[test]
546    fn test_valid_iri() {
547        let v = TurtleValidator::new();
548        assert!(v.validate_iri("http://example.org/"));
549    }
550
551    #[test]
552    fn test_invalid_iri_empty() {
553        let v = TurtleValidator::new();
554        assert!(!v.validate_iri(""));
555    }
556
557    #[test]
558    fn test_invalid_iri_with_space() {
559        let v = TurtleValidator::new();
560        assert!(!v.validate_iri("http://example.org/ foo"));
561    }
562
563    #[test]
564    fn test_invalid_iri_with_tab() {
565        let v = TurtleValidator::new();
566        assert!(!v.validate_iri("http://example.org/\t"));
567    }
568
569    // ── validate_prefix_name ──────────────────────────────────────────────────
570
571    #[test]
572    fn test_valid_prefix_name_simple() {
573        let v = TurtleValidator::new();
574        assert!(v.validate_prefix_name("ex"));
575    }
576
577    #[test]
578    fn test_valid_prefix_name_with_dot() {
579        let v = TurtleValidator::new();
580        assert!(v.validate_prefix_name("ex.org"));
581    }
582
583    #[test]
584    fn test_valid_prefix_name_empty() {
585        let v = TurtleValidator::new();
586        assert!(v.validate_prefix_name(""));
587    }
588
589    #[test]
590    fn test_invalid_prefix_name_starts_with_digit() {
591        let v = TurtleValidator::new();
592        assert!(!v.validate_prefix_name("1bad"));
593    }
594
595    #[test]
596    fn test_invalid_prefix_name_ends_with_dot() {
597        let v = TurtleValidator::new();
598        assert!(!v.validate_prefix_name("bad."));
599    }
600
601    #[test]
602    fn test_invalid_prefix_name_ends_with_dash() {
603        let v = TurtleValidator::new();
604        assert!(!v.validate_prefix_name("bad-"));
605    }
606
607    #[test]
608    fn test_valid_prefix_name_with_underscore() {
609        let v = TurtleValidator::new();
610        assert!(v.validate_prefix_name("my_prefix"));
611    }
612
613    // ── count_triples_approx ──────────────────────────────────────────────────
614
615    #[test]
616    fn test_count_triples_approx_simple() {
617        let input = r#"
618@prefix ex: <http://example.org/> .
619ex:Alice ex:knows ex:Bob .
620ex:Bob ex:knows ex:Carol .
621"#;
622        assert_eq!(TurtleValidator::count_triples_approx(input), 2);
623    }
624
625    #[test]
626    fn test_count_triples_approx_zero_on_empty() {
627        assert_eq!(TurtleValidator::count_triples_approx(""), 0);
628    }
629
630    #[test]
631    fn test_count_triples_approx_ignores_prefixes() {
632        let input = "@prefix ex: <http://example.org/> .\nex:s ex:p ex:o .\n";
633        assert_eq!(TurtleValidator::count_triples_approx(input), 1);
634    }
635
636    #[test]
637    fn test_count_triples_approx_ignores_comments() {
638        let input = "# comment .\nex:s ex:p ex:o .\n";
639        assert_eq!(TurtleValidator::count_triples_approx(input), 1);
640    }
641
642    // ── ValidationReport methods ──────────────────────────────────────────────
643
644    #[test]
645    fn test_report_is_valid_no_errors() {
646        let mut report = ValidationReport::default();
647        report.issues.push(ValidationIssue::Warning("w".into()));
648        assert!(report.is_valid());
649    }
650
651    #[test]
652    fn test_report_is_invalid_with_error() {
653        let mut report = ValidationReport::default();
654        report.issues.push(ValidationIssue::Error("e".into()));
655        assert!(!report.is_valid());
656    }
657
658    #[test]
659    fn test_report_error_count() {
660        let mut report = ValidationReport::default();
661        report.issues.push(ValidationIssue::Error("e1".into()));
662        report.issues.push(ValidationIssue::Error("e2".into()));
663        report.issues.push(ValidationIssue::Warning("w".into()));
664        assert_eq!(report.error_count(), 2);
665        assert_eq!(report.warning_count(), 1);
666    }
667
668    #[test]
669    fn test_report_errors_and_warnings_vecs() {
670        let mut report = ValidationReport::default();
671        report.issues.push(ValidationIssue::Error("err".into()));
672        report.issues.push(ValidationIssue::Warning("warn".into()));
673        assert!(report.errors().contains(&"err"));
674        assert!(report.warnings().contains(&"warn"));
675    }
676
677    // ── strict mode ───────────────────────────────────────────────────────────
678
679    #[test]
680    fn test_strict_mode_upgrades_warnings_to_errors() {
681        let input = "unk:s unk:p unk:o .\n";
682        let v = TurtleValidator::new().strict();
683        let report = v.validate(input);
684        // Unknown prefix warning upgraded to error in strict mode
685        assert!(!report.is_valid());
686        assert!(report.error_count() >= 1);
687    }
688
689    #[test]
690    fn test_lenient_mode_keeps_warnings() {
691        let input = "unk:s unk:p unk:o .\n";
692        let v = TurtleValidator::new();
693        let report = v.validate(input);
694        assert!(report.is_valid()); // errors are zero — only warnings
695        assert!(report.warning_count() >= 1);
696    }
697
698    // ── ValidationIssue helpers ───────────────────────────────────────────────
699
700    #[test]
701    fn test_validation_issue_message_error() {
702        let i = ValidationIssue::Error("oops".into());
703        assert_eq!(i.message(), "oops");
704        assert!(i.is_error());
705    }
706
707    #[test]
708    fn test_validation_issue_message_warning() {
709        let i = ValidationIssue::Warning("hmm".into());
710        assert_eq!(i.message(), "hmm");
711        assert!(!i.is_error());
712    }
713
714    // ── Additional tests for round 12 (reaching ≥45 total) ───────────────────
715
716    #[test]
717    fn test_validate_valid_base_and_triple() {
718        let input = "@base <http://example.org/> .\n@prefix ex: <http://example.org/> .\nex:s ex:p ex:o .\n";
719        let v = TurtleValidator::new();
720        let report = v.validate(input);
721        assert!(report.is_valid());
722    }
723
724    #[test]
725    fn test_validate_empty_string() {
726        let v = TurtleValidator::new();
727        let report = v.validate("");
728        assert!(report.is_valid());
729        assert_eq!(report.triple_count, 0);
730    }
731
732    #[test]
733    fn test_validate_comment_only() {
734        let input = "# This is just a comment\n";
735        let v = TurtleValidator::new();
736        let report = v.validate(input);
737        assert!(report.is_valid());
738    }
739
740    #[test]
741    fn test_count_triples_approx_multiple() {
742        let input = "ex:a ex:b ex:c .\nex:d ex:e ex:f .\nex:g ex:h ex:i .\n";
743        assert_eq!(TurtleValidator::count_triples_approx(input), 3);
744    }
745
746    #[test]
747    fn test_validate_prefix_name_empty() {
748        let v = TurtleValidator::new();
749        // Empty prefix "" is valid in Turtle
750        assert!(v.validate_prefix_name(""));
751    }
752
753    #[test]
754    fn test_validate_prefix_name_digits_after_start() {
755        let v = TurtleValidator::new();
756        // Prefix starting with letter, then digits
757        assert!(v.validate_prefix_name("abc123"));
758    }
759
760    #[test]
761    fn test_validate_invalid_prefix_starts_with_digit() {
762        let v = TurtleValidator::new();
763        assert!(!v.validate_prefix_name("1bad"));
764    }
765
766    #[test]
767    fn test_report_default_has_no_issues() {
768        let report = ValidationReport::default();
769        assert!(report.issues.is_empty());
770        assert_eq!(report.triple_count, 0);
771        assert_eq!(report.line_count, 0);
772    }
773
774    #[test]
775    fn test_validation_issue_clone() {
776        let i = ValidationIssue::Error("err".into());
777        assert_eq!(i, i.clone());
778    }
779
780    #[test]
781    fn test_validation_issue_eq() {
782        assert_eq!(
783            ValidationIssue::Warning("w".into()),
784            ValidationIssue::Warning("w".into())
785        );
786        assert_ne!(
787            ValidationIssue::Error("e".into()),
788            ValidationIssue::Warning("e".into())
789        );
790    }
791}