1use serde::{Deserialize, Serialize};
2use serde_json::json;
3use uuid::Uuid;
4
5use crate::Data;
6use crate::DataPoint;
7use crate::has_datapoint::HasDataPoint;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct Document {
17 #[serde(flatten)]
19 pub base: DataPoint,
20 pub document_type: String,
22 pub name: String,
23 pub raw_data_location: String,
24 pub mime_type: String,
25 pub extension: String,
26 pub data_id: Uuid,
28 pub external_metadata: Option<String>,
30}
31
32pub fn doc_type_for_extension(ext: &str) -> Option<&'static str> {
40 extension_to_doc_type(ext)
41}
42
43fn extension_to_doc_type(ext: &str) -> Option<&'static str> {
48 match ext.to_lowercase().as_str() {
49 "pdf" => Some("pdf"),
50 "txt" => Some("text"),
51 "csv" => Some("csv"),
52 "docx" | "doc" | "odt" | "xls" | "xlsx" | "ppt" | "pptx" | "odp" | "ods" => {
53 Some("unstructured")
54 }
55 "png" | "dwg" | "xcf" | "jpg" | "jpx" | "apng" | "gif" | "webp" | "cr2" | "tif" | "bmp"
56 | "jxr" | "psd" | "ico" | "heic" | "avif" => Some("image"),
57 "aac" | "mid" | "mp3" | "m4a" | "ogg" | "flac" | "wav" | "amr" | "aiff" => Some("audio"),
58 "html" | "htm" => Some("html"),
66 _ => None,
67 }
68}
69
70fn doc_type_to_class_name(doc_type: &str) -> &'static str {
72 match doc_type {
73 "text" => "TextDocument",
74 "pdf" => "PdfDocument",
75 "csv" => "CsvDocument",
76 "image" => "ImageDocument",
77 "audio" => "AudioDocument",
78 "html" => "TextDocument",
81 "unstructured" => "UnstructuredDocument",
82 "dlt_row" => "DltRowDocument",
83 _ => "Document",
84 }
85}
86
87fn is_dlt_sourced(external_metadata: &Option<String>) -> bool {
91 external_metadata
92 .as_ref()
93 .and_then(|m| serde_json::from_str::<serde_json::Value>(m).ok())
94 .map(|v| metadata_value_is_dlt_sourced(&v))
95 .unwrap_or(false)
96}
97
98fn metadata_value_is_dlt_sourced(value: &serde_json::Value) -> bool {
99 value
100 .get("source")
101 .and_then(|source| source.as_str())
102 .map(|source| source == "dlt")
103 .unwrap_or(false)
104 || value
105 .get("data_item_external_metadata")
106 .map(metadata_value_is_dlt_sourced)
107 .unwrap_or(false)
108}
109
110pub fn classify_documents(data_items: &[Data]) -> Vec<Document> {
116 data_items
117 .iter()
118 .filter_map(|data| {
119 let doc_type = if is_dlt_sourced(&data.external_metadata) {
121 "dlt_row"
122 } else {
123 extension_to_doc_type(&data.extension)?
124 };
125
126 let class_name = doc_type_to_class_name(doc_type);
127 let mut base = DataPoint::new(class_name, None);
128 base.id = data.id; base.importance_weight = Some(data.importance_weight.unwrap_or(0.5));
131 base.set_metadata("index_fields", json!(["name"]));
132
133 let formatted_metadata = data.external_metadata.as_ref().and_then(|m| {
135 let v: serde_json::Value = serde_json::from_str(m).ok()?;
136 serde_json::to_string_pretty(&v).ok()
137 });
138
139 let mut doc = Document {
140 base,
141 document_type: doc_type.to_string(),
142 name: data.name.clone(),
143 raw_data_location: data.raw_data_location.clone(),
144 mime_type: data.mime_type.clone(),
145 extension: data.extension.clone(),
146 data_id: data.id,
147 external_metadata: formatted_metadata.or(data.external_metadata.clone()),
148 };
149
150 if let Some(ref meta_str) = doc.external_metadata
153 && let Ok(meta_val) = serde_json::from_str::<serde_json::Value>(meta_str)
154 && let Some(node_set_array) = meta_val.get("node_set").and_then(|v| v.as_array())
155 {
156 let node_set_values: Vec<serde_json::Value> = node_set_array
159 .iter()
160 .filter_map(|v| {
161 let name = v.as_str()?;
162 let key = format!("NodeSet:{name}")
163 .to_lowercase()
164 .replace(' ', "_")
165 .replace('\'', "");
166 let id = uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, key.as_bytes());
167 Some(json!({
168 "id": id.to_string(),
169 "name": name,
170 "type": "NodeSet"
171 }))
172 })
173 .collect();
174
175 if !node_set_values.is_empty() {
176 let names: Vec<&str> =
178 node_set_array.iter().filter_map(|v| v.as_str()).collect();
179 doc.base.source_node_set = Some(names.join(", "));
180 doc.base.belongs_to_set = Some(node_set_values);
181 }
182 }
183
184 Some(doc)
185 })
186 .collect()
187}
188
189impl HasDataPoint for Document {
190 fn data_point(&self) -> &DataPoint {
191 &self.base
192 }
193 fn data_point_mut(&mut self) -> &mut DataPoint {
194 &mut self.base
195 }
196 }
199
200#[cfg(test)]
201#[allow(
202 clippy::unwrap_used,
203 clippy::expect_used,
204 reason = "test code — panics are acceptable failures"
205)]
206mod tests {
207 use super::*;
208
209 fn make_data(mime_type: &str, extension: &str) -> Data {
210 Data::builder(
211 Uuid::new_v4(),
212 format!("test.{extension}"),
213 "/storage/test",
214 "text://test",
215 extension,
216 mime_type,
217 "hash123",
218 Uuid::new_v4(),
219 )
220 .build()
221 }
222
223 fn make_data_with_metadata(mime_type: &str, extension: &str, metadata: &str) -> Data {
224 Data::builder(
225 Uuid::new_v4(),
226 format!("test.{extension}"),
227 "/storage/test",
228 "text://test",
229 extension,
230 mime_type,
231 "hash123",
232 Uuid::new_v4(),
233 )
234 .external_metadata(metadata)
235 .build()
236 }
237
238 fn make_data_with_importance(
239 mime_type: &str,
240 extension: &str,
241 importance_weight: Option<f64>,
242 ) -> Data {
243 let mut builder = Data::builder(
244 Uuid::new_v4(),
245 format!("test.{extension}"),
246 "/storage/test",
247 "text://test",
248 extension,
249 mime_type,
250 "hash123",
251 Uuid::new_v4(),
252 );
253 if let Some(w) = importance_weight {
254 builder = builder.importance_weight(w);
255 }
256 builder.build()
257 }
258
259 #[test]
262 fn classify_documents_propagates_importance_weight() {
263 let data = vec![make_data_with_importance("text/plain", "txt", Some(0.9))];
264 let docs = classify_documents(&data);
265 assert_eq!(docs.len(), 1);
266 assert_eq!(docs[0].base.importance_weight, Some(0.9));
267 }
268
269 #[test]
270 fn classify_documents_coalesces_none_importance_weight_to_default() {
271 let data = vec![make_data_with_importance("text/plain", "txt", None)];
272 let docs = classify_documents(&data);
273 assert_eq!(docs.len(), 1);
274 assert_eq!(docs[0].base.importance_weight, Some(0.5));
275 }
276
277 #[test]
278 fn classifies_text_plain() {
279 let data = vec![make_data("text/plain", "txt")];
280 let docs = classify_documents(&data);
281 assert_eq!(docs.len(), 1);
282 assert_eq!(docs[0].document_type, "text");
283 assert_eq!(docs[0].base.data_type, "TextDocument");
284 assert_eq!(docs[0].mime_type, "text/plain");
285 assert_eq!(docs[0].data_id, data[0].id);
286 assert_eq!(docs[0].base.id, data[0].id);
287 assert_eq!(docs[0].base.data_type, "TextDocument");
288 assert_eq!(
289 docs[0].base.get_metadata("index_fields"),
290 Some(&serde_json::json!(["name"]))
291 );
292 }
293
294 #[test]
295 fn classifies_extracted_html_url_text_as_text_document() {
296 let data = vec![
297 Data::builder(
298 Uuid::new_v4(),
299 "text_hash",
300 "file:///storage/text_hash.txt",
301 "file:///storage/source.html",
302 "txt",
303 "text/plain",
304 "hash123",
305 Uuid::new_v4(),
306 )
307 .original_extension("html")
308 .original_mime_type("text/html")
309 .loader_engine("beautiful_soup_loader")
310 .external_metadata(
311 r#"{"source":"url","url":"https://example.test","content_type":"text/html"}"#,
312 )
313 .build(),
314 ];
315
316 let docs = classify_documents(&data);
317
318 assert_eq!(docs.len(), 1);
319 assert_eq!(docs[0].document_type, "text");
320 assert_eq!(docs[0].base.data_type, "TextDocument");
321 assert_eq!(docs[0].extension, "txt");
322 assert_eq!(docs[0].mime_type, "text/plain");
323 }
324
325 #[test]
326 fn classifies_pdf() {
327 let data = vec![make_data("application/pdf", "pdf")];
328 let docs = classify_documents(&data);
329 assert_eq!(docs.len(), 1);
330 assert_eq!(docs[0].document_type, "pdf");
331 assert_eq!(docs[0].base.data_type, "PdfDocument");
332 }
333
334 #[test]
335 fn classifies_csv() {
336 let data = vec![make_data("text/csv", "csv")];
337 let docs = classify_documents(&data);
338 assert_eq!(docs.len(), 1);
339 assert_eq!(docs[0].document_type, "csv");
340 assert_eq!(docs[0].base.data_type, "CsvDocument");
341 }
342
343 #[test]
344 fn classifies_image_extensions() {
345 for ext in &[
346 "png", "dwg", "xcf", "jpg", "jpx", "apng", "gif", "webp", "cr2", "tif", "bmp", "jxr",
347 "psd", "ico", "heic", "avif",
348 ] {
349 let data = vec![make_data(&format!("image/{ext}"), ext)];
350 let docs = classify_documents(&data);
351 assert_eq!(docs.len(), 1, "failed for extension: {ext}");
352 assert_eq!(
353 docs[0].document_type, "image",
354 "failed for extension: {ext}"
355 );
356 assert_eq!(
357 docs[0].base.data_type, "ImageDocument",
358 "failed for extension: {ext}"
359 );
360 }
361 }
362
363 #[test]
364 fn classifies_audio_extensions() {
365 for ext in &[
366 "aac", "mid", "mp3", "m4a", "ogg", "flac", "wav", "amr", "aiff",
367 ] {
368 let data = vec![make_data(&format!("audio/{ext}"), ext)];
369 let docs = classify_documents(&data);
370 assert_eq!(docs.len(), 1, "failed for extension: {ext}");
371 assert_eq!(
372 docs[0].document_type, "audio",
373 "failed for extension: {ext}"
374 );
375 assert_eq!(
376 docs[0].base.data_type, "AudioDocument",
377 "failed for extension: {ext}"
378 );
379 }
380 }
381
382 #[test]
383 fn classifies_unstructured_extensions() {
384 for ext in &[
385 "docx", "doc", "odt", "xls", "xlsx", "ppt", "pptx", "odp", "ods",
386 ] {
387 let data = vec![make_data("application/octet-stream", ext)];
388 let docs = classify_documents(&data);
389 assert_eq!(docs.len(), 1, "failed for extension: {ext}");
390 assert_eq!(
391 docs[0].document_type, "unstructured",
392 "failed for extension: {ext}"
393 );
394 assert_eq!(
395 docs[0].base.data_type, "UnstructuredDocument",
396 "failed for extension: {ext}"
397 );
398 }
399 }
400
401 #[test]
402 fn classifies_html_extensions() {
403 for ext in &["html", "htm"] {
404 let data = vec![make_data("text/html", ext)];
405 let docs = classify_documents(&data);
406 assert_eq!(docs.len(), 1, "failed for extension: {ext}");
407 assert_eq!(docs[0].document_type, "html", "failed for extension: {ext}");
408 assert_eq!(
411 docs[0].base.data_type, "TextDocument",
412 "failed for extension: {ext}"
413 );
414 }
415 }
416
417 #[test]
420 fn skips_unknown_extensions() {
421 let data = vec![make_data("application/octet-stream", "xyz")];
422 let docs = classify_documents(&data);
423 assert!(docs.is_empty());
424 }
425
426 #[test]
427 fn source_code_extensions_are_not_classified() {
428 for ext in &["py", "rs", "js", "ts", "c", "cpp", "go", "java", "rb", "sh"] {
429 let data = vec![make_data("text/plain", ext)];
430 let docs = classify_documents(&data);
431 assert!(docs.is_empty(), "extension .{ext} should not be classified");
432 }
433 }
434
435 #[test]
438 fn mixed_input_filters_correctly() {
439 let data = vec![
440 make_data("text/plain", "txt"),
441 make_data("application/octet-stream", "xyz"),
442 make_data("application/pdf", "pdf"),
443 make_data("image/png", "png"),
444 make_data("audio/mp3", "mp3"),
445 ];
446 let docs = classify_documents(&data);
447 assert_eq!(docs.len(), 4);
448 assert_eq!(docs[0].document_type, "text");
449 assert_eq!(docs[1].document_type, "pdf");
450 assert_eq!(docs[2].document_type, "image");
451 assert_eq!(docs[3].document_type, "audio");
452 }
453
454 #[test]
457 fn classifies_dlt_sourced_data() {
458 let data = vec![make_data_with_metadata(
459 "text/plain",
460 "txt",
461 r#"{"source": "dlt"}"#,
462 )];
463 let docs = classify_documents(&data);
464 assert_eq!(docs.len(), 1);
465 assert_eq!(docs[0].document_type, "dlt_row");
466 assert_eq!(docs[0].base.data_type, "DltRowDocument");
467 }
468
469 #[test]
470 fn dlt_detection_with_unknown_extension() {
471 let data = vec![make_data_with_metadata(
473 "application/octet-stream",
474 "xyz",
475 r#"{"source": "dlt"}"#,
476 )];
477 let docs = classify_documents(&data);
478 assert_eq!(docs.len(), 1);
479 assert_eq!(docs[0].document_type, "dlt_row");
480 }
481
482 #[test]
483 fn dlt_detection_survives_url_metadata_merge_conflict() {
484 let data = vec![make_data_with_metadata(
485 "text/plain",
486 "txt",
487 r#"{"source":"url","url":"https://example.test","data_item_external_metadata":{"source":"dlt","table":"events"}}"#,
488 )];
489 let docs = classify_documents(&data);
490 assert_eq!(docs.len(), 1);
491 assert_eq!(docs[0].document_type, "dlt_row");
492 assert_eq!(docs[0].base.data_type, "DltRowDocument");
493 }
494
495 #[test]
496 fn non_dlt_metadata_does_not_affect_classification() {
497 let data = vec![make_data_with_metadata(
498 "text/plain",
499 "txt",
500 r#"{"source": "other"}"#,
501 )];
502 let docs = classify_documents(&data);
503 assert_eq!(docs.len(), 1);
504 assert_eq!(docs[0].document_type, "text");
505 }
506
507 #[test]
510 fn formats_external_metadata_as_pretty_json() {
511 let data = vec![make_data_with_metadata(
512 "text/plain",
513 "txt",
514 r#"{"key":"value","nested":{"a":1}}"#,
515 )];
516 let docs = classify_documents(&data);
517 assert_eq!(docs.len(), 1);
518 let meta = docs[0].external_metadata.as_ref().unwrap();
519 assert!(meta.contains('\n'));
521 assert!(meta.contains(" "));
522 }
523
524 #[test]
525 fn invalid_json_metadata_passed_through_as_is() {
526 let data = vec![make_data_with_metadata("text/plain", "txt", "not-json")];
527 let docs = classify_documents(&data);
528 assert_eq!(docs.len(), 1);
529 assert_eq!(docs[0].external_metadata.as_ref().unwrap(), "not-json");
531 }
532
533 #[test]
536 fn document_has_index_fields_metadata() {
537 let data = vec![make_data("text/plain", "txt")];
538 let docs = classify_documents(&data);
539 assert_eq!(
540 docs[0].base.get_metadata("index_fields"),
541 Some(&json!(["name"]))
542 );
543 }
544
545 #[test]
546 fn document_id_matches_data_id() {
547 let data = vec![make_data("text/plain", "txt")];
548 let docs = classify_documents(&data);
549 assert_eq!(docs[0].base.id, data[0].id);
550 assert_eq!(docs[0].data_id, data[0].id);
551 }
552
553 #[test]
556 fn empty_input() {
557 let docs = classify_documents(&[]);
558 assert!(docs.is_empty());
559 }
560
561 #[test]
564 fn extension_matching_is_case_insensitive() {
565 assert_eq!(extension_to_doc_type("PDF"), Some("pdf"));
566 assert_eq!(extension_to_doc_type("Txt"), Some("text"));
567 assert_eq!(extension_to_doc_type("PNG"), Some("image"));
568 assert_eq!(extension_to_doc_type("MP3"), Some("audio"));
569 }
570
571 #[test]
574 fn node_set_populates_belongs_to_set_and_source_node_set() {
575 let data = vec![make_data_with_metadata(
576 "text/plain",
577 "txt",
578 r#"{"node_set": ["setA", "setB"]}"#,
579 )];
580 let docs = classify_documents(&data);
581 assert_eq!(docs.len(), 1);
582
583 let bts = docs[0].base.belongs_to_set.as_ref().unwrap();
585 assert_eq!(bts.len(), 2);
586
587 assert_eq!(bts[0]["name"], "setA");
589 assert_eq!(bts[0]["type"], "NodeSet");
590 assert_eq!(bts[1]["name"], "setB");
591 assert_eq!(bts[1]["type"], "NodeSet");
592
593 let key_a = "nodeset:seta"; let expected_id_a =
596 uuid::Uuid::new_v5(&uuid::Uuid::NAMESPACE_OID, key_a.as_bytes()).to_string();
597 assert_eq!(bts[0]["id"], expected_id_a);
598
599 assert_eq!(docs[0].base.source_node_set.as_ref().unwrap(), "setA, setB");
601 }
602
603 #[test]
604 fn node_set_single_entry() {
605 let data = vec![make_data_with_metadata(
606 "text/plain",
607 "txt",
608 r#"{"node_set": ["only_one"]}"#,
609 )];
610 let docs = classify_documents(&data);
611 assert_eq!(docs.len(), 1);
612
613 let bts = docs[0].base.belongs_to_set.as_ref().unwrap();
614 assert_eq!(bts.len(), 1);
615 assert_eq!(bts[0]["name"], "only_one");
616
617 assert_eq!(docs[0].base.source_node_set.as_ref().unwrap(), "only_one");
618 }
619
620 #[test]
621 fn no_node_set_key_leaves_belongs_to_set_unset() {
622 let data = vec![make_data_with_metadata(
623 "text/plain",
624 "txt",
625 r#"{"other_key": "value"}"#,
626 )];
627 let docs = classify_documents(&data);
628 assert_eq!(docs.len(), 1);
629 assert!(docs[0].base.belongs_to_set.is_none());
630 assert!(docs[0].base.source_node_set.is_none());
631 }
632
633 #[test]
634 fn node_set_not_array_leaves_belongs_to_set_unset() {
635 let data = vec![make_data_with_metadata(
636 "text/plain",
637 "txt",
638 r#"{"node_set": "not_an_array"}"#,
639 )];
640 let docs = classify_documents(&data);
641 assert_eq!(docs.len(), 1);
642 assert!(docs[0].base.belongs_to_set.is_none());
643 assert!(docs[0].base.source_node_set.is_none());
644 }
645
646 #[test]
647 fn node_set_empty_array_leaves_belongs_to_set_unset() {
648 let data = vec![make_data_with_metadata(
649 "text/plain",
650 "txt",
651 r#"{"node_set": []}"#,
652 )];
653 let docs = classify_documents(&data);
654 assert_eq!(docs.len(), 1);
655 assert!(docs[0].base.belongs_to_set.is_none());
657 assert!(docs[0].base.source_node_set.is_none());
658 }
659
660 #[test]
661 fn node_set_with_no_metadata_leaves_belongs_to_set_unset() {
662 let data = vec![make_data("text/plain", "txt")];
663 let docs = classify_documents(&data);
664 assert_eq!(docs.len(), 1);
665 assert!(docs[0].base.belongs_to_set.is_none());
666 assert!(docs[0].base.source_node_set.is_none());
667 }
668
669 #[test]
670 fn document_implements_has_datapoint() {
671 let data = vec![make_data("text/plain", "txt")];
672 let docs = classify_documents(&data);
673 assert_eq!(docs.len(), 1);
674 let dp_id = docs[0].base.id;
675 assert_eq!(docs[0].data_point().id, dp_id);
676 let mut doc = docs[0].clone();
677 assert_eq!(doc.data_point_mut().id, dp_id);
678 }
679}