Skip to main content

rec/session/
list.rs

1//! Session list command implementation.
2//!
3//! Lists all recorded sessions with formatting, tag filtering, pagination,
4//! and JSON output support. Uses header-only loading for efficiency.
5
6use std::io::IsTerminal;
7
8use crate::cli::Output;
9use crate::error::Result;
10use crate::models::SessionFooter;
11use crate::storage::SessionStore;
12
13/// Summary info for a session in the list view.
14struct SessionSummary {
15    id: String,
16    name: String,
17    started_at: f64,
18    tags: Vec<String>,
19    footer: Option<SessionFooter>,
20}
21
22/// List sessions with optional tag filtering, pagination, and JSON output.
23///
24/// Loads only headers and footers for efficiency. Sessions are sorted by
25/// `started_at` descending (most recent first).
26///
27/// # Tag filtering
28/// - If `tags` is non-empty and `tag_all` is false: sessions matching ANY tag are shown
29/// - If `tags` is non-empty and `tag_all` is true: sessions matching ALL tags are shown
30///
31/// # Pagination
32/// In interactive terminals, shows 20 sessions at a time with a "Show more?" prompt.
33///
34/// # Errors
35/// Returns an error if reading sessions from the store fails.
36pub 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    // Load header+footer for all sessions
46    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    // Sort by started_at descending (most recent first)
60    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    // Apply tag filter (normalized comparison for backward compat)
67    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                // ALL specified tags must be present (compare normalized forms)
74                normalized_filter_tags
75                    .iter()
76                    .all(|ft| session_normalized.iter().any(|st| st == ft))
77            } else {
78                // ANY specified tag must be present (compare normalized forms)
79                session_normalized
80                    .iter()
81                    .any(|st| normalized_filter_tags.iter().any(|ft| ft == st))
82            }
83        });
84    }
85
86    // Handle empty state
87    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    // JSON output
99    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    // Table output with pagination
132    let page_size = 20;
133    let total = summaries.len();
134    let interactive = std::io::stdout().is_terminal();
135
136    // Print header line
137    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        // Pagination: pause every page_size in interactive mode
146        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
187/// Format a Unix timestamp as YYYY-MM-DD HH:MM.
188fn 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
198/// Format a duration in seconds as a human-readable string.
199fn 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
214/// Truncate a string to `max_len`, appending "..." if truncated.
215fn 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        // 2026-01-26 at some time
232        let ts = 1737878400.0; // 2025-01-26T08:00:00Z
233        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        // Create session with mixed-case tags
268        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        // Create session with lowercase tags
279        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        // Load summaries and apply tag filter — simulating list_sessions logic
290        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        // Filter with lowercase "deploy" — should match "Deploy"
305        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        // Filter with uppercase "SETUP" — should match "SETUP"
326        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}