1use chrono::{DateTime, Utc};
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use std::ops::Range;
35use std::path::PathBuf;
36use uuid::Uuid;
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct FileRecord {
45 pub id: Uuid,
47 pub path: PathBuf,
49 pub size_bytes: u64,
51 pub mime_type: String,
53 pub content_hash: String,
55 pub modified_at: DateTime<Utc>,
57 pub indexed_at: Option<DateTime<Utc>>,
59 pub chunk_count: u32,
61 pub status: FileStatus,
63 pub error_message: Option<String>,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "lowercase")]
70pub enum FileStatus {
71 Pending,
73 Indexing,
75 Indexed,
77 Error,
79 Deleted,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct Chunk {
90 pub id: Uuid,
92 pub file_id: Uuid,
94 pub file_path: PathBuf,
96 pub content: String,
98 pub content_type: ContentType,
100 pub mime_type: Option<String>,
102 pub chunk_index: u32,
104 pub byte_range: Range<u64>,
106 pub line_range: Option<Range<u32>>,
108 pub parent_chunk_id: Option<Uuid>,
110 pub depth: u8,
112 pub embedding: Option<Vec<f32>>,
114 pub dir_path: String,
116 pub dir_depth: u16,
118 pub path_components: String,
120 pub metadata: ChunkMetadata,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
129pub struct DirectoryScope {
130 pub dir_path: String,
132 pub dir_depth: u16,
134 pub path_components: String,
136}
137
138impl DirectoryScope {
139 #[must_use]
141 pub fn from_paths(file_path: &std::path::Path, root: Option<&std::path::Path>) -> Self {
142 let rel = root
143 .and_then(|r| file_path.strip_prefix(r).ok())
144 .unwrap_or(file_path);
145 let parts: Vec<String> = rel
146 .components()
147 .filter_map(|c| match c {
148 std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
149 _ => None,
150 })
151 .collect();
152
153 let dir_parts = parts.get(..parts.len().saturating_sub(1)).unwrap_or(&[]);
154 let dir_path = dir_parts.join("/");
155 let dir_depth = dir_parts.len() as u16;
156 let path_components = parts.join(",");
157
158 Self {
159 dir_path,
160 dir_depth,
161 path_components,
162 }
163 }
164
165 #[must_use]
167 pub fn from_file_path(file_path: &std::path::Path) -> Self {
168 Self::from_paths(file_path, None)
169 }
170
171 #[must_use]
173 pub fn normalize_prefix(scope: &str) -> String {
174 scope.replace('\\', "/").trim_matches('/').to_string()
175 }
176
177 #[must_use]
179 pub fn matches_prefix(&self, scope: &str) -> bool {
180 dir_path_matches_scope(&self.dir_path, scope)
181 }
182
183 #[must_use]
185 pub fn infer_root(file_path: &std::path::Path, dir_path: &str) -> Option<std::path::PathBuf> {
186 let parent = file_path.parent()?;
187 let dir = Self::normalize_prefix(dir_path);
188 if dir.is_empty() {
189 return Some(parent.to_path_buf());
190 }
191 let parent_norm = parent.to_string_lossy().replace('\\', "/");
192 parent_norm
193 .strip_suffix(&dir)
194 .map(|root| std::path::PathBuf::from(root.trim_end_matches('/')))
195 }
196}
197
198#[must_use]
207pub fn dir_path_matches_scope(dir_path: &str, scope: &str) -> bool {
208 let scope = DirectoryScope::normalize_prefix(scope);
209 if scope.is_empty() || scope == "." {
210 return true;
211 }
212 let dir = DirectoryScope::normalize_prefix(dir_path);
213 dir == scope
214 || dir.starts_with(&format!("{scope}/"))
215 || dir.ends_with(&format!("/{scope}"))
216 || dir.contains(&format!("/{scope}/"))
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize)]
221#[serde(tag = "type", rename_all = "snake_case")]
222pub enum ContentType {
223 Text,
225 Code {
227 language: String,
229 symbol: Option<CodeSymbol>,
231 },
232 ImageCaption,
234 PdfPage {
236 page_num: u32,
238 },
239 Markdown,
241}
242
243#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct CodeSymbol {
246 pub kind: SymbolKind,
248 pub name: String,
250}
251
252#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum SymbolKind {
256 Function,
257 Method,
258 Class,
259 Struct,
260 Enum,
261 Module,
262 Constant,
263 Variable,
264 Interface,
265 Trait,
266}
267
268#[derive(Debug, Clone, Default, Serialize, Deserialize)]
270pub struct ChunkMetadata {
271 pub embedding_model: Option<String>,
273 pub indexed_at: Option<DateTime<Utc>>,
275 pub token_count: Option<usize>,
277 #[serde(flatten)]
279 pub extra: HashMap<String, String>,
280}
281
282#[derive(Debug, Clone)]
288pub struct ExtractedContent {
289 pub text: String,
291 pub elements: Vec<ContentElement>,
293 pub images: Vec<ExtractedImage>,
295 pub metadata: ContentMetadataInfo,
297}
298
299#[derive(Debug, Clone)]
301pub enum ContentElement {
302 Heading {
303 level: u8,
304 text: String,
305 byte_offset: u64,
306 },
307 Paragraph {
308 text: String,
309 byte_offset: u64,
310 },
311 CodeBlock {
312 language: Option<String>,
313 code: String,
314 byte_offset: u64,
315 },
316 List {
317 items: Vec<String>,
318 ordered: bool,
319 byte_offset: u64,
320 },
321 Table {
322 headers: Vec<String>,
323 rows: Vec<Vec<String>>,
324 byte_offset: u64,
325 },
326}
327
328#[derive(Debug, Clone)]
330pub struct ExtractedImage {
331 pub data: Vec<u8>,
333 pub mime_type: String,
335 pub caption: Option<String>,
337 pub page: Option<u32>,
339}
340
341#[derive(Debug, Clone, Default)]
343pub struct ContentMetadataInfo {
344 pub title: Option<String>,
346 pub author: Option<String>,
348 pub language: Option<String>,
350 pub page_count: Option<u32>,
352 pub created_at: Option<DateTime<Utc>>,
354}
355
356#[derive(Debug, Clone, Serialize, Deserialize)]
362pub struct ChunkConfig {
363 pub target_size: usize,
365 pub max_size: usize,
367 pub overlap: usize,
369 pub hierarchical: bool,
371 pub max_depth: u8,
373}
374
375impl Default for ChunkConfig {
376 fn default() -> Self {
377 Self {
378 target_size: 512,
379 max_size: 1024,
380 overlap: 64,
381 hierarchical: true,
382 max_depth: 2,
383 }
384 }
385}
386
387#[derive(Debug, Clone)]
389pub struct ChunkOutput {
390 pub content: String,
392 pub byte_range: Range<u64>,
394 pub line_range: Option<Range<u32>>,
396 pub parent_index: Option<usize>,
398 pub depth: u8,
400 pub metadata: ChunkOutputMetadata,
402}
403
404#[derive(Debug, Clone, Default)]
406pub struct ChunkOutputMetadata {
407 pub symbol_type: Option<String>,
409 pub symbol_name: Option<String>,
411 pub language: Option<String>,
413}
414
415#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
421#[serde(rename_all = "lowercase")]
422pub enum Modality {
423 Text,
424 Image,
425 Audio,
426}
427
428#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct EmbeddingConfig {
431 pub normalize: bool,
433 pub instruction: Option<String>,
435 pub batch_size: usize,
437}
438
439impl Default for EmbeddingConfig {
440 fn default() -> Self {
441 Self {
442 normalize: true,
443 instruction: None,
444 batch_size: 32,
445 }
446 }
447}
448
449#[derive(Debug, Clone)]
451pub struct EmbeddingOutput {
452 pub embedding: Vec<f32>,
454 pub token_count: usize,
456}
457
458#[derive(Debug, Clone)]
464pub struct SearchQuery {
465 pub embedding: Vec<f32>,
467 pub text: Option<String>,
469 pub limit: usize,
471 pub filters: Vec<SearchFilter>,
473 pub metric: DistanceMetric,
475 pub scope_prefix: Option<String>,
480}
481
482#[derive(Debug, Clone)]
484pub enum SearchFilter {
485 PathPrefix(String),
487 PathGlob(String),
489 MimeType(String),
491 Language(String),
493 ModifiedAfter(DateTime<Utc>),
495 ModifiedBefore(DateTime<Utc>),
497 MinDepth(u8),
499 MaxDepth(u8),
501}
502
503#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
505#[serde(rename_all = "lowercase")]
506pub enum DistanceMetric {
507 #[default]
508 Cosine,
509 L2,
510 Dot,
511}
512
513#[derive(Debug, Clone, Serialize, Deserialize)]
515pub struct SearchResult {
516 pub chunk_id: Uuid,
518 pub file_path: PathBuf,
520 pub content: String,
522 pub score: f32,
524 pub byte_range: Range<u64>,
526 pub line_range: Option<Range<u32>>,
528 pub metadata: HashMap<String, String>,
530}
531
532#[derive(Debug, Clone, Serialize, Deserialize)]
534pub struct StoreStats {
535 pub total_chunks: u64,
537 pub total_files: u64,
539 pub index_size_bytes: u64,
541 pub last_updated: Option<DateTime<Utc>>,
543}
544
545#[derive(Debug, Clone, Default, Serialize, Deserialize)]
551pub struct IndexStats {
552 pub total_files: u64,
554 pub indexed_files: u64,
556 pub pending_files: u64,
558 pub error_files: u64,
560 pub total_chunks: u64,
562 pub last_update: Option<DateTime<Utc>>,
564}
565
566#[derive(Debug, Clone)]
572pub enum FileEvent {
573 Created(PathBuf),
574 Modified(PathBuf),
575 Deleted(PathBuf),
576 Renamed { from: PathBuf, to: PathBuf },
577}
578
579#[cfg(test)]
580mod tests {
581 use super::*;
582
583 #[test]
586 fn test_file_record_serialization() {
587 let record = FileRecord {
588 id: Uuid::new_v4(),
589 path: PathBuf::from("/test/file.txt"),
590 size_bytes: 1024,
591 mime_type: "text/plain".to_string(),
592 content_hash: "abc123".to_string(),
593 modified_at: Utc::now(),
594 indexed_at: Some(Utc::now()),
595 chunk_count: 5,
596 status: FileStatus::Indexed,
597 error_message: None,
598 };
599
600 let json = serde_json::to_string(&record).unwrap();
601 let deserialized: FileRecord = serde_json::from_str(&json).unwrap();
602
603 assert_eq!(record.id, deserialized.id);
604 assert_eq!(record.path, deserialized.path);
605 assert_eq!(record.size_bytes, deserialized.size_bytes);
606 assert_eq!(record.status, deserialized.status);
607 }
608
609 #[test]
610 fn test_file_status_serialization() {
611 assert_eq!(
612 serde_json::to_string(&FileStatus::Pending).unwrap(),
613 "\"pending\""
614 );
615 assert_eq!(
616 serde_json::to_string(&FileStatus::Indexed).unwrap(),
617 "\"indexed\""
618 );
619 assert_eq!(
620 serde_json::to_string(&FileStatus::Error).unwrap(),
621 "\"error\""
622 );
623 }
624
625 #[test]
626 fn test_file_status_equality() {
627 assert_eq!(FileStatus::Pending, FileStatus::Pending);
628 assert_ne!(FileStatus::Pending, FileStatus::Indexed);
629 }
630
631 #[test]
634 fn test_chunk_serialization() {
635 let chunk = Chunk {
636 id: Uuid::new_v4(),
637 file_id: Uuid::new_v4(),
638 file_path: PathBuf::from("/test/file.rs"),
639 content: "fn main() {}".to_string(),
640 content_type: ContentType::Code {
641 language: "rust".to_string(),
642 symbol: Some(CodeSymbol {
643 kind: SymbolKind::Function,
644 name: "main".to_string(),
645 }),
646 },
647 mime_type: Some("text/x-rust".to_string()),
648 chunk_index: 0,
649 byte_range: 0..12,
650 line_range: Some(0..1),
651 parent_chunk_id: None,
652 depth: 0,
653 embedding: None,
654 dir_path: "test".to_string(),
655 dir_depth: 1,
656 path_components: "test,file.rs".to_string(),
657 metadata: ChunkMetadata::default(),
658 };
659
660 let json = serde_json::to_string(&chunk).unwrap();
661 let deserialized: Chunk = serde_json::from_str(&json).unwrap();
662
663 assert_eq!(chunk.id, deserialized.id);
664 assert_eq!(chunk.content, deserialized.content);
665 }
666
667 #[test]
668 fn test_content_type_text() {
669 let ct = ContentType::Text;
670 let json = serde_json::to_string(&ct).unwrap();
671 assert!(json.contains("\"type\":\"text\""));
672 }
673
674 #[test]
675 fn test_content_type_code() {
676 let ct = ContentType::Code {
677 language: "python".to_string(),
678 symbol: None,
679 };
680 let json = serde_json::to_string(&ct).unwrap();
681 assert!(json.contains("\"type\":\"code\""));
682 assert!(json.contains("\"language\":\"python\""));
683 }
684
685 #[test]
686 fn test_content_type_pdf_page() {
687 let ct = ContentType::PdfPage { page_num: 5 };
688 let json = serde_json::to_string(&ct).unwrap();
689 assert!(json.contains("\"type\":\"pdf_page\""));
690 assert!(json.contains("\"page_num\":5"));
691 }
692
693 #[test]
694 fn test_content_type_markdown() {
695 let ct = ContentType::Markdown;
696 let json = serde_json::to_string(&ct).unwrap();
697 assert!(json.contains("\"type\":\"markdown\""));
698 }
699
700 #[test]
701 fn test_symbol_kind_serialization() {
702 assert_eq!(
703 serde_json::to_string(&SymbolKind::Function).unwrap(),
704 "\"function\""
705 );
706 assert_eq!(
707 serde_json::to_string(&SymbolKind::Struct).unwrap(),
708 "\"struct\""
709 );
710 assert_eq!(
711 serde_json::to_string(&SymbolKind::Trait).unwrap(),
712 "\"trait\""
713 );
714 }
715
716 #[test]
719 fn test_chunk_config_default() {
720 let config = ChunkConfig::default();
721 assert_eq!(config.target_size, 512);
722 assert_eq!(config.max_size, 1024);
723 assert_eq!(config.overlap, 64);
724 assert!(config.hierarchical);
725 assert_eq!(config.max_depth, 2);
726 }
727
728 #[test]
729 fn test_chunk_config_serialization() {
730 let config = ChunkConfig::default();
731 let json = serde_json::to_string(&config).unwrap();
732 let deserialized: ChunkConfig = serde_json::from_str(&json).unwrap();
733
734 assert_eq!(config.target_size, deserialized.target_size);
735 assert_eq!(config.max_size, deserialized.max_size);
736 }
737
738 #[test]
741 fn test_embedding_config_default() {
742 let config = EmbeddingConfig::default();
743 assert!(config.normalize);
744 assert!(config.instruction.is_none());
745 assert_eq!(config.batch_size, 32);
746 }
747
748 #[test]
749 fn test_embedding_config_serialization() {
750 let config = EmbeddingConfig {
751 normalize: false,
752 instruction: Some("Search: ".to_string()),
753 batch_size: 16,
754 };
755 let json = serde_json::to_string(&config).unwrap();
756 let deserialized: EmbeddingConfig = serde_json::from_str(&json).unwrap();
757
758 assert_eq!(config.normalize, deserialized.normalize);
759 assert_eq!(config.instruction, deserialized.instruction);
760 assert_eq!(config.batch_size, deserialized.batch_size);
761 }
762
763 #[test]
766 fn test_modality_serialization() {
767 assert_eq!(serde_json::to_string(&Modality::Text).unwrap(), "\"text\"");
768 assert_eq!(
769 serde_json::to_string(&Modality::Image).unwrap(),
770 "\"image\""
771 );
772 assert_eq!(
773 serde_json::to_string(&Modality::Audio).unwrap(),
774 "\"audio\""
775 );
776 }
777
778 #[test]
779 fn test_modality_equality() {
780 assert_eq!(Modality::Text, Modality::Text);
781 assert_ne!(Modality::Text, Modality::Image);
782 }
783
784 #[test]
787 fn test_distance_metric_default() {
788 let metric = DistanceMetric::default();
789 assert_eq!(metric, DistanceMetric::Cosine);
790 }
791
792 #[test]
793 fn test_distance_metric_serialization() {
794 assert_eq!(
795 serde_json::to_string(&DistanceMetric::Cosine).unwrap(),
796 "\"cosine\""
797 );
798 assert_eq!(
799 serde_json::to_string(&DistanceMetric::L2).unwrap(),
800 "\"l2\""
801 );
802 assert_eq!(
803 serde_json::to_string(&DistanceMetric::Dot).unwrap(),
804 "\"dot\""
805 );
806 }
807
808 #[test]
811 fn test_search_result_serialization() {
812 let result = SearchResult {
813 chunk_id: Uuid::new_v4(),
814 file_path: PathBuf::from("/test/file.txt"),
815 content: "Test content".to_string(),
816 score: 0.95,
817 byte_range: 0..12,
818 line_range: Some(0..1),
819 metadata: HashMap::new(),
820 };
821
822 let json = serde_json::to_string(&result).unwrap();
823 let deserialized: SearchResult = serde_json::from_str(&json).unwrap();
824
825 assert_eq!(result.chunk_id, deserialized.chunk_id);
826 assert_eq!(result.score, deserialized.score);
827 assert_eq!(result.content, deserialized.content);
828 }
829
830 #[test]
833 fn test_store_stats_serialization() {
834 let stats = StoreStats {
835 total_chunks: 100,
836 total_files: 10,
837 index_size_bytes: 1024 * 1024,
838 last_updated: Some(Utc::now()),
839 };
840
841 let json = serde_json::to_string(&stats).unwrap();
842 let deserialized: StoreStats = serde_json::from_str(&json).unwrap();
843
844 assert_eq!(stats.total_chunks, deserialized.total_chunks);
845 assert_eq!(stats.total_files, deserialized.total_files);
846 }
847
848 #[test]
851 fn test_index_stats_default() {
852 let stats = IndexStats::default();
853 assert_eq!(stats.total_files, 0);
854 assert_eq!(stats.indexed_files, 0);
855 assert_eq!(stats.pending_files, 0);
856 assert_eq!(stats.error_files, 0);
857 assert_eq!(stats.total_chunks, 0);
858 assert!(stats.last_update.is_none());
859 }
860
861 #[test]
862 fn test_index_stats_serialization() {
863 let stats = IndexStats {
864 total_files: 50,
865 indexed_files: 45,
866 pending_files: 3,
867 error_files: 2,
868 total_chunks: 500,
869 last_update: Some(Utc::now()),
870 };
871
872 let json = serde_json::to_string(&stats).unwrap();
873 let deserialized: IndexStats = serde_json::from_str(&json).unwrap();
874
875 assert_eq!(stats.total_files, deserialized.total_files);
876 assert_eq!(stats.indexed_files, deserialized.indexed_files);
877 }
878
879 #[test]
882 fn test_chunk_metadata_default() {
883 let meta = ChunkMetadata::default();
884 assert!(meta.embedding_model.is_none());
885 assert!(meta.indexed_at.is_none());
886 assert!(meta.token_count.is_none());
887 assert!(meta.extra.is_empty());
888 }
889
890 #[test]
893 fn test_chunk_output_metadata_default() {
894 let meta = ChunkOutputMetadata::default();
895 assert!(meta.symbol_type.is_none());
896 assert!(meta.symbol_name.is_none());
897 assert!(meta.language.is_none());
898 }
899
900 #[test]
903 fn test_content_metadata_info_default() {
904 let meta = ContentMetadataInfo::default();
905 assert!(meta.title.is_none());
906 assert!(meta.author.is_none());
907 assert!(meta.language.is_none());
908 assert!(meta.page_count.is_none());
909 assert!(meta.created_at.is_none());
910 }
911
912 #[test]
915 fn test_directory_scope_is_relative_to_root() {
916 let root = PathBuf::from("/project");
917 let file = PathBuf::from("/project/src/auth/login.rs");
918 let scope = DirectoryScope::from_paths(&file, Some(&root));
919
920 assert_eq!(scope.dir_path, "src/auth");
921 assert_eq!(scope.dir_depth, 2);
922 assert_eq!(scope.path_components, "src,auth,login.rs");
923 }
924
925 #[test]
926 fn test_directory_scope_index_root_file() {
927 let root = PathBuf::from("/project");
928 let file = PathBuf::from("/project/README.md");
929 let scope = DirectoryScope::from_paths(&file, Some(&root));
930
931 assert_eq!(scope.dir_path, "");
932 assert_eq!(scope.dir_depth, 0);
933 assert_eq!(scope.path_components, "README.md");
934 }
935
936 #[test]
937 fn test_directory_scope_without_root_strips_only_prefix_components() {
938 let file = PathBuf::from("/test/file.rs");
939 let scope = DirectoryScope::from_file_path(&file);
940 assert_eq!(scope.dir_path, "test");
941 assert_eq!(scope.path_components, "test,file.rs");
942 }
943
944 #[test]
945 fn test_dir_path_matches_scope_exact_and_subdirectory() {
946 assert!(dir_path_matches_scope("src/auth", "src/auth"));
947 assert!(dir_path_matches_scope("src/auth/oauth", "src/auth"));
948 assert!(dir_path_matches_scope("src/auth", "src/auth/"));
949 assert!(!dir_path_matches_scope("src/other", "src/auth"));
950 assert!(!dir_path_matches_scope("src/auth-backup", "src/auth"));
951 assert!(dir_path_matches_scope("src", ""));
952 assert!(dir_path_matches_scope("src", "."));
953 }
954
955 #[test]
956 fn test_dir_path_matches_scope_migrated_absolute_suffix() {
957 assert!(dir_path_matches_scope("project/src/auth", "src/auth"));
959 assert!(dir_path_matches_scope("project/src/auth/oauth", "src/auth"));
960 assert!(dir_path_matches_scope("project/src", "src"));
961 assert!(dir_path_matches_scope("project/src/auth", "src"));
962 assert!(!dir_path_matches_scope("project/src-backup", "src"));
963 assert!(!dir_path_matches_scope("project/other", "src/auth"));
964 assert!(!dir_path_matches_scope("project/author/notes", "auth"));
965 }
966
967 #[test]
970 fn test_file_event_created() {
971 let event = FileEvent::Created(PathBuf::from("/test/new.txt"));
972 match event {
973 FileEvent::Created(path) => assert_eq!(path, PathBuf::from("/test/new.txt")),
974 _ => panic!("Expected Created event"),
975 }
976 }
977
978 #[test]
979 fn test_file_event_modified() {
980 let event = FileEvent::Modified(PathBuf::from("/test/changed.txt"));
981 match event {
982 FileEvent::Modified(path) => assert_eq!(path, PathBuf::from("/test/changed.txt")),
983 _ => panic!("Expected Modified event"),
984 }
985 }
986
987 #[test]
988 fn test_file_event_deleted() {
989 let event = FileEvent::Deleted(PathBuf::from("/test/removed.txt"));
990 match event {
991 FileEvent::Deleted(path) => assert_eq!(path, PathBuf::from("/test/removed.txt")),
992 _ => panic!("Expected Deleted event"),
993 }
994 }
995
996 #[test]
997 fn test_file_event_renamed() {
998 let event = FileEvent::Renamed {
999 from: PathBuf::from("/test/old.txt"),
1000 to: PathBuf::from("/test/new.txt"),
1001 };
1002 match event {
1003 FileEvent::Renamed { from, to } => {
1004 assert_eq!(from, PathBuf::from("/test/old.txt"));
1005 assert_eq!(to, PathBuf::from("/test/new.txt"));
1006 }
1007 _ => panic!("Expected Renamed event"),
1008 }
1009 }
1010}