From b1b0fb16e7c77eeaeb7ad2c4375f03697cd36008 Mon Sep 17 00:00:00 2001 From: Elijah Voigt Date: Sat, 28 Mar 2026 11:12:54 -0700 Subject: [PATCH] feat(sessions): implement sessions list, dump, transcribe + --follow [claudbg-bh11, claudbg-ei6j, claudbg-m4p1, claudbg-zwoj] - Replace stub sessions commands with full implementations - sessions list: lazy-syncs all discovered sessions, queries DB sorted most-recent-first, supports table/json/xml output and --verbose full UUID display - sessions dump: resolves 8-char prefix via LIKE query, fetches raw JSONL from DB, renders table/json/xml with content preview (truncated at 80 chars unless --verbose) - sessions transcribe: prints stats header then chat log; thinking/tool-result blocks gated by --include flags; json/xml output serialize structured messages - --follow for dump and transcribe: bypasses DB, polls file with byte-seek offset at 500ms intervals - Update agents.rs stubs to accept &GlobalOpts (Wave 6 prep) - Update main.rs to pass &cli.global to all session and agent commands - 89 tests pass, 0 clippy warnings Co-Authored-By: Claude Sonnet 4.6 --- .beans/claudbg-bh11--sessions-list-command.md | 5 +- .beans/claudbg-ei6j--sessions-dump-command.md | 5 +- ...audbg-m4p1--sessions-transcribe-command.md | 5 +- ...zwoj--follow-flag-for-sessions-commands.md | 5 +- src/commands/agents.rs | 32 +- src/commands/sessions.rs | 719 +++++++++++++++++- src/main.rs | 20 +- 7 files changed, 741 insertions(+), 50 deletions(-) diff --git a/.beans/claudbg-bh11--sessions-list-command.md b/.beans/claudbg-bh11--sessions-list-command.md index 6ca8f22..d6f079c 100644 --- a/.beans/claudbg-bh11--sessions-list-command.md +++ b/.beans/claudbg-bh11--sessions-list-command.md @@ -1,10 +1,11 @@ --- # claudbg-bh11 title: sessions list command -status: todo +status: in-progress type: task +priority: normal created_at: 2026-03-27T19:40:02Z -updated_at: 2026-03-27T19:40:02Z +updated_at: 2026-03-28T18:08:05Z parent: claudbg-4d4h --- diff --git a/.beans/claudbg-ei6j--sessions-dump-command.md b/.beans/claudbg-ei6j--sessions-dump-command.md index 6d6a953..4adf1a9 100644 --- a/.beans/claudbg-ei6j--sessions-dump-command.md +++ b/.beans/claudbg-ei6j--sessions-dump-command.md @@ -1,10 +1,11 @@ --- # claudbg-ei6j title: sessions dump command -status: todo +status: in-progress type: task +priority: normal created_at: 2026-03-27T19:40:02Z -updated_at: 2026-03-27T19:40:02Z +updated_at: 2026-03-28T18:12:41Z parent: claudbg-4d4h --- diff --git a/.beans/claudbg-m4p1--sessions-transcribe-command.md b/.beans/claudbg-m4p1--sessions-transcribe-command.md index 417dc7a..b72815d 100644 --- a/.beans/claudbg-m4p1--sessions-transcribe-command.md +++ b/.beans/claudbg-m4p1--sessions-transcribe-command.md @@ -1,10 +1,11 @@ --- # claudbg-m4p1 title: sessions transcribe command -status: todo +status: in-progress type: task +priority: normal created_at: 2026-03-27T19:40:02Z -updated_at: 2026-03-27T19:40:02Z +updated_at: 2026-03-28T18:12:42Z parent: claudbg-4d4h --- diff --git a/.beans/claudbg-zwoj--follow-flag-for-sessions-commands.md b/.beans/claudbg-zwoj--follow-flag-for-sessions-commands.md index 9faa57f..b31e18f 100644 --- a/.beans/claudbg-zwoj--follow-flag-for-sessions-commands.md +++ b/.beans/claudbg-zwoj--follow-flag-for-sessions-commands.md @@ -1,10 +1,11 @@ --- # claudbg-zwoj title: follow flag for sessions commands -status: todo +status: in-progress type: task +priority: normal created_at: 2026-03-27T19:40:28Z -updated_at: 2026-03-27T19:40:28Z +updated_at: 2026-03-28T18:12:42Z parent: claudbg-4d4h --- diff --git a/src/commands/agents.rs b/src/commands/agents.rs index 2d248e7..2bebe3d 100644 --- a/src/commands/agents.rs +++ b/src/commands/agents.rs @@ -1,12 +1,12 @@ -//! Agents subcommand implementations. +//! Agents subcommand implementations (stubs; full implementation in Wave 6). use crate::error::Result; /// Run `agents list`. /// /// Lists all agent runs within the session identified by `session_id`. -/// Full UUIDs shown when `verbose` is true. -pub async fn list(_session_id: &str, _verbose: bool) -> Result<()> { +/// (Wave 6 implementation pending.) +pub async fn list(_session_id: &str, _opts: &crate::cli::GlobalOpts) -> Result<()> { println!("agents list: coming soon"); Ok(()) } @@ -15,7 +15,13 @@ pub async fn list(_session_id: &str, _verbose: bool) -> Result<()> { /// /// Dumps raw messages from the agent run identified by `agent_id` within `session_id`. /// Streams new entries when `follow` is true. -pub async fn dump(_session_id: &str, _agent_id: &str, _follow: bool, _verbose: bool) -> Result<()> { +/// (Wave 6 implementation pending.) +pub async fn dump( + _session_id: &str, + _agent_id: &str, + _follow: bool, + _opts: &crate::cli::GlobalOpts, +) -> Result<()> { println!("agents dump: coming soon"); Ok(()) } @@ -24,11 +30,12 @@ pub async fn dump(_session_id: &str, _agent_id: &str, _follow: bool, _verbose: b /// /// Shows a human-readable transcript of the agent run identified by `agent_id`. /// Streams new entries when `follow` is true. +/// (Wave 6 implementation pending.) pub async fn transcribe( _session_id: &str, _agent_id: &str, _follow: bool, - _verbose: bool, + _opts: &crate::cli::GlobalOpts, ) -> Result<()> { println!("agents transcribe: coming soon"); Ok(()) @@ -37,25 +44,34 @@ pub async fn transcribe( #[cfg(test)] mod tests { use super::*; + use crate::cli::{GlobalOpts, IncludeList, OutputFormat}; + + fn default_opts() -> GlobalOpts { + GlobalOpts { + output: OutputFormat::Table, + verbose: false, + include: IncludeList::default(), + } + } /// `list` returns `Ok` without panicking. #[tokio::test] async fn list_returns_ok() { - let result = list("abc12345", false).await; + let result = list("abc12345", &default_opts()).await; assert!(result.is_ok()); } /// `dump` returns `Ok` without panicking. #[tokio::test] async fn dump_returns_ok() { - let result = dump("abc12345", "def67890", false, false).await; + let result = dump("abc12345", "def67890", false, &default_opts()).await; assert!(result.is_ok()); } /// `transcribe` returns `Ok` without panicking. #[tokio::test] async fn transcribe_returns_ok() { - let result = transcribe("abc12345", "def67890", false, false).await; + let result = transcribe("abc12345", "def67890", false, &default_opts()).await; assert!(result.is_ok()); } } diff --git a/src/commands/sessions.rs b/src/commands/sessions.rs index 6f4d69d..4955a76 100644 --- a/src/commands/sessions.rs +++ b/src/commands/sessions.rs @@ -2,54 +2,731 @@ use crate::error::Result; +/// Resolve an 8-char-or-full session ID prefix to a full session_id from the DB. +/// +/// Returns [`crate::error::AppError::NotFound`] if no match, [`crate::error::AppError::InvalidArg`] if multiple matches. +async fn resolve_session_id( + db: &crate::db::connection::DbHandle, + prefix: &str, +) -> crate::error::Result { + let conn = db + .connect() + .map_err(|e| crate::error::AppError::Db(e.to_string()))?; + let pattern = format!("{prefix}%"); + let mut rows = conn + .query( + "SELECT session_id FROM sessions WHERE session_id LIKE ?1", + libsql::params![pattern], + ) + .await + .map_err(|e| crate::error::AppError::Db(e.to_string()))?; + + let mut matches = Vec::new(); + while let Some(row) = rows + .next() + .await + .map_err(|e| crate::error::AppError::Db(e.to_string()))? + { + let sid: String = row + .get(0) + .map_err(|e| crate::error::AppError::Db(e.to_string()))?; + matches.push(sid); + } + + match matches.len() { + 0 => Err(crate::error::AppError::NotFound(format!( + "no session matching '{prefix}'" + ))), + 1 => Ok(matches.remove(0)), + _ => Err(crate::error::AppError::InvalidArg(format!( + "ambiguous prefix '{prefix}': {} matches", + matches.len() + ))), + } +} + +/// Extract a content preview string from a [`crate::models::session::RawEntry`]. +/// +/// The preview is truncated to `max_len` bytes (aligned to a UTF-8 char boundary). +fn content_preview(entry: &crate::models::session::RawEntry, max_len: usize) -> String { + let raw = match entry.message.as_ref().and_then(|m| m.content.as_ref()) { + None => return String::new(), + Some(c) => match c { + crate::models::session::MessageContent::Text(t) => t.clone(), + crate::models::session::MessageContent::Blocks(blocks) => blocks + .iter() + .map(|b| match b { + crate::models::session::ContentBlock::Text { text } => text.clone(), + crate::models::session::ContentBlock::Thinking { thinking } => { + format!("[thinking: {}]", &thinking[..thinking.len().min(40)]) + } + crate::models::session::ContentBlock::ToolUse { name, .. } => { + format!("[tool_use: {name}]") + } + crate::models::session::ContentBlock::ToolResult { tool_use_id, .. } => { + format!("[tool_result: {tool_use_id}]") + } + crate::models::session::ContentBlock::Image { .. } => "[image]".to_string(), + crate::models::session::ContentBlock::Unknown => "[unknown]".to_string(), + }) + .collect::>() + .join(" "), + }, + }; + if max_len == usize::MAX || raw.len() <= max_len { + raw + } else { + format!("{}…", &raw[..raw.floor_char_boundary(max_len)]) + } +} + +/// Render a single entry to stdout in human-readable text format. +/// +/// Thinking blocks are shown only when `opts.include.thinking` is set. +/// Tool result blocks are shown only when `opts.include.output` is set. +fn render_entry_text(entry: &crate::models::session::RawEntry, opts: &crate::cli::GlobalOpts) { + let Some(msg) = &entry.message else { return }; + let role = msg.role.as_deref().unwrap_or("?"); + + match &msg.content { + None => {} + Some(crate::models::session::MessageContent::Text(t)) => { + println!("[{role}]: {t}"); + } + Some(crate::models::session::MessageContent::Blocks(blocks)) => { + for block in blocks { + match block { + crate::models::session::ContentBlock::Text { text } => { + println!("[{role}]: {text}"); + } + crate::models::session::ContentBlock::Thinking { thinking } => { + if opts.include.thinking { + println!("[thinking]: {thinking}"); + } + } + crate::models::session::ContentBlock::ToolUse { name, input, .. } => { + let input_preview = serde_json::to_string(input).unwrap_or_default(); + let boundary = input_preview.floor_char_boundary(120); + let input_short = &input_preview[..boundary]; + println!("[tool: {name}] {input_short}"); + } + crate::models::session::ContentBlock::ToolResult { + content, is_error, .. + } => { + if opts.include.output { + let err_flag = if is_error.unwrap_or(false) { + " (error)" + } else { + "" + }; + let preview = content + .as_ref() + .and_then(|c| c.as_str().map(|s| s.to_string())) + .unwrap_or_else(|| { + content.as_ref().map(|c| c.to_string()).unwrap_or_default() + }); + let boundary = preview.floor_char_boundary(200); + let short = &preview[..boundary]; + println!("[tool_result{err_flag}]: {short}"); + } + } + crate::models::session::ContentBlock::Image { .. } => { + println!("[image]"); + } + crate::models::session::ContentBlock::Unknown => { + println!("[unknown block]"); + } + } + } + } + } +} + /// Run `sessions list`. /// -/// Lists all sessions most-recent-first. Full UUIDs shown when `verbose` is true. -pub async fn list(_verbose: bool) -> Result<()> { - println!("sessions list: coming soon"); +/// Discovers all session files, syncs them to the DB, then queries the DB +/// for a summary sorted most-recent-first. Respects `opts.output` and +/// `opts.verbose` (verbose shows full UUIDs instead of 8-char prefixes). +pub async fn list(opts: &crate::cli::GlobalOpts) -> Result<()> { + let db_path = crate::db::connection::default_db_path(); + let db = crate::db::connection::open_db(&db_path, false).await?; + + // Lazy sync all discovered sessions. + let sessions = crate::parser::discovery::discover_sessions()?; + for session_ref in &sessions { + crate::db::sync::ensure_synced(&db, session_ref).await?; + } + + // Query DB for display. + let conn = db + .connect() + .map_err(|e| crate::error::AppError::Db(e.to_string()))?; + let mut rows_cursor = conn + .query( + "SELECT session_id, project_path, model, last_msg_at, message_count \ + FROM sessions ORDER BY last_msg_at DESC", + (), + ) + .await + .map_err(|e| crate::error::AppError::Db(e.to_string()))?; + + // Collect rows. + let mut rows: Vec> = Vec::new(); + while let Some(row) = rows_cursor + .next() + .await + .map_err(|e| crate::error::AppError::Db(e.to_string()))? + { + let session_id: String = row.get(0).unwrap_or_default(); + let project_path: String = row.get(1).unwrap_or_default(); + let model: String = row.get(2).unwrap_or_default(); + let last_msg_at: String = row.get(3).unwrap_or_default(); + let message_count: i64 = row.get(4).unwrap_or_default(); + + let display_id = if opts.verbose { + session_id.clone() + } else { + crate::util::short_id(&session_id).to_string() + }; + + rows.push(vec![ + display_id, + last_msg_at, + project_path, + model, + message_count.to_string(), + ]); + } + + let output = match opts.output { + crate::cli::OutputFormat::Table => { + crate::output::render_table(&["ID", "Date", "Project", "Model", "Messages"], &rows)? + } + crate::cli::OutputFormat::Json => crate::output::render_json(&rows)?, + crate::cli::OutputFormat::Xml => crate::output::render_xml_rows( + &["session_id", "date", "project", "model", "messages"], + &rows, + )?, + }; + + println!("{output}"); Ok(()) } /// Run `sessions dump`. /// /// Dumps raw messages from the session identified by `id` (8-char prefix or full UUID). -/// Streams new entries as they arrive when `follow` is true. -pub async fn dump(_id: &str, _follow: bool, _verbose: bool) -> Result<()> { - println!("sessions dump: coming soon"); +/// When `follow` is true, streams new entries directly from the file as they arrive, +/// bypassing the DB cache. +pub async fn dump(id: &str, follow: bool, opts: &crate::cli::GlobalOpts) -> Result<()> { + if follow { + return dump_follow(id, opts).await; + } + + let db_path = crate::db::connection::default_db_path(); + let db = crate::db::connection::open_db(&db_path, false).await?; + + // Sync all sessions so prefix resolution works. + let sessions = crate::parser::discovery::discover_sessions()?; + for sr in &sessions { + crate::db::sync::ensure_synced(&db, sr).await?; + } + + let session_id = resolve_session_id(&db, id).await?; + + // Fetch raw JSONL from DB. + let conn = db + .connect() + .map_err(|e| crate::error::AppError::Db(e.to_string()))?; + let mut rows_cursor = conn + .query( + "SELECT raw_jsonl FROM raw_sessions WHERE session_id = ?1", + libsql::params![session_id.clone()], + ) + .await + .map_err(|e| crate::error::AppError::Db(e.to_string()))?; + + let raw_jsonl = if let Some(row) = rows_cursor + .next() + .await + .map_err(|e| crate::error::AppError::Db(e.to_string()))? + { + row.get::(0) + .map_err(|e| crate::error::AppError::Db(e.to_string()))? + } else { + return Err(crate::error::AppError::NotFound(format!( + "no raw data for session '{session_id}'" + ))); + }; + + let truncate = if opts.verbose { usize::MAX } else { 80 }; + + let mut rows: Vec> = Vec::new(); + for (i, line) in raw_jsonl.lines().enumerate() { + if line.is_empty() { + continue; + } + let entry: crate::models::session::RawEntry = match serde_json::from_str(line) { + Ok(e) => e, + Err(_) => continue, + }; + + let entry_type = entry.entry_type.clone().unwrap_or_default(); + let role = entry + .message + .as_ref() + .and_then(|m| m.role.clone()) + .unwrap_or_default(); + let timestamp = entry.timestamp.clone().unwrap_or_default(); + let preview = content_preview(&entry, truncate); + + rows.push(vec![ + (i + 1).to_string(), + timestamp, + entry_type, + role, + preview, + ]); + } + + let output = match opts.output { + crate::cli::OutputFormat::Table => { + crate::output::render_table(&["#", "Timestamp", "Type", "Role", "Content"], &rows)? + } + crate::cli::OutputFormat::Json => crate::output::render_json(&rows)?, + crate::cli::OutputFormat::Xml => { + crate::output::render_xml_rows(&["seq", "timestamp", "type", "role", "content"], &rows)? + } + }; + println!("{output}"); Ok(()) } +/// Follow mode for `sessions dump`: polls the session file and prints new entries +/// as they arrive, bypassing the DB cache entirely. +async fn dump_follow(id: &str, opts: &crate::cli::GlobalOpts) -> Result<()> { + // Find the session file directly (bypass DB). + let sessions = crate::parser::discovery::discover_sessions()?; + let session_ref = sessions + .iter() + .find(|s| s.session_id.starts_with(id) || s.session_id == id) + .ok_or_else(|| crate::error::AppError::NotFound(format!("session '{id}' not found")))? + .clone(); + + let mut byte_offset: u64 = 0; + let mut seq = 0usize; + + loop { + let file = tokio::fs::File::open(&session_ref.file_path).await?; + let metadata = file.metadata().await?; + let file_size = metadata.len(); + + if file_size > byte_offset { + use tokio::io::{AsyncBufReadExt, AsyncSeekExt}; + let mut reader = tokio::io::BufReader::new(file); + reader.seek(std::io::SeekFrom::Start(byte_offset)).await?; + + let mut new_offset = byte_offset; + let mut lines = reader.lines(); + while let Some(line) = lines.next_line().await? { + new_offset += line.len() as u64 + 1; // +1 for newline + if line.is_empty() { + continue; + } + let entry: crate::models::session::RawEntry = match serde_json::from_str(&line) { + Ok(e) => e, + Err(_) => continue, + }; + let preview = content_preview(&entry, if opts.verbose { usize::MAX } else { 80 }); + let entry_type = entry.entry_type.clone().unwrap_or_default(); + let role = entry + .message + .as_ref() + .and_then(|m| m.role.clone()) + .unwrap_or_default(); + let ts = entry.timestamp.clone().unwrap_or_default(); + seq += 1; + println!("{seq:4} | {ts} | {entry_type:12} | {role:9} | {preview}"); + } + byte_offset = new_offset; + } + + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } +} + /// Run `sessions transcribe`. /// /// Shows a human-readable transcript of the session identified by `id`. -/// Streams new entries as they arrive when `follow` is true. -pub async fn transcribe(_id: &str, _follow: bool, _verbose: bool) -> Result<()> { - println!("sessions transcribe: coming soon"); +/// Emits a stats header followed by the chat log. Thinking blocks and tool +/// results are gated by `opts.include`. When `follow` is true, streams new +/// entries from the file directly, bypassing the DB cache. +pub async fn transcribe(id: &str, follow: bool, opts: &crate::cli::GlobalOpts) -> Result<()> { + if follow { + return transcribe_follow(id, opts).await; + } + + let db_path = crate::db::connection::default_db_path(); + let db = crate::db::connection::open_db(&db_path, false).await?; + + let sessions = crate::parser::discovery::discover_sessions()?; + for sr in &sessions { + crate::db::sync::ensure_synced(&db, sr).await?; + } + + let session_id = resolve_session_id(&db, id).await?; + + // Fetch raw JSONL. + let conn = db + .connect() + .map_err(|e| crate::error::AppError::Db(e.to_string()))?; + let mut rows_cursor = conn + .query( + "SELECT raw_jsonl FROM raw_sessions WHERE session_id = ?1", + libsql::params![session_id.clone()], + ) + .await + .map_err(|e| crate::error::AppError::Db(e.to_string()))?; + + let raw_jsonl = match rows_cursor + .next() + .await + .map_err(|e| crate::error::AppError::Db(e.to_string()))? + { + Some(row) => row + .get::(0) + .map_err(|e| crate::error::AppError::Db(e.to_string()))?, + None => { + return Err(crate::error::AppError::NotFound(format!( + "no raw data for '{session_id}'" + ))); + } + }; + + // Parse entries. + let entries: Vec = raw_jsonl + .lines() + .filter(|l| !l.is_empty()) + .filter_map(|l| serde_json::from_str(l).ok()) + .collect(); + + let stats = crate::models::stats::compute_stats(&entries); + + match opts.output { + crate::cli::OutputFormat::Table => { + // Print stats header. + println!("Session: {}", crate::util::short_id(&session_id)); + println!("Model: {}", stats.model.as_deref().unwrap_or("unknown")); + println!( + "Tokens: in={} out={} cache_read={} cache_write={}", + stats.input_tokens, + stats.output_tokens, + stats.cache_read_tokens, + stats.cache_creation_tokens + ); + println!("Tools: {:?}", stats.tool_calls); + println!("Duration: {}ms", stats.duration_ms); + println!("{}", "─".repeat(80)); + + // Print chat log. + for entry in &entries { + render_entry_text(entry, opts); + } + } + crate::cli::OutputFormat::Json => { + let messages: Vec = entries + .iter() + .filter(|e| matches!(e.entry_type.as_deref(), Some("user") | Some("assistant"))) + .map(|e| { + serde_json::json!({ + "role": e.message.as_ref().and_then(|m| m.role.clone()), + "type": e.entry_type, + "timestamp": e.timestamp, + "preview": content_preview(e, usize::MAX), + }) + }) + .collect(); + println!("{}", crate::output::render_json(&messages)?); + } + crate::cli::OutputFormat::Xml => { + let rows: Vec> = entries + .iter() + .filter(|e| matches!(e.entry_type.as_deref(), Some("user") | Some("assistant"))) + .map(|e| { + vec![ + e.entry_type.clone().unwrap_or_default(), + e.message + .as_ref() + .and_then(|m| m.role.clone()) + .unwrap_or_default(), + e.timestamp.clone().unwrap_or_default(), + content_preview(e, usize::MAX), + ] + }) + .collect(); + println!( + "{}", + crate::output::render_xml_rows(&["type", "role", "timestamp", "content"], &rows)? + ); + } + } + Ok(()) } +/// Follow mode for `sessions transcribe`: polls the session file and renders new +/// entries as they arrive, bypassing the DB cache entirely. +async fn transcribe_follow(id: &str, opts: &crate::cli::GlobalOpts) -> Result<()> { + let sessions = crate::parser::discovery::discover_sessions()?; + let session_ref = sessions + .iter() + .find(|s| s.session_id.starts_with(id) || s.session_id == id) + .ok_or_else(|| crate::error::AppError::NotFound(format!("session '{id}' not found")))? + .clone(); + + let mut byte_offset: u64 = 0; + + loop { + let file = tokio::fs::File::open(&session_ref.file_path).await?; + let metadata = file.metadata().await?; + + if metadata.len() > byte_offset { + use tokio::io::{AsyncBufReadExt, AsyncSeekExt}; + let mut reader = tokio::io::BufReader::new(file); + reader.seek(std::io::SeekFrom::Start(byte_offset)).await?; + + let mut new_offset = byte_offset; + let mut lines = reader.lines(); + while let Some(line) = lines.next_line().await? { + new_offset += line.len() as u64 + 1; + if line.is_empty() { + continue; + } + let entry: crate::models::session::RawEntry = match serde_json::from_str(&line) { + Ok(e) => e, + Err(_) => continue, + }; + render_entry_text(&entry, opts); + } + byte_offset = new_offset; + } + + tokio::time::sleep(std::time::Duration::from_millis(500)).await; + } +} + #[cfg(test)] mod tests { use super::*; + use crate::cli::{GlobalOpts, IncludeList, OutputFormat}; + use crate::models::session::{ContentBlock, Message, MessageContent, RawEntry}; + + fn default_opts() -> GlobalOpts { + GlobalOpts { + output: OutputFormat::Table, + verbose: false, + include: IncludeList::default(), + } + } + + fn make_raw_entry(entry_type: &str, role: &str, content: MessageContent) -> RawEntry { + RawEntry { + entry_type: Some(entry_type.to_string()), + session_id: None, + parent_session_id: None, + message: Some(Message { + role: Some(role.to_string()), + content: Some(content), + usage: None, + model: None, + stop_reason: None, + }), + system_message: None, + cwd: None, + timestamp: Some("2024-01-01T00:00:00Z".to_string()), + duration_ms: None, + extra: Default::default(), + } + } - /// `list` returns `Ok` without panicking. + /// `list` returns `Ok` with table output format without panicking. + /// + /// Uses an isolated temp HOME so the test doesn't conflict with the real DB. #[tokio::test] - async fn list_returns_ok() { - let result = list(false).await; - assert!(result.is_ok()); + async fn list_table_returns_ok() { + let dir = tempfile::tempdir().expect("tempdir"); + // SAFETY: single-threaded test context; no other threads read HOME concurrently. + unsafe { std::env::set_var("HOME", dir.path()) }; + let opts = default_opts(); + let result = list(&opts).await; + assert!(result.is_ok(), "list failed: {:?}", result.err()); } - /// `dump` returns `Ok` without panicking. + /// `list` returns `Ok` with JSON output format. + /// + /// Uses an isolated temp HOME so the test doesn't conflict with the real DB. #[tokio::test] - async fn dump_returns_ok() { - let result = dump("abc12345", false, false).await; - assert!(result.is_ok()); + async fn list_json_returns_ok() { + let dir = tempfile::tempdir().expect("tempdir"); + // SAFETY: single-threaded test context; no other threads read HOME concurrently. + unsafe { std::env::set_var("HOME", dir.path()) }; + let opts = GlobalOpts { + output: OutputFormat::Json, + ..default_opts() + }; + let result = list(&opts).await; + assert!(result.is_ok(), "list json failed: {:?}", result.err()); } - /// `transcribe` returns `Ok` without panicking. + /// `list` returns `Ok` with XML output format. + /// + /// Uses an isolated temp HOME so the test doesn't conflict with the real DB. #[tokio::test] - async fn transcribe_returns_ok() { - let result = transcribe("abc12345", false, false).await; - assert!(result.is_ok()); + async fn list_xml_returns_ok() { + let dir = tempfile::tempdir().expect("tempdir"); + // SAFETY: single-threaded test context; no other threads read HOME concurrently. + unsafe { std::env::set_var("HOME", dir.path()) }; + let opts = GlobalOpts { + output: OutputFormat::Xml, + ..default_opts() + }; + let result = list(&opts).await; + assert!(result.is_ok(), "list xml failed: {:?}", result.err()); + } + + /// `dump` with unknown ID returns `NotFound` (empty DB, no sessions discovered). + /// + /// Uses an isolated temp HOME so the test doesn't conflict with the real DB. + #[tokio::test] + async fn dump_unknown_id_returns_not_found_or_ok() { + let dir = tempfile::tempdir().expect("tempdir"); + // SAFETY: single-threaded test context; no other threads read HOME concurrently. + unsafe { std::env::set_var("HOME", dir.path()) }; + let opts = default_opts(); + // This ID is unlikely to exist; result should be Ok or NotFound, never a panic. + let result = dump("00000000", false, &opts).await; + match result { + Ok(_) => {} + Err(crate::error::AppError::NotFound(_)) => {} + Err(e) => panic!("unexpected error: {e}"), + } + } + + /// `transcribe` with unknown ID returns `NotFound` or `Ok`. + /// + /// Uses an isolated temp HOME so the test doesn't conflict with the real DB. + #[tokio::test] + async fn transcribe_unknown_id_returns_not_found_or_ok() { + let dir = tempfile::tempdir().expect("tempdir"); + // SAFETY: single-threaded test context; no other threads read HOME concurrently. + unsafe { std::env::set_var("HOME", dir.path()) }; + let opts = default_opts(); + let result = transcribe("00000000", false, &opts).await; + match result { + Ok(_) => {} + Err(crate::error::AppError::NotFound(_)) => {} + Err(e) => panic!("unexpected error: {e}"), + } + } + + /// `resolve_session_id` returns `NotFound` for an empty DB. + #[tokio::test] + async fn resolve_session_id_empty_db_not_found() { + let dir = tempfile::tempdir().expect("tempdir"); + let db_path = dir.path().join("test.db"); + let db = crate::db::connection::open_db(&db_path, false) + .await + .expect("open db"); + let result = resolve_session_id(&db, "abcdef01").await; + assert!(matches!(result, Err(crate::error::AppError::NotFound(_)))); + } + + /// `content_preview` returns empty string for an entry with no message. + #[test] + fn content_preview_no_message() { + let entry = RawEntry { + entry_type: Some("system".to_string()), + session_id: None, + parent_session_id: None, + message: None, + system_message: None, + cwd: None, + timestamp: None, + duration_ms: None, + extra: Default::default(), + }; + assert_eq!(content_preview(&entry, 80), ""); + } + + /// `content_preview` truncates long text to `max_len` bytes. + #[test] + fn content_preview_truncates_long_text() { + let entry = make_raw_entry("user", "user", MessageContent::Text("a".repeat(200))); + let preview = content_preview(&entry, 80); + // The ellipsis adds a multi-byte char, so total len > 80 but raw slice ≤ 80. + assert!(preview.len() <= 84, "preview too long: {}", preview.len()); + assert!(preview.ends_with('…'), "expected ellipsis"); + } + + /// `content_preview` returns full text when it fits within `max_len`. + #[test] + fn content_preview_short_text_unchanged() { + let entry = make_raw_entry("user", "user", MessageContent::Text("Hello".to_string())); + assert_eq!(content_preview(&entry, 80), "Hello"); + } + + /// `content_preview` with `usize::MAX` returns full content without truncation. + #[test] + fn content_preview_max_len_no_truncation() { + let text = "x".repeat(1000); + let entry = make_raw_entry("user", "user", MessageContent::Text(text.clone())); + assert_eq!(content_preview(&entry, usize::MAX), text); + } + + /// `content_preview` formats tool_use blocks with the tool name. + #[test] + fn content_preview_tool_use_block() { + let entry = make_raw_entry( + "assistant", + "assistant", + MessageContent::Blocks(vec![ContentBlock::ToolUse { + id: "t1".to_string(), + name: "Bash".to_string(), + input: serde_json::json!({"command": "ls"}), + }]), + ); + let preview = content_preview(&entry, usize::MAX); + assert!(preview.contains("Bash"), "expected tool name in preview"); + } + + /// `render_entry_text` does not print thinking blocks when `include.thinking` is false. + #[test] + fn render_entry_text_thinking_gated() { + // We can only check it doesn't panic; actual stdout capture would need more setup. + let entry = make_raw_entry( + "assistant", + "assistant", + MessageContent::Blocks(vec![ContentBlock::Thinking { + thinking: "secret thoughts".to_string(), + }]), + ); + let opts = default_opts(); // include.thinking = false + render_entry_text(&entry, &opts); // must not panic + } + + /// `render_entry_text` does not print tool results when `include.output` is false. + #[test] + fn render_entry_text_tool_result_gated() { + let entry = make_raw_entry( + "user", + "user", + MessageContent::Blocks(vec![ContentBlock::ToolResult { + tool_use_id: "t1".to_string(), + content: Some(serde_json::json!("output text")), + is_error: Some(false), + }]), + ); + let opts = default_opts(); // include.output = false + render_entry_text(&entry, &opts); // must not panic } } diff --git a/src/main.rs b/src/main.rs index 05f68c8..df5051b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,38 +9,32 @@ async fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Commands::Sessions { cmd } => match cmd { - SessionsCmd::List => claudbg::commands::sessions::list(cli.global.verbose).await?, + SessionsCmd::List => claudbg::commands::sessions::list(&cli.global).await?, SessionsCmd::Dump { id, follow } => { - claudbg::commands::sessions::dump(&id, follow, cli.global.verbose).await? + claudbg::commands::sessions::dump(&id, follow, &cli.global).await? } SessionsCmd::Transcribe { id, follow } => { - claudbg::commands::sessions::transcribe(&id, follow, cli.global.verbose).await? + claudbg::commands::sessions::transcribe(&id, follow, &cli.global).await? } }, Commands::Agents { cmd } => match cmd { AgentsCmd::List { session_id } => { - claudbg::commands::agents::list(&session_id, cli.global.verbose).await? + claudbg::commands::agents::list(&session_id, &cli.global).await? } AgentsCmd::Dump { session_id, agent_id, follow, } => { - claudbg::commands::agents::dump(&session_id, &agent_id, follow, cli.global.verbose) - .await? + claudbg::commands::agents::dump(&session_id, &agent_id, follow, &cli.global).await? } AgentsCmd::Transcribe { session_id, agent_id, follow, } => { - claudbg::commands::agents::transcribe( - &session_id, - &agent_id, - follow, - cli.global.verbose, - ) - .await? + claudbg::commands::agents::transcribe(&session_id, &agent_id, follow, &cli.global) + .await? } }, Commands::Index { force } => claudbg::commands::index::run(force).await?,