goosedump 0.1.3

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 text;

use argh::FromArgs;
use std::process;

#[derive(FromArgs)]
#[argh(description = "coding agent context data browser")]
struct GoosedumpArgs {
    #[argh(positional, description = "client: crush, opencode, or pi")]
    client: String,

    #[argh(positional, description = "context/session id and trailing arguments")]
    positional: Vec<String>,

    #[argh(switch, description = "compact entry-id overview")]
    compact: 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, description = "regex filter on message content")]
    grep: Option<String>,

    #[argh(switch, description = "TF-IDF score and sort matching messages")]
    rank: bool,

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

const DEFAULT_PAGE_SIZE: usize = 5;

fn validate_args(args: &GoosedumpArgs, context_id: Option<&str>) -> Result<(), &'static str> {
    if args.client != "crush"
        && args.client != "goose"
        && args.client != "opencode"
        && args.client != "pi"
    {
        return Err("goosedump: client must be crush, goose, opencode, or pi");
    }
    if args.rank && args.grep.is_none() {
        return Err("goosedump: --rank requires --grep");
    }
    if args.page.is_some() && !args.rank {
        return Err("goosedump: --page requires --rank");
    }
    if (args.compact || args.ids.is_some() || args.grep.is_some() || args.rank)
        && context_id.is_none()
    {
        return Err("goosedump: display flags require a context-id");
    }
    if args.scope != "lineage" && args.scope != "all" {
        return Err("goosedump: --scope must be lineage or all");
    }
    if let Some(ref page) = args.page
        && *page == 0
    {
        return Err("goosedump: --page must be a positive integer");
    }
    Ok(())
}

fn main() {
    let args: GoosedumpArgs = argh::from_env();

    let context_id = args.positional.first().map(std::string::String::as_str);

    if let Err(msg) = validate_args(&args, context_id) {
        eprintln!("{msg}\n\n{}", resolver::USAGE);
        process::exit(2);
    }

    let (reader, listings) = match resolver::resolve_client(&args.client, context_id) {
        Ok(r) => r,
        Err(e) => {
            eprintln!("{e}");
            process::exit(1);
        }
    };

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

    let context_id = context_id.as_ref().unwrap();

    let mut ctx = match reader.read_context(context_id) {
        Ok(c) => c,
        Err(e) => {
            eprintln!("goosedump: {e}");
            process::exit(1);
        }
    };

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

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

    if let Some(ref pattern) = args.grep {
        let print_hits = |hits: &[message::SearchHit]| {
            if args.compact {
                println!("{}", display::plain_compact_hits(hits));
            } else {
                println!("{}", display::plain_hits(hits));
            }
        };
        if args.rank {
            let page = args.page.unwrap_or(1);
            let (hits, total) = search::query(&ctx.messages, pattern, page, DEFAULT_PAGE_SIZE);
            if hits.is_empty() {
                if total == 0 {
                    println!("no results for \"{pattern}\"");
                } else {
                    let total_pages = total.div_ceil(DEFAULT_PAGE_SIZE);
                    println!("no more results for \"{pattern}\" (page {page} of {total_pages})");
                }
            } else {
                let total_pages = total.div_ceil(DEFAULT_PAGE_SIZE);
                println!("results for \"{pattern}\" (page {page} of {total_pages}):");
                print_hits(&hits);
            }
        } else {
            let hits = search::grep(&ctx.messages, pattern);
            if hits.is_empty() {
                println!("no results for \"{pattern}\"");
            } else {
                println!("results for \"{pattern}\":");
                print_hits(&hits);
            }
        }
    } else if args.compact {
        println!("{}", display::plain_compact(&ctx.messages));
    } else {
        println!("{}", display::plain_messages(&ctx.messages));
    }
}