1use anyhow::Result;
2use chrono::DateTime;
3
4use crate::cli::ResolvedFilter;
5use crate::ui;
6use claudex::index::IndexStore;
7use claudex::parser::parse_session;
8use claudex::providers::enabled_default;
9use claudex::store::{SessionStore, decode_project_name, display_project_name, short_name};
10use claudex::types::SessionInfo;
11
12pub fn run(
13 project: Option<&str>,
14 file: Option<&str>,
15 limit: usize,
16 json: bool,
17 no_index: bool,
18 filter: &ResolvedFilter,
19) -> Result<()> {
20 if !no_index && let Ok(()) = run_indexed(project, file, limit, json, filter) {
21 return Ok(());
22 }
23 run_from_files(project, file, limit, json, filter)
24}
25
26fn run_indexed(
27 project: Option<&str>,
28 file: Option<&str>,
29 limit: usize,
30 json: bool,
31 filter: &ResolvedFilter,
32) -> Result<()> {
33 let providers = enabled_default()?;
34 let mut idx = IndexStore::open()?;
35 idx.ensure_fresh(&providers)?;
36 let rows = idx.query_sessions(project, file, filter, limit)?;
37
38 if json {
39 let output: Vec<_> = rows
40 .iter()
41 .map(|s| {
42 let date = s
43 .first_timestamp_ms
44 .and_then(DateTime::from_timestamp_millis)
45 .map(|d| d.to_rfc3339());
46 serde_json::json!({
47 "provider": s.provider,
48 "project": s.project_name,
49 "session_id": s.session_id,
50 "file_path": s.file_path,
51 "date": date,
52 "message_count": s.message_count,
53 "duration_ms": s.duration_ms,
54 "model": s.model,
55 "extras": s.extras.as_deref().and_then(|raw| serde_json::from_str::<serde_json::Value>(raw).ok()),
56 "present_on_disk": s.present_on_disk,
57 "archived_at": s.archived_at.and_then(|secs| DateTime::from_timestamp(secs, 0)).map(|d| d.to_rfc3339()),
58 })
59 })
60 .collect();
61 println!("{}", serde_json::to_string_pretty(&output)?);
62 return Ok(());
63 }
64
65 let show_provider = ui::spans_providers(rows.iter().map(|r| r.provider.as_str()));
66 let mut table = ui::table();
67 let mut headers = vec![
68 "Project", "Session", "Date", "Messages", "Duration", "Model",
69 ];
70 if show_provider {
71 headers.insert(0, "Provider");
72 }
73 table.set_header(ui::header(headers));
74 ui::right_align(&mut table, if show_provider { &[4, 5] } else { &[3, 4] });
75
76 for s in &rows {
77 let sid: String = s
78 .session_id
79 .as_deref()
80 .unwrap_or("-")
81 .chars()
82 .take(8)
83 .collect();
84 let date = s
85 .first_timestamp_ms
86 .and_then(DateTime::from_timestamp_millis)
87 .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
88 .unwrap_or_else(|| "-".to_string());
89 let model = s
90 .model
91 .as_deref()
92 .map(|m| m.trim_start_matches("claude-"))
93 .unwrap_or("-")
94 .to_string();
95 let mut cells = vec![
96 ui::cell_project(&short_name(&s.project_name)),
97 ui::cell_dim(&sid),
98 ui::cell_dim(&date),
99 ui::cell_count(s.message_count as u64),
100 ui::cell_plain(format_duration(s.duration_ms as u64)),
101 ui::cell_model(&model),
102 ];
103 if show_provider {
104 cells.insert(0, ui::cell_provider(&s.provider));
105 }
106 table.add_row(cells);
107 }
108 println!("{table}");
109 Ok(())
110}
111
112fn run_from_files(
113 project: Option<&str>,
114 file: Option<&str>,
115 limit: usize,
116 json: bool,
117 filter: &ResolvedFilter,
118) -> Result<()> {
119 filter.ensure_no_index_supported()?;
120
121 let store = SessionStore::new()?;
122 let mut sessions: Vec<SessionInfo> = Vec::new();
123
124 for (project_raw, path) in store.all_session_files(project)? {
125 let stats = match parse_session(&path) {
126 Ok(s) => s,
127 Err(_) => continue,
128 };
129 if !filter.matches("claude", &stats, false) {
132 continue;
133 }
134 if let Some(file_filter) = file
135 && !stats
136 .file_paths_modified
137 .iter()
138 .any(|p| p.contains(file_filter))
139 {
140 continue;
141 }
142 let session_id = stats
143 .session_id
144 .or_else(|| path.file_stem().map(|s| s.to_string_lossy().into_owned()))
145 .unwrap_or_default();
146 sessions.push(SessionInfo {
147 project: display_project_name(&decode_project_name(&project_raw)),
148 session_id,
149 file_path: Some(path.to_string_lossy().into_owned()),
150 date: stats.first_timestamp,
151 message_count: stats.message_count,
152 duration_ms: stats.total_duration_ms,
153 model: stats.model,
154 });
155 }
156
157 sessions.sort_by_key(|s| std::cmp::Reverse(s.date));
158 sessions.truncate(limit);
159
160 if json {
161 let output: Vec<_> = sessions
162 .iter()
163 .map(|s| {
164 serde_json::json!({
165 "provider": "claude",
166 "project": s.project,
167 "session_id": s.session_id,
168 "file_path": s.file_path,
169 "date": s.date.map(|d| d.to_rfc3339()),
170 "message_count": s.message_count,
171 "duration_ms": s.duration_ms,
172 "model": s.model,
173 })
174 })
175 .collect();
176 println!("{}", serde_json::to_string_pretty(&output)?);
177 return Ok(());
178 }
179
180 let mut table = ui::table();
181 table.set_header(ui::header([
182 "Project", "Session", "Date", "Messages", "Duration", "Model",
183 ]));
184 ui::right_align(&mut table, &[3, 4]);
185
186 for s in &sessions {
187 let sid: String = s.session_id.chars().take(8).collect();
188 let date = s
189 .date
190 .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
191 .unwrap_or_else(|| "-".to_string());
192 let proj = short_name(&s.project);
193 let model = s
194 .model
195 .as_deref()
196 .map(|m| m.trim_start_matches("claude-"))
197 .unwrap_or("-")
198 .to_string();
199 table.add_row([
200 ui::cell_project(&proj),
201 ui::cell_dim(&sid),
202 ui::cell_dim(&date),
203 ui::cell_count(s.message_count as u64),
204 ui::cell_plain(format_duration(s.duration_ms)),
205 ui::cell_model(&model),
206 ]);
207 }
208 println!("{table}");
209 Ok(())
210}
211
212pub fn format_duration(ms: u64) -> String {
213 if ms == 0 {
214 return "-".to_string();
215 }
216 let secs = ms / 1000;
217 if secs < 60 {
218 format!("{secs}s")
219 } else if secs < 3600 {
220 format!("{}m{}s", secs / 60, secs % 60)
221 } else {
222 format!("{}h{}m", secs / 3600, (secs % 3600) / 60)
223 }
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn duration_zero() {
232 assert_eq!(format_duration(0), "-");
233 }
234
235 #[test]
236 fn duration_seconds() {
237 assert_eq!(format_duration(45_000), "45s");
238 }
239
240 #[test]
241 fn duration_minutes() {
242 assert_eq!(format_duration(90_000), "1m30s");
243 }
244
245 #[test]
246 fn duration_hours() {
247 assert_eq!(format_duration(3_661_000), "1h1m");
248 }
249}