codex-recall 0.1.2

Local search and recall for Codex session JSONL archives
Documentation
use crate::config::{default_db_path, default_source_roots};
use crate::indexer::index_sources_with_progress;
use crate::output::progress_line;
use crate::store::Store;
use anyhow::{Context, Result};
use clap::Args;
use std::fs;
use std::path::{Path, PathBuf};
use std::time::Instant;

#[derive(Debug, Clone, Args)]
pub struct IndexArgs {
    #[arg(long, help = "SQLite index path")]
    pub db: Option<PathBuf>,
    #[arg(long = "source", help = "Session archive root to scan; repeatable")]
    pub sources: Vec<PathBuf>,
}

#[derive(Debug, Clone, Args)]
pub struct RebuildArgs {
    #[arg(long, help = "SQLite index path")]
    pub db: Option<PathBuf>,
    #[arg(long = "source", help = "Session archive root to scan; repeatable")]
    pub sources: Vec<PathBuf>,
}

pub fn run_index(args: IndexArgs) -> Result<()> {
    let db_path = args.db.unwrap_or(default_db_path()?);
    let sources = resolve_sources(args.sources)?;
    let store = Store::open(&db_path)?;
    let started = Instant::now();
    let report = index_sources_with_progress(&store, &sources, |report| {
        eprintln!("{}", progress_line(report, started.elapsed()));
    })?;
    println!(
        "indexed {} session files, {} events from {}/{} files ({} skipped: {} unchanged, {} missing, {} non-session) into {}",
        report.sessions_indexed,
        report.events_indexed,
        report.files_seen,
        report.files_total,
        report.files_skipped,
        report.skipped_unchanged,
        report.skipped_missing,
        report.skipped_non_session,
        db_path.display()
    );
    Ok(())
}

pub fn run_rebuild(args: RebuildArgs) -> Result<()> {
    let db_path = args.db.unwrap_or(default_db_path()?);
    let sources = resolve_sources(args.sources)?;
    remove_db_files(&db_path)?;
    let store = Store::open(&db_path)?;
    let started = Instant::now();
    let report = index_sources_with_progress(&store, &sources, |report| {
        eprintln!("{}", progress_line(report, started.elapsed()));
    })?;
    println!(
        "rebuilt {} session files, {} events from {}/{} files ({} skipped: {} unchanged, {} missing, {} non-session) into {}",
        report.sessions_indexed,
        report.events_indexed,
        report.files_seen,
        report.files_total,
        report.files_skipped,
        report.skipped_unchanged,
        report.skipped_missing,
        report.skipped_non_session,
        db_path.display()
    );
    Ok(())
}

pub fn resolve_sources(sources: Vec<PathBuf>) -> Result<Vec<PathBuf>> {
    if sources.is_empty() {
        default_source_roots()
    } else {
        Ok(sources)
    }
}

fn remove_db_files(db_path: &Path) -> Result<()> {
    for path in [
        db_path.to_path_buf(),
        PathBuf::from(format!("{}-wal", db_path.display())),
        PathBuf::from(format!("{}-shm", db_path.display())),
    ] {
        match fs::remove_file(&path) {
            Ok(()) => {}
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
            Err(error) => {
                return Err(error).with_context(|| format!("remove {}", path.display()));
            }
        }
    }
    Ok(())
}