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 image_ocr;
15mod key_phrases;
16mod location_timestamp;
17mod pdf_content;
18mod renamer;
19mod scorer;
20mod series_detector;
21mod stem_analyzer;
22mod text_content;
23mod video_ocr;
24
25// Re-export public types
26pub use deps_check::{detect_needed_dependencies, Dependency, DependencyNeeds};
27pub use detector::FileCategory;
28
29/// Configuration options for the rename engine
30#[derive(Debug, Clone)]
31pub struct RenameConfig {
32    /// Skip hidden files and directories (starting with .)
33    pub skip_hidden: bool,
34    /// Include GPS location in filenames (for photos/videos)
35    pub include_location: bool,
36    /// Include formatted timestamp in filenames
37    pub include_timestamp: bool,
38    /// Use multi-frame video analysis (slower but better OCR)
39    pub multiframe_video: bool,
40}
41
42impl Default for RenameConfig {
43    fn default() -> Self {
44        Self {
45            skip_hidden: false,
46            include_location: false,
47            include_timestamp: false,
48            multiframe_video: true, // Multi-frame video analysis is now the default
49        }
50    }
51}
52
53/// Result of analyzing a single file
54#[derive(Debug, Clone)]
55pub struct FileAnalysis {
56    /// Original file path
57    pub original_path: PathBuf,
58    /// Original filename
59    pub original_name: String,
60    /// Proposed new filename (None if no suitable name found)
61    pub proposed_name: Option<String>,
62    /// File category detected
63    pub file_category: FileCategory,
64}
65
66/// Result of a rename operation
67#[derive(Debug, Clone)]
68pub struct RenameResult {
69    /// Original file path
70    pub original_path: PathBuf,
71    /// New filename applied
72    pub new_name: String,
73    /// Whether the rename was successful
74    pub success: bool,
75    /// Error message if failed
76    pub error: Option<String>,
77}
78
79/// Main rename engine that handles file analysis and renaming
80pub struct RenameEngine {
81    config: RenameConfig,
82}
83
84impl RenameEngine {
85    /// Create a new rename engine with the given configuration
86    pub fn new(config: RenameConfig) -> Self {
87        Self { config }
88    }
89
90    /// Create a rename engine with default configuration
91    pub fn default() -> Self {
92        Self::new(RenameConfig::default())
93    }
94
95    /// Analyze all files in a directory and return proposed renames
96    /// This does not perform any actual renaming - use for preview
97    pub fn analyze_directory(&self, directory: &Path) -> Result<Vec<FileAnalysis>> {
98        let mut analyses = Vec::new();
99
100        // Scan files
101        let files = self.scan_files(directory)?;
102
103        // Pre-populate existing names
104        let mut existing_names = HashSet::new();
105        for file_path in &files {
106            if let Some(filename) = file_path.file_name() {
107                if let Some(name) = filename.to_str() {
108                    existing_names.insert(name.to_string());
109                }
110            }
111        }
112
113        // Analyze each file
114        for file_path in files {
115            match self.analyze_file(&file_path, &mut existing_names) {
116                Ok(analysis) => analyses.push(analysis),
117                Err(e) => {
118                    log::warn!("Failed to analyze {}: {}", file_path.display(), e);
119                    // Still add to results but with no proposed name
120                    if let Some(name) = file_path.file_name().and_then(|n| n.to_str()) {
121                        analyses.push(FileAnalysis {
122                            original_path: file_path.clone(),
123                            original_name: name.to_string(),
124                            proposed_name: None,
125                            file_category: FileCategory::Unknown,
126                        });
127                    }
128                }
129            }
130        }
131
132        Ok(analyses)
133    }
134
135    /// Rename files based on analysis results
136    /// Only renames files where analysis.proposed_name is Some()
137    pub fn rename_files(&self, analyses: &[FileAnalysis], dry_run: bool) -> Vec<RenameResult> {
138        let mut results = Vec::new();
139
140        for analysis in analyses {
141            if let Some(new_name) = &analysis.proposed_name {
142                match renamer::rename_file(&analysis.original_path, new_name, dry_run) {
143                    Ok(_) => {
144                        results.push(RenameResult {
145                            original_path: analysis.original_path.clone(),
146                            new_name: new_name.clone(),
147                            success: true,
148                            error: None,
149                        });
150                    }
151                    Err(e) => {
152                        results.push(RenameResult {
153                            original_path: analysis.original_path.clone(),
154                            new_name: new_name.clone(),
155                            success: false,
156                            error: Some(e.to_string()),
157                        });
158                    }
159                }
160            }
161        }
162
163        results
164    }
165
166    /// Analyze and rename files in one step (like the original CLI behavior)
167    pub fn process_directory(&self, directory: &Path, dry_run: bool) -> Result<Vec<RenameResult>> {
168        let analyses = self.analyze_directory(directory)?;
169        Ok(self.rename_files(&analyses, dry_run))
170    }
171
172    // Private helper methods
173
174    fn scan_files(&self, directory: &Path) -> Result<Vec<PathBuf>> {
175        use walkdir::WalkDir;
176
177        let mut files = Vec::new();
178
179        for entry in WalkDir::new(directory)
180            .follow_links(false)
181            .into_iter()
182            .filter_entry(|e| {
183                if self.config.skip_hidden {
184                    !e.file_name()
185                        .to_str()
186                        .map(|s| s.starts_with('.'))
187                        .unwrap_or(false)
188                } else {
189                    true
190                }
191            })
192        {
193            match entry {
194                Ok(entry) => {
195                    if entry.file_type().is_file() {
196                        files.push(entry.path().to_path_buf());
197                    }
198                }
199                Err(e) => {
200                    log::warn!("Failed to access entry: {}", e);
201                }
202            }
203        }
204
205        Ok(files)
206    }
207
208    fn analyze_file(
209        &self,
210        file_path: &Path,
211        existing_names: &mut HashSet<String>,
212    ) -> Result<FileAnalysis> {
213        // Detect file type
214        let file_category = detector::detect_file_type(file_path)?;
215
216        let original_name = file_path
217            .file_name()
218            .and_then(|n| n.to_str())
219            .unwrap_or("unknown")
220            .to_string();
221
222        // Skip unknown file types
223        if file_category == FileCategory::Unknown {
224            return Ok(FileAnalysis {
225                original_path: file_path.to_path_buf(),
226                original_name,
227                proposed_name: None,
228                file_category,
229            });
230        }
231
232        // Extract metadata with configuration
233        let metadata = match extractor::extract_metadata(file_path, &self.config) {
234            Ok(m) => m,
235            Err(_) => {
236                return Ok(FileAnalysis {
237                    original_path: file_path.to_path_buf(),
238                    original_name,
239                    proposed_name: None,
240                    file_category,
241                });
242            }
243        };
244
245        // Extract candidate name
246        let candidate_name = metadata.extract_name(&file_category, file_path);
247
248        let proposed_name = candidate_name.map(|name| {
249            let extension = file_path.extension();
250            generator::generate_filename(&name, extension, existing_names)
251        });
252
253        Ok(FileAnalysis {
254            original_path: file_path.to_path_buf(),
255            original_name,
256            proposed_name,
257            file_category,
258        })
259    }
260}
261
262/// Check if all required dependencies are installed
263pub fn check_dependencies() -> Result<()> {
264    deps::print_dependency_status();
265    Ok(())
266}
267
268/// Install missing dependencies (interactive)
269pub fn install_dependencies() -> Result<()> {
270    deps::run_installer().map_err(|e| anyhow::anyhow!(e))
271}
272
273/// Install dependencies with progress callback
274pub fn install_dependencies_with_progress(
275    progress: Option<deps::ProgressCallback>,
276) -> Result<()> {
277    deps::run_installer_with_progress(progress).map_err(|e| anyhow::anyhow!(e))
278}
279
280/// Re-export progress callback type
281pub use deps::ProgressCallback;