1use std::io::IsTerminal;
7
8use crate::cli::Output;
9use crate::error::Result;
10use crate::models::SessionFooter;
11use crate::storage::SessionStore;
12
13struct SessionSummary {
15 id: String,
16 name: String,
17 started_at: f64,
18 tags: Vec<String>,
19 footer: Option<SessionFooter>,
20}
21
22pub fn list_sessions(
37 store: &SessionStore,
38 tags: &[String],
39 tag_all: bool,
40 json: bool,
41 output: &Output,
42) -> Result<()> {
43 let ids = store.list()?;
44
45 let mut summaries: Vec<SessionSummary> = Vec::new();
47 for id in &ids {
48 if let Ok((header, footer)) = store.load_header_and_footer(id) {
49 summaries.push(SessionSummary {
50 id: id.clone(),
51 name: header.name.clone(),
52 started_at: header.started_at,
53 tags: header.tags.clone(),
54 footer,
55 });
56 }
57 }
58
59 summaries.sort_by(|a, b| {
61 b.started_at
62 .partial_cmp(&a.started_at)
63 .unwrap_or(std::cmp::Ordering::Equal)
64 });
65
66 if !tags.is_empty() {
68 use crate::session::normalize_tag;
69 let normalized_filter_tags: Vec<String> = tags.iter().map(|t| normalize_tag(t)).collect();
70 summaries.retain(|s| {
71 let session_normalized: Vec<String> = s.tags.iter().map(|t| normalize_tag(t)).collect();
72 if tag_all {
73 normalized_filter_tags
75 .iter()
76 .all(|ft| session_normalized.iter().any(|st| st == ft))
77 } else {
78 session_normalized
80 .iter()
81 .any(|st| normalized_filter_tags.iter().any(|ft| ft == st))
82 }
83 });
84 }
85
86 if summaries.is_empty() {
88 if json {
89 println!("[]");
90 } else if !tags.is_empty() {
91 output.info("No sessions match tag filter");
92 } else {
93 output.info("No sessions found");
94 }
95 return Ok(());
96 }
97
98 if json {
100 let json_entries: Vec<serde_json::Value> = summaries
101 .iter()
102 .map(|s| {
103 let mut obj = serde_json::json!({
104 "id": s.id,
105 "name": s.name,
106 "date": format_date(s.started_at),
107 "tags": s.tags,
108 });
109
110 if let Some(ref footer) = s.footer {
111 obj["command_count"] = serde_json::json!(footer.command_count);
112 let duration_secs = footer.ended_at - s.started_at;
113 obj["duration"] = serde_json::json!(format_duration(duration_secs));
114 obj["duration_seconds"] = serde_json::json!(duration_secs);
115 } else {
116 obj["command_count"] = serde_json::json!(null);
117 obj["duration"] = serde_json::json!("active");
118 }
119
120 obj
121 })
122 .collect();
123
124 println!(
125 "{}",
126 serde_json::to_string_pretty(&json_entries).unwrap_or_else(|_| "[]".to_string())
127 );
128 return Ok(());
129 }
130
131 let page_size = 20;
133 let total = summaries.len();
134 let interactive = std::io::stdout().is_terminal();
135
136 println!();
138 println!(
139 " {:<30} {:<18} {:>5} {:>10} TAGS",
140 "NAME", "DATE", "CMDS", "DURATION"
141 );
142 println!(" {}", "-".repeat(80));
143
144 for (i, s) in summaries.iter().enumerate() {
145 if interactive && i > 0 && i % page_size == 0 {
147 let remaining = total - i;
148 let show_more = dialoguer::Confirm::new()
149 .with_prompt(format!("Show more? ({remaining} remaining)"))
150 .default(true)
151 .interact()
152 .unwrap_or(false);
153 if !show_more {
154 break;
155 }
156 }
157
158 let name = truncate(&s.name, 30);
159 let date = format_date(s.started_at);
160
161 let cmds = match &s.footer {
162 Some(f) => format!("{}", f.command_count),
163 None => "?".to_string(),
164 };
165
166 let duration = match &s.footer {
167 Some(f) => format_duration(f.ended_at - s.started_at),
168 None => "active".to_string(),
169 };
170
171 let tags = if s.tags.is_empty() {
172 String::new()
173 } else {
174 truncate(&s.tags.join(", "), 20)
175 };
176
177 println!(" {name:<30} {date:<18} {cmds:>5} {duration:>10} {tags}");
178 }
179
180 println!();
181 output.info(&format!("{total} session(s)"));
182 println!();
183
184 Ok(())
185}
186
187fn format_date(timestamp: f64) -> String {
189 chrono::DateTime::from_timestamp(timestamp as i64, 0).map_or_else(
190 || "unknown".to_string(),
191 |dt| {
192 let local: chrono::DateTime<chrono::Local> = dt.into();
193 local.format("%Y-%m-%d %H:%M").to_string()
194 },
195 )
196}
197
198fn format_duration(seconds: f64) -> String {
200 let total_secs = seconds as u64;
201 let hours = total_secs / 3600;
202 let minutes = (total_secs % 3600) / 60;
203 let secs = total_secs % 60;
204
205 if hours > 0 {
206 format!("{hours}h {minutes}m {secs}s")
207 } else if minutes > 0 {
208 format!("{minutes}m {secs}s")
209 } else {
210 format!("{secs}s")
211 }
212}
213
214fn truncate(s: &str, max_len: usize) -> String {
216 if s.len() <= max_len {
217 s.to_string()
218 } else if max_len > 3 {
219 format!("{}...", &s[..max_len - 3])
220 } else {
221 s[..max_len].to_string()
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn test_format_date() {
231 let ts = 1737878400.0; let date = format_date(ts);
234 assert!(date.starts_with("2025-01-26"));
235 }
236
237 #[test]
238 fn test_format_duration() {
239 assert_eq!(format_duration(5.0), "5s");
240 assert_eq!(format_duration(65.0), "1m 5s");
241 assert_eq!(format_duration(3665.0), "1h 1m 5s");
242 }
243
244 #[test]
245 fn test_truncate() {
246 assert_eq!(truncate("hello", 10), "hello");
247 assert_eq!(truncate("hello world!", 8), "hello...");
248 assert_eq!(truncate("hi", 2), "hi");
249 }
250
251 #[test]
252 fn test_tag_filter_case_insensitive() {
253 use crate::models::{Command, Session, SessionStatus};
254 use crate::storage::{Paths, SessionStore};
255 use std::path::PathBuf;
256 use tempfile::TempDir;
257
258 let temp_dir = TempDir::new().unwrap();
259 let paths = Paths {
260 data_dir: temp_dir.path().join("sessions"),
261 config_dir: temp_dir.path().join("config"),
262 config_file: temp_dir.path().join("config").join("config.toml"),
263 state_dir: temp_dir.path().join("state"),
264 };
265 let store = SessionStore::new(paths);
266
267 let mut s1 = Session::new("deploy-session");
269 s1.header.tags = vec!["Deploy".to_string(), "SETUP".to_string()];
270 s1.commands.push(Command::new(
271 0,
272 "echo hello".to_string(),
273 PathBuf::from("/tmp"),
274 ));
275 s1.complete(SessionStatus::Completed);
276 store.save(&s1).unwrap();
277
278 let mut s2 = Session::new("other-session");
280 s2.header.tags = vec!["rust".to_string()];
281 s2.commands.push(Command::new(
282 0,
283 "echo hello".to_string(),
284 PathBuf::from("/tmp"),
285 ));
286 s2.complete(SessionStatus::Completed);
287 store.save(&s2).unwrap();
288
289 let ids = store.list().unwrap();
291 let mut summaries: Vec<SessionSummary> = Vec::new();
292 for id in &ids {
293 if let Ok((header, footer)) = store.load_header_and_footer(id) {
294 summaries.push(SessionSummary {
295 id: id.clone(),
296 name: header.name.clone(),
297 started_at: header.started_at,
298 tags: header.tags.clone(),
299 footer,
300 });
301 }
302 }
303
304 let filter_tags = ["deploy".to_string()];
306 {
307 use crate::session::normalize_tag;
308 let normalized_filter_tags: Vec<String> =
309 filter_tags.iter().map(|t| normalize_tag(t)).collect();
310 let filtered: Vec<&SessionSummary> = summaries
311 .iter()
312 .filter(|s| {
313 let session_normalized: Vec<String> =
314 s.tags.iter().map(|t| normalize_tag(t)).collect();
315 session_normalized
316 .iter()
317 .any(|st| normalized_filter_tags.iter().any(|ft| ft == st))
318 })
319 .collect();
320
321 assert_eq!(filtered.len(), 1);
322 assert_eq!(filtered[0].name, "deploy-session");
323 }
324
325 let filter_tags2 = ["setup".to_string()];
327 {
328 use crate::session::normalize_tag;
329 let normalized_filter_tags: Vec<String> =
330 filter_tags2.iter().map(|t| normalize_tag(t)).collect();
331 let filtered: Vec<&SessionSummary> = summaries
332 .iter()
333 .filter(|s| {
334 let session_normalized: Vec<String> =
335 s.tags.iter().map(|t| normalize_tag(t)).collect();
336 session_normalized
337 .iter()
338 .any(|st| normalized_filter_tags.iter().any(|ft| ft == st))
339 })
340 .collect();
341
342 assert_eq!(filtered.len(), 1);
343 assert_eq!(filtered[0].name, "deploy-session");
344 }
345 }
346}