//! 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 { 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 { if s.eq_ignore_ascii_case("all") { return Ok(Limit::All); } s.parse::() .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(&self, items: Vec) -> Vec { 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, }, /// 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, }, /// 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, }, /// 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]); } }