1use std::collections::HashMap;
8
9use serde::Serialize;
10
11use crate::cli::Output;
12use crate::error::Result;
13use crate::storage::SessionStore;
14
15#[derive(Debug, Clone, Serialize)]
17pub struct TagCount {
18 pub tag: String,
20 pub count: u64,
22}
23
24#[derive(Debug, Serialize)]
26pub struct RecStats {
27 pub total_sessions: u64,
29 pub total_commands: u64,
31 pub total_duration_secs: f64,
33 pub storage_bytes: u64,
35 pub tag_counts: Vec<TagCount>,
37}
38
39impl RecStats {
40 #[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 #[must_use]
56 pub fn storage_human(&self) -> String {
57 format_bytes(self.storage_bytes)
58 }
59}
60
61pub 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; };
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 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 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
125pub 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 if stats.total_sessions == 0 {
152 output.info("No sessions recorded yet");
153 return Ok(());
154 }
155
156 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#[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#[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); 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 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, 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}