1use crate::toolkit::FastScanner;
15use std::path::Path;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
19pub enum RdfFormat {
20 Turtle,
22 NTriples,
24 NQuads,
26 TriG,
28}
29
30impl RdfFormat {
31 pub fn extension(&self) -> &'static str {
33 match self {
34 RdfFormat::Turtle => "ttl",
35 RdfFormat::NTriples => "nt",
36 RdfFormat::NQuads => "nq",
37 RdfFormat::TriG => "trig",
38 }
39 }
40
41 pub fn mime_type(&self) -> &'static str {
43 match self {
44 RdfFormat::Turtle => "text/turtle",
45 RdfFormat::NTriples => "application/n-triples",
46 RdfFormat::NQuads => "application/n-quads",
47 RdfFormat::TriG => "application/trig",
48 }
49 }
50
51 pub fn name(&self) -> &'static str {
53 match self {
54 RdfFormat::Turtle => "Turtle",
55 RdfFormat::NTriples => "N-Triples",
56 RdfFormat::NQuads => "N-Quads",
57 RdfFormat::TriG => "TriG",
58 }
59 }
60}
61
62#[derive(Debug, Clone)]
64pub struct DetectionResult {
65 pub format: RdfFormat,
67 pub confidence: f64,
69 pub method: DetectionMethod,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq)]
75pub enum DetectionMethod {
76 FileExtension,
78 MimeType,
80 ContentAnalysis,
82 Combined,
84}
85
86#[derive(Debug, Clone)]
88pub struct FormatDetector {
89 sample_size: usize,
91 min_confidence: f64,
93}
94
95impl Default for FormatDetector {
96 fn default() -> Self {
97 Self::new()
98 }
99}
100
101impl FormatDetector {
102 pub fn new() -> Self {
104 Self {
105 sample_size: 4096, min_confidence: 0.6,
107 }
108 }
109
110 pub fn with_sample_size(mut self, size: usize) -> Self {
112 self.sample_size = size;
113 self
114 }
115
116 pub fn with_min_confidence(mut self, confidence: f64) -> Self {
118 self.min_confidence = confidence.clamp(0.0, 1.0);
119 self
120 }
121
122 pub fn detect_from_path(&self, path: &Path) -> Option<DetectionResult> {
124 path.extension()
125 .and_then(|ext| ext.to_str())
126 .and_then(|ext| self.detect_from_extension(ext))
127 }
128
129 pub fn detect_from_extension(&self, extension: &str) -> Option<DetectionResult> {
131 let ext_lower = extension.to_lowercase();
132 let format = match ext_lower.as_str() {
133 "ttl" | "turtle" => RdfFormat::Turtle,
134 "nt" | "ntriples" => RdfFormat::NTriples,
135 "nq" | "nquads" => RdfFormat::NQuads,
136 "trig" => RdfFormat::TriG,
137 _ => return None,
138 };
139
140 Some(DetectionResult {
141 format,
142 confidence: 0.9, method: DetectionMethod::FileExtension,
144 })
145 }
146
147 pub fn detect_from_mime_type(&self, mime_type: &str) -> Option<DetectionResult> {
149 let mime_lower = mime_type.to_lowercase();
150 let format = if mime_lower.contains("turtle") {
151 RdfFormat::Turtle
152 } else if mime_lower.contains("n-triples") || mime_lower.contains("ntriples") {
153 RdfFormat::NTriples
154 } else if mime_lower.contains("n-quads") || mime_lower.contains("nquads") {
155 RdfFormat::NQuads
156 } else if mime_lower.contains("trig") {
157 RdfFormat::TriG
158 } else {
159 return None;
160 };
161
162 Some(DetectionResult {
163 format,
164 confidence: 0.95, method: DetectionMethod::MimeType,
166 })
167 }
168
169 pub fn detect_from_content(&self, content: &[u8]) -> Option<DetectionResult> {
171 let sample = &content[..content.len().min(self.sample_size)];
172 let scanner = FastScanner::new(sample);
173
174 let mut scores = FormatScores::default();
175
176 self.analyze_directives(&scanner, &mut scores);
178 self.analyze_syntax(&scanner, &mut scores);
179 self.analyze_structure(&scanner, &mut scores);
180
181 scores.determine_format(self.min_confidence)
183 }
184
185 pub fn detect(
187 &self,
188 path: Option<&Path>,
189 mime_type: Option<&str>,
190 content: Option<&[u8]>,
191 ) -> Option<DetectionResult> {
192 let mut results = Vec::new();
193
194 if let Some(path) = path {
196 if let Some(result) = self.detect_from_path(path) {
197 results.push(result);
198 }
199 }
200
201 if let Some(mime) = mime_type {
203 if let Some(result) = self.detect_from_mime_type(mime) {
204 results.push(result);
205 }
206 }
207
208 if let Some(content) = content {
210 if let Some(result) = self.detect_from_content(content) {
211 results.push(result);
212 }
213 }
214
215 self.combine_results(results)
217 }
218
219 fn analyze_directives(&self, scanner: &FastScanner, scores: &mut FormatScores) {
221 let mut pos = 0;
222 let mut has_prefix_directive = false;
223
224 while pos < scanner.len() {
225 pos = scanner.skip_whitespace_and_comments(pos);
226 if pos >= scanner.len() {
227 break;
228 }
229
230 if scanner.byte_at(pos) == Some(b'@') {
233 scores.turtle += 0.8;
234 scores.trig += 0.5; has_prefix_directive = true;
236 break; }
238
239 let slice = scanner.slice(pos, pos + 6);
241 if slice.starts_with(b"PREFIX") || slice.starts_with(b"BASE") {
242 scores.turtle += 0.7;
243 scores.trig += 0.4; has_prefix_directive = true;
245 break;
246 }
247
248 if let Some(newline) = scanner.find_line_end(pos) {
250 pos = newline + 1;
251 } else {
252 break;
253 }
254 }
255
256 if has_prefix_directive {
258 scores.ntriples = 0.0;
259 scores.nquads = 0.0;
260 }
261 }
262
263 fn analyze_syntax(&self, scanner: &FastScanner, scores: &mut FormatScores) {
265 let content = scanner.slice(0, scanner.len());
266 let mut has_curly_braces = false;
267
268 for byte in content {
270 match byte {
271 b';' => {
272 scores.turtle += 0.1;
273 scores.trig += 0.1;
274 }
275 b',' => {
276 scores.turtle += 0.05;
277 scores.trig += 0.05;
278 }
279 b'[' | b']' => {
280 scores.turtle += 0.08;
281 scores.trig += 0.08;
282 }
283 b'{' | b'}' => {
284 has_curly_braces = true;
286 scores.trig += 0.5;
287 }
288 _ => {}
289 }
290 }
291
292 if has_curly_braces {
294 scores.turtle *= 0.5; }
296 }
297
298 fn analyze_structure(&self, scanner: &FastScanner, scores: &mut FormatScores) {
300 let mut pos = 0;
301 let mut line_count = 0;
302 let mut triple_lines = 0;
303 let mut quad_lines = 0;
304 let mut has_prefixed_names = false;
305 let mut has_full_iris = false;
306
307 while pos < scanner.len() && line_count < 10 {
308 pos = scanner.skip_whitespace_and_comments(pos);
309 if pos >= scanner.len() {
310 break;
311 }
312
313 let line_start = pos;
314 let line_end = scanner.find_line_end(pos).unwrap_or(scanner.len());
315
316 let line_content = scanner.slice(line_start, line_end);
318 let element_count = self.count_line_elements(line_content);
319
320 if self.has_prefixed_name(line_content) {
323 has_prefixed_names = true;
324 }
325
326 let trimmed_start = scanner.skip_whitespace(line_start);
328 if trimmed_start < scanner.len() {
329 match scanner.byte_at(trimmed_start) {
330 Some(b'<') | Some(b'_') => has_full_iris = true,
331 _ => {}
332 }
333 }
334
335 match element_count {
336 3 => triple_lines += 1,
337 4 => quad_lines += 1,
338 _ => {}
339 }
340
341 line_count += 1;
342 pos = line_end + 1;
343 }
344
345 if has_prefixed_names {
347 scores.turtle += 0.4;
348 scores.trig += 0.4;
349 scores.ntriples = 0.0;
350 scores.nquads = 0.0;
351 return;
352 }
353
354 if line_count > 0 && has_full_iris {
356 let triple_ratio = triple_lines as f64 / line_count as f64;
357 let quad_ratio = quad_lines as f64 / line_count as f64;
358
359 if triple_ratio > 0.7 {
360 scores.ntriples += 0.6;
361 }
362
363 if quad_ratio > 0.7 {
364 scores.nquads += 0.7;
365 }
366 }
367 }
368
369 fn has_prefixed_name(&self, line: &[u8]) -> bool {
371 let mut i = 0;
372 while i < line.len() {
373 while i < line.len() && (line[i] == b' ' || line[i] == b'\t') {
375 i += 1;
376 }
377
378 if i >= line.len() {
379 break;
380 }
381
382 if line[i] == b'<' {
384 while i < line.len() && line[i] != b'>' {
385 i += 1;
386 }
387 i += 1;
388 continue;
389 }
390
391 if line[i] == b'"' {
393 i += 1;
394 while i < line.len() && line[i] != b'"' {
395 if line[i] == b'\\' {
396 i += 2;
397 } else {
398 i += 1;
399 }
400 }
401 i += 1;
402 continue;
403 }
404
405 if line[i].is_ascii_alphabetic() {
407 while i < line.len() && (line[i].is_ascii_alphanumeric() || line[i] == b'_') {
408 i += 1;
409 }
410
411 if i < line.len() && line[i] == b':' {
413 if i + 1 < line.len() && line[i + 1] != b' ' {
415 return true;
416 }
417 }
418
419 continue;
420 }
421
422 i += 1;
423 }
424
425 false
426 }
427
428 fn count_line_elements(&self, line: &[u8]) -> usize {
434 let mut count = 0;
435 let mut in_string = false;
436 let mut in_angle_bracket = false;
437 let mut prev_space = true;
438 let mut elements = Vec::new();
439 let mut current_element_start = 0;
440
441 for (i, &byte) in line.iter().enumerate() {
442 match byte {
443 b'"' => in_string = !in_string,
444 b'<' if !in_string => in_angle_bracket = true,
445 b'>' if !in_string => in_angle_bracket = false,
446 b' ' | b'\t' if !in_string && !in_angle_bracket => {
447 if !prev_space {
448 elements.push(&line[current_element_start..i]);
449 }
450 prev_space = true;
451 current_element_start = i + 1;
452 }
453 _ => {
454 if prev_space && !in_string {
455 prev_space = false;
456 }
457 }
458 }
459 }
460
461 if !prev_space && current_element_start < line.len() {
463 elements.push(&line[current_element_start..]);
464 }
465
466 for elem in elements {
468 if elem.is_empty() || elem == b"." {
470 continue;
471 }
472 count += 1;
473 }
474
475 count
476 }
477
478 fn combine_results(&self, results: Vec<DetectionResult>) -> Option<DetectionResult> {
480 if results.is_empty() {
481 return None;
482 }
483
484 if results.len() == 1 {
485 return Some(results[0].clone());
486 }
487
488 let mut format_scores: std::collections::HashMap<RdfFormat, f64> =
490 std::collections::HashMap::new();
491
492 for result in &results {
493 let weight = match result.method {
494 DetectionMethod::MimeType => 1.5,
495 DetectionMethod::FileExtension => 1.2,
496 DetectionMethod::ContentAnalysis => 1.0,
497 DetectionMethod::Combined => 1.3,
498 };
499
500 *format_scores.entry(result.format).or_insert(0.0) += result.confidence * weight;
501 }
502
503 format_scores
505 .into_iter()
506 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
507 .map(|(format, score)| DetectionResult {
508 format,
509 confidence: (score / results.len() as f64).min(1.0),
510 method: DetectionMethod::Combined,
511 })
512 }
513}
514
515#[derive(Debug, Default)]
517struct FormatScores {
518 turtle: f64,
519 ntriples: f64,
520 nquads: f64,
521 trig: f64,
522}
523
524impl FormatScores {
525 fn determine_format(&self, min_confidence: f64) -> Option<DetectionResult> {
526 let formats = [
527 (RdfFormat::Turtle, self.turtle),
528 (RdfFormat::NTriples, self.ntriples),
529 (RdfFormat::NQuads, self.nquads),
530 (RdfFormat::TriG, self.trig),
531 ];
532
533 formats
534 .iter()
535 .max_by(|(_, a), (_, b)| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal))
536 .filter(|(_, score)| *score >= min_confidence)
537 .map(|(format, score)| DetectionResult {
538 format: *format,
539 confidence: *score,
540 method: DetectionMethod::ContentAnalysis,
541 })
542 }
543}
544
545#[cfg(test)]
546mod tests {
547 use super::*;
548
549 #[test]
550 fn test_detect_turtle_from_extension() {
551 let detector = FormatDetector::new();
552
553 let result = detector
554 .detect_from_extension("ttl")
555 .expect("detection should succeed");
556 assert_eq!(result.format, RdfFormat::Turtle);
557 assert!(result.confidence > 0.8);
558 }
559
560 #[test]
561 fn test_detect_ntriples_from_extension() {
562 let detector = FormatDetector::new();
563
564 let result = detector
565 .detect_from_extension("nt")
566 .expect("detection should succeed");
567 assert_eq!(result.format, RdfFormat::NTriples);
568 }
569
570 #[test]
571 fn test_detect_from_path() {
572 let detector = FormatDetector::new();
573 let path = Path::new("test.ttl");
574
575 let result = detector
576 .detect_from_path(path)
577 .expect("detection should succeed");
578 assert_eq!(result.format, RdfFormat::Turtle);
579 }
580
581 #[test]
582 fn test_detect_turtle_from_content() {
583 let detector = FormatDetector::new();
584 let content = b"@prefix ex: <http://example.org/> .\nex:subject ex:predicate ex:object .";
585
586 let result = detector
587 .detect_from_content(content)
588 .expect("detection should succeed");
589 assert_eq!(result.format, RdfFormat::Turtle);
590 }
591
592 #[test]
593 fn test_detect_trig_from_content() {
594 let detector = FormatDetector::new();
595 let content = b"@prefix ex: <http://example.org/> .\nex:graph { ex:s ex:p ex:o . }";
596
597 let result = detector
598 .detect_from_content(content)
599 .expect("detection should succeed");
600 assert_eq!(result.format, RdfFormat::TriG);
601 }
602
603 #[test]
604 fn test_detect_ntriples_from_content() {
605 let detector = FormatDetector::new();
606 let content = b"<http://example.org/s> <http://example.org/p> <http://example.org/o> .\n<http://example.org/s2> <http://example.org/p2> <http://example.org/o2> .";
607
608 let result = detector
609 .detect_from_content(content)
610 .expect("detection should succeed");
611 assert_eq!(result.format, RdfFormat::NTriples);
612 }
613
614 #[test]
615 fn test_detect_from_mime_type() {
616 let detector = FormatDetector::new();
617
618 let result = detector
619 .detect_from_mime_type("text/turtle")
620 .expect("detection should succeed");
621 assert_eq!(result.format, RdfFormat::Turtle);
622
623 let result = detector
624 .detect_from_mime_type("application/n-triples")
625 .expect("detection should succeed");
626 assert_eq!(result.format, RdfFormat::NTriples);
627 }
628
629 #[test]
630 fn test_combined_detection() {
631 let detector = FormatDetector::new();
632 let path = Path::new("test.ttl");
633 let content = b"@prefix ex: <http://example.org/> .";
634
635 let result = detector
636 .detect(Some(path), None, Some(content))
637 .expect("detection should succeed");
638 assert_eq!(result.format, RdfFormat::Turtle);
639 assert!(result.confidence > 0.8);
640 }
641
642 #[test]
643 fn test_format_properties() {
644 assert_eq!(RdfFormat::Turtle.extension(), "ttl");
645 assert_eq!(RdfFormat::Turtle.mime_type(), "text/turtle");
646 assert_eq!(RdfFormat::Turtle.name(), "Turtle");
647 }
648}