codex-recall 0.1.2

Local search and recall for Codex session JSONL archives
Documentation
use crate::config::{default_db_path, default_pins_path};
use crate::output::{now_timestamp, shell_quote};
use crate::store::{SessionMatch, Store};
use anyhow::{bail, Context, Result};
use clap::Args;
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::fs;
use std::path::{Path, PathBuf};

#[derive(Debug, Clone, Args)]
pub struct PinArgs {
    #[arg(help = "Session id or session key to pin")]
    pub session_ref: String,
    #[arg(long, help = "Human-readable pin label")]
    pub label: String,
    #[arg(long, help = "SQLite index path")]
    pub db: Option<PathBuf>,
    #[arg(long, help = "Pins JSON path")]
    pub pins: Option<PathBuf>,
}

#[derive(Debug, Clone, Args)]
pub struct PinsArgs {
    #[arg(long, help = "Pins JSON path")]
    pub pins: Option<PathBuf>,
    #[arg(long, default_value_t = 50, help = "Maximum pins to print")]
    pub limit: usize,
    #[arg(long, help = "Restrict pins to a repo name")]
    pub repo: Option<String>,
    #[arg(long, help = "Restrict pins to a cwd substring")]
    pub cwd: Option<String>,
    #[arg(long, help = "Emit machine-readable JSON")]
    pub json: bool,
}

#[derive(Debug, Clone, Args)]
pub struct UnpinArgs {
    #[arg(help = "Session id or session key to unpin")]
    pub session_ref: String,
    #[arg(long, help = "Pins JSON path")]
    pub pins: Option<PathBuf>,
}

#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct PinsFile {
    version: u32,
    pins: Vec<PinRecord>,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
struct PinRecord {
    session_key: String,
    session_id: String,
    label: String,
    repo: String,
    #[serde(default)]
    repos: Vec<String>,
    cwd: String,
    source_file_path: PathBuf,
    created_at: String,
    updated_at: String,
}

pub fn run_pin(args: PinArgs) -> Result<()> {
    let db_path = args.db.unwrap_or(default_db_path()?);
    let pins_path = args.pins.unwrap_or(default_pins_path()?);
    let store = Store::open_readonly(&db_path)?;
    let matches = store.resolve_session_reference(&args.session_ref)?;
    let session = resolve_single_session(&args.session_ref, &matches)?;
    let repos = store.session_repos(&session.session_key)?;
    let mut pins_file = read_pins_file(&pins_path)?;
    let now = now_timestamp();

    if let Some(existing) = pins_file
        .pins
        .iter_mut()
        .find(|pin| pin.session_key == session.session_key)
    {
        existing.session_id = session.session_id.clone();
        existing.label = args.label;
        existing.repo = session.repo.clone();
        existing.repos = repos;
        existing.cwd = session.cwd.clone();
        existing.source_file_path = session.source_file_path.clone();
        existing.updated_at = now;
    } else {
        pins_file.pins.push(PinRecord {
            session_key: session.session_key.clone(),
            session_id: session.session_id.clone(),
            label: args.label,
            repo: session.repo.clone(),
            repos,
            cwd: session.cwd.clone(),
            source_file_path: session.source_file_path.clone(),
            created_at: now.clone(),
            updated_at: now,
        });
    }

    write_pins_file(&pins_path, &pins_file)?;
    println!("pinned {}  {}", session.session_id, session.session_key);
    Ok(())
}

pub fn run_pins(args: PinsArgs) -> Result<()> {
    let pins_path = args.pins.unwrap_or(default_pins_path()?);
    let mut pins = read_pins_file(&pins_path)?.pins;
    pins.sort_by(|left, right| {
        right
            .updated_at
            .cmp(&left.updated_at)
            .then_with(|| right.created_at.cmp(&left.created_at))
            .then_with(|| left.session_key.cmp(&right.session_key))
    });
    pins.retain(|pin| pin_matches_filters(pin, args.repo.as_deref(), args.cwd.as_deref()));
    pins.truncate(args.limit.clamp(1, 100));

    if args.json {
        print_pins_json(&pins)?;
        return Ok(());
    }

    if pins.is_empty() {
        println!("no pins");
        return Ok(());
    }

    print_pins(&pins);
    Ok(())
}

pub fn run_unpin(args: UnpinArgs) -> Result<()> {
    let pins_path = args.pins.unwrap_or(default_pins_path()?);
    let mut pins_file = read_pins_file(&pins_path)?;
    let Some(index) = pins_file
        .pins
        .iter()
        .position(|pin| pin.session_key == args.session_ref || pin.session_id == args.session_ref)
    else {
        bail!("no pin matches `{}`", args.session_ref);
    };
    let removed = pins_file.pins.remove(index);
    write_pins_file(&pins_path, &pins_file)?;
    println!("unpinned {}  {}", removed.session_id, removed.session_key);
    Ok(())
}

fn resolve_single_session<'a>(
    session_ref: &str,
    matches: &'a [SessionMatch],
) -> Result<&'a SessionMatch> {
    if matches.is_empty() {
        bail!("no indexed session matches `{session_ref}`");
    }
    if matches.len() > 1 {
        let choices = matches
            .iter()
            .map(|session| {
                format!(
                    "  {}  {}  {}",
                    session.session_key,
                    session.cwd,
                    session.source_file_path.display()
                )
            })
            .collect::<Vec<_>>()
            .join("\n");
        bail!("multiple indexed sessions match `{session_ref}`; use one session_key:\n{choices}");
    }
    Ok(&matches[0])
}

fn read_pins_file(path: &Path) -> Result<PinsFile> {
    let bytes = match fs::read(path) {
        Ok(bytes) => bytes,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
            return Ok(PinsFile {
                version: 1,
                pins: Vec::new(),
            });
        }
        Err(error) => return Err(error).with_context(|| format!("read {}", path.display())),
    };
    serde_json::from_slice(&bytes).with_context(|| format!("parse {}", path.display()))
}

fn write_pins_file(path: &Path, pins_file: &PinsFile) -> Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
    }
    let bytes = serde_json::to_vec_pretty(pins_file)?;
    fs::write(path, bytes).with_context(|| format!("write {}", path.display()))?;
    Ok(())
}

fn pin_matches_filters(pin: &PinRecord, repo: Option<&str>, cwd: Option<&str>) -> bool {
    if let Some(repo) = repo {
        if !pin.repo.eq_ignore_ascii_case(repo)
            && !pin
                .repos
                .iter()
                .any(|membership| membership.eq_ignore_ascii_case(repo))
        {
            return false;
        }
    }
    if let Some(cwd) = cwd {
        if !pin.cwd.contains(cwd) {
            return false;
        }
    }
    true
}

fn print_pins(pins: &[PinRecord]) {
    for (index, pin) in pins.iter().enumerate() {
        println!(
            "{}. {}  {}  {}",
            index + 1,
            pin.label,
            pin.session_id,
            pin.repo
        );
        if !pin.repos.is_empty() {
            println!("   repos: {}", pin.repos.join(", "));
        }
        println!("   session_key: {}", pin.session_key);
        println!("   pinned_at: {}", pin.created_at);
        println!("   updated_at: {}", pin.updated_at);
        println!("   cwd: {}", pin.cwd);
        println!("   source: {}", pin.source_file_path.display());
        println!(
            "   show: codex-recall show {} --limit 120",
            shell_quote(&pin.session_key)
        );
    }
}

fn print_pins_json(pins: &[PinRecord]) -> Result<()> {
    let values = pins
        .iter()
        .map(|pin| {
            json!({
                "session_key": pin.session_key,
                "session_id": pin.session_id,
                "label": pin.label,
                "repo": pin.repo,
                "repos": pin.repos,
                "cwd": pin.cwd,
                "source_file_path": pin.source_file_path,
                "created_at": pin.created_at,
                "updated_at": pin.updated_at,
                "show_command": format!(
                    "codex-recall show {} --limit 120",
                    shell_quote(&pin.session_key)
                ),
            })
        })
        .collect::<Vec<_>>();
    let value = json!({
        "count": values.len(),
        "pins": values,
    });
    println!("{}", serde_json::to_string_pretty(&value)?);
    Ok(())
}