goosedump 0.2.0

Coding agent context data browser
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) Jarkko Sakkinen 2026

#![deny(clippy::all)]
#![deny(clippy::pedantic)]

mod context;
mod display;
mod message;
mod resolver;
mod search;
mod summary;
mod text;

use argh::FromArgs;
use std::{env, process};

#[derive(FromArgs)]
#[argh(description = "coding agent context data browser")]
struct GoosedumpArgs {
    #[argh(subcommand)]
    command: GoosedumpCommand,
}

#[derive(FromArgs)]
#[argh(subcommand)]
enum GoosedumpCommand {
    List(ListArgs),
    Show(ShowArgs),
    Compact(CompactArgs),
    Grep(GrepArgs),
    Search(SearchArgs),
}

#[derive(FromArgs)]
#[argh(subcommand, name = "list", description = "list contexts for a client")]
struct ListArgs {
    #[argh(
        positional,
        description = "client: codex, crush, goose, opencode, or pi"
    )]
    client: String,
}

#[derive(FromArgs)]
#[argh(subcommand, name = "show", description = "show a context transcript")]
struct ShowArgs {
    #[argh(
        positional,
        description = "client: codex, crush, goose, opencode, or pi"
    )]
    client: String,

    #[argh(positional, description = "context/session id")]
    context_id: String,

    #[argh(switch, description = "clipped entry-id overview")]
    clip: bool,

    #[argh(option, description = "filter to comma-separated entry IDs")]
    ids: Option<String>,

    #[argh(
        option,
        default = "\"lineage\".to_string()",
        description = "scope: lineage or all"
    )]
    scope: String,
}

#[derive(FromArgs)]
#[argh(
    subcommand,
    name = "compact",
    description = "compact a context for compaction"
)]
struct CompactArgs {
    #[argh(
        positional,
        description = "client: codex, crush, goose, opencode, or pi"
    )]
    client: String,

    #[argh(positional, description = "context/session id")]
    context_id: String,

    #[argh(option, description = "filter to comma-separated entry IDs")]
    ids: Option<String>,

    #[argh(
        option,
        default = "\"lineage\".to_string()",
        description = "scope: lineage or all"
    )]
    scope: String,
}

#[derive(FromArgs)]
#[argh(subcommand, name = "grep", description = "filter messages by regex")]
struct GrepArgs {
    #[argh(
        positional,
        description = "client: codex, crush, goose, opencode, or pi"
    )]
    client: String,

    #[argh(positional, description = "context/session id")]
    context_id: String,

    #[argh(positional, description = "regex pattern")]
    pattern: String,

    #[argh(switch, description = "clipped hit overview")]
    clip: bool,

    #[argh(option, description = "filter to comma-separated entry IDs")]
    ids: Option<String>,

    #[argh(
        option,
        default = "\"lineage\".to_string()",
        description = "scope: lineage or all"
    )]
    scope: String,
}

#[derive(FromArgs)]
#[argh(
    subcommand,
    name = "search",
    description = "rank messages by query relevance"
)]
struct SearchArgs {
    #[argh(
        positional,
        description = "client: codex, crush, goose, opencode, or pi"
    )]
    client: String,

    #[argh(positional, description = "context/session id")]
    context_id: String,

    #[argh(positional, description = "search query")]
    query: String,

    #[argh(switch, description = "clipped hit overview")]
    clip: bool,

    #[argh(option, description = "filter to comma-separated entry IDs")]
    ids: Option<String>,

    #[argh(
        option,
        default = "\"lineage\".to_string()",
        description = "scope: lineage or all"
    )]
    scope: String,

    #[argh(
        option,
        short = 'p',
        description = "page ranked results (5 hits per page)"
    )]
    page: Option<usize>,
}

const DEFAULT_PAGE_SIZE: usize = 5;

fn validate_client(client: &str) -> Result<(), &'static str> {
    if !matches!(client, "codex" | "crush" | "goose" | "opencode" | "pi") {
        return Err("goosedump: client must be codex, crush, goose, opencode, or pi");
    }
    Ok(())
}

fn validate_scope(scope: &str) -> Result<(), &'static str> {
    if scope != "lineage" && scope != "all" {
        return Err("goosedump: --scope must be lineage or all");
    }
    Ok(())
}

fn validate_page(page: Option<usize>) -> Result<(), &'static str> {
    if let Some(page) = page
        && page == 0
    {
        return Err("goosedump: --page must be a positive integer");
    }
    Ok(())
}

fn print_hits(hits: &[message::SearchHit], clip: bool) {
    if clip {
        println!("{}", display::plain_compact_hits(hits));
    } else {
        println!("{}", display::plain_hits(hits));
    }
}

fn load_context(
    client: &str,
    context_id: &str,
    scope: &str,
    ids_csv: Option<&str>,
) -> anyhow::Result<message::Context> {
    let (reader, _) = resolver::resolve_client(client, Some(context_id))?;
    let mut ctx = reader.read_context(context_id)?;

    if scope == "lineage" {
        let lineage_ids = context::active_lineage_ids(&ctx.entries);
        ctx.messages = context::filter_messages(ctx.messages, &lineage_ids);
    }

    if let Some(ids_csv) = ids_csv {
        let ids = context::parse_id_set(ids_csv);
        ctx.messages = context::filter_messages(ctx.messages, &ids);
    }

    Ok(ctx)
}

fn load_context_or_exit(
    client: &str,
    context_id: &str,
    scope: &str,
    ids_csv: Option<&str>,
) -> message::Context {
    match load_context(client, context_id, scope, ids_csv) {
        Ok(ctx) => ctx,
        Err(e) => {
            eprintln!("goosedump: {e}");
            process::exit(1);
        }
    }
}

fn list_contexts(args: &ListArgs) -> Result<(), &'static str> {
    validate_client(&args.client)?;
    let (_, listings) = match resolver::resolve_client(&args.client, None) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("{e}");
            process::exit(1);
        }
    };

    if let Some(listings) = listings {
        println!("{}", display::plain_contexts(&listings));
    }
    Ok(())
}

fn show_context(args: &ShowArgs) -> Result<(), &'static str> {
    validate_client(&args.client)?;
    validate_scope(&args.scope)?;

    let ctx = load_context_or_exit(
        &args.client,
        &args.context_id,
        &args.scope,
        args.ids.as_deref(),
    );

    if args.clip {
        println!("{}", display::plain_compact(&ctx.messages));
    } else {
        println!("{}", display::plain_messages(&ctx.messages));
    }
    Ok(())
}

fn compact_context(args: &CompactArgs) -> Result<(), &'static str> {
    validate_client(&args.client)?;
    validate_scope(&args.scope)?;

    let ctx = load_context_or_exit(
        &args.client,
        &args.context_id,
        &args.scope,
        args.ids.as_deref(),
    );

    println!("{}", summary::plain_summary(&ctx.messages));
    Ok(())
}

fn grep_context(args: &GrepArgs) -> Result<(), &'static str> {
    validate_client(&args.client)?;
    validate_scope(&args.scope)?;

    let ctx = load_context_or_exit(
        &args.client,
        &args.context_id,
        &args.scope,
        args.ids.as_deref(),
    );

    let hits = search::grep(&ctx.messages, &args.pattern);
    if hits.is_empty() {
        println!("no results for \"{}\"", args.pattern);
    } else {
        println!("results for \"{}\":", args.pattern);
        print_hits(&hits, args.clip);
    }
    Ok(())
}

fn search_context(args: &SearchArgs) -> Result<(), &'static str> {
    validate_client(&args.client)?;
    validate_scope(&args.scope)?;
    validate_page(args.page)?;

    let ctx = load_context_or_exit(
        &args.client,
        &args.context_id,
        &args.scope,
        args.ids.as_deref(),
    );

    let page = args.page.unwrap_or(1);
    let (hits, total) = search::query(&ctx.messages, &args.query, page, DEFAULT_PAGE_SIZE);
    let total_pages = total.div_ceil(DEFAULT_PAGE_SIZE);
    if hits.is_empty() {
        if total == 0 {
            println!("no results for \"{}\"", args.query);
        } else {
            println!(
                "no more results for \"{}\" (page {page} of {total_pages})",
                args.query
            );
        }
    } else {
        println!(
            "results for \"{}\" (page {page} of {total_pages}):",
            args.query
        );
        print_hits(&hits, args.clip);
    }
    Ok(())
}

fn print_version() {
    println!("{} {}", env!("CARGO_PKG_NAME"), env!("CARGO_PKG_VERSION"));
}

fn requested_version() -> bool {
    let mut args = env::args_os();
    let _program = args.next();
    matches!(args.next().as_deref(), Some(arg) if arg == "-V" || arg == "--version")
        && args.next().is_none()
}

fn main() {
    if requested_version() {
        print_version();
        return;
    }
    let args: GoosedumpArgs = argh::from_env();

    let result = match &args.command {
        GoosedumpCommand::List(args) => list_contexts(args),
        GoosedumpCommand::Show(args) => show_context(args),
        GoosedumpCommand::Compact(args) => compact_context(args),
        GoosedumpCommand::Grep(args) => grep_context(args),
        GoosedumpCommand::Search(args) => search_context(args),
    };

    if let Err(msg) = result {
        eprintln!("{msg}");
        process::exit(2);
    }
}