Skip to main content

aprender_rag_cli/
lib.rs

1//! Trueno-RAG CLI
2//!
3//! Command-line interface for the Trueno-RAG pipeline.
4//!
5//! ## Features
6//!
7//! - `embeddings` - Enable production semantic embeddings via fastembed (ONNX Runtime)
8//!
9//! ## Usage
10//!
11//! ```bash
12//! # Build with semantic embeddings support
13//! cargo build --release --features embeddings
14//!
15//! # Index documents with semantic embeddings
16//! trueno-rag index --path docs/ --output index/ --embedder semantic
17//!
18//! # Index with recursive directory walking and subtitle support
19//! trueno-rag index --path /data/ --output index/ --recursive
20//!
21//! # Index with timestamp-aware chunking for media transcripts
22//! trueno-rag index --path /data/ --output index/ --recursive --chunk-strategy timestamp
23//! ```
24
25// APR-MONO §S #1976: this crate joined the workspace via flat-layout relocation, so it now
26// inherits the workspace `.clippy.toml` disallowed-methods policy. Test code uses unwrap()
27// freely on fixtures; allow it in cfg(test) per aprender-core convention.
28#![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/// Embedder type selection
44#[derive(Debug, Clone, Copy, Default, ValueEnum)]
45pub enum EmbedderType {
46    /// TF-IDF statistical embeddings (default, no downloads)
47    #[default]
48    Tfidf,
49    /// Semantic embeddings via fastembed (requires `embeddings` feature)
50    Semantic,
51}
52
53/// Model selection for semantic embeddings
54#[derive(Debug, Clone, Copy, Default, ValueEnum)]
55pub enum SemanticModel {
56    /// all-MiniLM-L6-v2: Fast, good quality (384 dims)
57    #[default]
58    MiniLm,
59    /// BGE-small-en-v1.5: Balanced performance (384 dims)
60    BgeSmall,
61    /// BGE-base-en-v1.5: Higher quality (768 dims)
62    BgeBase,
63}
64
65/// Chunking strategy selection
66#[derive(Debug, Clone, Copy, Default, ValueEnum)]
67pub enum ChunkStrategy {
68    /// Auto-select: TimestampChunker for media, RecursiveChunker for text
69    #[default]
70    Auto,
71    /// Recursive character splitting (works for all content)
72    Recursive,
73    /// Timestamp-aware chunking (best for subtitle/transcript content)
74    Timestamp,
75}
76
77/// Compute backend for transcription
78#[derive(Debug, Clone, Copy, Default, ValueEnum)]
79pub enum BackendType {
80    /// CPU with SIMD acceleration
81    #[default]
82    Cpu,
83    /// GPU via wgpu (cross-platform)
84    Gpu,
85    /// NVIDIA CUDA (Linux/Windows)
86    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    /// The command to run
96    #[command(subcommand)]
97    pub command: Commands,
98}
99
100/// Every command the Trueno-RAG CLI offers.
101///
102/// `Clone` so `apr rag` can hand an already-parsed command to [`dispatch`]
103/// without re-parsing `argv`.
104#[derive(Subcommand, Clone, Debug)]
105pub enum Commands {
106    /// Run a demo RAG query
107    Demo {
108        /// Query string
109        // Long-only: apr propagates its global -v/--verbose and -q/--quiet
110        // into every subcommand, so a derived short here makes the whole
111        // clap tree invalid and the subcommand panics on any invocation.
112        #[arg(long, default_value = "What is machine learning?")]
113        query: String,
114
115        /// Number of results to return
116        #[arg(short, long, default_value = "3")]
117        top_k: usize,
118    },
119
120    /// Index documents from a file or directory
121    Index {
122        /// Path to document(s)
123        #[arg(short, long)]
124        path: String,
125
126        /// Output directory for index
127        #[arg(short, long)]
128        output: String,
129
130        /// Chunk size in characters (for recursive chunker)
131        #[arg(long, default_value = "512")]
132        chunk_size: usize,
133
134        /// Chunk overlap in characters (for recursive chunker)
135        #[arg(long, default_value = "64")]
136        chunk_overlap: usize,
137
138        /// Embedding dimension (only for tfidf embedder)
139        #[arg(long, default_value = "256")]
140        dimension: usize,
141
142        /// Embedder type (tfidf or semantic)
143        #[arg(short, long, value_enum, default_value = "tfidf")]
144        embedder: EmbedderType,
145
146        /// Model for semantic embeddings (mini-lm, bge-small, bge-base)
147        #[arg(short, long, value_enum, default_value = "mini-lm")]
148        model: SemanticModel,
149
150        /// Recursively scan subdirectories
151        #[arg(short, long, default_value = "false")]
152        recursive: bool,
153
154        /// Chunking strategy (auto, recursive, timestamp)
155        #[arg(long, value_enum, default_value = "auto")]
156        chunk_strategy: ChunkStrategy,
157
158        /// Number of parallel loading jobs
159        #[arg(short, long, default_value = "1")]
160        jobs: usize,
161
162        /// Write a JSON manifest of indexed files and chunks
163        #[arg(long, default_value = "false")]
164        manifest: bool,
165
166        /// Glob patterns to exclude files/directories (repeatable)
167        #[arg(long)]
168        exclude: Vec<String>,
169
170        /// Deduplicate chunks with identical content (keeps first occurrence)
171        #[arg(long, default_value = "false")]
172        dedup: bool,
173
174        /// Also export a SQLite+FTS5 index (requires sqlite feature)
175        #[arg(long, default_value = "false")]
176        sqlite: bool,
177
178        /// Incremental mode: only re-index changed files (requires --sqlite)
179        #[arg(long, default_value = "false")]
180        incremental: bool,
181    },
182
183    /// Query the RAG pipeline
184    Query {
185        /// Query string
186        query: String,
187
188        /// Path to index directory
189        #[arg(short, long)]
190        index: String,
191
192        /// Number of results
193        #[arg(short, long, default_value = "5")]
194        top_k: usize,
195
196        /// Output format (text, json)
197        #[arg(short, long, default_value = "text")]
198        format: String,
199
200        /// Retrieval mode: dense, sparse (BM25), hybrid (BM25 + dense RRF)
201        #[arg(long, default_value = "hybrid")]
202        mode: String,
203
204        /// Fusion strategy (hybrid mode only): rrf, linear, dbsf
205        #[arg(long, default_value = "rrf")]
206        fusion: String,
207
208        /// Fusion parameter: RRF k value or Linear dense_weight
209        #[arg(long)]
210        fusion_k: Option<f32>,
211
212        /// Candidates per source for hybrid retrieval
213        #[arg(long, default_value = "50")]
214        candidates: usize,
215
216        /// Reranking strategy: none, lexical
217        #[arg(long, default_value = "none")]
218        rerank: String,
219
220        /// Enable HyDE (Hypothetical Document Embeddings) query expansion.
221        /// Generates a hypothetical answer via Claude API and uses it for retrieval.
222        /// Requires ANTHROPIC_API_KEY environment variable and --features eval.
223        #[arg(long, default_value = "false")]
224        hyde: bool,
225    },
226
227    /// Batch transcribe media files to .srt sidecars
228    Transcribe {
229        /// Path to directory containing media files
230        #[arg(short, long)]
231        path: String,
232
233        /// Recursively scan subdirectories
234        #[arg(short, long, default_value = "false")]
235        recursive: bool,
236
237        /// Skip files that already have .srt/.vtt sidecars
238        #[arg(long, default_value = "true")]
239        skip_existing: bool,
240
241        /// Number of parallel transcription jobs (CPU mode)
242        #[arg(short, long, default_value = "1")]
243        jobs: usize,
244
245        /// Path to Whisper .apr model file (e.g. base.apr, large-v3-turbo.apr)
246        #[arg(short, long)]
247        model: Option<String>,
248
249        /// Compute backend (cpu, gpu, cuda)
250        #[arg(short, long, value_enum, default_value = "cpu")]
251        backend: BackendType,
252
253        /// Only report what would be transcribed (dry run)
254        #[arg(long, default_value = "false")]
255        dry_run: bool,
256
257        /// Initial prompt to condition decoder vocabulary (e.g. "AWS, Kubernetes, YAML")
258        #[arg(long)]
259        prompt: Option<String>,
260
261        /// Path to file with hotwords (one per line) to boost during decoding
262        #[arg(long)]
263        hotwords: Option<String>,
264
265        /// Glob patterns to exclude files/directories (repeatable)
266        #[arg(long)]
267        exclude: Vec<String>,
268    },
269
270    /// Extract keyframes from video files at scene changes (requires ffmpeg)
271    ExtractFrames {
272        /// Path to directory containing video files
273        #[arg(short, long)]
274        path: String,
275
276        /// Recursively scan subdirectories
277        #[arg(short, long, default_value = "false")]
278        recursive: bool,
279
280        /// Scene change detection threshold (0.0-1.0, lower = more frames)
281        #[arg(long, default_value = "0.3")]
282        threshold: f64,
283
284        /// Minimum seconds between extracted frames
285        #[arg(long, default_value = "5.0")]
286        min_interval: f64,
287
288        /// Number of parallel extraction jobs
289        #[arg(short, long, default_value = "4")]
290        jobs: usize,
291
292        /// Skip videos that already have frames/ directory
293        #[arg(long, default_value = "true")]
294        skip_existing: bool,
295
296        /// Only report what would be extracted (dry run)
297        #[arg(long, default_value = "false")]
298        dry_run: bool,
299
300        /// Glob patterns to exclude files/directories (repeatable)
301        #[arg(long)]
302        exclude: Vec<String>,
303    },
304
305    /// Show pipeline info
306    Info,
307
308    /// Evaluation framework: generate ground truth, run retrieval, judge relevance
309    #[cfg(feature = "eval")]
310    Eval {
311        #[command(subcommand)]
312        action: EvalAction,
313    },
314}
315
316/// Eval sub-subcommands
317#[cfg(feature = "eval")]
318#[derive(Subcommand)]
319enum EvalAction {
320    /// Sample chunks from index for ground truth generation (no API needed)
321    Sample {
322        /// Path to index directory (containing index.json)
323        #[arg(short, long)]
324        index: String,
325
326        /// Output path for sampled-chunks JSONL
327        #[arg(short, long)]
328        output: String,
329
330        /// Number of chunks to sample
331        #[arg(long, default_value = "250")]
332        sample_size: usize,
333
334        /// Random seed for reproducibility
335        #[arg(long, default_value = "42")]
336        seed: u64,
337    },
338
339    /// Generate synthetic ground truth from an index via Claude API (requires ANTHROPIC_API_KEY)
340    Generate {
341        /// Path to index directory (containing index.json)
342        #[arg(short, long)]
343        index: String,
344
345        /// Output path for ground-truth JSONL
346        #[arg(short, long)]
347        output: String,
348
349        /// Number of query-chunk pairs to generate
350        #[arg(long, default_value = "250")]
351        sample_size: usize,
352
353        /// Random seed for reproducibility
354        #[arg(long, default_value = "42")]
355        seed: u64,
356
357        /// Claude model for question generation
358        #[arg(long, default_value = "claude-sonnet-4-20250514")]
359        model: String,
360
361        /// Sample chunks only — no API calls (dry run)
362        #[arg(long, default_value = "false")]
363        dry_run: bool,
364    },
365
366    /// Run retrieval queries from ground truth and dump raw results
367    Retrieve {
368        /// Path to index directory
369        #[arg(short, long)]
370        index: String,
371
372        /// Path to ground-truth JSONL
373        #[arg(short, long)]
374        ground_truth: String,
375
376        /// Output path for retrieval results JSONL
377        #[arg(short, long)]
378        output: String,
379
380        /// Number of results per query
381        #[arg(long, default_value = "10")]
382        top_k: usize,
383
384        /// Retrieval mode: dense (TF-IDF only), sparse (BM25 only), hybrid (fused)
385        #[arg(long, default_value = "dense")]
386        mode: String,
387
388        /// Fusion strategy (hybrid mode only): rrf, linear, dbsf
389        #[arg(long, default_value = "rrf")]
390        fusion: String,
391
392        /// Fusion parameter: RRF k value or Linear dense_weight
393        #[arg(long)]
394        fusion_k: Option<f32>,
395
396        /// Candidates per source for hybrid retrieval
397        #[arg(long, default_value = "50")]
398        candidates: usize,
399
400        /// Reranking strategy: none, lexical
401        #[arg(long, default_value = "none")]
402        rerank: String,
403
404        /// Enable HyDE (Hypothetical Document Embeddings) query expansion.
405        /// Generates a hypothetical answer via Claude API and uses it for retrieval.
406        /// Requires ANTHROPIC_API_KEY environment variable and --features eval.
407        #[arg(long, default_value = "false")]
408        hyde: bool,
409    },
410
411    /// Judge retrieval results for relevance via Claude API and compute metrics
412    Judge {
413        /// Path to retrieval-results JSONL
414        #[arg(short, long)]
415        retrieval_results: String,
416
417        /// Path to ground-truth JSONL (for metadata)
418        #[arg(short, long)]
419        ground_truth: String,
420
421        /// Output path for eval results JSON
422        #[arg(short, long)]
423        output: String,
424
425        /// Path to judge cache JSON (created if absent)
426        #[arg(long, default_value = "judge-cache.json")]
427        cache: String,
428
429        /// Number of results to judge per query
430        #[arg(long, default_value = "10")]
431        top_k: usize,
432
433        /// Claude model for judging
434        #[arg(long, default_value = "claude-sonnet-4-20250514")]
435        model: String,
436    },
437
438    /// Compute IR metrics from pre-judged results (no API needed)
439    Metrics {
440        /// Path to retrieval-results JSONL
441        #[arg(short, long)]
442        retrieval_results: String,
443
444        /// Path to judgments JSONL (produced by Claude Code or external judge)
445        #[arg(short, long)]
446        judgments: String,
447
448        /// Output path for eval results JSON
449        #[arg(short, long)]
450        output: String,
451    },
452
453    /// Compare two eval result files
454    Compare {
455        /// Baseline results JSON
456        #[arg(long)]
457        baseline: String,
458
459        /// Candidate results JSON
460        #[arg(long)]
461        candidate: String,
462    },
463
464    /// Regression gate — exit non-zero if below thresholds
465    Gate {
466        /// Path to eval results JSON
467        #[arg(long)]
468        results: String,
469
470        /// Minimum MRR threshold
471        #[arg(long, default_value = "0.50")]
472        min_mrr: f64,
473
474        /// Minimum Hit@5 threshold
475        #[arg(long, default_value = "0.70")]
476        min_hit5: f64,
477    },
478}
479
480/// Persisted index structure
481#[derive(Serialize, Deserialize)]
482struct PersistedIndex {
483    chunks: Vec<PersistedChunk>,
484    embeddings: Vec<Vec<f32>>,
485    dimension: usize,
486    /// Embedder type used (for query compatibility)
487    #[serde(default)]
488    embedder_type: String,
489    /// Model name (for semantic embeddings)
490    #[serde(default)]
491    model_name: Option<String>,
492}
493
494/// Persisted chunk data
495#[derive(Serialize, Deserialize)]
496struct PersistedChunk {
497    content: String,
498    title: Option<String>,
499    source: Option<String>,
500    /// Timestamp metadata for media-derived chunks
501    #[serde(default, skip_serializing_if = "Option::is_none")]
502    start_secs: Option<f64>,
503    /// Timestamp metadata for media-derived chunks
504    #[serde(default, skip_serializing_if = "Option::is_none")]
505    end_secs: Option<f64>,
506}
507
508/// Parse `argv` and run one command. This is the whole of the standalone
509/// `trueno-rag` binary.
510///
511/// # Errors
512/// Propagates whatever the selected command returns.
513pub fn run() -> Result<()> {
514    dispatch(Cli::parse().command)
515}
516
517/// Run one already-parsed command.
518///
519/// Split out from [`run`] so a caller that did its own parsing -- `apr rag`
520/// -- executes the identical code path rather than a copy of it.
521///
522/// # Errors
523/// Propagates whatever the selected command returns.
524#[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    // Re-import functions from submodules for testing
687    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, &registry, &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(); // unsupported
750        fs::write(sub.join("d.txt"), "d").unwrap(); // in subdir
751
752        let registry = LoaderRegistry::new();
753        let files = discover_files(&dir, false, &registry, &None).unwrap();
754        // Only a.txt and b.md in top-level (not c.mp4, not sub/d.txt)
755        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, &registry, &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, &registry, &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        // Ensure Auto is the default
808        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, &registry, &exclude).unwrap();
867
868        // Should have keep.txt and build/also_keep.txt but NOT RAW/skip.txt
869        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        // Verify doc and chunk counts via SqliteIndex API
918        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        // Verify FTS5 search works
923        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        // 4 chunks from 2 docs
941        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, // no source
999                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        // Create a stale DB
1025        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    // ================================================================
1100    // Coverage-targeted tests for uncovered pure-logic functions
1101    // ================================================================
1102
1103    #[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        // Should not panic -- no media documents means no output
1161        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        // Should print "1 with timestamps, 1 plain text"
1173        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        // First result should be the Rust chunk (index 0)
1204        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        // TF-IDF embedder doesn't require external models -- it can be created inline
1224        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        // TimestampChunker with no cues falls back to RecursiveChunker
1392        let docs = vec![doc];
1393
1394        let result = chunk_and_embed(
1395            &docs,
1396            &embedder,
1397            &recursive,
1398            &timestamp,
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        // Only create an unsupported file
1411        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        // Create 101 files to trigger the progress reporting line
1422        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, &registry);
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        // Create a valid text file and a binary that will fail to load as text
1463        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, &registry);
1469        // Should succeed with at least the good file loaded (bad.bin may or may not error)
1470        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        // One empty doc + one valid doc
1486        let docs = vec![
1487            Document::new(String::new()), // empty -- should be skipped
1488            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            &timestamp,
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        // Two identical documents -- dedup should remove duplicates
1516        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            &timestamp,
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    // --- Incremental indexing tests ---
1579
1580    #[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        // Same content should produce same hash
1594        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        // Different content should produce different hash
1599        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(&current, &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(&current, &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(&current, &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(&current, &stored);
1646        assert!(changed.is_empty());
1647        assert_eq!(deleted.len(), 1);
1648        assert_eq!(deleted[0], "/a.md");
1649    }
1650}