#![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));
}
}