Skip to main content

rec/session/
stats.rs

1//! Recording statistics aggregation.
2//!
3//! Computes aggregate statistics across all recorded sessions using
4//! efficient header-only loading. Supports both human-readable and
5//! JSON output formats.
6
7use std::collections::HashMap;
8
9use serde::Serialize;
10
11use crate::cli::Output;
12use crate::error::Result;
13use crate::storage::SessionStore;
14
15/// Tag usage count for statistics display.
16#[derive(Debug, Clone, Serialize)]
17pub struct TagCount {
18    /// Tag name
19    pub tag: String,
20    /// Number of sessions using this tag
21    pub count: u64,
22}
23
24/// Aggregate recording statistics.
25#[derive(Debug, Serialize)]
26pub struct RecStats {
27    /// Total number of recorded sessions
28    pub total_sessions: u64,
29    /// Total commands across all sessions
30    pub total_commands: u64,
31    /// Total recording duration in seconds
32    pub total_duration_secs: f64,
33    /// Total storage used in bytes
34    pub storage_bytes: u64,
35    /// Most-used tags, sorted by count descending
36    pub tag_counts: Vec<TagCount>,
37}
38
39impl RecStats {
40    /// Average session length in seconds.
41    ///
42    /// Returns 0.0 if there are no sessions.
43    #[must_use]
44    pub fn average_session_length_secs(&self) -> f64 {
45        if self.total_sessions == 0 {
46            0.0
47        } else {
48            self.total_duration_secs / self.total_sessions as f64
49        }
50    }
51
52    /// Format storage bytes as a human-readable string.
53    ///
54    /// Examples: "512 B", "1.2 KB", "3.4 MB", "5.6 GB"
55    #[must_use]
56    pub fn storage_human(&self) -> String {
57        format_bytes(self.storage_bytes)
58    }
59}
60
61/// Compute aggregate statistics across all sessions.
62///
63/// Uses `load_header_and_footer()` for efficient scanning — skips
64/// full command deserialization. Collects:
65/// - Session count
66/// - Total commands and duration (from footer)
67/// - Storage size (from file metadata)
68/// - Tag frequency counts (top 10)
69///
70/// # Errors
71///
72/// Returns an error if the session list cannot be read.
73pub fn compute_stats(store: &SessionStore) -> Result<RecStats> {
74    let ids = store.list()?;
75
76    let mut total_sessions: u64 = 0;
77    let mut total_commands: u64 = 0;
78    let mut total_duration_secs: f64 = 0.0;
79    let mut storage_bytes: u64 = 0;
80    let mut tag_map: HashMap<String, u64> = HashMap::new();
81
82    for id in &ids {
83        let Ok((header, footer)) = store.load_header_and_footer(id) else {
84            continue; // skip corrupt sessions
85        };
86
87        total_sessions += 1;
88
89        if let Some(ref footer) = footer {
90            total_commands += u64::from(footer.command_count);
91            let duration = footer.ended_at - header.started_at;
92            if duration > 0.0 {
93                total_duration_secs += duration;
94            }
95        }
96
97        for tag in &header.tags {
98            *tag_map.entry(tag.clone()).or_insert(0) += 1;
99        }
100
101        // Get file size from disk
102        let path = store.session_file_path(id);
103        if let Ok(metadata) = std::fs::metadata(&path) {
104            storage_bytes += metadata.len();
105        }
106    }
107
108    // Sort tags by count descending, take top 10
109    let mut tag_counts: Vec<TagCount> = tag_map
110        .into_iter()
111        .map(|(tag, count)| TagCount { tag, count })
112        .collect();
113    tag_counts.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.tag.cmp(&b.tag)));
114    tag_counts.truncate(10);
115
116    Ok(RecStats {
117        total_sessions,
118        total_commands,
119        total_duration_secs,
120        storage_bytes,
121        tag_counts,
122    })
123}
124
125/// Display recording statistics in human-readable or JSON format.
126///
127/// # Errors
128///
129/// Returns an error if JSON serialization fails.
130pub fn format_stats(stats: &RecStats, json: bool, output: &Output) -> Result<()> {
131    if json {
132        let json_obj = serde_json::json!({
133            "total_sessions": stats.total_sessions,
134            "total_commands": stats.total_commands,
135            "average_session_length_seconds": stats.average_session_length_secs(),
136            "total_duration_seconds": stats.total_duration_secs,
137            "storage_bytes": stats.storage_bytes,
138            "storage_human": stats.storage_human(),
139            "most_used_tags": stats.tag_counts.iter().map(|tc| {
140                serde_json::json!({ "tag": tc.tag, "count": tc.count })
141            }).collect::<Vec<_>>(),
142        });
143        println!(
144            "{}",
145            serde_json::to_string_pretty(&json_obj).unwrap_or_else(|_| "{}".to_string())
146        );
147        return Ok(());
148    }
149
150    // Handle empty state
151    if stats.total_sessions == 0 {
152        output.info("No sessions recorded yet");
153        return Ok(());
154    }
155
156    // Human-readable aligned key-value output
157    println!();
158    println!("  Recording Statistics");
159    println!();
160    println!("  Sessions:        {}", stats.total_sessions);
161    println!("  Commands:        {}", stats.total_commands);
162    println!(
163        "  Avg length:      {}",
164        format_duration_stats(stats.average_session_length_secs())
165    );
166    println!(
167        "  Total duration:  {}",
168        format_duration_stats(stats.total_duration_secs)
169    );
170    println!("  Storage:         {}", stats.storage_human());
171
172    if !stats.tag_counts.is_empty() {
173        println!();
174        println!("  Most used tags:");
175        for tc in &stats.tag_counts {
176            println!("    {:<20} {}", tc.tag, tc.count);
177        }
178    }
179
180    println!();
181    Ok(())
182}
183
184/// Format a duration in seconds as a human-readable string for stats.
185///
186/// Examples: "5s", "3m 24s", "1h 5m 30s"
187#[must_use]
188pub fn format_duration_stats(seconds: f64) -> String {
189    if seconds <= 0.0 {
190        return "0s".to_string();
191    }
192
193    let total_secs = seconds as u64;
194    let hours = total_secs / 3600;
195    let minutes = (total_secs % 3600) / 60;
196    let secs = total_secs % 60;
197
198    if hours > 0 {
199        format!("{hours}h {minutes}m {secs}s")
200    } else if minutes > 0 {
201        format!("{minutes}m {secs}s")
202    } else {
203        format!("{secs}s")
204    }
205}
206
207/// Format a byte count as a human-readable string.
208///
209/// Examples: "0 B", "512 B", "1.2 KB", "3.4 MB", "5.6 GB"
210#[must_use]
211pub fn format_bytes(bytes: u64) -> String {
212    const KB: f64 = 1024.0;
213    const MB: f64 = 1024.0 * 1024.0;
214    const GB: f64 = 1024.0 * 1024.0 * 1024.0;
215
216    let bytes_f = bytes as f64;
217
218    if bytes_f >= GB {
219        format!("{:.1} GB", bytes_f / GB)
220    } else if bytes_f >= MB {
221        format!("{:.1} MB", bytes_f / MB)
222    } else if bytes_f >= KB {
223        format!("{:.1} KB", bytes_f / KB)
224    } else {
225        format!("{bytes} B")
226    }
227}
228
229#[cfg(test)]
230#[allow(clippy::float_cmp)]
231mod tests {
232    use super::*;
233    use crate::models::{Command, Session, SessionStatus};
234    use crate::storage::{Paths, SessionStore};
235    use std::path::PathBuf;
236    use tempfile::TempDir;
237
238    fn create_test_paths(temp_dir: &TempDir) -> Paths {
239        Paths {
240            data_dir: temp_dir.path().join("sessions"),
241            config_dir: temp_dir.path().join("config"),
242            config_file: temp_dir.path().join("config").join("config.toml"),
243            state_dir: temp_dir.path().join("state"),
244        }
245    }
246
247    fn create_session_with_tags(name: &str, tags: Vec<&str>, cmd_count: usize) -> Session {
248        let mut session = Session::new(name);
249        session.header.tags = tags.into_iter().map(String::from).collect();
250        for i in 0..cmd_count {
251            session.commands.push(Command::new(
252                i as u32,
253                format!("cmd-{i}"),
254                PathBuf::from("/tmp"),
255            ));
256        }
257        session.complete(SessionStatus::Completed);
258        session
259    }
260
261    #[test]
262    fn test_compute_stats_empty_store() {
263        let temp_dir = TempDir::new().unwrap();
264        let paths = create_test_paths(&temp_dir);
265        let store = SessionStore::new(paths);
266
267        let stats = compute_stats(&store).unwrap();
268        assert_eq!(stats.total_sessions, 0);
269        assert_eq!(stats.total_commands, 0);
270        assert_eq!(stats.total_duration_secs, 0.0);
271        assert_eq!(stats.storage_bytes, 0);
272        assert!(stats.tag_counts.is_empty());
273    }
274
275    #[test]
276    fn test_compute_stats_with_sessions() {
277        let temp_dir = TempDir::new().unwrap();
278        let paths = create_test_paths(&temp_dir);
279        let store = SessionStore::new(paths);
280
281        let s1 = create_session_with_tags("session-1", vec!["deploy"], 3);
282        let s2 = create_session_with_tags("session-2", vec!["setup"], 5);
283        let s3 = create_session_with_tags("session-3", vec!["deploy"], 2);
284
285        store.save(&s1).unwrap();
286        store.save(&s2).unwrap();
287        store.save(&s3).unwrap();
288
289        let stats = compute_stats(&store).unwrap();
290        assert_eq!(stats.total_sessions, 3);
291        assert_eq!(stats.total_commands, 10); // 3 + 5 + 2
292        assert!(stats.storage_bytes > 0);
293    }
294
295    #[test]
296    fn test_compute_stats_tag_counting() {
297        let temp_dir = TempDir::new().unwrap();
298        let paths = create_test_paths(&temp_dir);
299        let store = SessionStore::new(paths);
300
301        let s1 = create_session_with_tags("s1", vec!["deploy", "docker"], 1);
302        let s2 = create_session_with_tags("s2", vec!["deploy", "setup"], 1);
303        let s3 = create_session_with_tags("s3", vec!["deploy"], 1);
304        let s4 = create_session_with_tags("s4", vec!["setup"], 1);
305
306        store.save(&s1).unwrap();
307        store.save(&s2).unwrap();
308        store.save(&s3).unwrap();
309        store.save(&s4).unwrap();
310
311        let stats = compute_stats(&store).unwrap();
312
313        // deploy: 3 times, setup: 2 times, docker: 1 time
314        assert_eq!(stats.tag_counts.len(), 3);
315        assert_eq!(stats.tag_counts[0].tag, "deploy");
316        assert_eq!(stats.tag_counts[0].count, 3);
317        assert_eq!(stats.tag_counts[1].tag, "setup");
318        assert_eq!(stats.tag_counts[1].count, 2);
319        assert_eq!(stats.tag_counts[2].tag, "docker");
320        assert_eq!(stats.tag_counts[2].count, 1);
321    }
322
323    #[test]
324    fn test_format_bytes() {
325        assert_eq!(format_bytes(0), "0 B");
326        assert_eq!(format_bytes(512), "512 B");
327        assert_eq!(format_bytes(1024), "1.0 KB");
328        assert_eq!(format_bytes(1536), "1.5 KB");
329        assert_eq!(format_bytes(1048576), "1.0 MB");
330        assert_eq!(format_bytes(1258291), "1.2 MB");
331        assert_eq!(format_bytes(1073741824), "1.0 GB");
332        assert_eq!(format_bytes(6006636544), "5.6 GB");
333    }
334
335    #[test]
336    fn test_average_session_length() {
337        let stats = RecStats {
338            total_sessions: 3,
339            total_commands: 10,
340            total_duration_secs: 612.0, // 3 sessions × avg 204s
341            storage_bytes: 1024,
342            tag_counts: vec![],
343        };
344
345        let avg = stats.average_session_length_secs();
346        assert!((avg - 204.0).abs() < 0.001);
347    }
348
349    #[test]
350    fn test_average_session_length_zero_sessions() {
351        let stats = RecStats {
352            total_sessions: 0,
353            total_commands: 0,
354            total_duration_secs: 0.0,
355            storage_bytes: 0,
356            tag_counts: vec![],
357        };
358
359        assert_eq!(stats.average_session_length_secs(), 0.0);
360    }
361
362    #[test]
363    fn test_storage_human_formatting() {
364        let stats = RecStats {
365            total_sessions: 1,
366            total_commands: 5,
367            total_duration_secs: 60.0,
368            storage_bytes: 1258291,
369            tag_counts: vec![],
370        };
371
372        assert_eq!(stats.storage_human(), "1.2 MB");
373
374        let stats_small = RecStats {
375            total_sessions: 1,
376            total_commands: 1,
377            total_duration_secs: 5.0,
378            storage_bytes: 512,
379            tag_counts: vec![],
380        };
381
382        assert_eq!(stats_small.storage_human(), "512 B");
383    }
384
385    #[test]
386    fn test_format_duration_stats() {
387        assert_eq!(format_duration_stats(0.0), "0s");
388        assert_eq!(format_duration_stats(5.0), "5s");
389        assert_eq!(format_duration_stats(65.0), "1m 5s");
390        assert_eq!(format_duration_stats(204.0), "3m 24s");
391        assert_eq!(format_duration_stats(3665.0), "1h 1m 5s");
392        assert_eq!(format_duration_stats(-1.0), "0s");
393    }
394}