You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

368 lines
11 KiB
Rust

//! CLI types: output format, include list, limit, global options, and command structure.
/// Output format for all commands.
#[derive(Debug, Clone, Default, clap::ValueEnum)]
pub enum OutputFormat {
/// Human-readable table (default).
#[default]
Table,
/// JSON output.
Json,
/// XML output.
Xml,
}
/// Comma-separated list of optional content to include in transcripts.
///
/// Accepted tokens: `thinking`, `output`.
#[derive(Debug, Clone, Default)]
pub struct IncludeList {
/// Include `thinking` blocks in transcripts.
pub thinking: bool,
/// Include tool `output` blocks in transcripts.
pub output: bool,
}
impl std::str::FromStr for IncludeList {
type Err = crate::error::AppError;
/// Parse a comma-separated string of include tokens.
///
/// Accepted tokens: `thinking`, `output`.
/// Returns [`AppError::InvalidArg`] for any unknown token.
fn from_str(s: &str) -> crate::error::Result<Self> {
let mut list = IncludeList::default();
for token in s.split(',') {
let token = token.trim();
if token.is_empty() {
continue;
}
match token {
"thinking" => list.thinking = true,
"output" => list.output = true,
other => {
return Err(crate::error::AppError::InvalidArg(format!(
"unknown include token: {other}"
)));
}
}
}
Ok(list)
}
}
/// How many entries to show in list commands.
///
/// Accepts an integer (e.g. `10`) or the keyword `all` to show every entry.
#[derive(Debug, Clone)]
pub enum Limit {
/// Show at most N entries.
Count(usize),
/// Show all entries without truncation.
All,
}
impl Default for Limit {
fn default() -> Self {
Limit::Count(10)
}
}
impl std::str::FromStr for Limit {
type Err = crate::error::AppError;
fn from_str(s: &str) -> crate::error::Result<Self> {
if s.eq_ignore_ascii_case("all") {
return Ok(Limit::All);
}
s.parse::<usize>()
.map(Limit::Count)
.map_err(|_| crate::error::AppError::InvalidArg(format!("invalid limit: {s}")))
}
}
impl Limit {
/// Apply this limit to a vector, returning a truncated or full slice.
pub fn apply<T>(&self, items: Vec<T>) -> Vec<T> {
match self {
Limit::All => items,
Limit::Count(n) => items.into_iter().take(*n).collect(),
}
}
}
/// Top-level CLI entry point for claudbg.
#[derive(Debug, clap::Parser)]
#[command(name = "claudbg", about = "Claude Code session inspector")]
pub struct Cli {
/// Global options shared across all subcommands.
#[command(flatten)]
pub global: GlobalOpts,
/// The subcommand to execute.
#[command(subcommand)]
pub command: Commands,
}
/// Global options available on every subcommand.
#[derive(Debug, Clone, clap::Args)]
pub struct GlobalOpts {
/// Output format: table (default), json, xml.
#[arg(long, global = true, default_value = "table")]
pub output: OutputFormat,
/// Show full UUIDs and extra detail.
#[arg(long, global = true)]
pub verbose: bool,
/// Comma-separated list of extra content to include in transcriptions.
/// Valid values: thinking, output. Example: --include thinking,output
#[arg(long, global = true, default_value = "")]
pub include: IncludeList,
/// Force color output even when not writing to a terminal.
#[arg(long, global = true, overrides_with = "no_color")]
pub color: bool,
/// Disable color output (also honored via the NO_COLOR env var).
#[arg(long = "no-color", global = true, overrides_with = "color")]
pub no_color: bool,
}
impl GlobalOpts {
/// Determine whether color output should be enabled.
///
/// Priority (highest → lowest):
/// 1. `NO_COLOR` env var set to a non-empty value → disabled.
/// 2. `--no-color` flag passed → disabled.
/// 3. `--color` flag passed → enabled.
/// 4. Auto-detect: enabled iff stdout is a TTY.
pub fn color_enabled(&self) -> bool {
use std::io::IsTerminal as _;
// NO_COLOR spec: any non-empty value disables color.
if std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()) {
return false;
}
if self.no_color {
return false;
}
if self.color {
return true;
}
std::io::stdout().is_terminal()
}
}
/// Top-level subcommands.
#[derive(Debug, clap::Subcommand)]
pub enum Commands {
/// List, dump, and transcribe sessions.
#[command(subcommand_required = false)]
Sessions {
/// Sessions subcommand (defaults to `list` when omitted).
#[command(subcommand)]
cmd: Option<SessionsCmd>,
},
/// List, dump, and transcribe sub-agent runs.
Agents {
/// Agents subcommand.
#[command(subcommand)]
cmd: AgentsCmd,
},
/// Sync all session files into the local cache DB.
Index {
/// Force full rebuild even if files appear up to date.
#[arg(long)]
force: bool,
},
/// Launch terminal UI (coming soon).
Tui,
/// Run ad-hoc queries against the cache DB (coming soon).
Query,
}
/// Subcommands for `sessions`.
#[derive(Debug, clap::Subcommand)]
pub enum SessionsCmd {
/// List all sessions, most recent first.
List {
/// Maximum number of sessions to show, or "all" for no limit.
#[arg(long, default_value = "10")]
limit: Limit,
/// Filter expression (may be passed multiple times; all filters are ANDed).
/// Example: --filter 'model:haiku' --filter 'agents>0'
#[arg(long, action = clap::ArgAction::Append)]
filter: Vec<String>,
},
/// Dump raw messages from a session.
Dump {
/// Session ID or 8-char prefix.
id: String,
/// Stream new entries as the session file is written.
#[arg(long)]
follow: bool,
},
/// Show a human-readable transcript of a session.
Transcribe {
/// Session ID or 8-char prefix.
id: String,
/// Stream new entries as the session file is written.
#[arg(long)]
follow: bool,
},
}
/// Subcommands for `agents`.
#[derive(Debug, clap::Subcommand)]
pub enum AgentsCmd {
/// List all agent runs within a session.
List {
/// Parent session ID or 8-char prefix.
session_id: String,
/// Maximum number of agents to show, or "all" for no limit.
#[arg(long, default_value = "10")]
limit: Limit,
/// Filter expression (may be passed multiple times; all filters are ANDed).
/// Example: --filter 'id:abc12345'
#[arg(long, action = clap::ArgAction::Append)]
filter: Vec<String>,
},
/// Dump raw messages from an agent run.
Dump {
/// Parent session ID or 8-char prefix.
session_id: String,
/// Agent ID or 8-char prefix.
agent_id: String,
/// Stream new entries as the agent file is written.
#[arg(long)]
follow: bool,
},
/// Show a human-readable transcript of an agent run.
Transcribe {
/// Parent session ID or 8-char prefix.
session_id: String,
/// Agent ID or 8-char prefix.
agent_id: String,
/// Stream new entries as the agent file is written.
#[arg(long)]
follow: bool,
},
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
/// Empty string produces a default `IncludeList` with both flags false.
#[test]
fn include_list_empty_string() {
let list = IncludeList::from_str("").unwrap();
assert!(!list.thinking);
assert!(!list.output);
}
/// Parsing `"thinking"` sets the thinking flag only.
#[test]
fn include_list_thinking_only() {
let list = IncludeList::from_str("thinking").unwrap();
assert!(list.thinking);
assert!(!list.output);
}
/// Parsing `"output"` sets the output flag only.
#[test]
fn include_list_output_only() {
let list = IncludeList::from_str("output").unwrap();
assert!(!list.thinking);
assert!(list.output);
}
/// Parsing `"thinking,output"` sets both flags.
#[test]
fn include_list_both() {
let list = IncludeList::from_str("thinking,output").unwrap();
assert!(list.thinking);
assert!(list.output);
}
/// An unknown token returns an `AppError::InvalidArg`.
#[test]
fn include_list_unknown_token() {
let result = IncludeList::from_str("images");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("unknown include token: images"), "got: {msg}");
}
/// Whitespace around tokens is trimmed.
#[test]
fn include_list_whitespace_trim() {
let list = IncludeList::from_str(" thinking , output ").unwrap();
assert!(list.thinking);
assert!(list.output);
}
/// `OutputFormat` defaults to `Table`.
#[test]
fn output_format_default() {
let fmt = OutputFormat::default();
assert!(matches!(fmt, OutputFormat::Table));
}
/// Parsing a number produces `Limit::Count`.
#[test]
fn limit_parse_count() {
let limit = Limit::from_str("5").unwrap();
assert!(matches!(limit, Limit::Count(5)));
}
/// Parsing `"all"` produces `Limit::All`.
#[test]
fn limit_parse_all() {
let limit = Limit::from_str("all").unwrap();
assert!(matches!(limit, Limit::All));
}
/// Parsing `"ALL"` (case-insensitive) also produces `Limit::All`.
#[test]
fn limit_parse_all_uppercase() {
let limit = Limit::from_str("ALL").unwrap();
assert!(matches!(limit, Limit::All));
}
/// Parsing an invalid string returns an error.
#[test]
fn limit_parse_invalid() {
let result = Limit::from_str("banana");
assert!(result.is_err());
}
/// Default limit is `Count(10)`.
#[test]
fn limit_default_is_ten() {
let limit = Limit::default();
assert!(matches!(limit, Limit::Count(10)));
}
/// `Limit::Count(3).apply` truncates a longer vec.
#[test]
fn limit_apply_count_truncates() {
let v = vec![1, 2, 3, 4, 5];
let result = Limit::Count(3).apply(v);
assert_eq!(result, vec![1, 2, 3]);
}
/// `Limit::All.apply` returns the full vec unchanged.
#[test]
fn limit_apply_all_returns_all() {
let v = vec![1, 2, 3, 4, 5];
let result = Limit::All.apply(v);
assert_eq!(result, vec![1, 2, 3, 4, 5]);
}
/// `Limit::Count(N).apply` on a shorter vec returns the full vec.
#[test]
fn limit_apply_count_shorter_vec_unchanged() {
let v = vec![1, 2];
let result = Limit::Count(10).apply(v);
assert_eq!(result, vec![1, 2]);
}
}