nameback_core/
lib.rs

1use anyhow::Result;
2use std::collections::HashSet;
3use std::path::{Path, PathBuf};
4
5// Internal modules (private)
6mod code_docstring;
7mod deps;
8mod deps_check;
9mod detector;
10mod dir_context;
11mod extractor;
12mod format_handlers;
13mod generator;
14mod geocoding;
15mod image_ocr;
16mod key_phrases;
17mod location_timestamp;
18mod metadata_cache;
19mod pdf_content;
20mod rename_history;
21mod renamer;
22mod scorer;
23mod series_detector;
24mod stem_analyzer;
25mod text_content;
26mod video_ocr;
27
28// Re-export public types
29pub use deps_check::{detect_needed_dependencies, Dependency, DependencyNeeds};
30pub use detector::FileCategory;
31pub use rename_history::{RenameHistory, RenameOperation};
32
33/// Configuration options for the rename engine
34#[derive(Debug, Clone)]
35pub struct RenameConfig {
36    /// Skip hidden files and directories (starting with .)
37    pub skip_hidden: bool,
38    /// Include GPS location in filenames (for photos/videos)
39    pub include_location: bool,
40    /// Include formatted timestamp in filenames
41    pub include_timestamp: bool,
42    /// Use multi-frame video analysis (slower but better OCR)
43    pub multiframe_video: bool,
44    /// Use geocoding to convert GPS coordinates to city names (defaults to true)
45    /// When false, shows coordinates like "47.6N_122.3W" instead of "Seattle_WA"
46    pub geocode: bool,
47    /// Enable metadata caching to speed up re-analysis
48    pub enable_cache: bool,
49    /// Cache file path (None = use default location)
50    pub cache_path: Option<PathBuf>,
51}
52
53impl Default for RenameConfig {
54    fn default() -> Self {
55        Self {
56            skip_hidden: false,
57            include_location: true, // Include GPS location by default
58            include_timestamp: true, // Include timestamps by default
59            multiframe_video: true, // Multi-frame video analysis is now the default
60            geocode: true, // Geocoding is enabled by default
61            enable_cache: true, // Metadata caching enabled by default
62            cache_path: None, // Use default cache location
63        }
64    }
65}
66
67/// Result of analyzing a single file
68#[derive(Debug, Clone)]
69pub struct FileAnalysis {
70    /// Original file path
71    pub original_path: PathBuf,
72    /// Original filename
73    pub original_name: String,
74    /// Proposed new filename (None if no suitable name found)
75    pub proposed_name: Option<String>,
76    /// File category detected
77    pub file_category: FileCategory,
78}
79
80/// Result of a rename operation
81#[derive(Debug, Clone)]
82pub struct RenameResult {
83    /// Original file path
84    pub original_path: PathBuf,
85    /// New filename applied
86    pub new_name: String,
87    /// Whether the rename was successful
88    pub success: bool,
89    /// Error message if failed
90    pub error: Option<String>,
91}
92
93/// Main rename engine that handles file analysis and renaming
94pub struct RenameEngine {
95    config: RenameConfig,
96}
97
98impl RenameEngine {
99    /// Create a new rename engine with the given configuration
100    pub fn new(config: RenameConfig) -> Self {
101        Self { config }
102    }
103
104    /// Create a rename engine with default configuration
105    pub fn default() -> Self {
106        Self::new(RenameConfig::default())
107    }
108
109    /// Analyze all files in a directory and return proposed renames
110    /// This does not perform any actual renaming - use for preview
111    pub fn analyze_directory(&self, directory: &Path) -> Result<Vec<FileAnalysis>> {
112        let mut analyses = Vec::new();
113
114        // Scan files
115        let files = self.scan_files(directory)?;
116
117        // Load or create metadata cache
118        let cache_path = self.config.cache_path.clone().unwrap_or_else(|| {
119            directory.join(".nameback_cache.json")
120        });
121
122        let mut cache = if self.config.enable_cache {
123            metadata_cache::MetadataCache::load(cache_path.clone()).unwrap_or_else(|_| {
124                log::debug!("Failed to load cache, creating new one");
125                metadata_cache::MetadataCache::new(cache_path.clone())
126            })
127        } else {
128            metadata_cache::MetadataCache::new(cache_path.clone())
129        };
130
131        // Clean up stale cache entries
132        if self.config.enable_cache {
133            cache.cleanup_stale_entries(&files);
134        }
135
136        // Detect file series (e.g., IMG_001.jpg, IMG_002.jpg, etc.)
137        let series_list = series_detector::detect_series(&files);
138        log::info!("Detected {} file series", series_list.len());
139
140        // Build a map of file paths to their series
141        let mut file_series_map = std::collections::HashMap::new();
142        for series in &series_list {
143            for (file_path, _) in &series.files {
144                file_series_map.insert(file_path.clone(), series.clone());
145            }
146        }
147
148        // Pre-populate existing names
149        let mut existing_names = HashSet::new();
150        for file_path in &files {
151            if let Some(filename) = file_path.file_name() {
152                if let Some(name) = filename.to_str() {
153                    existing_names.insert(name.to_string());
154                }
155            }
156        }
157
158        // Analyze each file in parallel using rayon
159        use rayon::prelude::*;
160        use std::sync::Mutex;
161
162        // Wrap existing_names and cache in Mutex for thread-safe access
163        let existing_names = Mutex::new(existing_names);
164        let cache = Mutex::new(cache);
165
166        // Process files in parallel
167        analyses = files
168            .par_iter()
169            .filter_map(|file_path| {
170                // Check cache first if enabled
171                if self.config.enable_cache {
172                    let cache_guard = cache.lock().unwrap();
173                    if let Ok(true) = cache_guard.has_valid_entry(file_path) {
174                        if let Some(entry) = cache_guard.get(file_path) {
175                            log::debug!("Cache hit for {}", file_path.display());
176                            let category = match entry.category.as_str() {
177                                "Image" => FileCategory::Image,
178                                "Document" => FileCategory::Document,
179                                "Audio" => FileCategory::Audio,
180                                "Video" => FileCategory::Video,
181                                "Email" => FileCategory::Email,
182                                "Web" => FileCategory::Web,
183                                "Archive" => FileCategory::Archive,
184                                "SourceCode" => FileCategory::SourceCode,
185                                _ => FileCategory::Unknown,
186                            };
187
188                            let original_name = file_path
189                                .file_name()
190                                .and_then(|n| n.to_str())
191                                .unwrap_or("unknown")
192                                .to_string();
193
194                            return Some(FileAnalysis {
195                                original_path: file_path.clone(),
196                                original_name,
197                                proposed_name: entry.proposed_name.clone(),
198                                file_category: category,
199                            });
200                        }
201                    }
202                    drop(cache_guard); // Release lock before analysis
203                }
204
205                // Cache miss or caching disabled - analyze the file
206                match self.analyze_file_parallel(file_path, &existing_names) {
207                    Ok(mut analysis) => {
208                        // Check if this file is part of a series
209                        if let Some(series) = file_series_map.get(file_path) {
210                            // Apply series naming if we have a proposed name
211                            if let Some(proposed_name) = &analysis.proposed_name {
212                                // Extract just the base name without extension
213                                let base_name = if let Some(pos) = proposed_name.rfind('.') {
214                                    &proposed_name[..pos]
215                                } else {
216                                    proposed_name
217                                };
218
219                                // Apply series naming pattern
220                                if let Some(series_name) = series_detector::apply_series_naming(
221                                    series,
222                                    file_path,
223                                    base_name,
224                                ) {
225                                    analysis.proposed_name = Some(series_name);
226                                }
227                            }
228                        }
229
230                        // Update cache if enabled
231                        if self.config.enable_cache {
232                            let mut cache_guard = cache.lock().unwrap();
233                            let category_str = match analysis.file_category {
234                                FileCategory::Image => "Image",
235                                FileCategory::Document => "Document",
236                                FileCategory::Audio => "Audio",
237                                FileCategory::Video => "Video",
238                                FileCategory::Email => "Email",
239                                FileCategory::Web => "Web",
240                                FileCategory::Archive => "Archive",
241                                FileCategory::SourceCode => "SourceCode",
242                                FileCategory::Unknown => "Unknown",
243                            };
244
245                            if let Err(e) = cache_guard.insert(
246                                file_path,
247                                analysis.proposed_name.clone(),
248                                category_str,
249                            ) {
250                                log::warn!("Failed to cache entry for {}: {}", file_path.display(), e);
251                            }
252                        }
253
254                        Some(analysis)
255                    },
256                    Err(e) => {
257                        log::warn!("Failed to analyze {}: {}", file_path.display(), e);
258                        // Still add to results but with no proposed name
259                        if let Some(name) = file_path.file_name().and_then(|n| n.to_str()) {
260                            Some(FileAnalysis {
261                                original_path: file_path.clone(),
262                                original_name: name.to_string(),
263                                proposed_name: None,
264                                file_category: FileCategory::Unknown,
265                            })
266                        } else {
267                            None
268                        }
269                    }
270                }
271            })
272            .collect();
273
274        // Save cache to disk if enabled
275        if self.config.enable_cache {
276            let cache_guard = cache.lock().unwrap();
277            if let Err(e) = cache_guard.save() {
278                log::warn!("Failed to save cache: {}", e);
279            } else {
280                let stats = cache_guard.stats();
281                log::info!(
282                    "Cached {} entries ({} bytes)",
283                    stats.total_entries,
284                    stats.cache_size_bytes
285                );
286            }
287        }
288
289        Ok(analyses)
290    }
291
292    /// Rename files based on analysis results
293    /// Only renames files where analysis.proposed_name is Some()
294    pub fn rename_files(&self, analyses: &[FileAnalysis], dry_run: bool) -> Vec<RenameResult> {
295        self.rename_files_with_history(analyses, dry_run, None)
296    }
297
298    /// Rename files with history tracking
299    /// If history is provided, successful renames will be added to the history
300    pub fn rename_files_with_history(
301        &self,
302        analyses: &[FileAnalysis],
303        dry_run: bool,
304        mut history: Option<&mut RenameHistory>,
305    ) -> Vec<RenameResult> {
306        let mut results = Vec::new();
307
308        for analysis in analyses {
309            if let Some(new_name) = &analysis.proposed_name {
310                match renamer::rename_file(&analysis.original_path, new_name, dry_run) {
311                    Ok(new_path) => {
312                        // Add to history if provided and not dry run
313                        if let Some(hist) = history.as_deref_mut() {
314                            if !dry_run {
315                                let operation = RenameOperation::new(
316                                    analysis.original_path.clone(),
317                                    new_path.clone(),
318                                );
319                                hist.add(operation);
320                            }
321                        }
322
323                        results.push(RenameResult {
324                            original_path: analysis.original_path.clone(),
325                            new_name: new_name.clone(),
326                            success: true,
327                            error: None,
328                        });
329                    }
330                    Err(e) => {
331                        results.push(RenameResult {
332                            original_path: analysis.original_path.clone(),
333                            new_name: new_name.clone(),
334                            success: false,
335                            error: Some(e.to_string()),
336                        });
337                    }
338                }
339            }
340        }
341
342        results
343    }
344
345    /// Analyze and rename files in one step (like the original CLI behavior)
346    pub fn process_directory(&self, directory: &Path, dry_run: bool) -> Result<Vec<RenameResult>> {
347        let analyses = self.analyze_directory(directory)?;
348        Ok(self.rename_files(&analyses, dry_run))
349    }
350
351    // Private helper methods
352
353    fn scan_files(&self, directory: &Path) -> Result<Vec<PathBuf>> {
354        use walkdir::WalkDir;
355
356        let mut files = Vec::new();
357
358        for entry in WalkDir::new(directory)
359            .follow_links(false)
360            .into_iter()
361            .filter_entry(|e| {
362                let filename = e.file_name().to_str().unwrap_or("");
363
364                // Always skip cache file
365                if filename == ".nameback_cache.json" {
366                    return false;
367                }
368
369                // Skip hidden files if configured
370                if self.config.skip_hidden && filename.starts_with('.') {
371                    return false;
372                }
373
374                true
375            })
376        {
377            match entry {
378                Ok(entry) => {
379                    if entry.file_type().is_file() {
380                        files.push(entry.path().to_path_buf());
381                    }
382                }
383                Err(e) => {
384                    log::warn!("Failed to access entry: {}", e);
385                }
386            }
387        }
388
389        Ok(files)
390    }
391
392    fn analyze_file(
393        &self,
394        file_path: &Path,
395        existing_names: &mut HashSet<String>,
396    ) -> Result<FileAnalysis> {
397        // Detect file type
398        let file_category = detector::detect_file_type(file_path)?;
399
400        let original_name = file_path
401            .file_name()
402            .and_then(|n| n.to_str())
403            .unwrap_or("unknown")
404            .to_string();
405
406        // Skip unknown file types
407        if file_category == FileCategory::Unknown {
408            return Ok(FileAnalysis {
409                original_path: file_path.to_path_buf(),
410                original_name,
411                proposed_name: None,
412                file_category,
413            });
414        }
415
416        // Extract metadata with configuration
417        let metadata = match extractor::extract_metadata(file_path, &self.config) {
418            Ok(m) => m,
419            Err(_) => {
420                return Ok(FileAnalysis {
421                    original_path: file_path.to_path_buf(),
422                    original_name,
423                    proposed_name: None,
424                    file_category,
425                });
426            }
427        };
428
429        // Extract candidate name
430        let candidate_name = metadata.extract_name(&file_category, file_path);
431
432        let proposed_name = candidate_name.map(|name| {
433            let extension = file_path.extension();
434            generator::generate_filename_with_metadata(&name, extension, existing_names, Some(&metadata))
435        });
436
437        Ok(FileAnalysis {
438            original_path: file_path.to_path_buf(),
439            original_name,
440            proposed_name,
441            file_category,
442        })
443    }
444
445    /// Parallel version of analyze_file that uses Mutex-protected existing_names
446    fn analyze_file_parallel(
447        &self,
448        file_path: &Path,
449        existing_names: &std::sync::Mutex<HashSet<String>>,
450    ) -> Result<FileAnalysis> {
451        // Detect file type
452        let file_category = detector::detect_file_type(file_path)?;
453
454        let original_name = file_path
455            .file_name()
456            .and_then(|n| n.to_str())
457            .unwrap_or("unknown")
458            .to_string();
459
460        // Skip unknown file types
461        if file_category == FileCategory::Unknown {
462            return Ok(FileAnalysis {
463                original_path: file_path.to_path_buf(),
464                original_name,
465                proposed_name: None,
466                file_category,
467            });
468        }
469
470        // Extract metadata with configuration
471        let metadata = match extractor::extract_metadata(file_path, &self.config) {
472            Ok(m) => m,
473            Err(_) => {
474                return Ok(FileAnalysis {
475                    original_path: file_path.to_path_buf(),
476                    original_name,
477                    proposed_name: None,
478                    file_category,
479                });
480            }
481        };
482
483        // Extract candidate name
484        let candidate_name = metadata.extract_name(&file_category, file_path);
485
486        let proposed_name = candidate_name.map(|name| {
487            let extension = file_path.extension();
488            // Lock the mutex to access existing_names
489            let mut names = existing_names.lock().unwrap();
490            generator::generate_filename_with_metadata(&name, extension, &mut names, Some(&metadata))
491        });
492
493        Ok(FileAnalysis {
494            original_path: file_path.to_path_buf(),
495            original_name,
496            proposed_name,
497            file_category,
498        })
499    }
500}
501
502/// Check if all required dependencies are installed
503pub fn check_dependencies() -> Result<()> {
504    deps::print_dependency_status();
505    Ok(())
506}
507
508/// Install missing dependencies (interactive)
509pub fn install_dependencies() -> Result<()> {
510    deps::run_installer().map_err(|e| anyhow::anyhow!(e))
511}
512
513/// Install dependencies with progress callback
514pub fn install_dependencies_with_progress(
515    progress: Option<deps::ProgressCallback>,
516) -> Result<()> {
517    deps::run_installer_with_progress(progress).map_err(|e| anyhow::anyhow!(e))
518}
519
520/// Re-export progress callback type
521pub use deps::ProgressCallback;