use std::io::Write;
use std::time::Duration;
use claudectl::discovery;
use claudectl::models;
use claudectl::monitor;
use claudectl::session::{ClaudeSession, RawSession, SessionStatus, TelemetryStatus};
fn make_session(cpu: f32, last_message_age_secs: u64) -> ClaudeSession {
let raw = RawSession {
pid: 1,
session_id: "test-session".into(),
cwd: "/tmp/test-project".into(),
started_at: 0,
};
let mut s = ClaudeSession::from_raw(raw);
s.cpu_percent = cpu;
s.telemetry_status = TelemetryStatus::Available;
s.usage_metrics_available = true;
let now_ms = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_millis() as u64;
s.last_message_ts = now_ms.saturating_sub(last_message_age_secs * 1000);
s
}
#[test]
fn status_high_cpu_always_processing() {
let mut s = make_session(50.0, 0);
monitor::infer_status(&mut s, "", "", false);
assert_eq!(s.status, SessionStatus::Processing);
}
#[test]
fn status_high_cpu_overrides_waiting_for_task() {
let mut s = make_session(10.0, 0);
monitor::infer_status(&mut s, "assistant", "end_turn", true);
assert_eq!(s.status, SessionStatus::Processing);
}
#[test]
fn status_high_cpu_overrides_end_turn() {
let mut s = make_session(20.0, 60);
monitor::infer_status(&mut s, "assistant", "end_turn", false);
assert_eq!(s.status, SessionStatus::Processing);
}
#[test]
fn status_waiting_for_task_needs_input() {
let mut s = make_session(0.5, 10);
monitor::infer_status(&mut s, "", "", true);
assert_eq!(s.status, SessionStatus::NeedsInput);
}
#[test]
fn status_end_turn_recent_waiting_input() {
let mut s = make_session(0.5, 120);
monitor::infer_status(&mut s, "assistant", "end_turn", false);
assert_eq!(s.status, SessionStatus::WaitingInput);
}
#[test]
fn status_end_turn_old_idle() {
let mut s = make_session(0.5, 15 * 60);
monitor::infer_status(&mut s, "assistant", "end_turn", false);
assert_eq!(s.status, SessionStatus::Idle);
}
#[test]
fn status_end_turn_exactly_10min_still_waiting() {
let mut s = make_session(0.5, 10 * 60);
monitor::infer_status(&mut s, "assistant", "end_turn", false);
assert_eq!(s.status, SessionStatus::WaitingInput);
}
#[test]
fn status_end_turn_11min_idle() {
let mut s = make_session(0.5, 11 * 60);
monitor::infer_status(&mut s, "assistant", "end_turn", false);
assert_eq!(s.status, SessionStatus::Idle);
}
#[test]
fn status_tool_use_low_cpu_old_needs_input() {
let mut s = make_session(0.5, 30);
monitor::infer_status(&mut s, "assistant", "tool_use", false);
assert_eq!(s.status, SessionStatus::NeedsInput);
}
#[test]
fn status_tool_use_low_cpu_recent_processing() {
let mut s = make_session(0.5, 2);
monitor::infer_status(&mut s, "assistant", "tool_use", false);
assert_eq!(s.status, SessionStatus::Processing);
}
#[test]
fn status_tool_use_high_cpu_processing() {
let mut s = make_session(15.0, 30);
monitor::infer_status(&mut s, "assistant", "tool_use", false);
assert_eq!(s.status, SessionStatus::Processing);
}
#[test]
fn status_user_message_pending_processing() {
let mut s = make_session(3.0, 5);
monitor::infer_status(&mut s, "user", "", false);
assert_eq!(s.status, SessionStatus::Processing);
}
#[test]
fn status_user_message_low_cpu_still_processing() {
let mut s = make_session(0.5, 5);
monitor::infer_status(&mut s, "user", "", false);
assert_eq!(s.status, SessionStatus::Processing);
}
#[test]
fn status_no_signals_idle() {
let mut s = make_session(0.0, 0);
monitor::infer_status(&mut s, "", "", false);
assert_eq!(s.status, SessionStatus::Idle);
}
#[test]
fn status_no_telemetry_unknown() {
let raw = RawSession {
pid: 1,
session_id: "test-session".into(),
cwd: "/tmp/test-project".into(),
started_at: 0,
};
let mut s = ClaudeSession::from_raw(raw);
monitor::infer_status(&mut s, "", "", false);
assert_eq!(s.status, SessionStatus::Unknown);
}
#[test]
fn status_cpu_threshold_boundary() {
let mut s = make_session(5.0, 0);
monitor::infer_status(&mut s, "", "", false);
assert_eq!(s.status, SessionStatus::Idle);
let mut s2 = make_session(5.1, 0);
monitor::infer_status(&mut s2, "", "", false);
assert_eq!(s2.status, SessionStatus::Processing);
}
#[test]
fn status_persisted_tool_use_survives_empty_tick() {
let mut s = make_session(0.5, 30);
monitor::infer_status(&mut s, "assistant", "tool_use", false);
assert_eq!(s.status, SessionStatus::NeedsInput);
s.last_msg_type = "assistant".into();
s.last_stop_reason = "tool_use".into();
s.is_waiting_for_task = false;
let msg_type = s.last_msg_type.clone();
let stop_reason = s.last_stop_reason.clone();
let waiting = s.is_waiting_for_task;
monitor::infer_status(&mut s, &msg_type, &stop_reason, waiting);
assert_eq!(s.status, SessionStatus::NeedsInput);
}
#[test]
fn cost_opus_tokens() {
let mut s = make_session(0.0, 0);
s.model = "opus-4.6".into();
s.total_input_tokens = 1_000_000;
s.total_output_tokens = 100_000;
s.cache_read_tokens = 500_000;
s.cache_write_tokens = 200_000;
let cost = monitor::estimate_cost(&s);
let expected = 16.6875;
assert!(
(cost - expected).abs() < 0.001,
"opus cost={cost}, expected={expected}"
);
}
#[test]
fn cost_sonnet_tokens() {
let mut s = make_session(0.0, 0);
s.model = "sonnet-4.6".into();
s.total_input_tokens = 100_000;
s.total_output_tokens = 50_000;
s.cache_read_tokens = 0;
s.cache_write_tokens = 0;
let cost = monitor::estimate_cost(&s);
let expected = 1.05;
assert!(
(cost - expected).abs() < 0.001,
"sonnet cost={cost}, expected={expected}"
);
}
#[test]
fn cost_haiku_tokens() {
let mut s = make_session(0.0, 0);
s.model = "haiku".into();
s.total_input_tokens = 100_000;
s.total_output_tokens = 50_000;
s.cache_read_tokens = 0;
s.cache_write_tokens = 0;
let cost = monitor::estimate_cost(&s);
let expected = 0.28;
assert!(
(cost - expected).abs() < 0.001,
"haiku cost={cost}, expected={expected}"
);
}
#[test]
fn cost_unknown_model_defaults_to_opus() {
let mut s = make_session(0.0, 0);
s.model = "some-future-model".into();
s.total_input_tokens = 1_000_000;
s.total_output_tokens = 0;
s.cache_read_tokens = 0;
s.cache_write_tokens = 0;
let cost = monitor::estimate_cost(&s);
let expected = 15.0;
assert!(
(cost - expected).abs() < 0.001,
"unknown model cost={cost}, expected={expected}"
);
}
#[test]
fn cost_zero_tokens() {
let s = make_session(0.0, 0);
let cost = monitor::estimate_cost(&s);
assert_eq!(cost, 0.0);
}
#[test]
fn context_max_opus() {
assert_eq!(monitor::model_context_max("opus-4.6"), 1_000_000);
assert_eq!(monitor::model_context_max("opus"), 1_000_000);
}
#[test]
fn context_max_sonnet() {
assert_eq!(monitor::model_context_max("sonnet-4.6"), 200_000);
assert_eq!(monitor::model_context_max("sonnet"), 200_000);
}
#[test]
fn context_max_haiku() {
assert_eq!(monitor::model_context_max("haiku"), 200_000);
}
#[test]
fn context_max_unknown() {
assert_eq!(monitor::model_context_max("unknown-model"), 200_000);
}
#[test]
fn shorten_model_opus_46() {
assert_eq!(
monitor::shorten_model("claude-opus-4-6-20260401"),
"opus-4.6"
);
}
#[test]
fn shorten_model_opus_generic() {
assert_eq!(monitor::shorten_model("claude-opus-20260101"), "opus");
}
#[test]
fn shorten_model_sonnet_46() {
assert_eq!(
monitor::shorten_model("claude-sonnet-4-6-20260401"),
"sonnet-4.6"
);
}
#[test]
fn shorten_model_sonnet_generic() {
assert_eq!(monitor::shorten_model("claude-sonnet-20260101"), "sonnet");
}
#[test]
fn shorten_model_haiku() {
assert_eq!(monitor::shorten_model("claude-haiku-4-5-20251001"), "haiku");
}
#[test]
fn shorten_model_unknown() {
assert_eq!(monitor::shorten_model("gpt-4o"), "gpt-4o");
}
fn make_session_with_jsonl(content: &str) -> (ClaudeSession, tempfile::NamedTempFile) {
let mut file = tempfile::NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
file.flush().unwrap();
let raw = RawSession {
pid: 1,
session_id: "test".into(),
cwd: "/tmp/test".into(),
started_at: 0,
};
let mut s = ClaudeSession::from_raw(raw);
s.jsonl_path = Some(file.path().to_path_buf());
(s, file)
}
fn make_session_with_paths(
cwd: String,
session_id: String,
jsonl_path: std::path::PathBuf,
) -> ClaudeSession {
let raw = RawSession {
pid: 1,
session_id,
cwd,
started_at: 0,
};
let mut s = ClaudeSession::from_raw(raw);
s.jsonl_path = Some(jsonl_path);
s
}
fn write_jsonl(path: &std::path::Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
fn expected_cost(model: &str, input_tokens: u64, output_tokens: u64) -> f64 {
let profile = models::resolve(model).profile;
(input_tokens as f64 / 1_000_000.0) * profile.input_per_m
+ (output_tokens as f64 / 1_000_000.0) * profile.output_per_m
}
#[test]
fn jsonl_parse_token_usage() {
let jsonl = r#"{"type":"assistant","message":{"model":"claude-opus-4-6-20260401","stop_reason":"end_turn","usage":{"input_tokens":50000,"output_tokens":10000,"cache_read_input_tokens":20000,"cache_creation_input_tokens":5000}}}"#;
let (mut s, _file) = make_session_with_jsonl(jsonl);
monitor::update_tokens(&mut s);
assert_eq!(s.total_input_tokens, 75000); assert_eq!(s.total_output_tokens, 10000);
assert_eq!(s.cache_read_tokens, 20000);
assert_eq!(s.cache_write_tokens, 5000);
assert_eq!(s.model, "opus-4.6");
assert_eq!(s.context_max, 1_000_000);
}
#[test]
fn jsonl_parse_multiple_entries() {
let jsonl = concat!(
r#"{"type":"user","message":{"type":"user"}}"#,
"\n",
r#"{"type":"assistant","message":{"model":"claude-sonnet-4-6-20260401","stop_reason":"tool_use","usage":{"input_tokens":1000,"output_tokens":500,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#,
"\n",
r#"{"type":"assistant","message":{"model":"claude-sonnet-4-6-20260401","stop_reason":"end_turn","usage":{"input_tokens":2000,"output_tokens":1000,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#,
);
let (mut s, _file) = make_session_with_jsonl(jsonl);
monitor::update_tokens(&mut s);
assert_eq!(s.total_input_tokens, 3000); assert_eq!(s.total_output_tokens, 1500); assert_eq!(s.model, "sonnet-4.6");
}
#[test]
fn jsonl_incremental_reads() {
let mut file = tempfile::NamedTempFile::new().unwrap();
let line1 = r#"{"type":"assistant","message":{"model":"claude-opus-4-6-20260401","stop_reason":"end_turn","usage":{"input_tokens":1000,"output_tokens":500,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#;
writeln!(file, "{line1}").unwrap();
file.flush().unwrap();
let raw = RawSession {
pid: 1,
session_id: "test".into(),
cwd: "/tmp/test".into(),
started_at: 0,
};
let mut s = ClaudeSession::from_raw(raw);
s.jsonl_path = Some(file.path().to_path_buf());
monitor::update_tokens(&mut s);
assert_eq!(s.total_input_tokens, 1000);
assert_eq!(s.total_output_tokens, 500);
monitor::update_tokens(&mut s);
assert_eq!(s.total_input_tokens, 1000);
assert_eq!(s.total_output_tokens, 500);
let line2 = r#"{"type":"assistant","message":{"model":"claude-opus-4-6-20260401","stop_reason":"end_turn","usage":{"input_tokens":2000,"output_tokens":800,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#;
writeln!(file, "{line2}").unwrap();
file.flush().unwrap();
monitor::update_tokens(&mut s);
assert_eq!(s.total_input_tokens, 3000);
assert_eq!(s.total_output_tokens, 1300);
}
#[test]
fn jsonl_empty_file() {
let (mut s, _file) = make_session_with_jsonl("");
monitor::update_tokens(&mut s);
assert_eq!(s.total_input_tokens, 0);
assert_eq!(s.total_output_tokens, 0);
}
#[test]
fn jsonl_corrupted_lines_skipped() {
let jsonl = concat!(
"not valid json at all\n",
"{\"type\":\"something but no usage\"}\n",
r#"{"type":"assistant","message":{"model":"claude-opus-4-6-20260401","stop_reason":"end_turn","usage":{"input_tokens":5000,"output_tokens":1000,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#,
);
let (mut s, _file) = make_session_with_jsonl(jsonl);
monitor::update_tokens(&mut s);
assert_eq!(s.total_input_tokens, 5000);
assert_eq!(s.total_output_tokens, 1000);
}
#[test]
fn jsonl_waiting_for_task_detection() {
let jsonl = concat!(
r#"{"type":"assistant","message":{"model":"claude-opus-4-6-20260401","stop_reason":"end_turn","usage":{"input_tokens":1000,"output_tokens":500,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#,
"\n",
r#"{"type":"progress","data":"waiting_for_task"}"#,
);
let (mut s, _file) = make_session_with_jsonl(jsonl);
s.cpu_percent = 0.5; monitor::update_tokens(&mut s);
assert_eq!(s.status, SessionStatus::NeedsInput);
}
#[test]
fn jsonl_missing_file() {
let raw = RawSession {
pid: 1,
session_id: "test".into(),
cwd: "/tmp/test".into(),
started_at: 0,
};
let mut s = ClaudeSession::from_raw(raw);
s.jsonl_path = Some(std::path::PathBuf::from("/nonexistent/path.jsonl"));
monitor::update_tokens(&mut s);
assert_eq!(s.total_input_tokens, 0);
}
#[test]
fn jsonl_no_path() {
let raw = RawSession {
pid: 1,
session_id: "test".into(),
cwd: "/tmp/test".into(),
started_at: 0,
};
let mut s = ClaudeSession::from_raw(raw);
monitor::update_tokens(&mut s);
assert_eq!(s.total_input_tokens, 0);
}
#[test]
fn jsonl_rolls_up_subagent_tokens_and_cost() {
let temp = tempfile::tempdir().unwrap();
let parent_jsonl = temp.path().join("parent.jsonl");
write_jsonl(
&parent_jsonl,
r#"{"type":"assistant","message":{"model":"claude-sonnet-4-6-20260401","stop_reason":"end_turn","usage":{"input_tokens":100000,"output_tokens":50000,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#,
);
let session_id = format!("subagent-rollup-{}", std::process::id());
let cwd = format!("/tmp/claudectl-rollup-{}", std::process::id());
let slug = cwd.replace('/', "-");
let uid = unsafe { libc::getuid() };
let tasks_dir = std::path::PathBuf::from(format!("/tmp/claude-{uid}"))
.join(&slug)
.join(&session_id)
.join("tasks");
write_jsonl(
&tasks_dir.join("agent-1.jsonl"),
r#"{"type":"assistant","message":{"model":"claude-opus-4-6-20260401","stop_reason":"end_turn","usage":{"input_tokens":200000,"output_tokens":50000,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#,
);
write_jsonl(
&tasks_dir.join("nested/agent-2.jsonl"),
r#"{"type":"assistant","message":{"model":"claude-haiku-4-5-20260101","stop_reason":"end_turn","usage":{"input_tokens":50000,"output_tokens":10000,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#,
);
let mut s = make_session_with_paths(cwd, session_id, parent_jsonl);
discovery::scan_subagents(std::slice::from_mut(&mut s));
monitor::update_tokens(&mut s);
assert_eq!(s.active_subagent_count, 2);
assert_eq!(s.subagent_count, 2);
assert_eq!(s.total_input_tokens, 350_000);
assert_eq!(s.total_output_tokens, 110_000);
let expected = expected_cost("sonnet-4.6", 100_000, 50_000)
+ expected_cost("opus-4.6", 200_000, 50_000)
+ expected_cost("haiku", 50_000, 10_000);
assert!((s.cost_usd - expected).abs() < 0.0001);
assert!(!s.cost_estimate_unverified);
let _ = std::fs::remove_dir_all(
std::path::PathBuf::from(format!("/tmp/claude-{uid}"))
.join(&slug)
.join(&s.session_id),
);
}
#[test]
fn subagent_rollup_persists_after_task_file_disappears() {
let temp = tempfile::tempdir().unwrap();
let parent_jsonl = temp.path().join("parent.jsonl");
write_jsonl(
&parent_jsonl,
r#"{"type":"assistant","message":{"model":"claude-sonnet-4-6-20260401","stop_reason":"end_turn","usage":{"input_tokens":100000,"output_tokens":10000,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#,
);
let session_id = format!("subagent-persist-{}", std::process::id());
let cwd = format!("/tmp/claudectl-persist-{}", std::process::id());
let slug = cwd.replace('/', "-");
let uid = unsafe { libc::getuid() };
let subagent_root = std::path::PathBuf::from(format!("/tmp/claude-{uid}"))
.join(&slug)
.join(&session_id);
let tasks_dir = subagent_root.join("tasks");
write_jsonl(
&tasks_dir.join("agent-1.jsonl"),
r#"{"type":"assistant","message":{"model":"claude-sonnet-4-6-20260401","stop_reason":"end_turn","usage":{"input_tokens":200000,"output_tokens":20000,"cache_read_input_tokens":0,"cache_creation_input_tokens":0}}}"#,
);
let mut s = make_session_with_paths(cwd, session_id, parent_jsonl);
discovery::scan_subagents(std::slice::from_mut(&mut s));
monitor::update_tokens(&mut s);
assert_eq!(s.active_subagent_count, 1);
assert_eq!(s.subagent_count, 1);
assert_eq!(s.total_input_tokens, 300_000);
assert_eq!(s.total_output_tokens, 30_000);
std::fs::remove_dir_all(&subagent_root).unwrap();
discovery::scan_subagents(std::slice::from_mut(&mut s));
monitor::update_tokens(&mut s);
assert_eq!(s.active_subagent_count, 0);
assert_eq!(s.subagent_count, 1);
assert_eq!(s.total_input_tokens, 300_000);
assert_eq!(s.total_output_tokens, 30_000);
}
#[test]
fn context_percent_zero_max() {
let mut s = make_session(0.0, 0);
s.context_max = 0;
s.context_tokens = 1000;
assert_eq!(s.context_percent(), 0.0);
}
#[test]
fn context_percent_zero_tokens() {
let mut s = make_session(0.0, 0);
s.context_max = 200_000;
s.context_tokens = 0;
assert_eq!(s.context_percent(), 0.0);
}
#[test]
fn context_percent_calculation() {
let mut s = make_session(0.0, 0);
s.context_max = 200_000;
s.context_tokens = 100_000;
assert!((s.context_percent() - 50.0).abs() < 0.01);
}
#[test]
fn sparkline_empty() {
let s = make_session(0.0, 0);
assert_eq!(s.format_sparkline(), "-");
}
#[test]
fn sparkline_records_and_renders() {
let mut s = make_session(0.0, 0);
s.status = SessionStatus::Processing;
s.record_activity();
s.status = SessionStatus::Idle;
s.record_activity();
let sparkline = s.format_sparkline();
assert_eq!(sparkline.chars().count(), 2);
}
#[test]
fn sparkline_ring_buffer_limit() {
let mut s = make_session(0.0, 0);
for _ in 0..20 {
s.status = SessionStatus::Processing;
s.record_activity();
}
assert_eq!(s.activity_history.len(), 15);
}
#[test]
fn json_export_format() {
let mut s = make_session(0.0, 0);
s.model = "opus-4.6".into();
s.cost_usd = 1.234;
s.total_input_tokens = 50000;
s.total_output_tokens = 10000;
s.elapsed = Duration::from_secs(300);
let json = s.to_json_value();
assert_eq!(json["pid"], 1);
assert_eq!(json["status"], "Idle");
assert_eq!(json["elapsed_secs"], 300);
assert_eq!(json["tokens_in"], 50000);
assert_eq!(json["tokens_out"], 10000);
assert!(json["subagent_breakdown"].as_array().unwrap().is_empty());
}
#[test]
fn json_export_includes_subagent_breakdown() {
let mut s = make_session(0.0, 0);
s.active_subagent_jsonl_paths = vec![std::path::PathBuf::from(
"/tmp/claude-1/-tmp-project/session-1/tasks/agent-2.jsonl",
)];
s.subagent_rollups.insert(
std::path::PathBuf::from("/tmp/claude-1/-tmp-project/session-1/tasks/agent-1.jsonl"),
claudectl::session::SubagentRollup {
input_tokens: 20_000,
output_tokens: 2_000,
cost_usd: 0.4,
usage_metrics_available: true,
..claudectl::session::SubagentRollup::default()
},
);
s.subagent_rollups.insert(
std::path::PathBuf::from("/tmp/claude-1/-tmp-project/session-1/tasks/agent-2.jsonl"),
claudectl::session::SubagentRollup {
input_tokens: 10_000,
output_tokens: 1_000,
cost_usd: 0.2,
usage_metrics_available: true,
..claudectl::session::SubagentRollup::default()
},
);
s.subagent_count = 2;
s.active_subagent_count = 1;
let json = s.to_json_value();
let breakdown = json["subagent_breakdown"].as_array().unwrap();
assert_eq!(breakdown.len(), 2);
assert_eq!(breakdown[0]["label"], "completed");
assert_eq!(breakdown[0]["state"], "Completed");
assert_eq!(breakdown[0]["tokens_in"], 20000);
assert_eq!(breakdown[1]["label"], "agent-2");
assert_eq!(breakdown[1]["state"], "Active");
}
#[test]
fn burn_rate_formatting() {
let mut s = make_session(0.0, 0);
assert_eq!(s.format_burn_rate(), "-");
s.burn_rate_per_hr = 0.50;
assert_eq!(s.format_burn_rate(), "$0.50/h");
s.burn_rate_per_hr = 3.5;
assert_eq!(s.format_burn_rate(), "$3.5/h");
}
#[test]
fn mem_formatting() {
let mut s = make_session(0.0, 0);
assert_eq!(s.format_mem(), "-");
s.mem_mb = 256.7;
assert_eq!(s.format_mem(), "257M");
}
#[test]
fn context_bar_formatting() {
let mut s = make_session(0.0, 0);
assert_eq!(s.format_context_bar(10), "-");
s.context_max = 200_000;
s.context_tokens = 100_000; let bar = s.format_context_bar(10);
assert!(bar.contains("50%"));
assert!(bar.contains("█████"));
assert!(bar.contains("░░░░░"));
}
#[test]
fn session_recorder_produces_highlight_reel() {
use claudectl::session_recorder::SessionRecorder;
let mut jsonl_file = tempfile::NamedTempFile::new().unwrap();
jsonl_file.flush().unwrap();
let output_file = tempfile::NamedTempFile::new().unwrap();
let output_path = output_file.path().to_str().unwrap().to_string() + ".cast";
let mut rec = SessionRecorder::new(jsonl_file.path(), &output_path, "test-project", 120, 40)
.expect("Failed to create session recorder");
writeln!(jsonl_file, r#"{{"message":{{"role":"assistant","type":"message","content":[{{"type":"text","text":"I'll fix the authentication bug by updating the middleware."}}],"stop_reason":"tool_use"}}}}"#).unwrap();
writeln!(jsonl_file, r#"{{"message":{{"role":"assistant","type":"message","content":[{{"type":"tool_use","name":"Edit","input":{{"file_path":"/src/auth.rs","old_string":"fn check()","new_string":"fn check_auth(token: &str)"}}}}],"stop_reason":"tool_use"}}}}"#).unwrap();
writeln!(jsonl_file, r#"{{"message":{{"role":"assistant","type":"message","content":[{{"type":"tool_use","name":"Bash","input":{{"command":"cargo test"}}}}],"stop_reason":"tool_use"}}}}"#).unwrap();
writeln!(jsonl_file, r#"{{"message":{{"role":"user","type":"message","content":[{{"type":"tool_result","content":"test result: ok. 12 passed","is_error":false}}]}}}}"#).unwrap();
writeln!(jsonl_file, r#"{{"message":{{"role":"assistant","type":"message","content":[{{"type":"tool_use","name":"Read","input":{{"file_path":"/src/main.rs"}}}}],"stop_reason":"tool_use"}}}}"#).unwrap();
jsonl_file.flush().unwrap();
let had_events = rec.poll().expect("Failed to poll");
assert!(had_events, "Should have found events in the JSONL");
rec.finish().expect("Failed to finish recording");
let content = std::fs::read_to_string(&output_path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert!(
lines[0].contains("\"version\":2"),
"Should have asciicast v2 header"
);
assert!(
lines[0].contains("test-project"),
"Header should contain session name"
);
assert!(
lines.len() >= 4,
"Should have at least 4 lines (header + title + events + finish), got {}",
lines.len()
);
let full = content.to_string();
assert!(
full.contains("Update"),
"Should contain Update event for Edit tool"
);
assert!(full.contains("auth.rs"), "Should contain edited file name");
assert!(
full.contains("bash command"),
"Should contain bash command indicator"
);
assert!(full.contains("cargo test"), "Should contain bash command");
assert!(
full.contains("Read"),
"Read tool should appear as context line"
);
assert!(
full.contains("complete"),
"Should contain completion message"
);
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn session_recorder_empty_jsonl() {
use claudectl::session_recorder::SessionRecorder;
let jsonl_file = tempfile::NamedTempFile::new().unwrap();
let output_file = tempfile::NamedTempFile::new().unwrap();
let output_path = output_file.path().to_str().unwrap().to_string() + ".cast";
let mut rec = SessionRecorder::new(jsonl_file.path(), &output_path, "empty-session", 80, 24)
.expect("Failed to create recorder");
let had_events = rec.poll().expect("Failed to poll");
assert!(!had_events, "Empty JSONL should produce no events");
rec.finish().expect("Failed to finish");
let content = std::fs::read_to_string(&output_path).unwrap();
assert!(
content.contains("\"version\":2"),
"Should still have header"
);
let _ = std::fs::remove_file(&output_path);
}
#[test]
fn recorder_cast_file_creation() {
use claudectl::recorder::Recorder;
let output_file = tempfile::NamedTempFile::new().unwrap();
let output_path = output_file.path().to_str().unwrap().to_string() + ".cast";
let mut rec = Recorder::new(&output_path, 120, 40).expect("Failed to create recorder");
rec.capture(b"hello world");
rec.flush_frame().expect("Failed to flush");
rec.capture(b"second frame");
rec.flush_frame().expect("Failed to flush");
rec.finish().expect("Failed to finish");
let content = std::fs::read_to_string(&output_path).unwrap();
let lines: Vec<&str> = content.lines().collect();
assert!(lines[0].contains("\"version\":2"));
assert!(lines[0].contains("\"width\":120"));
assert!(lines[0].contains("\"height\":40"));
assert!(
lines.len() == 3,
"Should have header + 2 frames, got {}",
lines.len()
);
assert!(lines[1].contains("hello world"));
assert!(lines[2].contains("second frame"));
let _ = std::fs::remove_file(&output_path);
}