Files
ImageApi/src/cleanup/phase2.rs
T
Cameron b9d5578653 feat(bins): multi-library populate_knowledge + progress UX
populate_knowledge now loads real libraries from the DB instead of
fabricating a single library_id=1 row from BASE_PATH. Adds --library
<id|name> to restrict the walk and validates --path against the selected
library roots. The full library set is still passed to InsightGenerator so
resolve_full_path can probe every root when an insight resolves to a
different library than the one being walked.

Adds indicatif progress bars across the long-running utility binaries via
a shared src/bin_progress.rs helper (determinate bar + open-ended spinner
with consistent styling). Per-batch info! noise is replaced by the bar's
throughput/ETA; warnings and errors route through pb.println so they
scroll above the bar instead of fighting with it.

  populate_knowledge   spinner during scan, determinate bar over all libs
  backfill_hashes      spinner with running hashed/missing/errors counts
  import_calendar      determinate bar; embedding/store failures inline
  import_location_*    determinate bar advancing by chunk size
  import_search_*      determinate bar; pb cloned into the spawn task
  cleanup_files P1     determinate bar over DB paths
  cleanup_files P2     determinate bar; pb.suspend() around y/n/a/s prompt

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 23:55:33 -04:00

260 lines
9.4 KiB
Rust

use crate::bin_progress;
use crate::cleanup::database_updater::DatabaseUpdater;
use crate::cleanup::file_type_detector::{detect_file_type, should_rename};
use crate::cleanup::types::{CleanupConfig, CleanupStats};
use anyhow::Result;
use indicatif::ProgressBar;
use log::{error, warn};
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
/// Phase 2: Validate file types and rename mismatches
pub fn validate_file_types(
config: &CleanupConfig,
db_updater: &mut DatabaseUpdater,
) -> Result<CleanupStats> {
let mut stats = CleanupStats::new();
let mut auto_fix_all = config.auto_fix;
let mut skip_all = false;
println!("\nPhase 2: File Type Validation");
println!("------------------------------");
// Walk the filesystem
println!("Scanning filesystem...");
let files: Vec<PathBuf> = WalkDir::new(&config.base_path)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
.filter(|e| is_supported_media_file(e.path()))
.map(|e| e.path().to_path_buf())
.collect();
println!("Files found: {}\n", files.len());
stats.files_checked = files.len();
let mut mismatches_found = 0;
let mut files_renamed = 0;
let mut user_skipped = 0;
let pb = bin_progress::determinate(files.len() as u64, "detecting");
for file_path in files {
// Get current extension
let current_ext = match file_path.extension() {
Some(ext) => ext.to_str().unwrap_or(""),
None => {
pb.inc(1);
continue;
}
};
// Detect actual file type
match detect_file_type(&file_path) {
Ok(Some(detected_ext)) => {
// Check if we should rename
if should_rename(current_ext, &detected_ext) {
mismatches_found += 1;
stats.issues_found += 1;
// Get relative path for display and database
let relative_path = match file_path.strip_prefix(&config.base_path) {
Ok(rel) => rel.to_str().unwrap_or(""),
Err(_) => {
error!("Failed to get relative path for {:?}", file_path);
pb.inc(1);
continue;
}
};
pb.println(format!(
"mismatch: {} .{} → .{}",
relative_path, current_ext, detected_ext
));
// Calculate new path
let new_file_path = file_path.with_extension(&detected_ext);
let new_relative_path = match new_file_path.strip_prefix(&config.base_path) {
Ok(rel) => rel.to_str().unwrap_or(""),
Err(_) => {
error!("Failed to get new relative path for {:?}", new_file_path);
pb.inc(1);
continue;
}
};
// Check if destination already exists
if new_file_path.exists() {
warn!("✗ Destination already exists: {}", new_relative_path);
stats.add_error(format!(
"Destination exists for {}: {}",
relative_path, new_relative_path
));
pb.inc(1);
continue;
}
// Determine if we should proceed
let should_proceed = if config.dry_run {
pb.println(format!(
" (dry-run — would rename to {})",
new_relative_path
));
false
} else if skip_all {
user_skipped += 1;
false
} else if auto_fix_all {
true
} else {
// Interactive prompt — suspend the bar so the prompt is visible.
let decision = pb.suspend(|| prompt_for_rename(new_relative_path, &pb));
match decision {
RenameDecision::Yes => true,
RenameDecision::No => {
user_skipped += 1;
false
}
RenameDecision::All => {
auto_fix_all = true;
true
}
RenameDecision::SkipAll => {
skip_all = true;
user_skipped += 1;
false
}
}
};
if should_proceed {
// Rename the file
match fs::rename(&file_path, &new_file_path) {
Ok(_) => {
// Update database
match db_updater.update_file_path(relative_path, new_relative_path)
{
Ok(_) => {
files_renamed += 1;
stats.issues_fixed += 1;
}
Err(e) => {
error!(
"File renamed but DB update failed for {}: {:?}",
relative_path, e
);
stats.add_error(format!(
"DB update failed for {}: {}",
relative_path, e
));
}
}
}
Err(e) => {
error!("✗ Failed to rename file: {:?}", e);
stats.add_error(format!(
"Rename failed for {}: {}",
relative_path, e
));
}
}
}
}
}
Ok(None) => {
// Could not detect file type - skip
// This is normal for some RAW formats or corrupted files
}
Err(e) => {
warn!("Failed to detect type for {:?}: {:?}", file_path, e);
}
}
pb.set_message(format!(
"mismatches={} renamed={} skipped={}",
mismatches_found, files_renamed, user_skipped
));
pb.inc(1);
}
pb.finish_and_clear();
println!("\nResults:");
println!("- Files scanned: {}", stats.files_checked);
println!("- Mismatches found: {}", mismatches_found);
if config.dry_run {
println!("- Would rename: {}", mismatches_found);
} else {
println!("- Files renamed: {}", files_renamed);
if user_skipped > 0 {
println!("- User skipped: {}", user_skipped);
}
}
if !stats.errors.is_empty() {
println!("- Errors: {}", stats.errors.len());
}
Ok(stats)
}
/// Check if a file is a supported media file based on extension
fn is_supported_media_file(path: &Path) -> bool {
use crate::file_types::is_media_file;
is_media_file(path)
}
#[derive(Debug)]
enum RenameDecision {
Yes,
No,
All,
SkipAll,
}
/// Prompt the user for rename decision. Caller must `pb.suspend` so the
/// progress bar isn't redrawing over the prompt.
fn prompt_for_rename(new_path: &str, _pb: &ProgressBar) -> RenameDecision {
println!("\nRename to {}?", new_path);
println!(" [y] Yes");
println!(" [n] No (default)");
println!(" [a] Yes to all");
println!(" [s] Skip all remaining");
print!("Choice: ");
// Force flush stdout
use std::io::{self, Write};
let _ = io::stdout().flush();
let mut input = String::new();
match io::stdin().read_line(&mut input) {
Ok(_) => {
let choice = input.trim().to_lowercase();
match choice.as_str() {
"y" | "yes" => RenameDecision::Yes,
"a" | "all" => RenameDecision::All,
"s" | "skip" => RenameDecision::SkipAll,
_ => RenameDecision::No,
}
}
Err(_) => RenameDecision::No,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_supported_media_file() {
assert!(is_supported_media_file(Path::new("test.jpg")));
assert!(is_supported_media_file(Path::new("test.JPG")));
assert!(is_supported_media_file(Path::new("test.png")));
assert!(is_supported_media_file(Path::new("test.webp")));
assert!(is_supported_media_file(Path::new("test.mp4")));
assert!(is_supported_media_file(Path::new("test.mov")));
assert!(!is_supported_media_file(Path::new("test.txt")));
assert!(!is_supported_media_file(Path::new("test")));
}
}