shotext 0.1.4

High-performance OCR & Full-Text Search Indexer for macOS Screenshots
Documentation
use crate::config::Config;
use crate::error::AppError;
use std::env;
use std::path::PathBuf;

pub use cli::{Cli, Commands};

pub mod cli;
pub mod colours;
pub mod config;
pub mod db;
pub mod error;
pub mod ingest;
pub mod ocr;
pub mod search;
pub mod viewer;
pub mod watch;

/// Initialise or open the Tantivy search index located at the specified path.
pub fn initialise_search_index(config: &Config) -> Result<tantivy::Index, AppError> {
    let search_index_path = match env::var("SHOTEXT_DB_PATH") {
        Ok(path_str) => PathBuf::from(path_str).join("search_index"),
        Err(_) => config
            .paths
            .database
            .parent()
            .map(|p| p.join("search_index"))
            .unwrap_or_else(|| {
                dirs::data_dir()
                    .unwrap_or_else(|| PathBuf::from("."))
                    .join("shotext")
                    .join("search_index")
            }),
    };

    let index = search::open_index(&search_index_path)?;
    Ok(index)
}

// The main logic function, which takes the parsed CLI commands
pub fn run(cli: Cli, config: Config) -> Result<(), AppError> {
    // Open the database
    let db = db::open(config.clone())?; // Clone config for search index init
                                        // Initialise the search index
    let search_index =
        initialise_search_index(&config).map_err(|e| AppError::Search(e.to_string()))?;

    match cli.command {
        Commands::Ingest { force } => {
            let report = ingest::run(&config, &db, &search_index, force)?;
            colours::info(&format!(
                "\nDone — {} found, {} new, {} skipped, {} errors",
                report.found, report.new, report.skipped, report.errors
            ));
            Ok(())
        }
        Commands::Watch => {
            watch::run(&config, &db, &search_index)?;
            Ok(())
        }
        Commands::List { verbose } => {
            let mut records = search::all_records(&db);
            if records.is_empty() {
                colours::info("No screenshots indexed yet. Run `shotext ingest` first.");
                return Ok(());
            }

            records.sort_by(|a, b| a.created_at.cmp(&b.created_at));

            colours::info(&format!("{} indexed screenshots\n", records.len()));

            // Header
            if verbose {
                println!("{:<12}  {:<16}  {:<60}  {}", "HASH", "DATE", "PATH", "TEXT");
                println!("{}", "".repeat(120));
            } else {
                println!("{:<64}  {:<16}  {}", "HASH", "DATE", "PATH");
                println!("{}", "".repeat(120));
            }

            for r in &records {
                let file_name = std::path::Path::new(&r.path)
                    .file_name()
                    .and_then(|n| n.to_str())
                    .unwrap_or(&r.path);

                if verbose {
                    println!("{:<64}  {:<16}  {}", r.hash, r.created_at, r.path);
                    let snippet = ocr::truncate(&r.content, 75).replace('\n', " ");
                    if !snippet.is_empty() {
                        println!("  └─ {}", snippet);
                    }
                } else {
                    println!("{:<64}  {:<16}  {:<60}", r.hash, r.created_at, file_name);
                }
            }
            Ok(())
        }
        Commands::Search { query } => {
            match query {
                Some(q) if !q.is_empty() => {
                    // Tantivy full-text search
                    colours::info(&format!("Searching for: \"{}\"", q));
                    let results = search::query(&search_index, &q, 20)?;
                    search::print_results(&results);
                }
                _ => {
                    // Interactive skim fuzzy search over all ingested records
                    let records = search::all_records(&db);
                    if records.is_empty() {
                        colours::info("No screenshots indexed yet. Run `shotext ingest` first.");
                        return Ok(());
                    }
                    colours::info(&format!(
                        "Loaded {} records — launching fuzzy finder…",
                        records.len()
                    ));
                    match search::interactive_search(&records) {
                        Some(idx) => {
                            let r = &records[idx];
                            launch_viewer(&r.path, r.content.clone())?;
                        }
                        None => colours::info("Search cancelled."),
                    }
                }
            }
            Ok(())
        }
        Commands::View { target } => {
            let (path, text) = resolve_view_target(&target, &db)?;
            launch_viewer(&path, text)?;
            Ok(())
        }
        Commands::Config { edit } => {
            if edit {
                let path = config::config_path();
                let editor = env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
                std::process::Command::new(&editor)
                    .arg(&path)
                    .status()
                    .map_err(|e| AppError::ConfigError(format!("Failed to open editor: {}", e)))?;
            } else {
                let path = config::config_path();
                colours::info(&format!("Config file: {}\n", path.display()));
                println!("{}", config);
            }
            Ok(())
        }
    }
}

/// Resolve a view target to a `(file_path, extracted_text)` pair.
///
/// The target can be:
/// - A file path to a PNG (hashes the file and looks up text in sled)
/// - A blake3 hash (looks up the record directly in sled)
fn resolve_view_target(target: &str, db: &sled::Db) -> Result<(String, String), AppError> {
    let path = std::path::Path::new(target);

    // 1. Try as a file path
    if path.exists() && path.is_file() {
        let bytes = std::fs::read(path)?;
        let hash = blake3::hash(&bytes).to_hex().to_string();

        if let Some(val) = db.get(hash.as_bytes())? {
            let record: ingest::ShotRecord = serde_json::from_slice(&val)
                .map_err(|e| AppError::Database(format!("Corrupt record: {}", e)))?;
            return Ok((target.to_string(), record.content));
        }

        // File exists but hasn't been indexed yet
        return Ok((
            target.to_string(),
            "(not yet indexed — run `shotext ingest` first)".to_string(),
        ));
    }

    // 2. Try as a blake3 hash
    if let Some(val) = db.get(target.as_bytes())? {
        let record: ingest::ShotRecord = serde_json::from_slice(&val)
            .map_err(|e| AppError::Database(format!("Corrupt record: {}", e)))?;
        return Ok((record.path, record.content));
    }

    Err(AppError::GuiError(format!(
        "Target not found: '{}' — provide a file path or a known hash",
        target
    )))
}

/// Read the image from disk and open the egui viewer window.
fn launch_viewer(path: &str, text: String) -> Result<(), AppError> {
    let image_bytes = std::fs::read(path)
        .map_err(|e| AppError::GuiError(format!("Failed to read image {}: {}", path, e)))?;

    colours::info(&format!("Opening viewer for: {}", path));
    let v = viewer::ShotViewer::new(path, text, image_bytes);
    v.launch().map_err(|e| AppError::GuiError(e.to_string()))?;
    Ok(())
}