2025-09-11 13:42:18 -04:00
use std ::sync ::{ Mutex , Arc } ;
use std ::ffi ::OsString ;
use std ::fs ::{ File , read_to_string } ;
use std ::io ::{ Read , Seek , SeekFrom } ;
2026-04-21 02:34:40 -04:00
use std ::process ::{ Command , Stdio } ;
2025-09-11 13:42:18 -04:00
use std ::time ::UNIX_EPOCH ;
2025-09-11 14:32:04 -04:00
use std ::collections ::HashMap ;
2025-09-11 13:42:18 -04:00
use sha2 ::{ Sha256 , Digest } ;
2026-04-21 02:34:40 -04:00
use walkdir ::{ DirEntry , WalkDir } ;
2025-09-11 13:42:18 -04:00
use rusqlite ::{ params , Connection } ;
use crate ::document_extraction ::extract_document_text ;
2025-09-11 16:32:21 -04:00
use crate ::config ::Config ;
2025-09-11 13:42:18 -04:00
2026-04-21 02:34:40 -04:00
#[ derive(Debug, Clone, Copy, PartialEq, Eq) ]
pub struct ExistingFileEntry {
2025-09-11 14:32:04 -04:00
pub moddate : u64 ,
}
2026-04-21 02:34:40 -04:00
pub const PLAINTEXT_EXTENSIONS_LIST : [ & 'static str ; 84 ] =
[ " c " , " cs " , " csx " , // C
" cpp " , " cc " , " cxx " , " hpp " , " hh " , " hxx " , " h " , // C++
" cfg " , " conf " , " ini " , " gitattributes " , " gitignore " , // Config (General)
" toml " , " env " , " tf " , " tfvars " , // Config (Infrastructure)
" scss " , " sass " , " less " , // CSS Preprocessors
" dart " , // Dart
" diff " , " patch " , // Diffs
" go " , // Go
" graphql " , " gql " , // GraphQL
" html " , " htm " , " xhtml " , " xht " , " jsp " , " asp " , " aspx " , // HTML
" java " , // Java
" js " , " cjs " , " mjs " , " jsx " , " ts " , " tsx " , // Javascript and TypeScript
" vue " , " svelte " , // JS Frameworks
" kt " , " kts " , // Kotlin
" tex " , " bib " , // LaTeX
" css " , " xml " , " md " , " json " , " yaml " , " yml " , // Markup
" m " , // Objective-C
" pl " , " pm " , " t " , // Perl
" php " , " phtml " , // PHP
" proto " , // Protocol Buffers
" py " , " pyw " , " pyi " , " ipynb " , // Python
" r " , // R
" rb " , // Ruby
" rs " , // Rust
" sh " , " bat " , " cmd " , " bash " , " ps1 " , " psm1 " , " psd1 " , // Scripts
" sql " , // SQL
" csv " , // Spreadsheet
" svg " , // SVG
" swift " , // Swift
" " , " txt " , " rtf " , " log " , // Text Documents
" wasm " , // Web Assembly
2025-09-11 13:42:18 -04:00
] ;
2025-09-11 18:36:42 -04:00
pub const SUPPORTED_DOCUMENT_EXTENSIONS_LIST : [ & 'static str ; 9 ] =
2025-09-11 13:42:18 -04:00
[ " odt " , " docx " , " doc " , // Office Documents
" ppt " , " pptx " , " odp " , // Presentation
" xls " , " xlsx " , " ods " ] ; // Spreadsheet
2026-04-21 02:34:40 -04:00
/// Load path and moddate per row for incremental classification (hash/size loaded only when updating a file).
pub fn load_existing_files ( conn : & Connection ) -> Result < HashMap < String , ExistingFileEntry > , rusqlite ::Error > {
2025-09-11 14:32:04 -04:00
let mut existing_files = HashMap ::new ( ) ;
2026-04-21 02:34:40 -04:00
let mut stmt = conn . prepare ( " SELECT path, moddate FROM files " ) ? ;
2025-09-11 14:32:04 -04:00
let rows = stmt . query_map ( [ ] , | row | {
2026-04-21 02:34:40 -04:00
Ok ( (
row . get ::< _ , String > ( 0 ) ? ,
ExistingFileEntry {
moddate : row . get ( 1 ) ? ,
} ,
) )
2025-09-11 14:32:04 -04:00
} ) ? ;
for row in rows {
2026-04-21 02:34:40 -04:00
let ( path , entry ) = row ? ;
existing_files . insert ( path , entry ) ;
2025-09-11 14:32:04 -04:00
}
2026-04-21 02:34:40 -04:00
2025-09-11 14:32:04 -04:00
Ok ( existing_files )
}
2026-04-21 02:34:40 -04:00
pub fn indexed_walk_file_entries ( path : & str , follow_symlinks : bool ) -> impl Iterator < Item = DirEntry > {
WalkDir ::new ( path )
. follow_links ( follow_symlinks )
. into_iter ( )
. filter_map ( | e | e . ok ( ) )
. filter ( | entry | ! entry . metadata ( ) . map ( | m | m . is_dir ( ) ) . unwrap_or ( true ) )
}
2025-09-11 14:32:04 -04:00
2026-04-21 02:34:40 -04:00
fn parse_wc_l_stdout ( bytes : & [ u8 ] ) -> Result < usize , String > {
let s = String ::from_utf8_lossy ( bytes ) ;
let token = s
. trim ( )
. split_whitespace ( )
. next ( )
. ok_or_else ( | | " wc: empty output " . to_string ( ) ) ? ;
token
. parse ( )
. map_err ( | e | format! ( " wc: invalid count {:?} : {} " , token , e ) )
}
2025-09-11 14:32:04 -04:00
2026-04-21 02:34:40 -04:00
#[ cfg(unix) ]
fn count_find_pipe_wc ( path : & str ) -> Result < usize , String > {
let mut find = Command ::new ( " find " )
. arg ( path )
. stdout ( Stdio ::piped ( ) )
. stderr ( Stdio ::null ( ) )
. spawn ( )
. map_err ( | e | format! ( " find: {} " , e ) ) ? ;
let find_stdout = find . stdout . take ( ) . ok_or ( " find: stdout " ) ? ;
let wc = Command ::new ( " wc " )
. arg ( " -l " )
. stdin ( find_stdout )
. stdout ( Stdio ::piped ( ) )
. output ( )
. map_err ( | e | format! ( " wc: {} " , e ) ) ? ;
find . wait ( ) . map_err ( | e | format! ( " find wait: {} " , e ) ) ? ;
if ! wc . status . success ( ) {
return Err ( format! ( " wc exited with {} " , wc . status ) ) ;
}
parse_wc_l_stdout ( & wc . stdout )
}
2025-09-11 14:32:04 -04:00
2026-04-21 02:34:40 -04:00
#[ cfg(target_os = " linux " ) ]
fn count_find_printf_wc ( path : & str ) -> Result < usize , String > {
let mut find = Command ::new ( " find " )
. arg ( path )
. arg ( " -printf " )
. arg ( " \n " )
. stdout ( Stdio ::piped ( ) )
. stderr ( Stdio ::null ( ) )
. spawn ( )
. map_err ( | e | format! ( " find: {} " , e ) ) ? ;
let find_stdout = find . stdout . take ( ) . ok_or ( " find: stdout " ) ? ;
let wc = Command ::new ( " wc " )
. arg ( " -l " )
. stdin ( find_stdout )
. stdout ( Stdio ::piped ( ) )
. output ( )
. map_err ( | e | format! ( " wc: {} " , e ) ) ? ;
find . wait ( ) . map_err ( | e | format! ( " find wait: {} " , e ) ) ? ;
if ! wc . status . success ( ) {
return Err ( format! ( " wc exited with {} " , wc . status ) ) ;
}
parse_wc_l_stdout ( & wc . stdout )
}
#[ cfg(windows) ]
fn count_tree_entries_windows ( path : & str ) -> Result < usize , String > {
let lit = path . replace ( '\'' , " '' " ) ;
let ps = format! (
" (Get-ChildItem -LiteralPath '{}' -Recurse -Force -ErrorAction SilentlyContinue | Measure-Object).Count " ,
lit
) ;
let out = Command ::new ( " powershell.exe " )
. args ( [ " -NoProfile " , " -NonInteractive " , " -Command " , & ps ] )
. output ( )
. map_err ( | e | format! ( " powershell: {} " , e ) ) ? ;
if ! out . status . success ( ) {
return Err ( format! (
" powershell exited with {}: {} " ,
out . status ,
String ::from_utf8_lossy ( & out . stderr )
) ) ;
}
String ::from_utf8_lossy ( & out . stdout )
. trim ( )
. parse ( )
. map_err ( | e | format! ( " invalid count output: {} " , e ) )
}
/// Rough tree entry count for progress totals (Linux: `find DIR -printf '\n' | wc -l` when GNU find is available, else `find DIR | wc -l`; macOS/other Unix: `find DIR | wc -l`; Windows: PowerShell `Get-ChildItem -Recurse`). Scope is not identical to the indexer’ s classified file count.
pub fn count_tree_entries_fast ( path : & str ) -> Result < usize , String > {
#[ cfg(windows) ]
{
return count_tree_entries_windows ( path ) ;
}
#[ cfg(all(unix, target_os = " linux " )) ]
{
return count_find_printf_wc ( path ) . or_else ( | _ | count_find_pipe_wc ( path ) ) ;
}
#[ cfg(all(unix, not(target_os = " linux " ))) ]
{
return count_find_pipe_wc ( path ) ;
}
#[ cfg(not(any(windows, unix))) ]
{
Err ( " tree entry count is not supported on this target " . to_string ( ) )
}
}
#[ derive(Debug, Clone, Copy, PartialEq, Eq) ]
pub enum FileIndexAction {
Skip ,
Update ,
Insert ,
}
/// Classify a walkdir entry for Phase 1 file indexing. Returns `None` if the path is not indexable.
pub fn classify_dir_entry_for_indexing (
entry : & DirEntry ,
existing_files : & HashMap < String , ExistingFileEntry > ,
) -> Option < FileIndexAction > {
let fpath = match entry . path ( ) . canonicalize ( ) {
Ok ( fp ) = > {
let path_str = fp . to_string_lossy ( ) . to_string ( ) ;
if path_str . starts_with ( " \\ \\ ? \\ " ) {
path_str [ 4 .. ] . to_string ( )
} else {
path_str
2025-09-11 14:32:04 -04:00
}
2026-04-21 02:34:40 -04:00
}
Err ( _ ) = > return None ,
} ;
let meta = match std ::fs ::metadata ( & fpath ) {
Ok ( m ) if m . is_file ( ) = > m ,
_ = > return None ,
} ;
let fmodified = match meta
. modified ( )
. ok ( )
. and_then ( | m | m . duration_since ( UNIX_EPOCH ) . ok ( ) . map ( | d | d . as_secs ( ) ) )
{
Some ( time ) = > time ,
None = > return None ,
} ;
if let Some ( existing ) = existing_files . get ( & fpath ) {
if existing . moddate ! = fmodified {
Some ( FileIndexAction ::Update )
2025-09-11 14:32:04 -04:00
} else {
2026-04-21 02:34:40 -04:00
Some ( FileIndexAction ::Skip )
2025-09-11 14:32:04 -04:00
}
2026-04-21 02:34:40 -04:00
} else {
Some ( FileIndexAction ::Insert )
2025-09-11 14:32:04 -04:00
}
}
2025-09-11 18:36:42 -04:00
/// Safely truncate a string to at most max_bytes bytes while respecting UTF-8 character boundaries
fn safe_truncate_string ( s : & str , max_bytes : usize ) -> String {
if s . len ( ) < = max_bytes {
return s . to_string ( ) ;
}
// Find the last valid UTF-8 character boundary at or before max_bytes
let mut end = max_bytes ;
while end > 0 & & ! s . is_char_boundary ( end ) {
end - = 1 ;
}
s [ .. end ] . to_string ( )
}
2025-09-11 16:32:21 -04:00
/// Get a hash of a file by reading the first and last hash_length bytes of the file
fn get_file_hash ( size : u64 , path : OsString , hash_length : usize ) -> Result < Vec < u8 > , std ::io ::Error > {
2025-09-11 13:42:18 -04:00
let mut hasher = Sha256 ::new ( ) ;
let mut f : File = File ::open ( path ) ? ;
hasher . update ( & size . to_le_bytes ( ) ) ;
2025-09-11 16:32:21 -04:00
if size > hash_length as u64 {
let mut file_start_block = vec! [ 0 u8 ; hash_length ] ;
2025-09-11 13:42:18 -04:00
f . read_exact ( & mut file_start_block ) ? ;
2025-09-11 16:32:21 -04:00
hasher . update ( & file_start_block ) ;
f . seek ( SeekFrom ::End ( 0 - hash_length as i64 ) ) ? ;
let mut file_end_block = vec! [ 0 u8 ; hash_length ] ;
2025-09-11 13:42:18 -04:00
f . read_exact ( & mut file_end_block ) ? ;
2025-09-11 16:32:21 -04:00
hasher . update ( & file_end_block ) ;
2025-09-11 13:42:18 -04:00
} else if size > 0 {
let mut file_block = Vec ::new ( ) ;
f . read_to_end ( & mut file_block ) ? ;
hasher . update ( file_block ) ;
}
drop ( f ) ;
Ok ( hasher . finalize ( ) . to_vec ( ) )
}
2026-04-21 02:34:40 -04:00
fn format_progress_pair ( visit_index : usize , progress_display_total : Option < usize > ) -> String {
match progress_display_total {
Some ( t ) = > format! ( " {} / {} " , visit_index , t ) ,
None = > format! ( " {} " , visit_index ) ,
}
}
const FTS_SQL_AUTOMERGE_8 : & str =
" INSERT INTO searchabletext(searchabletext, rank) VALUES('automerge', 8) " ;
const FTS_SQL_REBUILD : & str = " INSERT INTO searchabletext(searchabletext) VALUES('rebuild') " ;
pub fn fts_finalize_after_text_indexing ( conn : & Connection ) -> Result < ( ) , String > {
conn . execute ( FTS_SQL_AUTOMERGE_8 , [ ] )
. map_err ( | e | format! ( " FTS automerge(8): {} " , e ) ) ? ;
conn . execute ( FTS_SQL_REBUILD , [ ] )
. map_err ( | e | format! ( " FTS rebuild: {} " , e ) ) ? ;
Ok ( ( ) )
}
fn fts_remove_document_for_path (
tx : & rusqlite ::Transaction < '_ > ,
path : & str ,
) -> Result < ( ) , String > {
let id_opt : Option < i64 > = match tx . query_row (
" SELECT id FROM documents WHERE path = ?1 " ,
params! [ path ] ,
| r | r . get ( 0 ) ,
) {
Ok ( id ) = > Some ( id ) ,
Err ( rusqlite ::Error ::QueryReturnedNoRows ) = > None ,
Err ( e ) = > return Err ( format! ( " documents id lookup: {} " , e ) ) ,
} ;
if let Some ( doc_id ) = id_opt {
tx . execute (
" INSERT INTO searchabletext(searchabletext, rowid) VALUES('delete', ?1) " ,
params! [ doc_id ] ,
)
. map_err ( | e | format! ( " FTS delete doc {} : {} " , doc_id , e ) ) ? ;
tx . execute ( " DELETE FROM documents WHERE id = ?1 " , params! [ doc_id ] )
. map_err ( | e | format! ( " delete documents row: {} " , e ) ) ? ;
}
Ok ( ( ) )
}
struct PreparedFileUpdate {
path_db : String ,
fsize : u64 ,
fmodified : u64 ,
fhash : Vec < u8 > ,
filename : String ,
visit_index : usize ,
}
2025-09-11 18:36:42 -04:00
/// Process updated files in batch with transaction - files table only (no text extraction)
pub fn process_batch_updates_files_only (
2025-09-11 14:32:04 -04:00
conn_mutex : & Arc < Mutex < Connection > > ,
2026-04-21 02:34:40 -04:00
files_to_update : & [ ( DirEntry , usize ) ] ,
2025-09-11 14:32:04 -04:00
stop_flag : & Arc < Mutex < bool > > ,
2025-09-11 16:32:21 -04:00
status_callback : Option < Box < dyn Fn ( & str ) + Send + Sync > > ,
progress_callback : Option < Box < dyn Fn ( usize ) + Send + Sync > > ,
2026-04-21 02:34:40 -04:00
config : & Config ,
progress_display_total : Option < usize > ,
2025-09-11 14:32:04 -04:00
) -> Result < ( ) , String > {
if files_to_update . is_empty ( ) {
return Ok ( ( ) ) ;
}
2026-04-21 02:34:40 -04:00
let fts_batch = config . processing . fts_update_batch_size . max ( 1 ) ;
2025-09-11 14:32:04 -04:00
2026-04-21 02:34:40 -04:00
for batch in files_to_update . chunks ( fts_batch ) {
2025-09-11 14:32:04 -04:00
if * stop_flag . lock ( ) . unwrap ( ) {
return Ok ( ( ) ) ;
}
2026-04-21 02:34:40 -04:00
let mut prepared : Vec < PreparedFileUpdate > = Vec ::new ( ) ;
2025-09-11 14:32:04 -04:00
2026-04-21 02:34:40 -04:00
for ( entry , visit_index ) in batch . iter ( ) {
2025-09-11 14:32:04 -04:00
if * stop_flag . lock ( ) . unwrap ( ) {
return Ok ( ( ) ) ;
}
2026-04-21 02:34:40 -04:00
let filename = entry
. path ( )
. file_name ( )
. and_then ( | n | n . to_str ( ) )
. unwrap_or ( " unknown " )
. to_string ( ) ;
2025-09-11 14:32:04 -04:00
if let Some ( ref callback ) = status_callback {
2026-04-21 02:34:40 -04:00
let pair = format_progress_pair ( * visit_index , progress_display_total ) ;
callback ( & format! ( " Hashing changed files {} : {} " , pair , filename ) ) ;
2025-09-11 16:32:21 -04:00
}
2025-09-11 14:32:04 -04:00
2026-04-21 02:34:40 -04:00
if let Some ( ref progress_cb ) = progress_callback {
progress_cb ( * visit_index ) ;
2025-09-11 18:36:42 -04:00
}
2025-09-11 14:32:04 -04:00
2025-09-11 18:36:42 -04:00
let fpath = match entry . path ( ) . canonicalize ( ) {
2025-09-12 00:31:52 -04:00
Ok ( fp ) = > {
let path_str = fp . to_string_lossy ( ) . to_string ( ) ;
if path_str . starts_with ( " \\ \\ ? \\ " ) {
std ::ffi ::OsString ::from ( & path_str [ 4 .. ] )
} else {
fp . into_os_string ( )
}
2026-04-21 02:34:40 -04:00
}
2025-09-11 18:36:42 -04:00
Err ( _ ) = > continue ,
} ;
2025-09-11 13:42:18 -04:00
2026-04-21 02:34:40 -04:00
let meta = match std ::fs ::metadata ( & fpath ) {
Ok ( m ) if m . is_file ( ) = > m ,
_ = > continue ,
} ;
2025-09-11 18:36:42 -04:00
let fsize = meta . len ( ) ;
2026-04-21 02:34:40 -04:00
let fmodified = meta
. modified ( )
2025-09-11 18:36:42 -04:00
. map_err ( | e | format! ( " Failed to get modified time: {} " , e ) ) ?
. duration_since ( UNIX_EPOCH )
. map_err ( | e | format! ( " Failed to calculate duration: {} " , e ) ) ?
. as_secs ( ) ;
2025-09-11 14:32:04 -04:00
2026-04-21 02:34:40 -04:00
let fhash = match get_file_hash ( fsize , fpath . clone ( ) , config . processing . hash_length ) {
Ok ( h ) = > h ,
Err ( e ) = > {
eprintln! (
" Warning: Skipping file (cannot hash) {}: {} " ,
fpath . to_string_lossy ( ) ,
e
) ;
continue ;
}
} ;
2025-09-11 14:32:04 -04:00
2026-04-21 02:34:40 -04:00
if * stop_flag . lock ( ) . unwrap ( ) {
return Ok ( ( ) ) ;
}
prepared . push ( PreparedFileUpdate {
path_db : fpath . to_string_lossy ( ) . into_owned ( ) ,
fsize ,
fmodified ,
fhash ,
filename ,
visit_index : * visit_index ,
} ) ;
}
if prepared . is_empty ( ) {
continue ;
}
let conn = conn_mutex . lock ( ) . unwrap ( ) ;
let tx = conn
. unchecked_transaction ( )
. map_err ( | e | format! ( " Failed to begin transaction: {} " , e ) ) ? ;
for row in & prepared {
2025-09-11 18:36:42 -04:00
if * stop_flag . lock ( ) . unwrap ( ) {
drop ( tx ) ;
drop ( conn ) ;
return Ok ( ( ) ) ;
2025-09-11 14:32:04 -04:00
}
2025-09-11 18:36:42 -04:00
2026-04-21 02:34:40 -04:00
if let Some ( ref callback ) = status_callback {
let pair = format_progress_pair ( row . visit_index , progress_display_total ) ;
callback ( & format! (
" Applying index updates {}: {} " ,
pair , row . filename
) ) ;
}
2025-09-11 18:36:42 -04:00
tx . execute (
2026-04-21 02:34:40 -04:00
" UPDATE files SET size = ?1, moddate = ?2, hash = ?3 WHERE path = ?4 " ,
params! [ row . fsize , row . fmodified , row . fhash , row . path_db ] ,
)
. map_err ( | e | format! ( " Failed to update file record: {} " , e ) ) ? ;
fts_remove_document_for_path ( & tx , & row . path_db ) . map_err ( | e | {
format! (
" Failed to remove old document / FTS entry for {}: {} " ,
row . path_db , e
)
} ) ? ;
2025-09-11 14:32:04 -04:00
}
2026-04-21 02:34:40 -04:00
tx . commit ( )
. map_err ( | e | format! ( " Failed to commit transaction: {} " , e ) ) ? ;
2025-09-11 14:32:04 -04:00
}
2026-04-21 02:34:40 -04:00
2025-09-11 14:32:04 -04:00
Ok ( ( ) )
}
2025-09-11 18:36:42 -04:00
/// Process new files in batch with transaction - files table only (no text extraction)
pub fn process_batch_inserts_files_only (
2025-09-11 14:32:04 -04:00
conn_mutex : & Arc < Mutex < Connection > > ,
2026-04-21 02:34:40 -04:00
files_to_insert : & [ ( DirEntry , usize ) ] ,
2025-09-11 14:32:04 -04:00
stop_flag : & Arc < Mutex < bool > > ,
2025-09-11 16:32:21 -04:00
status_callback : Option < Box < dyn Fn ( & str ) + Send + Sync > > ,
progress_callback : Option < Box < dyn Fn ( usize ) + Send + Sync > > ,
2026-04-21 02:34:40 -04:00
config : & Config ,
progress_display_total : Option < usize > ,
2025-09-11 14:32:04 -04:00
) -> Result < ( ) , String > {
if files_to_insert . is_empty ( ) {
return Ok ( ( ) ) ;
}
2025-09-11 16:32:21 -04:00
let batch_size = config . processing . batch_size ;
2025-09-11 14:32:04 -04:00
2025-09-11 16:32:21 -04:00
// Process files in batches of batch_size
2026-04-21 02:34:40 -04:00
for batch in files_to_insert . chunks ( batch_size ) {
2025-09-11 14:32:04 -04:00
if * stop_flag . lock ( ) . unwrap ( ) {
return Ok ( ( ) ) ;
}
let conn = conn_mutex . lock ( ) . unwrap ( ) ;
let tx = conn . unchecked_transaction ( ) . map_err ( | e | format! ( " Failed to begin transaction: {} " , e ) ) ? ;
2026-04-21 02:34:40 -04:00
for ( entry , visit_index ) in batch . iter ( ) {
// Check stop flag for early termination
2025-09-11 14:32:04 -04:00
if * stop_flag . lock ( ) . unwrap ( ) {
drop ( tx ) ;
drop ( conn ) ;
return Ok ( ( ) ) ;
}
// Update status with current file
if let Some ( ref callback ) = status_callback {
2025-09-11 18:36:42 -04:00
let filename = entry . path ( ) . file_name ( )
. and_then ( | n | n . to_str ( ) )
2025-09-11 14:32:04 -04:00
. unwrap_or ( " unknown " ) ;
2026-04-21 02:34:40 -04:00
let pair = format_progress_pair ( * visit_index , progress_display_total ) ;
callback ( & format! ( " Indexing file metadata {} : {} " , pair , filename ) ) ;
2025-09-11 16:32:21 -04:00
}
// Update progress counter
if let Some ( ref progress_cb ) = progress_callback {
2026-04-21 02:34:40 -04:00
progress_cb ( * visit_index ) ;
2025-09-11 18:36:42 -04:00
}
let fpath = match entry . path ( ) . canonicalize ( ) {
2025-09-12 00:31:52 -04:00
Ok ( fp ) = > {
let path_str = fp . to_string_lossy ( ) . to_string ( ) ;
// Remove Windows UNC prefix \\?\
if path_str . starts_with ( " \\ \\ ? \\ " ) {
std ::ffi ::OsString ::from ( & path_str [ 4 .. ] )
} else {
fp . into_os_string ( )
}
} ,
2025-09-11 18:36:42 -04:00
Err ( _ ) = > continue ,
} ;
2026-04-21 02:34:40 -04:00
let meta = match std ::fs ::metadata ( & fpath ) {
Ok ( m ) if m . is_file ( ) = > m ,
_ = > continue ,
} ;
2025-09-11 18:36:42 -04:00
let fsize = meta . len ( ) ;
let fmodified = meta . modified ( )
. map_err ( | e | format! ( " Failed to get modified time: {} " , e ) ) ?
. duration_since ( UNIX_EPOCH )
. map_err ( | e | format! ( " Failed to calculate duration: {} " , e ) ) ?
. as_secs ( ) ;
2026-04-21 02:34:40 -04:00
let fhash = match get_file_hash ( fsize , fpath . clone ( ) , config . processing . hash_length ) {
Ok ( h ) = > h ,
Err ( e ) = > {
eprintln! (
" Warning: Skipping file (cannot hash) {}: {} " ,
fpath . to_string_lossy ( ) ,
e
) ;
continue ;
}
} ;
2025-09-11 18:36:42 -04:00
let fname = entry . path ( ) . file_name ( ) . unwrap ( ) . to_os_string ( ) ;
// Insert into files table
tx . execute (
" INSERT INTO files VALUES (?1, ?2, ?3, ?4, ?5) " ,
params! [ fname . to_str ( ) , fpath . to_string_lossy ( ) , fsize , fmodified , fhash ]
) . map_err ( | e | format! ( " Failed to insert file record: {} " , e ) ) ? ;
2025-09-11 14:32:04 -04:00
}
2026-04-21 02:34:40 -04:00
// Update status with current file
if let Some ( ref callback ) = status_callback {
callback ( " Committing file updates to database… " ) ;
}
2025-09-11 18:36:42 -04:00
tx . commit ( ) . map_err ( | e | format! ( " Failed to commit transaction: {} " , e ) ) ? ;
}
Ok ( ( ) )
}
2025-09-11 14:32:04 -04:00
2026-04-21 02:34:40 -04:00
pub fn cleanup_stale_index_entries (
conn_mutex : & Arc < Mutex < Connection > > ,
stale_paths : & [ String ] ,
stop_flag : & Arc < Mutex < bool > > ,
status_callback : Option < Box < dyn Fn ( & str ) + Send + Sync > > ,
) -> Result < usize , String > {
if stale_paths . is_empty ( ) {
return Ok ( 0 ) ;
}
let conn = conn_mutex . lock ( ) . unwrap ( ) ;
let tx = conn
. unchecked_transaction ( )
. map_err ( | e | format! ( " Failed to begin stale cleanup transaction: {} " , e ) ) ? ;
let mut deleted_count = 0 usize ;
for path in stale_paths {
if * stop_flag . lock ( ) . unwrap ( ) {
let _ = tx . commit ( ) ;
drop ( conn ) ;
return Ok ( deleted_count ) ;
}
if let Some ( ref callback ) = status_callback {
callback ( & format! ( " Removing stale index entry: {} " , path ) ) ;
}
fts_remove_document_for_path ( & tx , path ) . map_err ( | e | {
format! (
" Failed to remove stale document / FTS entry for {}: {} " ,
path , e
)
} ) ? ;
tx . execute ( " DELETE FROM files WHERE path = ?1 " , params! [ path ] )
. map_err ( | e | format! ( " Failed to delete stale file record {} : {} " , path , e ) ) ? ;
deleted_count + = 1 ;
}
tx . commit ( )
. map_err ( | e | format! ( " Failed to commit stale cleanup transaction: {} " , e ) ) ? ;
if deleted_count > 0 & & ! * stop_flag . lock ( ) . unwrap ( ) {
if let Some ( ref callback ) = status_callback {
callback ( " Rebuilding FTS index after stale cleanup... " ) ;
}
fts_finalize_after_text_indexing ( & conn ) ? ;
}
Ok ( deleted_count )
}
/// Process text indexing for files - writes `documents`; FTS rebuilt in `fts_finalize_after_text_indexing`.
2025-09-11 18:36:42 -04:00
pub fn process_text_indexing (
conn_mutex : & Arc < Mutex < Connection > > ,
stop_flag : & Arc < Mutex < bool > > ,
status_callback : Option < Box < dyn Fn ( & str ) + Send + Sync > > ,
progress_callback : Option < Box < dyn Fn ( usize ) + Send + Sync > > ,
config : & Config
) -> Result < ( ) , String > {
2026-04-21 02:34:40 -04:00
let max_size = config . processing . maximum_text_file_size ;
2025-09-11 18:36:42 -04:00
let batch_size = config . processing . batch_size ;
2026-04-21 02:34:40 -04:00
let batch_limit = batch_size as i64 ;
2025-09-11 14:32:04 -04:00
2026-04-21 02:34:40 -04:00
if let Some ( ref callback ) = status_callback {
callback ( " Counting files pending text index… " ) ;
}
let total_files : usize = {
let conn = conn_mutex . lock ( ) . unwrap ( ) ;
conn . query_row (
" SELECT COUNT(*) FROM files f
LEFT JOIN documents d ON f . path = d . path
WHERE d . path IS NULL AND f . size < = ? 1 " ,
[ max_size ] ,
| row | row . get ( 0 ) ,
)
. map_err ( | e | format! ( " Failed to count pending text files: {} " , e ) ) ?
} ;
let mut cursor_path = String ::new ( ) ;
let mut global_index : usize = 0 ;
loop {
2025-09-11 18:36:42 -04:00
if * stop_flag . lock ( ) . unwrap ( ) {
return Ok ( ( ) ) ;
}
2025-09-11 14:32:04 -04:00
2026-04-21 02:34:40 -04:00
let batch : Vec < ( String , String , u64 ) > = {
let conn = conn_mutex . lock ( ) . unwrap ( ) ;
let mut stmt = conn
. prepare (
" SELECT f.name, f.path, f.size FROM files f
LEFT JOIN documents d ON f . path = d . path
WHERE d . path IS NULL AND f . size < = ? 1
AND ( ? 2 = ' ' OR f . path > ? 2 )
ORDER BY f . path
LIMIT ? 3 " ,
)
. map_err ( | e | format! ( " Failed to prepare text indexing query: {} " , e ) ) ? ;
let rows = stmt
. query_map (
rusqlite ::params! [ max_size , cursor_path . as_str ( ) , batch_limit ] ,
| row | {
Ok ( (
row . get ::< _ , String > ( 0 ) ? ,
row . get ::< _ , String > ( 1 ) ? ,
row . get ::< _ , u64 > ( 2 ) ? ,
) )
} ,
)
. map_err ( | e | format! ( " Failed to query files for text indexing: {} " , e ) ) ? ;
rows . collect ::< Result < Vec < _ > , _ > > ( )
. map_err ( | e | format! ( " Failed to read file row: {} " , e ) ) ?
} ;
if batch . is_empty ( ) {
break ;
}
let last_path = batch . last ( ) . unwrap ( ) . 1. clone ( ) ;
cursor_path = last_path ;
2025-09-11 18:36:42 -04:00
let conn = conn_mutex . lock ( ) . unwrap ( ) ;
2026-04-21 02:34:40 -04:00
let tx = conn
. unchecked_transaction ( )
. map_err ( | e | format! ( " Failed to begin transaction: {} " , e ) ) ? ;
2025-09-11 14:32:04 -04:00
2026-04-21 02:34:40 -04:00
for ( fname , fpath , _fsize ) in batch . iter ( ) {
2025-09-11 18:36:42 -04:00
if * stop_flag . lock ( ) . unwrap ( ) {
2025-09-12 00:31:52 -04:00
let _ = tx . commit ( ) ;
2025-09-11 18:36:42 -04:00
drop ( conn ) ;
return Ok ( ( ) ) ;
}
2026-04-21 02:34:40 -04:00
global_index + = 1 ;
2025-09-11 18:36:42 -04:00
if let Some ( ref callback ) = status_callback {
2026-04-21 02:34:40 -04:00
callback ( & format! (
" Extracting text for search indexing {}/{}: {} " ,
global_index , total_files , fname
) ) ;
2025-09-11 18:36:42 -04:00
}
2026-04-21 02:34:40 -04:00
2025-09-11 18:36:42 -04:00
if let Some ( ref progress_cb ) = progress_callback {
progress_cb ( global_index ) ;
}
2026-04-21 02:34:40 -04:00
let path = std ::path ::Path ::new ( fpath . as_str ( ) ) ;
2025-09-11 14:32:04 -04:00
let default_ext = OsString ::new ( ) ;
2026-04-21 02:34:40 -04:00
let file_extension = path
. extension ( )
. unwrap_or ( & default_ext )
. to_ascii_lowercase ( )
. to_str ( )
. unwrap_or ( " " )
. to_string ( ) ;
2025-09-11 14:32:04 -04:00
let ext_str = file_extension . as_str ( ) ;
2026-04-21 02:34:40 -04:00
2025-09-11 18:36:42 -04:00
let text_result = if PLAINTEXT_EXTENSIONS_LIST . contains ( & ext_str ) {
match read_to_string ( fpath ) {
Ok ( file_string ) = > {
2026-04-21 02:34:40 -04:00
let trimmed_file_string =
safe_truncate_string ( & file_string , config . processing . maximum_text_size ) ;
2025-09-11 18:36:42 -04:00
Some ( trimmed_file_string )
}
2026-04-21 02:34:40 -04:00
Err ( _e ) = > None ,
2025-09-11 14:32:04 -04:00
}
} else if SUPPORTED_DOCUMENT_EXTENSIONS_LIST . contains ( & ext_str ) {
2025-09-11 18:36:42 -04:00
match extract_document_text ( & std ::ffi ::OsString ::from ( fpath ) , ext_str ) {
Ok ( extracted_text ) = > {
if ! extracted_text . trim ( ) . is_empty ( ) {
2026-04-21 02:34:40 -04:00
Some ( safe_truncate_string (
& extracted_text ,
config . processing . maximum_text_size ,
) )
2025-09-11 14:32:04 -04:00
} else {
2025-09-11 18:36:42 -04:00
None
}
}
Err ( e ) = > {
2026-04-21 02:34:40 -04:00
eprintln! (
" Warning: Failed to extract text from document {}: {} " ,
fpath , e
) ;
2025-09-11 18:36:42 -04:00
None
2025-09-11 14:32:04 -04:00
}
}
2025-09-11 18:36:42 -04:00
} else {
None
} ;
if let Some ( text_content ) = text_result {
if let Err ( e ) = tx . execute (
2026-04-21 02:34:40 -04:00
" INSERT OR REPLACE INTO documents(name, path, text) VALUES (?1, ?2, ?3) " ,
params! [ fname , fpath , text_content ] ,
2025-09-11 18:36:42 -04:00
) {
2026-04-21 02:34:40 -04:00
eprintln! ( " Warning: Failed to insert document row for {} : {} " , fpath , e ) ;
2025-09-11 18:36:42 -04:00
}
2025-09-11 14:32:04 -04:00
}
}
2026-04-21 02:34:40 -04:00
tx . commit ( )
. map_err ( | e | format! ( " Failed to commit transaction: {} " , e ) ) ? ;
2025-09-11 14:32:04 -04:00
}
2026-04-21 02:34:40 -04:00
if total_files > 0 & & ! * stop_flag . lock ( ) . unwrap ( ) {
let conn = conn_mutex . lock ( ) . unwrap ( ) ;
fts_finalize_after_text_indexing ( & conn ) ? ;
}
2025-09-11 14:32:04 -04:00
Ok ( ( ) )
}