use anyhow::{Context, Result};
use ccboard_core::models::SessionMetadata;
use chrono::{DateTime, Utc};
use comfy_table::{Cell, Color, ContentArrangement, Row, Table};
use std::sync::Arc;
#[derive(Debug)]
pub enum CliError {
NoResults {
query: String,
scanned: usize,
},
AmbiguousId {
prefix: String,
count: usize,
suggestions: String,
},
Core(ccboard_core::error::CoreError),
Other(anyhow::Error),
}
impl std::fmt::Display for CliError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CliError::NoResults { query, scanned } => {
write!(
f,
"No sessions match '{}' ({} sessions scanned)",
query, scanned
)
}
CliError::AmbiguousId {
prefix,
count,
suggestions,
} => {
write!(
f,
"Ambiguous ID prefix '{}': matches {} sessions\n{}",
prefix, count, suggestions
)
}
CliError::Core(e) => write!(f, "{}", e),
CliError::Other(e) => write!(f, "{}", e),
}
}
}
impl std::error::Error for CliError {}
impl From<ccboard_core::error::CoreError> for CliError {
fn from(e: ccboard_core::error::CoreError) -> Self {
CliError::Core(e)
}
}
impl From<anyhow::Error> for CliError {
fn from(e: anyhow::Error) -> Self {
CliError::Other(e)
}
}
pub enum DateFilter {
Days(u32),
Months(u32),
Years(u32),
Since(DateTime<Utc>),
}
impl DateFilter {
pub fn parse(s: &str) -> Result<Self> {
if let Some(stripped) = s.strip_suffix('d') {
let days = stripped
.parse::<u32>()
.context("Invalid days format (expected: 7d)")?;
return Ok(DateFilter::Days(days));
}
if let Some(stripped) = s.strip_suffix('m') {
let months = stripped
.parse::<u32>()
.context("Invalid months format (expected: 3m)")?;
return Ok(DateFilter::Months(months));
}
if let Some(stripped) = s.strip_suffix('y') {
let years = stripped
.parse::<u32>()
.context("Invalid years format (expected: 1y)")?;
return Ok(DateFilter::Years(years));
}
let date =
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").context("Invalid date format")?;
let datetime = date.and_hms_opt(0, 0, 0).context("Invalid time")?.and_utc();
Ok(DateFilter::Since(datetime))
}
pub fn cutoff(&self) -> DateTime<Utc> {
let now = Utc::now();
match self {
DateFilter::Days(d) => now - chrono::Duration::days(*d as i64),
DateFilter::Months(m) => now - chrono::Duration::days((*m as i64) * 30),
DateFilter::Years(y) => now - chrono::Duration::days((*y as i64) * 365),
DateFilter::Since(dt) => *dt,
}
}
pub fn matches(&self, timestamp: &DateTime<Utc>) -> bool {
*timestamp >= self.cutoff()
}
}
pub fn find_by_id_or_prefix(
sessions: &[Arc<SessionMetadata>],
id: &str,
) -> Result<Arc<SessionMetadata>, CliError> {
if let Some(session) = sessions.iter().find(|s| s.id == id) {
return Ok(Arc::clone(session));
}
if id.len() < 8 {
return Err(CliError::NoResults {
query: id.to_string(),
scanned: sessions.len(),
});
}
let matches: Vec<_> = sessions.iter().filter(|s| s.id.starts_with(id)).collect();
match matches.len() {
0 => Err(CliError::NoResults {
query: id.to_string(),
scanned: sessions.len(),
}),
1 => Ok(Arc::clone(matches[0])),
count => {
let suggestions = matches
.iter()
.take(5)
.map(|s| format!(" - {}", &s.id[..16.min(s.id.len())]))
.collect::<Vec<_>>()
.join("\n");
Err(CliError::AmbiguousId {
prefix: id.to_string(),
count,
suggestions,
})
}
}
}
pub fn search_sessions(
sessions: &[Arc<SessionMetadata>],
query: &str,
date_filter: Option<&DateFilter>,
limit: usize,
) -> Vec<Arc<SessionMetadata>> {
let query_lower = query.to_lowercase();
sessions
.iter()
.filter(|s| {
if let Some(filter) = date_filter {
if let Some(ts) = s.first_timestamp {
if !filter.matches(&ts) {
return false;
}
} else {
return false;
}
}
s.id.to_lowercase().contains(&query_lower)
|| s.project_path.to_lowercase().contains(&query_lower)
|| s.first_user_message
.as_ref()
.map(|m| m.to_lowercase().contains(&query_lower))
.unwrap_or(false)
|| s.branch
.as_ref()
.map(|b| b.to_lowercase().contains(&query_lower))
.unwrap_or(false)
})
.take(limit)
.cloned()
.collect()
}
pub fn format_session_table(
sessions: &[Arc<SessionMetadata>],
json: bool,
no_color: bool,
) -> String {
if json {
return serde_json::to_string_pretty(sessions).unwrap_or_else(|_| "[]".to_string());
}
if sessions.is_empty() {
return "No sessions found.".to_string();
}
let mut table = Table::new();
table.set_content_arrangement(ContentArrangement::Dynamic);
if no_color {
table.set_header(vec![
"ID", "Project", "Branch", "Date", "Msgs", "Tokens", "Duration", "Preview",
]);
} else {
table.set_header(vec![
Cell::new("ID").fg(Color::Cyan),
Cell::new("Project").fg(Color::Cyan),
Cell::new("Branch").fg(Color::Cyan),
Cell::new("Date").fg(Color::Cyan),
Cell::new("Msgs").fg(Color::Cyan),
Cell::new("Tokens").fg(Color::Cyan),
Cell::new("Duration").fg(Color::Cyan),
Cell::new("Preview").fg(Color::Cyan),
]);
}
for session in sessions {
let id_short = &session.id[..8.min(session.id.len())];
let project = shorten_project(&session.project_path);
let branch = session
.branch
.as_ref()
.map(|b| truncate(b, 15))
.unwrap_or_else(|| "-".to_string());
let date = session
.first_timestamp
.map(|ts| ts.format("%Y-%m-%d %H:%M").to_string())
.unwrap_or_else(|| "unknown".to_string());
let msgs = session.message_count.to_string();
let tokens = format_tokens(session.total_tokens);
let duration = session.duration_display();
let preview = session
.first_user_message
.as_ref()
.map(|m| truncate(m, 40))
.unwrap_or_else(|| "".to_string());
table.add_row(Row::from(vec![
id_short, &project, &branch, &date, &msgs, &tokens, &duration, &preview,
]));
}
table.to_string()
}
pub fn format_session_info(session: &SessionMetadata, json: bool) -> String {
if json {
return serde_json::to_string_pretty(session).unwrap_or_else(|_| "{}".to_string());
}
let mut lines = vec![];
lines.push(format!("Session ID: {}", session.id));
lines.push(format!("Project: {}", session.project_path));
lines.push(format!(
"Branch: {}",
session.branch.as_deref().unwrap_or("-")
));
lines.push(format!("File: {}", session.file_path.display()));
lines.push(format!(
"First timestamp: {}",
session
.first_timestamp
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "-".to_string())
));
lines.push(format!(
"Last timestamp: {}",
session
.last_timestamp
.map(|t| t.to_rfc3339())
.unwrap_or_else(|| "-".to_string())
));
lines.push(format!("Messages: {}", session.message_count));
lines.push(format!(
"Total tokens: {}",
format_tokens(session.total_tokens)
));
lines.push(format!(
" Input: {}",
format_tokens(session.input_tokens)
));
lines.push(format!(
" Output: {}",
format_tokens(session.output_tokens)
));
lines.push(format!(
" Cache creation: {}",
format_tokens(session.cache_creation_tokens)
));
lines.push(format!(
" Cache read: {}",
format_tokens(session.cache_read_tokens)
));
lines.push(format!(
"Models: {}",
session.models_used.join(", ")
));
lines.push(format!("File size: {}", session.size_display()));
lines.push(format!("Duration: {}", session.duration_display()));
lines.push(format!("Has subagents: {}", session.has_subagents));
lines.push(format!(
"First message: {}",
session.first_user_message.as_deref().unwrap_or("-")
));
lines.join("\n")
}
fn format_tokens(tokens: u64) -> String {
if tokens >= 1_000_000 {
format!("{:.1}M", tokens as f64 / 1_000_000.0)
} else if tokens >= 1_000 {
format!("{:.1}K", tokens as f64 / 1_000.0)
} else {
tokens.to_string()
}
}
fn truncate(s: &str, max: usize) -> String {
let char_count = s.chars().count();
if char_count <= max {
s.to_string()
} else {
s.chars().take(max - 1).collect::<String>() + "…"
}
}
fn shorten_project(path: &str) -> String {
let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
if parts.len() > 2 {
format!("…/{}", parts[parts.len() - 2..].join("/"))
} else {
path.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_date_filter_parse_days() {
let filter = DateFilter::parse("7d").unwrap();
if let DateFilter::Days(d) = filter {
assert_eq!(d, 7);
} else {
panic!("Expected Days variant");
}
}
#[test]
fn test_date_filter_parse_months() {
let filter = DateFilter::parse("3m").unwrap();
if let DateFilter::Months(m) = filter {
assert_eq!(m, 3);
} else {
panic!("Expected Months variant");
}
}
#[test]
fn test_date_filter_parse_years() {
let filter = DateFilter::parse("1y").unwrap();
if let DateFilter::Years(y) = filter {
assert_eq!(y, 1);
} else {
panic!("Expected Years variant");
}
}
#[test]
fn test_date_filter_parse_date() {
let filter = DateFilter::parse("2025-06-15").unwrap();
if let DateFilter::Since(dt) = filter {
assert_eq!(dt.format("%Y-%m-%d").to_string(), "2025-06-15");
} else {
panic!("Expected Since variant");
}
}
#[test]
fn test_date_filter_parse_invalid() {
assert!(DateFilter::parse("invalid").is_err());
}
#[test]
fn test_truncate_ascii() {
assert_eq!(truncate("hello world", 20), "hello world");
assert_eq!(truncate("hello world", 5), "hell…");
assert_eq!(truncate("hello", 5), "hello");
}
#[test]
fn test_truncate_emoji() {
assert_eq!(truncate("🔍 test", 10), "🔍 test");
assert_eq!(truncate("🔍 test", 4), "🔍 t…");
assert_eq!(truncate("hello 🔍 world", 8), "hello 🔍…");
}
#[test]
fn test_truncate_multi_emoji() {
assert_eq!(truncate("🚀🔍💡", 5), "🚀🔍💡");
assert_eq!(truncate("🚀🔍💡test", 4), "🚀🔍💡…");
assert_eq!(truncate("test🚀🔍💡", 5), "test…");
}
#[test]
fn test_truncate_unicode() {
assert_eq!(truncate("café", 10), "café");
assert_eq!(truncate("café", 3), "ca…");
assert_eq!(truncate("日本語", 5), "日本語");
assert_eq!(truncate("日本語テスト", 4), "日本語…");
}
#[test]
fn test_date_filter_cutoff_approximate() {
let filter = DateFilter::Days(7);
let cutoff = filter.cutoff();
let now = Utc::now();
let diff = (now - cutoff).num_days();
assert!((diff - 7).abs() <= 1); }
#[test]
fn test_date_filter_matches() {
let filter = DateFilter::Days(7);
let recent = Utc::now() - chrono::Duration::days(3);
let old = Utc::now() - chrono::Duration::days(10);
assert!(filter.matches(&recent));
assert!(!filter.matches(&old));
}
fn create_test_session(id: &str) -> Arc<SessionMetadata> {
Arc::new(SessionMetadata {
id: id.into(),
file_path: std::path::PathBuf::from(format!("/{}.jsonl", id)),
project_path: "/test".into(),
first_timestamp: Some(Utc::now()),
last_timestamp: Some(Utc::now()),
message_count: 10,
total_tokens: 1000,
input_tokens: 500,
output_tokens: 500,
cache_creation_tokens: 0,
cache_read_tokens: 0,
models_used: vec!["sonnet".to_string()],
model_segments: Vec::new(),
file_size_bytes: 1024,
first_user_message: Some("Test message".to_string()),
has_subagents: false,
parent_session_id: None,
duration_seconds: Some(1800),
branch: Some("main".to_string()),
tool_usage: std::collections::HashMap::new(),
tool_token_usage: std::collections::HashMap::new(),
source_tool: Default::default(),
lines_added: 0,
lines_removed: 0,
})
}
#[test]
fn test_find_by_id_exact_match() {
let sessions = vec![
create_test_session("abc123def456"),
create_test_session("xyz789ghi012"),
];
let result = find_by_id_or_prefix(&sessions, "abc123def456").unwrap();
assert_eq!(result.id, "abc123def456");
}
#[test]
fn test_find_by_id_prefix_match() {
let sessions = vec![
create_test_session("abc123def456"),
create_test_session("xyz789ghi012"),
];
let result = find_by_id_or_prefix(&sessions, "abc123de").unwrap();
assert_eq!(result.id, "abc123def456");
}
#[test]
fn test_find_by_id_ambiguous() {
let sessions = vec![
create_test_session("abc123de"),
create_test_session("abc123dx"),
];
let result = find_by_id_or_prefix(&sessions, "abc123d");
assert!(matches!(result, Err(CliError::NoResults { .. })));
}
#[test]
fn test_find_by_id_not_found() {
let sessions = vec![create_test_session("abc123def456")];
let result = find_by_id_or_prefix(&sessions, "notfound");
assert!(matches!(result, Err(CliError::NoResults { .. })));
}
#[test]
fn test_format_session_table_empty() {
let sessions: Vec<Arc<SessionMetadata>> = vec![];
let output = format_session_table(&sessions, false, false);
assert!(output.contains("No sessions found"));
}
#[test]
fn test_format_session_table_json() {
let sessions = vec![create_test_session("abc123def456")];
let output = format_session_table(&sessions, true, false);
assert!(output.contains("abc123def456"));
assert!(output.starts_with('['));
}
#[test]
fn test_format_session_info_json() {
let session = create_test_session("abc123def456");
let output = format_session_info(&session, true);
assert!(output.contains("abc123def456"));
assert!(output.starts_with('{'));
}
}