use std::collections::HashSet;
use anyhow::Result;
use colored::Colorize;
use crate::cli::OutputFormat;
use crate::storage::Database;
#[derive(clap::Args)]
#[command(after_help = "EXAMPLES:\n \
lore sessions List recent sessions (default 20)\n \
lore sessions --limit 50 Show up to 50 sessions\n \
lore sessions --repo . Filter to current directory\n \
lore sessions --repo /path Filter to specific path\n \
lore sessions --tag bug-fix Filter to sessions with 'bug-fix' tag\n \
lore sessions --format json Output as JSON")]
pub struct Args {
#[arg(short, long, value_name = "PATH")]
#[arg(
long_help = "Filter sessions to those with a working directory matching\n\
this path prefix. Use '.' for the current directory."
)]
pub repo: Option<String>,
#[arg(short, long, value_name = "LABEL")]
pub tag: Option<String>,
#[arg(short, long, default_value = "20", value_name = "N")]
pub limit: usize,
#[arg(short, long, value_enum, default_value = "text")]
pub format: OutputFormat,
}
pub fn run(args: Args) -> Result<()> {
let db = Database::open_default()?;
let working_dir = args.repo.map(|r| {
if r == "." {
std::env::current_dir()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| r)
} else {
r
}
});
let sessions = if let Some(ref tag_label) = args.tag {
let mut tagged_sessions = db.list_sessions_with_tag(tag_label, args.limit)?;
if let Some(ref wd) = working_dir {
tagged_sessions.retain(|s| s.working_directory.starts_with(wd));
}
tagged_sessions
} else {
db.list_sessions(args.limit, working_dir.as_deref())?
};
if sessions.is_empty() {
println!("{}", "No sessions found.".dimmed());
println!();
println!("Run 'lore import' to import sessions from Claude Code.");
return Ok(());
}
match args.format {
OutputFormat::Json => {
let json = serde_json::to_string_pretty(&sessions)?;
println!("{json}");
}
OutputFormat::Text | OutputFormat::Markdown => {
const ID_WIDTH: usize = 12;
const STARTED_WIDTH: usize = 16;
const MESSAGES_WIDTH: usize = 8;
const BRANCH_WIDTH: usize = 24;
println!(
"{}",
format!(
"{:<ID_WIDTH$} {:<STARTED_WIDTH$} {:>MESSAGES_WIDTH$} {:<BRANCH_WIDTH$} {}",
"ID", "STARTED", "MESSAGES", "BRANCH", "DIRECTORY"
)
.bold()
);
let session_ids: Vec<uuid::Uuid> = sessions.iter().map(|s| s.id).collect();
let sessions_with_summaries: HashSet<uuid::Uuid> = db
.get_sessions_with_summaries(&session_ids)
.unwrap_or_default();
for session in &sessions {
let id_short = &session.id.to_string()[..8];
let has_summary = sessions_with_summaries.contains(&session.id);
let id_display = if has_summary {
format!("{} {}", id_short.cyan(), "[S]".green())
} else {
format!("{}", id_short.cyan())
};
let started = session.started_at.format("%Y-%m-%d %H:%M").to_string();
let branch_history = db.get_session_branch_history(session.id)?;
let branch_display = format_branch_history(&branch_history, BRANCH_WIDTH);
let dir = session
.working_directory
.split('/')
.next_back()
.unwrap_or(&session.working_directory);
println!(
"{:<ID_WIDTH$} {:<STARTED_WIDTH$} {:>MESSAGES_WIDTH$} {:<BRANCH_WIDTH$} {}",
id_display,
started.dimmed(),
session.message_count,
branch_display.yellow(),
dir
);
}
}
}
Ok(())
}
fn truncate_to_width(s: &str, max_width: usize) -> String {
if s.len() <= max_width {
s.to_string()
} else if max_width <= 3 {
".".repeat(max_width)
} else {
format!("{}...", &s[..max_width - 3])
}
}
fn format_branch_history(branches: &[String], max_width: usize) -> String {
let result = match branches.len() {
0 => "-".to_string(),
1 => branches[0].clone(),
2 | 3 => branches.join(" -> "),
_ => {
format!(
"{} -> {} -> ... -> {}",
branches[0],
branches[1],
branches.last().unwrap()
)
}
};
truncate_to_width(&result, max_width)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_truncate_to_width_short_string() {
assert_eq!(truncate_to_width("hello", 10), "hello");
}
#[test]
fn test_truncate_to_width_exact_length() {
assert_eq!(truncate_to_width("hello", 5), "hello");
}
#[test]
fn test_truncate_to_width_needs_truncation() {
assert_eq!(truncate_to_width("hello world", 8), "hello...");
}
#[test]
fn test_truncate_to_width_very_small() {
assert_eq!(truncate_to_width("hello", 3), "...");
assert_eq!(truncate_to_width("hello", 2), "..");
assert_eq!(truncate_to_width("hello", 1), ".");
assert_eq!(truncate_to_width("hello", 0), "");
}
#[test]
fn test_truncate_to_width_minimum_for_ellipsis() {
assert_eq!(truncate_to_width("hello", 4), "h...");
}
#[test]
fn test_format_branch_history_empty() {
let branches: Vec<String> = vec![];
assert_eq!(format_branch_history(&branches, 24), "-");
}
#[test]
fn test_format_branch_history_single() {
let branches = vec!["main".to_string()];
assert_eq!(format_branch_history(&branches, 24), "main");
}
#[test]
fn test_format_branch_history_two() {
let branches = vec!["main".to_string(), "feat/auth".to_string()];
assert_eq!(format_branch_history(&branches, 24), "main -> feat/auth");
}
#[test]
fn test_format_branch_history_three() {
let branches = vec![
"main".to_string(),
"feat/auth".to_string(),
"main".to_string(),
];
assert_eq!(
format_branch_history(&branches, 30),
"main -> feat/auth -> main"
);
}
#[test]
fn test_format_branch_history_many_branches() {
let branches = vec![
"main".to_string(),
"feat/a".to_string(),
"feat/b".to_string(),
"feat/c".to_string(),
"main".to_string(),
];
assert_eq!(
format_branch_history(&branches, 50),
"main -> feat/a -> ... -> main"
);
}
#[test]
fn test_format_branch_history_truncates_long_result() {
let branches = vec![
"main".to_string(),
"feat/phase-6-configuration-ux".to_string(),
"main".to_string(),
];
let result = format_branch_history(&branches, 24);
assert_eq!(result.len(), 24);
assert_eq!(result, "main -> feat/phase-6-...");
}
#[test]
fn test_format_branch_history_single_long_branch() {
let branches = vec!["feat/very-long-branch-name-here".to_string()];
let result = format_branch_history(&branches, 20);
assert_eq!(result.len(), 20);
assert_eq!(result, "feat/very-long-br...");
}
#[test]
fn test_format_branch_history_fits_exactly() {
let branches = vec!["main".to_string(), "dev".to_string()];
let result = format_branch_history(&branches, 11);
assert_eq!(result, "main -> dev");
}
}