1#![cfg_attr(test, allow(clippy::disallowed_methods))]
29
30mod discover;
31mod eval_cmd;
32mod extract;
33mod incremental;
34mod ingest;
35mod query;
36mod transcribe;
37
38use anyhow::Result;
39use aprender_rag::loader::LoaderRegistry;
40use clap::{Parser, Subcommand, ValueEnum};
41use serde::{Deserialize, Serialize};
42
43#[derive(Debug, Clone, Copy, Default, ValueEnum)]
45pub enum EmbedderType {
46 #[default]
48 Tfidf,
49 Semantic,
51}
52
53#[derive(Debug, Clone, Copy, Default, ValueEnum)]
55pub enum SemanticModel {
56 #[default]
58 MiniLm,
59 BgeSmall,
61 BgeBase,
63}
64
65#[derive(Debug, Clone, Copy, Default, ValueEnum)]
67pub enum ChunkStrategy {
68 #[default]
70 Auto,
71 Recursive,
73 Timestamp,
75}
76
77#[derive(Debug, Clone, Copy, Default, ValueEnum)]
79pub enum BackendType {
80 #[default]
82 Cpu,
83 Gpu,
85 Cuda,
87}
88
89#[derive(Parser)]
90#[command(name = "trueno-rag")]
91#[command(author = "Pragmatic AI Labs")]
92#[command(version)]
93#[command(about = "Pure-Rust RAG pipeline CLI", long_about = None)]
94pub struct Cli {
95 #[command(subcommand)]
97 pub command: Commands,
98}
99
100#[derive(Subcommand, Clone, Debug)]
105pub enum Commands {
106 Demo {
108 #[arg(long, default_value = "What is machine learning?")]
113 query: String,
114
115 #[arg(short, long, default_value = "3")]
117 top_k: usize,
118 },
119
120 Index {
122 #[arg(short, long)]
124 path: String,
125
126 #[arg(short, long)]
128 output: String,
129
130 #[arg(long, default_value = "512")]
132 chunk_size: usize,
133
134 #[arg(long, default_value = "64")]
136 chunk_overlap: usize,
137
138 #[arg(long, default_value = "256")]
140 dimension: usize,
141
142 #[arg(short, long, value_enum, default_value = "tfidf")]
144 embedder: EmbedderType,
145
146 #[arg(short, long, value_enum, default_value = "mini-lm")]
148 model: SemanticModel,
149
150 #[arg(short, long, default_value = "false")]
152 recursive: bool,
153
154 #[arg(long, value_enum, default_value = "auto")]
156 chunk_strategy: ChunkStrategy,
157
158 #[arg(short, long, default_value = "1")]
160 jobs: usize,
161
162 #[arg(long, default_value = "false")]
164 manifest: bool,
165
166 #[arg(long)]
168 exclude: Vec<String>,
169
170 #[arg(long, default_value = "false")]
172 dedup: bool,
173
174 #[arg(long, default_value = "false")]
176 sqlite: bool,
177
178 #[arg(long, default_value = "false")]
180 incremental: bool,
181 },
182
183 Query {
185 query: String,
187
188 #[arg(short, long)]
190 index: String,
191
192 #[arg(short, long, default_value = "5")]
194 top_k: usize,
195
196 #[arg(short, long, default_value = "text")]
198 format: String,
199
200 #[arg(long, default_value = "hybrid")]
202 mode: String,
203
204 #[arg(long, default_value = "rrf")]
206 fusion: String,
207
208 #[arg(long)]
210 fusion_k: Option<f32>,
211
212 #[arg(long, default_value = "50")]
214 candidates: usize,
215
216 #[arg(long, default_value = "none")]
218 rerank: String,
219
220 #[arg(long, default_value = "false")]
224 hyde: bool,
225 },
226
227 Transcribe {
229 #[arg(short, long)]
231 path: String,
232
233 #[arg(short, long, default_value = "false")]
235 recursive: bool,
236
237 #[arg(long, default_value = "true")]
239 skip_existing: bool,
240
241 #[arg(short, long, default_value = "1")]
243 jobs: usize,
244
245 #[arg(short, long)]
247 model: Option<String>,
248
249 #[arg(short, long, value_enum, default_value = "cpu")]
251 backend: BackendType,
252
253 #[arg(long, default_value = "false")]
255 dry_run: bool,
256
257 #[arg(long)]
259 prompt: Option<String>,
260
261 #[arg(long)]
263 hotwords: Option<String>,
264
265 #[arg(long)]
267 exclude: Vec<String>,
268 },
269
270 ExtractFrames {
272 #[arg(short, long)]
274 path: String,
275
276 #[arg(short, long, default_value = "false")]
278 recursive: bool,
279
280 #[arg(long, default_value = "0.3")]
282 threshold: f64,
283
284 #[arg(long, default_value = "5.0")]
286 min_interval: f64,
287
288 #[arg(short, long, default_value = "4")]
290 jobs: usize,
291
292 #[arg(long, default_value = "true")]
294 skip_existing: bool,
295
296 #[arg(long, default_value = "false")]
298 dry_run: bool,
299
300 #[arg(long)]
302 exclude: Vec<String>,
303 },
304
305 Info,
307
308 #[cfg(feature = "eval")]
310 Eval {
311 #[command(subcommand)]
312 action: EvalAction,
313 },
314}
315
316#[cfg(feature = "eval")]
318#[derive(Subcommand)]
319enum EvalAction {
320 Sample {
322 #[arg(short, long)]
324 index: String,
325
326 #[arg(short, long)]
328 output: String,
329
330 #[arg(long, default_value = "250")]
332 sample_size: usize,
333
334 #[arg(long, default_value = "42")]
336 seed: u64,
337 },
338
339 Generate {
341 #[arg(short, long)]
343 index: String,
344
345 #[arg(short, long)]
347 output: String,
348
349 #[arg(long, default_value = "250")]
351 sample_size: usize,
352
353 #[arg(long, default_value = "42")]
355 seed: u64,
356
357 #[arg(long, default_value = "claude-sonnet-4-20250514")]
359 model: String,
360
361 #[arg(long, default_value = "false")]
363 dry_run: bool,
364 },
365
366 Retrieve {
368 #[arg(short, long)]
370 index: String,
371
372 #[arg(short, long)]
374 ground_truth: String,
375
376 #[arg(short, long)]
378 output: String,
379
380 #[arg(long, default_value = "10")]
382 top_k: usize,
383
384 #[arg(long, default_value = "dense")]
386 mode: String,
387
388 #[arg(long, default_value = "rrf")]
390 fusion: String,
391
392 #[arg(long)]
394 fusion_k: Option<f32>,
395
396 #[arg(long, default_value = "50")]
398 candidates: usize,
399
400 #[arg(long, default_value = "none")]
402 rerank: String,
403
404 #[arg(long, default_value = "false")]
408 hyde: bool,
409 },
410
411 Judge {
413 #[arg(short, long)]
415 retrieval_results: String,
416
417 #[arg(short, long)]
419 ground_truth: String,
420
421 #[arg(short, long)]
423 output: String,
424
425 #[arg(long, default_value = "judge-cache.json")]
427 cache: String,
428
429 #[arg(long, default_value = "10")]
431 top_k: usize,
432
433 #[arg(long, default_value = "claude-sonnet-4-20250514")]
435 model: String,
436 },
437
438 Metrics {
440 #[arg(short, long)]
442 retrieval_results: String,
443
444 #[arg(short, long)]
446 judgments: String,
447
448 #[arg(short, long)]
450 output: String,
451 },
452
453 Compare {
455 #[arg(long)]
457 baseline: String,
458
459 #[arg(long)]
461 candidate: String,
462 },
463
464 Gate {
466 #[arg(long)]
468 results: String,
469
470 #[arg(long, default_value = "0.50")]
472 min_mrr: f64,
473
474 #[arg(long, default_value = "0.70")]
476 min_hit5: f64,
477 },
478}
479
480#[derive(Serialize, Deserialize)]
482struct PersistedIndex {
483 chunks: Vec<PersistedChunk>,
484 embeddings: Vec<Vec<f32>>,
485 dimension: usize,
486 #[serde(default)]
488 embedder_type: String,
489 #[serde(default)]
491 model_name: Option<String>,
492}
493
494#[derive(Serialize, Deserialize)]
496struct PersistedChunk {
497 content: String,
498 title: Option<String>,
499 source: Option<String>,
500 #[serde(default, skip_serializing_if = "Option::is_none")]
502 start_secs: Option<f64>,
503 #[serde(default, skip_serializing_if = "Option::is_none")]
505 end_secs: Option<f64>,
506}
507
508pub fn run() -> Result<()> {
514 dispatch(Cli::parse().command)
515}
516
517#[allow(clippy::too_many_lines)]
525pub fn dispatch(command: Commands) -> Result<()> {
526 match command {
527 Commands::Demo { query, top_k } => query::run_demo(&query, top_k)?,
528 Commands::Index {
529 path,
530 output,
531 chunk_size,
532 chunk_overlap,
533 dimension,
534 embedder,
535 model,
536 recursive,
537 chunk_strategy,
538 jobs,
539 manifest,
540 exclude,
541 dedup,
542 sqlite,
543 incremental,
544 } => {
545 if incremental {
546 incremental::run_index_incremental(
547 &path,
548 &output,
549 chunk_size,
550 chunk_overlap,
551 dimension,
552 embedder,
553 model,
554 recursive,
555 chunk_strategy,
556 jobs,
557 &exclude,
558 dedup,
559 )?
560 } else {
561 ingest::run_index(
562 &path,
563 &output,
564 chunk_size,
565 chunk_overlap,
566 dimension,
567 embedder,
568 model,
569 recursive,
570 chunk_strategy,
571 jobs,
572 manifest,
573 &exclude,
574 dedup,
575 sqlite,
576 )?
577 }
578 }
579 Commands::Query {
580 query,
581 index,
582 top_k,
583 format,
584 mode,
585 fusion,
586 fusion_k,
587 candidates,
588 rerank,
589 hyde,
590 } => query::run_query(
591 &query, &index, top_k, &format, &mode, &fusion, fusion_k, candidates, &rerank, hyde,
592 )?,
593 Commands::Transcribe {
594 path,
595 recursive,
596 skip_existing,
597 jobs,
598 model,
599 backend,
600 dry_run,
601 prompt,
602 hotwords,
603 exclude,
604 } => transcribe::run_transcribe(
605 &path,
606 recursive,
607 skip_existing,
608 jobs,
609 model.as_deref(),
610 backend,
611 dry_run,
612 prompt.as_deref(),
613 hotwords.as_deref(),
614 &exclude,
615 )?,
616 Commands::ExtractFrames {
617 path,
618 recursive,
619 threshold,
620 min_interval,
621 jobs,
622 skip_existing,
623 dry_run,
624 exclude,
625 } => extract::run_extract_frames(
626 &path,
627 recursive,
628 threshold,
629 min_interval,
630 jobs,
631 skip_existing,
632 dry_run,
633 &exclude,
634 )?,
635 Commands::Info => run_info(),
636 #[cfg(feature = "eval")]
637 Commands::Eval { action } => eval_cmd::run_eval(action)?,
638 }
639
640 Ok(())
641}
642
643fn run_info() {
644 println!("Trueno-RAG Pipeline");
645 println!("==================");
646 println!("Version: {}", env!("CARGO_PKG_VERSION"));
647 println!();
648 println!("Components:");
649 println!(
650 " - Chunkers: Recursive, Fixed, Sentence, Paragraph, Semantic, Structural, Timestamp"
651 );
652 #[cfg(feature = "embeddings")]
653 println!(" - Embedders: TF-IDF, FastEmbed (semantic) ✓");
654 #[cfg(not(feature = "embeddings"))]
655 println!(" - Embedders: TF-IDF (trainable), Mock (testing)");
656 println!(" - Fusion: RRF, Linear, DBSF, Convex, Union, Intersection");
657 println!(" - Rerankers: Lexical, CrossEncoder (mock), Composite");
658 println!();
659 println!("Supported formats:");
660 let registry = LoaderRegistry::new();
661 let exts: Vec<&str> = registry.supported_extensions();
662 println!(" {}", exts.join(", "));
663 println!();
664 #[cfg(feature = "embeddings")]
665 {
666 println!("Semantic Embedding Models:");
667 println!(" - mini-lm: sentence-transformers/all-MiniLM-L6-v2 (384 dims, fast)");
668 println!(" - bge-small: BAAI/bge-small-en-v1.5 (384 dims, balanced)");
669 println!(" - bge-base: BAAI/bge-base-en-v1.5 (768 dims, quality)");
670 }
671 #[cfg(not(feature = "embeddings"))]
672 {
673 println!("Note: Build with --features embeddings for semantic search");
674 }
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use aprender_rag::fusion::FusionStrategy;
681 use aprender_rag::Document;
682 use std::collections::HashMap;
683 use std::fs;
684 use std::path::{Path, PathBuf};
685
686 use crate::discover::*;
688 use crate::incremental::*;
689 use crate::ingest::*;
690 use crate::query::*;
691 use crate::transcribe::TranscribeManifest;
692
693 #[test]
694 fn test_cosine_similarity_identical() {
695 let v = vec![1.0, 2.0, 3.0];
696 assert!((cosine_similarity(&v, &v) - 1.0).abs() < 0.001);
697 }
698
699 #[test]
700 fn test_cosine_similarity_orthogonal() {
701 let a = vec![1.0, 0.0];
702 let b = vec![0.0, 1.0];
703 assert!((cosine_similarity(&a, &b)).abs() < 0.001);
704 }
705
706 #[test]
707 fn test_cosine_similarity_opposite() {
708 let a = vec![1.0, 0.0];
709 let b = vec![-1.0, 0.0];
710 assert!((cosine_similarity(&a, &b) + 1.0).abs() < 0.001);
711 }
712
713 #[test]
714 fn test_cosine_similarity_different_lengths() {
715 let a = vec![1.0, 2.0];
716 let b = vec![1.0, 2.0, 3.0];
717 assert_eq!(cosine_similarity(&a, &b), 0.0);
718 }
719
720 #[test]
721 fn test_cosine_similarity_zero_vector() {
722 let a = vec![0.0, 0.0];
723 let b = vec![1.0, 2.0];
724 assert_eq!(cosine_similarity(&a, &b), 0.0);
725 }
726
727 #[test]
728 fn test_discover_files_single_file() {
729 let dir = std::env::temp_dir().join("trueno_rag_cli_test_discover_single");
730 let _ = fs::create_dir_all(&dir);
731 let file = dir.join("test.txt");
732 fs::write(&file, "hello").unwrap();
733
734 let registry = LoaderRegistry::new();
735 let files = discover_files(&file, false, ®istry, &None).unwrap();
736 assert_eq!(files.len(), 1);
737 assert_eq!(files[0], file);
738
739 let _ = fs::remove_dir_all(&dir);
740 }
741
742 #[test]
743 fn test_discover_files_directory_non_recursive() {
744 let dir = std::env::temp_dir().join("trueno_rag_cli_test_discover_dir");
745 let sub = dir.join("sub");
746 let _ = fs::create_dir_all(&sub);
747 fs::write(dir.join("a.txt"), "a").unwrap();
748 fs::write(dir.join("b.md"), "b").unwrap();
749 fs::write(dir.join("c.mp4"), "c").unwrap(); fs::write(sub.join("d.txt"), "d").unwrap(); let registry = LoaderRegistry::new();
753 let files = discover_files(&dir, false, ®istry, &None).unwrap();
754 assert_eq!(files.len(), 2);
756
757 let _ = fs::remove_dir_all(&dir);
758 }
759
760 #[test]
761 fn test_discover_files_recursive() {
762 let dir = std::env::temp_dir().join("trueno_rag_cli_test_discover_recursive");
763 let sub = dir.join("sub");
764 let deep = sub.join("deep");
765 let _ = fs::create_dir_all(&deep);
766 fs::write(dir.join("a.txt"), "a").unwrap();
767 fs::write(sub.join("b.srt"), "1\n00:00:01,000 --> 00:00:02,000\nb\n").unwrap();
768 fs::write(deep.join("c.md"), "c").unwrap();
769
770 let registry = LoaderRegistry::new();
771 let files = discover_files(&dir, true, ®istry, &None).unwrap();
772 assert_eq!(files.len(), 3);
773
774 let _ = fs::remove_dir_all(&dir);
775 }
776
777 #[test]
778 fn test_discover_files_unsupported_single() {
779 let dir = std::env::temp_dir().join("trueno_rag_cli_test_discover_unsup");
780 let _ = fs::create_dir_all(&dir);
781 let file = dir.join("video.mp4");
782 fs::write(&file, "fake").unwrap();
783
784 let registry = LoaderRegistry::new();
785 let result = discover_files(&file, false, ®istry, &None);
786 assert!(result.is_err());
787
788 let _ = fs::remove_dir_all(&dir);
789 }
790
791 #[test]
792 fn test_classify_files() {
793 let files = vec![
794 PathBuf::from("/data/a.txt"),
795 PathBuf::from("/data/b.txt"),
796 PathBuf::from("/data/c.srt"),
797 PathBuf::from("/data/d.md"),
798 ];
799 let counts = classify_files(&files);
800 assert_eq!(counts["txt"], 2);
801 assert_eq!(counts["srt"], 1);
802 assert_eq!(counts["md"], 1);
803 }
804
805 #[test]
806 fn test_chunk_strategy_default() {
807 let strategy = ChunkStrategy::default();
809 assert!(matches!(strategy, ChunkStrategy::Auto));
810 }
811
812 #[test]
813 fn test_build_exclude_set_empty() {
814 let result = build_exclude_set(&[]).unwrap();
815 assert!(result.is_none());
816 }
817
818 #[test]
819 fn test_build_exclude_set_valid() {
820 let patterns = vec!["*/RAW".to_string(), "*/RAW/*".to_string()];
821 let result = build_exclude_set(&patterns).unwrap();
822 assert!(result.is_some());
823 }
824
825 #[test]
826 fn test_build_exclude_set_invalid() {
827 let patterns = vec!["[invalid".to_string()];
828 let result = build_exclude_set(&patterns);
829 assert!(result.is_err());
830 }
831
832 #[test]
833 fn test_is_excluded_matches() {
834 let patterns = vec!["*/RAW".to_string(), "*/RAW/*".to_string()];
835 let exclude = build_exclude_set(&patterns).unwrap();
836 assert!(is_excluded(Path::new("/data/courses/aws/RAW"), &exclude));
837 assert!(is_excluded(
838 Path::new("/data/courses/aws/RAW/video.mp4"),
839 &exclude
840 ));
841 assert!(!is_excluded(
842 Path::new("/data/courses/aws/build/video.srt"),
843 &exclude
844 ));
845 }
846
847 #[test]
848 fn test_is_excluded_none() {
849 assert!(!is_excluded(Path::new("/any/path"), &None));
850 }
851
852 #[test]
853 fn test_discover_files_with_exclude() {
854 let dir = std::env::temp_dir().join("trueno_rag_cli_test_exclude");
855 let raw = dir.join("RAW");
856 let build = dir.join("build");
857 let _ = fs::remove_dir_all(&dir);
858 let _ = fs::create_dir_all(&raw);
859 let _ = fs::create_dir_all(&build);
860 fs::write(dir.join("keep.txt"), "keep").unwrap();
861 fs::write(raw.join("skip.txt"), "skip").unwrap();
862 fs::write(build.join("also_keep.txt"), "keep2").unwrap();
863
864 let registry = LoaderRegistry::new();
865 let exclude = build_exclude_set(&["*/RAW".to_string(), "*/RAW/*".to_string()]).unwrap();
866 let files = discover_files(&dir, true, ®istry, &exclude).unwrap();
867
868 assert_eq!(files.len(), 2);
870 assert!(files.iter().all(|f| !f.to_string_lossy().contains("RAW")));
871
872 let _ = fs::remove_dir_all(&dir);
873 }
874
875 #[test]
876 #[cfg(feature = "sqlite")]
877 fn test_export_sqlite_creates_db() {
878 let dir = std::env::temp_dir().join("trueno_rag_cli_test_sqlite_export");
879 let _ = fs::remove_dir_all(&dir);
880 fs::create_dir_all(&dir).unwrap();
881
882 let persisted = PersistedIndex {
883 chunks: vec![
884 PersistedChunk {
885 content: "Rust is a systems language.".to_string(),
886 title: Some("Rust Basics".to_string()),
887 source: Some("docs/rust.txt".to_string()),
888 start_secs: None,
889 end_secs: None,
890 },
891 PersistedChunk {
892 content: "The borrow checker ensures safety.".to_string(),
893 title: Some("Rust Basics".to_string()),
894 source: Some("docs/rust.txt".to_string()),
895 start_secs: None,
896 end_secs: None,
897 },
898 PersistedChunk {
899 content: "Python is interpreted.".to_string(),
900 title: Some("Python Intro".to_string()),
901 source: Some("docs/python.txt".to_string()),
902 start_secs: None,
903 end_secs: None,
904 },
905 ],
906 embeddings: vec![vec![0.0; 4]; 3],
907 dimension: 4,
908 embedder_type: "tfidf".to_string(),
909 model_name: None,
910 };
911
912 export_sqlite(&persisted, &dir).unwrap();
913
914 let db_path = dir.join("index.sqlite");
915 assert!(db_path.exists(), "index.sqlite should be created");
916
917 let idx = aprender_rag::SqliteIndex::open(&db_path).unwrap();
919 assert_eq!(idx.document_count().unwrap(), 2, "2 unique source docs");
920 assert_eq!(idx.chunk_count().unwrap(), 3, "3 chunks total");
921
922 let results = idx.search_fts("borrow checker", 5).unwrap();
924 assert!(!results.is_empty(), "FTS5 should find 'borrow checker'");
925 assert!(
926 results[0].content.contains("borrow checker"),
927 "Top result should contain query terms"
928 );
929
930 let _ = fs::remove_dir_all(&dir);
931 }
932
933 #[test]
934 #[cfg(feature = "sqlite")]
935 fn test_export_sqlite_groups_by_source() {
936 let dir = std::env::temp_dir().join("trueno_rag_cli_test_sqlite_grouping");
937 let _ = fs::remove_dir_all(&dir);
938 fs::create_dir_all(&dir).unwrap();
939
940 let persisted = PersistedIndex {
942 chunks: vec![
943 PersistedChunk {
944 content: "chunk A1".to_string(),
945 title: Some("Doc A".to_string()),
946 source: Some("a.txt".to_string()),
947 start_secs: None,
948 end_secs: None,
949 },
950 PersistedChunk {
951 content: "chunk A2".to_string(),
952 title: Some("Doc A".to_string()),
953 source: Some("a.txt".to_string()),
954 start_secs: None,
955 end_secs: None,
956 },
957 PersistedChunk {
958 content: "chunk B1".to_string(),
959 title: None,
960 source: Some("b.txt".to_string()),
961 start_secs: None,
962 end_secs: None,
963 },
964 PersistedChunk {
965 content: "chunk B2".to_string(),
966 title: None,
967 source: Some("b.txt".to_string()),
968 start_secs: None,
969 end_secs: None,
970 },
971 ],
972 embeddings: vec![vec![0.0; 4]; 4],
973 dimension: 4,
974 embedder_type: "tfidf".to_string(),
975 model_name: None,
976 };
977
978 export_sqlite(&persisted, &dir).unwrap();
979
980 let idx = aprender_rag::SqliteIndex::open(dir.join("index.sqlite")).unwrap();
981 assert_eq!(idx.document_count().unwrap(), 2);
982 assert_eq!(idx.chunk_count().unwrap(), 4);
983
984 let _ = fs::remove_dir_all(&dir);
985 }
986
987 #[test]
988 #[cfg(feature = "sqlite")]
989 fn test_export_sqlite_unknown_source() {
990 let dir = std::env::temp_dir().join("trueno_rag_cli_test_sqlite_unknown");
991 let _ = fs::remove_dir_all(&dir);
992 fs::create_dir_all(&dir).unwrap();
993
994 let persisted = PersistedIndex {
995 chunks: vec![PersistedChunk {
996 content: "orphan chunk".to_string(),
997 title: None,
998 source: None, start_secs: None,
1000 end_secs: None,
1001 }],
1002 embeddings: vec![vec![0.0; 4]],
1003 dimension: 4,
1004 embedder_type: "tfidf".to_string(),
1005 model_name: None,
1006 };
1007
1008 export_sqlite(&persisted, &dir).unwrap();
1009
1010 let idx = aprender_rag::SqliteIndex::open(dir.join("index.sqlite")).unwrap();
1011 assert_eq!(idx.document_count().unwrap(), 1, "unknown doc grouped");
1012 assert_eq!(idx.chunk_count().unwrap(), 1);
1013
1014 let _ = fs::remove_dir_all(&dir);
1015 }
1016
1017 #[test]
1018 #[cfg(feature = "sqlite")]
1019 fn test_export_sqlite_replaces_stale_db() {
1020 let dir = std::env::temp_dir().join("trueno_rag_cli_test_sqlite_replace");
1021 let _ = fs::remove_dir_all(&dir);
1022 fs::create_dir_all(&dir).unwrap();
1023
1024 fs::write(dir.join("index.sqlite"), b"stale data").unwrap();
1026
1027 let persisted = PersistedIndex {
1028 chunks: vec![PersistedChunk {
1029 content: "fresh content".to_string(),
1030 title: None,
1031 source: Some("fresh.txt".to_string()),
1032 start_secs: None,
1033 end_secs: None,
1034 }],
1035 embeddings: vec![vec![0.0; 4]],
1036 dimension: 4,
1037 embedder_type: "tfidf".to_string(),
1038 model_name: None,
1039 };
1040
1041 export_sqlite(&persisted, &dir).unwrap();
1042
1043 let idx = aprender_rag::SqliteIndex::open(dir.join("index.sqlite")).unwrap();
1044 assert_eq!(idx.document_count().unwrap(), 1);
1045 assert_eq!(idx.chunk_count().unwrap(), 1);
1046
1047 let _ = fs::remove_dir_all(&dir);
1048 }
1049
1050 #[test]
1051 #[cfg(not(feature = "sqlite"))]
1052 fn test_export_sqlite_stub_errors() {
1053 let dir = std::env::temp_dir().join("trueno_rag_cli_test_sqlite_stub");
1054 let _ = fs::remove_dir_all(&dir);
1055 fs::create_dir_all(&dir).unwrap();
1056
1057 let persisted = PersistedIndex {
1058 chunks: vec![],
1059 embeddings: vec![],
1060 dimension: 4,
1061 embedder_type: "tfidf".to_string(),
1062 model_name: None,
1063 };
1064
1065 let result = export_sqlite(&persisted, &dir);
1066 assert!(result.is_err());
1067 assert!(
1068 result.unwrap_err().to_string().contains("sqlite"),
1069 "Error should mention sqlite feature"
1070 );
1071
1072 let _ = fs::remove_dir_all(&dir);
1073 }
1074
1075 #[test]
1076 #[cfg(feature = "sqlite")]
1077 fn test_export_sqlite_empty_index() {
1078 let dir = std::env::temp_dir().join("trueno_rag_cli_test_sqlite_empty");
1079 let _ = fs::remove_dir_all(&dir);
1080 fs::create_dir_all(&dir).unwrap();
1081
1082 let persisted = PersistedIndex {
1083 chunks: vec![],
1084 embeddings: vec![],
1085 dimension: 4,
1086 embedder_type: "tfidf".to_string(),
1087 model_name: None,
1088 };
1089
1090 export_sqlite(&persisted, &dir).unwrap();
1091
1092 let idx = aprender_rag::SqliteIndex::open(dir.join("index.sqlite")).unwrap();
1093 assert_eq!(idx.document_count().unwrap(), 0);
1094 assert_eq!(idx.chunk_count().unwrap(), 0);
1095
1096 let _ = fs::remove_dir_all(&dir);
1097 }
1098
1099 #[test]
1104 fn test_parse_fusion_strategy_rrf() {
1105 let result = parse_fusion_strategy("rrf", None).unwrap();
1106 assert!(matches!(result, FusionStrategy::RRF { k } if (k - 60.0).abs() < 0.001));
1107 }
1108
1109 #[test]
1110 fn test_parse_fusion_strategy_rrf_custom_k() {
1111 let result = parse_fusion_strategy("rrf", Some(30.0)).unwrap();
1112 assert!(matches!(result, FusionStrategy::RRF { k } if (k - 30.0).abs() < 0.001));
1113 }
1114
1115 #[test]
1116 fn test_parse_fusion_strategy_linear() {
1117 let result = parse_fusion_strategy("linear", Some(0.7)).unwrap();
1118 assert!(
1119 matches!(result, FusionStrategy::Linear { dense_weight } if (dense_weight - 0.7).abs() < 0.001)
1120 );
1121 }
1122
1123 #[test]
1124 fn test_parse_fusion_strategy_dbsf() {
1125 let result = parse_fusion_strategy("dbsf", None).unwrap();
1126 assert!(matches!(result, FusionStrategy::DBSF));
1127 }
1128
1129 #[test]
1130 fn test_parse_fusion_strategy_unknown() {
1131 let result = parse_fusion_strategy("unknown", None);
1132 assert!(result.is_err());
1133 }
1134
1135 #[test]
1136 fn test_finish_load_report_success() {
1137 let docs = vec![Document::new("test content".to_string())];
1138 let result = finish_load_report(docs, 0).unwrap();
1139 assert_eq!(result.len(), 1);
1140 }
1141
1142 #[test]
1143 fn test_finish_load_report_with_errors() {
1144 let docs = vec![Document::new("test content".to_string())];
1145 let result = finish_load_report(docs, 3).unwrap();
1146 assert_eq!(result.len(), 1);
1147 }
1148
1149 #[test]
1150 fn test_finish_load_report_all_failed() {
1151 let docs: Vec<Document> = vec![];
1152 let result = finish_load_report(docs, 5);
1153 assert!(result.is_err());
1154 assert!(result.unwrap_err().to_string().contains("5 errors"));
1155 }
1156
1157 #[test]
1158 fn test_report_media_text_split_no_media() {
1159 let docs = vec![Document::new("plain text".to_string())];
1160 report_media_text_split(&docs);
1162 }
1163
1164 #[test]
1165 fn test_report_media_text_split_with_media() {
1166 let mut doc = Document::new("media content".to_string());
1167 doc.metadata.insert(
1168 "subtitle_cues".to_string(),
1169 serde_json::Value::String("cue data".to_string()),
1170 );
1171 let docs = vec![doc, Document::new("plain text".to_string())];
1172 report_media_text_split(&docs);
1174 }
1175
1176 #[test]
1177 fn test_query_sparse_basic() {
1178 let persisted = PersistedIndex {
1179 chunks: vec![
1180 PersistedChunk {
1181 content: "Rust borrow checker and ownership model".to_string(),
1182 title: Some("Rust".to_string()),
1183 source: Some("rust.txt".to_string()),
1184 start_secs: None,
1185 end_secs: None,
1186 },
1187 PersistedChunk {
1188 content: "Python garbage collector and reference counting".to_string(),
1189 title: Some("Python".to_string()),
1190 source: Some("python.txt".to_string()),
1191 start_secs: None,
1192 end_secs: None,
1193 },
1194 ],
1195 embeddings: vec![vec![0.0; 4]; 2],
1196 dimension: 4,
1197 embedder_type: "tfidf".to_string(),
1198 model_name: None,
1199 };
1200
1201 let results = query_sparse("borrow checker", &persisted, 5);
1202 assert!(!results.is_empty(), "BM25 should find 'borrow checker'");
1203 assert_eq!(results[0].0, 0);
1205 }
1206
1207 #[test]
1208 fn test_query_sparse_empty_corpus() {
1209 let persisted = PersistedIndex {
1210 chunks: vec![],
1211 embeddings: vec![],
1212 dimension: 4,
1213 embedder_type: "tfidf".to_string(),
1214 model_name: None,
1215 };
1216
1217 let results = query_sparse("anything", &persisted, 5);
1218 assert!(results.is_empty());
1219 }
1220
1221 #[test]
1222 fn test_query_dense_tfidf() {
1223 let persisted = PersistedIndex {
1225 chunks: vec![
1226 PersistedChunk {
1227 content: "alpha beta gamma".to_string(),
1228 title: None,
1229 source: None,
1230 start_secs: None,
1231 end_secs: None,
1232 },
1233 PersistedChunk {
1234 content: "delta epsilon zeta".to_string(),
1235 title: None,
1236 source: None,
1237 start_secs: None,
1238 end_secs: None,
1239 },
1240 ],
1241 embeddings: vec![vec![1.0, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]],
1242 dimension: 4,
1243 embedder_type: "tfidf".to_string(),
1244 model_name: None,
1245 };
1246
1247 let result = query_dense("alpha beta", &persisted, 2);
1248 assert!(result.is_ok());
1249 let scores = result.unwrap();
1250 assert_eq!(scores.len(), 2);
1251 }
1252
1253 #[test]
1254 fn test_format_query_results_text() {
1255 let chunks = vec![PersistedChunk {
1256 content: "Rust is a systems programming language focused on safety".to_string(),
1257 title: Some("Rust Intro".to_string()),
1258 source: Some("rust.txt".to_string()),
1259 start_secs: None,
1260 end_secs: None,
1261 }];
1262 let scores = vec![(0_usize, 0.95_f32)];
1263 let result = format_query_results("Rust", &scores, &chunks, "text");
1264 assert!(result.is_ok());
1265 }
1266
1267 #[test]
1268 fn test_format_query_results_json() {
1269 let chunks = vec![PersistedChunk {
1270 content: "Rust is a systems programming language".to_string(),
1271 title: Some("Rust".to_string()),
1272 source: Some("rust.txt".to_string()),
1273 start_secs: Some(10.5),
1274 end_secs: Some(25.0),
1275 }];
1276 let scores = vec![(0_usize, 0.8_f32)];
1277 let result = format_query_results("Rust", &scores, &chunks, "json");
1278 assert!(result.is_ok());
1279 }
1280
1281 #[test]
1282 fn test_format_query_results_json_no_timestamps() {
1283 let chunks = vec![PersistedChunk {
1284 content: "plain text content without timestamps".to_string(),
1285 title: None,
1286 source: Some("doc.txt".to_string()),
1287 start_secs: None,
1288 end_secs: None,
1289 }];
1290 let scores = vec![(0_usize, 0.5_f32)];
1291 let result = format_query_results("plain", &scores, &chunks, "json");
1292 assert!(result.is_ok());
1293 }
1294
1295 #[test]
1296 fn test_format_query_results_text_with_timestamps() {
1297 let chunks = vec![
1298 PersistedChunk {
1299 content: "lecture content about PDCA cycle in software engineering and continuous improvement".to_string(),
1300 title: Some("PDCA Lecture".to_string()),
1301 source: Some("lecture.srt".to_string()),
1302 start_secs: Some(120.0),
1303 end_secs: Some(180.0),
1304 },
1305 ];
1306 let scores = vec![(0_usize, 0.9_f32)];
1307 let result = format_query_results("PDCA", &scores, &chunks, "text");
1308 assert!(result.is_ok());
1309 }
1310
1311 #[test]
1312 fn test_apply_rerank_none() {
1313 let chunks = vec![PersistedChunk {
1314 content: "alpha".to_string(),
1315 title: None,
1316 source: None,
1317 start_secs: None,
1318 end_secs: None,
1319 }];
1320 let scores = vec![(0_usize, 1.0_f32)];
1321 let result = apply_rerank("none", "test", &scores, &chunks, 5).unwrap();
1322 assert_eq!(result.len(), 1);
1323 assert_eq!(result[0].0, 0);
1324 }
1325
1326 #[test]
1327 fn test_apply_rerank_lexical() {
1328 let chunks = vec![
1329 PersistedChunk {
1330 content: "Rust borrow checker ensures memory safety through ownership".to_string(),
1331 title: Some("Rust Safety".to_string()),
1332 source: Some("rust.txt".to_string()),
1333 start_secs: None,
1334 end_secs: None,
1335 },
1336 PersistedChunk {
1337 content:
1338 "Python garbage collector manages memory automatically with reference counting"
1339 .to_string(),
1340 title: Some("Python GC".to_string()),
1341 source: Some("python.txt".to_string()),
1342 start_secs: None,
1343 end_secs: None,
1344 },
1345 ];
1346 let scores = vec![(0_usize, 0.9_f32), (1_usize, 0.5_f32)];
1347 let result = apply_rerank("lexical", "borrow checker memory", &scores, &chunks, 5).unwrap();
1348 assert!(!result.is_empty());
1349 }
1350
1351 #[test]
1352 fn test_apply_rerank_unknown() {
1353 let chunks = vec![];
1354 let scores = vec![];
1355 let result = apply_rerank("invalid", "test", &scores, &chunks, 5);
1356 assert!(result.is_err());
1357 }
1358
1359 #[test]
1360 fn test_transcribe_manifest_save_load() {
1361 let dir = std::env::temp_dir().join("trueno_rag_cli_test_manifest");
1362 let _ = fs::remove_dir_all(&dir);
1363 fs::create_dir_all(&dir).unwrap();
1364
1365 let manifest = TranscribeManifest {
1366 completed: vec!["a.mp4".to_string(), "b.mp4".to_string()],
1367 failed: vec!["c.mp4".to_string()],
1368 };
1369 manifest.save(&dir).unwrap();
1370
1371 let loaded = TranscribeManifest::load(&dir);
1372 assert_eq!(loaded.completed.len(), 2);
1373 assert_eq!(loaded.failed.len(), 1);
1374
1375 let _ = fs::remove_dir_all(&dir);
1376 }
1377
1378 #[test]
1379 fn test_chunk_and_embed_timestamp_strategy() {
1380 use aprender_rag::chunk::RecursiveChunker;
1381 use aprender_rag::chunk::TimestampChunker;
1382 use aprender_rag::embed::MockEmbedder;
1383
1384 let embedder = MockEmbedder::new(4);
1385 let recursive = RecursiveChunker::new(512, 64);
1386 let timestamp = TimestampChunker::new(30.0);
1387
1388 let doc = Document::new(
1389 "This is a lecture about Rust programming and memory safety concepts".to_string(),
1390 );
1391 let docs = vec![doc];
1393
1394 let result = chunk_and_embed(
1395 &docs,
1396 &embedder,
1397 &recursive,
1398 ×tamp,
1399 ChunkStrategy::Timestamp,
1400 false,
1401 );
1402 assert!(result.is_ok());
1403 }
1404
1405 #[test]
1406 fn test_discover_and_load_empty_dir() {
1407 let dir = std::env::temp_dir().join("trueno_rag_cli_test_discover_load_empty");
1408 let _ = fs::remove_dir_all(&dir);
1409 fs::create_dir_all(&dir).unwrap();
1410 fs::write(dir.join("video.mp4"), "not a real file").unwrap();
1412
1413 let result = discover_and_load(&dir, false, 1, &None);
1414 assert!(result.is_err(), "Should fail with no supported files");
1415
1416 let _ = fs::remove_dir_all(&dir);
1417 }
1418
1419 #[test]
1420 fn test_load_documents_sequential_progress() {
1421 let dir = std::env::temp_dir().join("trueno_rag_cli_test_seq_progress");
1423 let _ = fs::remove_dir_all(&dir);
1424 fs::create_dir_all(&dir).unwrap();
1425
1426 let mut files = Vec::new();
1427 for i in 0..101 {
1428 let file = dir.join(format!("file_{i:03}.txt"));
1429 fs::write(&file, format!("Content of file {i}")).unwrap();
1430 files.push(file);
1431 }
1432
1433 let registry = LoaderRegistry::new();
1434 let result = load_documents_sequential(&files, ®istry);
1435 assert!(result.is_ok());
1436 let docs = result.unwrap();
1437 assert_eq!(docs.len(), 101);
1438
1439 let _ = fs::remove_dir_all(&dir);
1440 }
1441
1442 #[test]
1443 fn test_discover_media_files_recursive_subdirs() {
1444 let dir = std::env::temp_dir().join("trueno_rag_cli_test_media_recursive");
1445 let sub = dir.join("sub");
1446 let _ = fs::remove_dir_all(&dir);
1447 fs::create_dir_all(&sub).unwrap();
1448 fs::write(dir.join("a.mp4"), "fake").unwrap();
1449 fs::write(sub.join("b.mp4"), "fake").unwrap();
1450
1451 let files = discover_media_files(&dir, true, &None).unwrap();
1452 assert_eq!(files.len(), 2);
1453
1454 let _ = fs::remove_dir_all(&dir);
1455 }
1456
1457 #[test]
1458 fn test_load_documents_sequential_with_error() {
1459 let dir = std::env::temp_dir().join("trueno_rag_cli_test_seq_load_err");
1460 let _ = fs::remove_dir_all(&dir);
1461 fs::create_dir_all(&dir).unwrap();
1462 fs::write(dir.join("good.txt"), "valid text content").unwrap();
1464 fs::write(dir.join("bad.bin"), &[0xFF, 0xFE, 0x00, 0x01]).unwrap();
1465
1466 let registry = LoaderRegistry::new();
1467 let files = vec![dir.join("good.txt"), dir.join("bad.bin")];
1468 let result = load_documents_sequential(&files, ®istry);
1469 assert!(result.is_ok());
1471
1472 let _ = fs::remove_dir_all(&dir);
1473 }
1474
1475 #[test]
1476 fn test_chunk_and_embed_empty_document() {
1477 use aprender_rag::chunk::RecursiveChunker;
1478 use aprender_rag::chunk::TimestampChunker;
1479 use aprender_rag::embed::MockEmbedder;
1480
1481 let embedder = MockEmbedder::new(4);
1482 let recursive = RecursiveChunker::new(512, 64);
1483 let timestamp = TimestampChunker::new(30.0);
1484
1485 let docs = vec![
1487 Document::new(String::new()), Document::new("Some real content that has actual words".to_string()),
1489 ];
1490
1491 let result = chunk_and_embed(
1492 &docs,
1493 &embedder,
1494 &recursive,
1495 ×tamp,
1496 ChunkStrategy::Recursive,
1497 false,
1498 );
1499 assert!(result.is_ok());
1500 let (chunks, embeddings) = result.unwrap();
1501 assert!(!chunks.is_empty());
1502 assert_eq!(chunks.len(), embeddings.len());
1503 }
1504
1505 #[test]
1506 fn test_chunk_and_embed_with_dedup() {
1507 use aprender_rag::chunk::RecursiveChunker;
1508 use aprender_rag::chunk::TimestampChunker;
1509 use aprender_rag::embed::MockEmbedder;
1510
1511 let embedder = MockEmbedder::new(4);
1512 let recursive = RecursiveChunker::new(512, 64);
1513 let timestamp = TimestampChunker::new(30.0);
1514
1515 let docs = vec![
1517 Document::new("Duplicate content for dedup testing.".to_string()),
1518 Document::new("Duplicate content for dedup testing.".to_string()),
1519 ];
1520
1521 let result = chunk_and_embed(
1522 &docs,
1523 &embedder,
1524 &recursive,
1525 ×tamp,
1526 ChunkStrategy::Recursive,
1527 true,
1528 );
1529 assert!(result.is_ok());
1530 }
1531
1532 #[test]
1533 fn test_discover_media_files_single_file() {
1534 let dir = std::env::temp_dir().join("trueno_rag_cli_test_media_single");
1535 let _ = fs::remove_dir_all(&dir);
1536 fs::create_dir_all(&dir).unwrap();
1537 let file = dir.join("test.mp4");
1538 fs::write(&file, "fake media").unwrap();
1539
1540 let files = discover_media_files(&file, false, &None).unwrap();
1541 assert_eq!(files.len(), 1);
1542
1543 let _ = fs::remove_dir_all(&dir);
1544 }
1545
1546 #[test]
1547 fn test_discover_media_files_single_non_media() {
1548 let dir = std::env::temp_dir().join("trueno_rag_cli_test_media_nonmedia");
1549 let _ = fs::remove_dir_all(&dir);
1550 fs::create_dir_all(&dir).unwrap();
1551 let file = dir.join("test.txt");
1552 fs::write(&file, "not media").unwrap();
1553
1554 let result = discover_media_files(&file, false, &None);
1555 assert!(result.is_err());
1556
1557 let _ = fs::remove_dir_all(&dir);
1558 }
1559
1560 #[test]
1561 fn test_discover_media_files_with_exclude() {
1562 let dir = std::env::temp_dir().join("trueno_rag_cli_test_media_exclude");
1563 let raw = dir.join("RAW");
1564 let _ = fs::remove_dir_all(&dir);
1565 let _ = fs::create_dir_all(&raw);
1566 fs::write(dir.join("keep.mp4"), "fake media").unwrap();
1567 fs::write(raw.join("skip.mp4"), "fake media").unwrap();
1568
1569 let exclude = build_exclude_set(&["*/RAW".to_string(), "*/RAW/*".to_string()]).unwrap();
1570 let files = discover_media_files(&dir, true, &exclude).unwrap();
1571
1572 assert_eq!(files.len(), 1);
1573 assert!(files[0].to_string_lossy().contains("keep.mp4"));
1574
1575 let _ = fs::remove_dir_all(&dir);
1576 }
1577
1578 #[test]
1581 fn test_compute_file_hashes() {
1582 let dir = std::env::temp_dir().join("trueno_rag_test_hashes");
1583 let _ = fs::remove_dir_all(&dir);
1584 fs::create_dir_all(&dir).unwrap();
1585
1586 fs::write(dir.join("a.txt"), "hello").unwrap();
1587 fs::write(dir.join("b.txt"), "world").unwrap();
1588
1589 let files = vec![dir.join("a.txt"), dir.join("b.txt")];
1590 let hashes = compute_file_hashes(&files).unwrap();
1591 assert_eq!(hashes.len(), 2);
1592
1593 let hash_a = hashes[0].1;
1595 let hashes2 = compute_file_hashes(&[dir.join("a.txt")]).unwrap();
1596 assert_eq!(hash_a, hashes2[0].1);
1597
1598 assert_ne!(hashes[0].1, hashes[1].1);
1600
1601 let _ = fs::remove_dir_all(&dir);
1602 }
1603
1604 #[test]
1605 fn test_diff_fingerprints_detects_new() {
1606 let current = vec![
1607 (PathBuf::from("/a.md"), [1u8; 32]),
1608 (PathBuf::from("/b.md"), [2u8; 32]),
1609 ];
1610 let stored: HashMap<String, Vec<u8>> = HashMap::new();
1611
1612 let (changed, deleted) = diff_fingerprints(¤t, &stored);
1613 assert_eq!(changed.len(), 2);
1614 assert!(deleted.is_empty());
1615 }
1616
1617 #[test]
1618 fn test_diff_fingerprints_detects_changed() {
1619 let current = vec![(PathBuf::from("/a.md"), [2u8; 32])];
1620 let mut stored: HashMap<String, Vec<u8>> = HashMap::new();
1621 stored.insert("/a.md".to_string(), vec![1u8; 32]);
1622
1623 let (changed, deleted) = diff_fingerprints(¤t, &stored);
1624 assert_eq!(changed.len(), 1);
1625 assert!(deleted.is_empty());
1626 }
1627
1628 #[test]
1629 fn test_diff_fingerprints_detects_unchanged() {
1630 let current = vec![(PathBuf::from("/a.md"), [1u8; 32])];
1631 let mut stored: HashMap<String, Vec<u8>> = HashMap::new();
1632 stored.insert("/a.md".to_string(), vec![1u8; 32]);
1633
1634 let (changed, deleted) = diff_fingerprints(¤t, &stored);
1635 assert!(changed.is_empty());
1636 assert!(deleted.is_empty());
1637 }
1638
1639 #[test]
1640 fn test_diff_fingerprints_detects_deleted() {
1641 let current: Vec<(PathBuf, [u8; 32])> = vec![];
1642 let mut stored: HashMap<String, Vec<u8>> = HashMap::new();
1643 stored.insert("/a.md".to_string(), vec![1u8; 32]);
1644
1645 let (changed, deleted) = diff_fingerprints(¤t, &stored);
1646 assert!(changed.is_empty());
1647 assert_eq!(deleted.len(), 1);
1648 assert_eq!(deleted[0], "/a.md");
1649 }
1650}