@ -2,54 +2,731 @@
use crate ::error ::Result ;
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 < String > {
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 ::< Vec < _ > > ( )
. 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`.
/// Run `sessions list`.
///
///
/// Lists all sessions most-recent-first. Full UUIDs shown when `verbose` is true.
/// Discovers all session files, syncs them to the DB, then queries the DB
pub async fn list ( _verbose : bool ) -> Result < ( ) > {
/// for a summary sorted most-recent-first. Respects `opts.output` and
println! ( "sessions list: coming soon" ) ;
/// `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 < String > > = 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 ( ( ) )
Ok ( ( ) )
}
}
/// Run `sessions dump`.
/// Run `sessions dump`.
///
///
/// Dumps raw messages from the session identified by `id` (8-char prefix or full UUID).
/// 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.
/// When `follow` is true, streams new entries directly from the file as they arrive,
pub async fn dump ( _id : & str , _follow : bool , _verbose : bool ) -> Result < ( ) > {
/// bypassing the DB cache.
println! ( "sessions dump: coming soon" ) ;
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 ::< String > ( 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 < String > > = 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 ( ( ) )
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 = 0 usize ;
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`.
/// Run `sessions transcribe`.
///
///
/// Shows a human-readable transcript of the session identified by `id`.
/// Shows a human-readable transcript of the session identified by `id`.
/// Streams new entries as they arrive when `follow` is true.
/// Emits a stats header followed by the chat log. Thinking blocks and tool
pub async fn transcribe ( _id : & str , _follow : bool , _verbose : bool ) -> Result < ( ) > {
/// results are gated by `opts.include`. When `follow` is true, streams new
println! ( "sessions transcribe: coming soon" ) ;
/// 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 ::< String > ( 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 < crate ::models ::session ::RawEntry > = 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 < serde_json ::Value > = 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 < Vec < String > > = 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 ( ( ) )
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) ]
#[ cfg(test) ]
mod tests {
mod tests {
use super ::* ;
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` 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_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 ( ) ) ;
}
/// `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 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 ( ) ) ;
}
/// `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 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 ( ) ) ;
}
/// `list` returns `Ok` without panicking.
/// `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 ]
#[ tokio::test ]
async fn list_returns_ok ( ) {
async fn dump_unknown_id_returns_not_found_or_ok ( ) {
let result = list ( false ) . await ;
let dir = tempfile ::tempdir ( ) . expect ( "tempdir" ) ;
assert! ( result . is_ok ( ) ) ;
// 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}" ) ,
}
}
}
/// `dump` returns `Ok` without panicking.
/// `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 ]
#[ tokio::test ]
async fn dump_returns_ok ( ) {
async fn transcribe_unknown_id_returns_not_found_or_ok ( ) {
let result = dump ( "abc12345" , false , false ) . await ;
let dir = tempfile ::tempdir ( ) . expect ( "tempdir" ) ;
assert! ( result . is_ok ( ) ) ;
// 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}" ) ,
}
}
}
/// `transcribe` returns `Ok` without panicking.
/// ` resolve_session_id` returns `NotFound` for an empty DB .
#[ tokio::test ]
#[ tokio::test ]
async fn transcribe_returns_ok ( ) {
async fn resolve_session_id_empty_db_not_found ( ) {
let result = transcribe ( "abc12345" , false , false ) . await ;
let dir = tempfile ::tempdir ( ) . expect ( "tempdir" ) ;
assert! ( result . is_ok ( ) ) ;
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
}
}
}
}