1use anyhow::Result;
11use serde::Serialize;
12use std::io::Write;
13
14use crate::config;
15use crate::db::{self, CommandRecord};
16
17const TOP_N: usize = 10;
19
20#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)]
22pub struct Filters {
23 pub project: Option<String>,
24 pub branch: Option<String>,
25 pub since: Option<String>,
26}
27
28impl Filters {
29 fn is_empty(&self) -> bool {
30 self.project.is_none() && self.branch.is_none() && self.since.is_none()
31 }
32}
33
34#[derive(Debug, Default, Clone, PartialEq, Eq)]
36pub struct Stats {
37 pub total: usize,
38 pub git_projects: usize,
39 pub failed: usize,
40 pub ignored_for_top_commands: usize,
43 pub top_commands: Vec<(String, usize)>,
44 pub top_dirs: Vec<(String, usize)>,
45 pub top_projects: Vec<(String, usize)>,
46 pub top_shells: Vec<(String, usize)>,
48 pub daily: Vec<(String, usize)>,
50}
51
52pub fn failure_rate(stats: &Stats) -> f64 {
54 if stats.total == 0 {
55 0.0
56 } else {
57 stats.failed as f64 / stats.total as f64
58 }
59}
60
61pub fn run(
63 project: Option<String>,
64 branch: Option<String>,
65 since: Option<String>,
66 json: bool,
67) -> Result<()> {
68 let cfg = config::Config::load()?;
69 let ignored = &cfg.stats.ignored_commands;
70 let conn = db::open(&config::db_path()?)?;
71
72 let project = match project {
74 Some(p) if p.eq_ignore_ascii_case("current") => crate::project::current_name(),
75 other => other,
76 };
77
78 let since_bound = match since.as_deref() {
81 Some(spec) => match db::resolve_since(spec) {
82 Some(bound) => Some(bound),
83 None => {
84 eprintln!("Valeur --since invalide ({spec:?}) : filtre temporel ignoré.");
85 None
86 }
87 },
88 None => None,
89 };
90
91 let filter = db::QueryFilter {
92 project: project.clone(),
93 branch: branch.clone(),
94 since: since_bound,
95 ..Default::default()
96 };
97 let records = db::fetch_query(&conn, &filter, None)?;
98 let stats = compute(&records, ignored);
99 let filters = Filters {
100 project,
101 branch,
102 since,
103 };
104
105 let stdout = std::io::stdout();
109 let mut out = stdout.lock();
110
111 if json {
112 writeln!(out, "{}", render_json(&stats, &filters, ignored))?;
113 return Ok(());
114 }
115
116 if records.is_empty() && !filters.is_empty() {
118 writeln!(out, "Aucune commande trouvée pour ces filtres.")?;
119 return Ok(());
120 }
121
122 render_text(&mut out, &stats, &filters)?;
123 Ok(())
124}
125
126pub fn compute(records: &[CommandRecord], ignored_commands: &[String]) -> Stats {
132 use std::collections::{HashMap, HashSet};
133
134 let total = records.len();
135 let mut git_roots = HashSet::new();
136 let mut failed = 0usize;
137 let mut ignored = 0usize;
138
139 let mut cmd_counts: HashMap<String, usize> = HashMap::new();
140 let mut dir_counts: HashMap<String, usize> = HashMap::new();
141 let mut proj_counts: HashMap<String, usize> = HashMap::new();
142 let mut shell_counts: HashMap<String, usize> = HashMap::new();
143 let mut day_counts: HashMap<String, usize> = HashMap::new();
144
145 for r in records {
146 match normalize_command_name(&r.command) {
147 Some(name) if is_ignored(&name, ignored_commands) => ignored += 1,
148 Some(name) => *cmd_counts.entry(name).or_insert(0) += 1,
149 None => ignored += 1,
150 }
151 if let Some(cwd) = r.cwd.as_deref().filter(|s| !s.is_empty()) {
152 *dir_counts.entry(cwd.to_string()).or_insert(0) += 1;
153 }
154 if let Some(root) = r.git_root.as_deref().filter(|s| !s.is_empty()) {
155 git_roots.insert(root.to_string());
156 *proj_counts.entry(project_name(root)).or_insert(0) += 1;
157 }
158 if let Some(shell) = r.shell.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
159 *shell_counts.entry(shell.to_string()).or_insert(0) += 1;
160 }
161 if r.created_at.len() >= 10 {
163 *day_counts
164 .entry(r.created_at[..10].to_string())
165 .or_insert(0) += 1;
166 }
167 if matches!(r.exit_code, Some(code) if code != 0) {
168 failed += 1;
169 }
170 }
171
172 let mut daily: Vec<(String, usize)> = day_counts.into_iter().collect();
173 daily.sort_by(|a, b| a.0.cmp(&b.0));
174
175 Stats {
176 total,
177 git_projects: git_roots.len(),
178 failed,
179 ignored_for_top_commands: ignored,
180 top_commands: top_n(cmd_counts, TOP_N),
181 top_dirs: top_n(dir_counts, TOP_N),
182 top_projects: top_n(proj_counts, TOP_N),
183 top_shells: top_n(shell_counts, TOP_N),
184 daily,
185 }
186}
187
188const JUNK_TOKENS: &[&str] = &[
191 "-", "|", "||", "&&", "&", ";", ";;", "}", "{", ")", "(", "then", "fi", "done", "do", "else",
192 "elif", "function", "in", "esac",
193];
194
195const WRAPPERS: &[&str] = &["command", "builtin", "exec", "time", "nohup"];
197
198pub fn normalize_command_name(command: &str) -> Option<String> {
205 let trimmed = command.trim();
206 if trimmed.is_empty() || trimmed.starts_with('#') {
207 return None;
208 }
209
210 let tokens: Vec<&str> = trimmed.split_whitespace().collect();
211 let mut i = 0;
212
213 loop {
214 while i < tokens.len() && is_env_assignment(tokens[i]) {
216 i += 1;
217 }
218 let tok = match tokens.get(i) {
219 Some(t) => *t,
220 None => return None,
221 };
222
223 match tok {
225 "sudo" => {
226 i += 1;
227 while i < tokens.len() && tokens[i].starts_with('-') {
230 i += 1;
231 }
232 continue;
233 }
234 "env" => {
235 i += 1;
236 while i < tokens.len() && tokens[i].starts_with('-') {
237 i += 1;
238 }
239 continue;
240 }
241 _ if WRAPPERS.contains(&tok) => {
242 i += 1;
243 continue;
244 }
245 _ => {}
246 }
247
248 let name = basename(tok);
250 if name.is_empty() || JUNK_TOKENS.contains(&name) {
251 return None;
252 }
253 return Some(name.to_string());
254 }
255}
256
257fn is_env_assignment(token: &str) -> bool {
259 let Some((key, _)) = token.split_once('=') else {
260 return false;
261 };
262 if key.is_empty() {
263 return false;
264 }
265 let mut chars = key.chars();
266 let Some(first) = chars.next() else {
267 return false;
268 };
269 if !(first.is_ascii_alphabetic() || first == '_') {
270 return false;
271 }
272 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
273}
274
275fn basename(token: &str) -> &str {
277 token.rsplit('/').next().unwrap_or(token)
278}
279
280fn is_ignored(name: &str, ignored_commands: &[String]) -> bool {
283 let lowered = name.to_lowercase();
284 ignored_commands.iter().any(|c| c == &lowered)
285}
286
287fn project_name(root: &str) -> String {
289 root.trim_end_matches('/')
290 .rsplit('/')
291 .next()
292 .unwrap_or(root)
293 .to_string()
294}
295
296fn top_n(counts: std::collections::HashMap<String, usize>, n: usize) -> Vec<(String, usize)> {
299 let mut v: Vec<(String, usize)> = counts.into_iter().collect();
300 v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
301 v.truncate(n);
302 v
303}
304
305fn now_secs() -> u64 {
307 std::time::SystemTime::now()
308 .duration_since(std::time::UNIX_EPOCH)
309 .map(|d| d.as_secs())
310 .unwrap_or(0)
311}
312
313fn activity_window(
317 daily: &[(String, usize)],
318 today_secs: u64,
319 days: usize,
320) -> Vec<(String, usize)> {
321 use std::collections::HashMap;
322 let map: HashMap<&str, usize> = daily.iter().map(|(d, c)| (d.as_str(), *c)).collect();
323 let mut out = Vec::with_capacity(days);
324 for k in (0..days).rev() {
325 let secs = today_secs.saturating_sub(k as u64 * 86_400);
326 let date = db::format_timestamp(secs)[..10].to_string();
327 let count = map.get(date.as_str()).copied().unwrap_or(0);
328 out.push((date, count));
329 }
330 out
331}
332
333fn render_text<W: Write>(out: &mut W, stats: &Stats, filters: &Filters) -> std::io::Result<()> {
338 writeln!(out, "mnemo stats - statistiques d'usage")?;
339 writeln!(out, "----------------------------------")?;
340 if !filters.is_empty() {
341 writeln!(
342 out,
343 "Filtres : projet={}, branche={}, depuis={}",
344 filters.project.as_deref().unwrap_or("-"),
345 filters.branch.as_deref().unwrap_or("-"),
346 filters.since.as_deref().unwrap_or("-"),
347 )?;
348 }
349 writeln!(out, "Commandes enregistrées : {}", stats.total)?;
350 writeln!(out, "Projets Git détectés : {}", stats.git_projects)?;
351 writeln!(
352 out,
353 "Commandes en échec : {} (exit_code ≠ 0)",
354 stats.failed
355 )?;
356 writeln!(
357 out,
358 "Taux d'échec : {:.1} %",
359 failure_rate(stats) * 100.0
360 )?;
361
362 render_section(out, "Top commandes", &stats.top_commands)?;
363 if stats.ignored_for_top_commands > 0 {
364 writeln!(
365 out,
366 " Entrées ignorées dans le Top commandes : {}",
367 stats.ignored_for_top_commands
368 )?;
369 }
370 render_section(out, "Top dossiers", &stats.top_dirs)?;
371 render_section(out, "Top projets Git", &stats.top_projects)?;
372 render_section(out, "Top shells", &stats.top_shells)?;
373 render_activity(out, stats)?;
374 Ok(())
375}
376
377fn render_activity<W: Write>(out: &mut W, stats: &Stats) -> std::io::Result<()> {
380 let now = now_secs();
381 let week = activity_window(&stats.daily, now, 7);
382 let month = activity_window(&stats.daily, now, 30);
383 let month_total: usize = month.iter().map(|(_, c)| c).sum();
384 let max = week.iter().map(|(_, c)| *c).max().unwrap_or(0);
385
386 writeln!(out)?;
387 writeln!(out, "Activité (7 derniers jours) :")?;
388 for (date, count) in &week {
389 let bar_len = (count * 20).checked_div(max).unwrap_or(0);
390 let bar = "#".repeat(bar_len);
391 writeln!(out, " {date} {count:>4} {bar}")?;
392 }
393 writeln!(out, " 30 derniers jours : {month_total} commande(s)")?;
394 Ok(())
395}
396
397fn render_section<W: Write>(
398 out: &mut W,
399 title: &str,
400 entries: &[(String, usize)],
401) -> std::io::Result<()> {
402 writeln!(out)?;
403 writeln!(out, "{title} :")?;
404 if entries.is_empty() {
405 writeln!(out, " (aucune donnée)")?;
406 return Ok(());
407 }
408 for (label, count) in entries {
409 writeln!(out, " {count:>5} {label}")?;
410 }
411 Ok(())
412}
413
414#[derive(Serialize)]
419struct NamedCount {
420 name: String,
421 count: usize,
422}
423
424#[derive(Serialize)]
425struct PathCount {
426 path: String,
427 count: usize,
428}
429
430#[derive(Serialize)]
431struct DayCount {
432 date: String,
433 count: usize,
434}
435
436#[derive(Serialize)]
437struct JsonOutput {
438 total_commands: usize,
439 git_projects: usize,
440 failed_commands: usize,
441 failure_rate: f64,
442 ignored_for_top_commands: usize,
443 ignored_commands_config: Vec<String>,
444 filters: Filters,
445 top_commands: Vec<NamedCount>,
446 top_directories: Vec<PathCount>,
447 top_projects: Vec<NamedCount>,
448 top_shells: Vec<NamedCount>,
449 activity_last_7_days: Vec<DayCount>,
450 activity_last_30_days: Vec<DayCount>,
451}
452
453fn render_json(stats: &Stats, filters: &Filters, ignored_commands: &[String]) -> String {
454 let now = now_secs();
455 let output = JsonOutput {
456 total_commands: stats.total,
457 git_projects: stats.git_projects,
458 failed_commands: stats.failed,
459 failure_rate: failure_rate(stats),
460 ignored_for_top_commands: stats.ignored_for_top_commands,
461 ignored_commands_config: ignored_commands.to_vec(),
462 filters: filters.clone(),
463 top_commands: stats
464 .top_commands
465 .iter()
466 .map(|(name, count)| NamedCount {
467 name: name.clone(),
468 count: *count,
469 })
470 .collect(),
471 top_directories: stats
472 .top_dirs
473 .iter()
474 .map(|(path, count)| PathCount {
475 path: path.clone(),
476 count: *count,
477 })
478 .collect(),
479 top_projects: stats
480 .top_projects
481 .iter()
482 .map(|(name, count)| NamedCount {
483 name: name.clone(),
484 count: *count,
485 })
486 .collect(),
487 top_shells: stats
488 .top_shells
489 .iter()
490 .map(|(name, count)| NamedCount {
491 name: name.clone(),
492 count: *count,
493 })
494 .collect(),
495 activity_last_7_days: activity_window(&stats.daily, now, 7)
496 .into_iter()
497 .map(|(date, count)| DayCount { date, count })
498 .collect(),
499 activity_last_30_days: activity_window(&stats.daily, now, 30)
500 .into_iter()
501 .map(|(date, count)| DayCount { date, count })
502 .collect(),
503 };
504 serde_json::to_string_pretty(&output).unwrap_or_else(|_| "{}".to_string())
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510
511 fn rec(
512 command: &str,
513 cwd: Option<&str>,
514 git_root: Option<&str>,
515 exit: Option<i64>,
516 ) -> CommandRecord {
517 CommandRecord {
518 id: 0,
519 command: command.to_string(),
520 cwd: cwd.map(str::to_string),
521 shell: None,
522 hostname: None,
523 exit_code: exit,
524 created_at: "2026-06-14 10:00:00".to_string(),
525 git_root: git_root.map(str::to_string),
526 git_branch: None,
527 git_remote: None,
528 session_id: None,
529 }
530 }
531
532 #[test]
533 fn normalize_commandes_valides() {
534 assert_eq!(normalize_command_name("git status").as_deref(), Some("git"));
535 assert_eq!(
536 normalize_command_name("cargo build --release").as_deref(),
537 Some("cargo")
538 );
539 assert_eq!(
540 normalize_command_name("docker compose up -d").as_deref(),
541 Some("docker")
542 );
543 assert_eq!(
544 normalize_command_name("kubectl get pods").as_deref(),
545 Some("kubectl")
546 );
547 assert_eq!(
548 normalize_command_name("npx release-it").as_deref(),
549 Some("npx")
550 );
551 assert_eq!(
552 normalize_command_name("npm run build").as_deref(),
553 Some("npm")
554 );
555 }
556
557 #[test]
558 fn normalize_sudo_env_wrappers() {
559 assert_eq!(
560 normalize_command_name("sudo apt update").as_deref(),
561 Some("apt")
562 );
563 assert_eq!(
564 normalize_command_name("sudo -E apt update").as_deref(),
565 Some("apt")
566 );
567 assert_eq!(
568 normalize_command_name("sudo env FOO=bar cargo test").as_deref(),
569 Some("cargo")
570 );
571 assert_eq!(
572 normalize_command_name("env RUST_LOG=debug cargo test").as_deref(),
573 Some("cargo")
574 );
575 assert_eq!(
576 normalize_command_name("RUST_LOG=debug cargo test").as_deref(),
577 Some("cargo")
578 );
579 assert_eq!(
580 normalize_command_name("FOO=bar BAR=baz npm run build").as_deref(),
581 Some("npm")
582 );
583 assert_eq!(
584 normalize_command_name("time cargo test").as_deref(),
585 Some("cargo")
586 );
587 assert_eq!(
588 normalize_command_name("command git status").as_deref(),
589 Some("git")
590 );
591 }
592
593 #[test]
594 fn normalize_chemins() {
595 assert_eq!(
596 normalize_command_name("/usr/bin/git status").as_deref(),
597 Some("git")
598 );
599 assert_eq!(
600 normalize_command_name("./target/release/mnemo doctor").as_deref(),
601 Some("mnemo")
602 );
603 }
604
605 #[test]
606 fn normalize_rejette_le_bruit() {
607 assert_eq!(normalize_command_name("# commentaire"), None);
608 assert_eq!(normalize_command_name("-"), None);
609 assert_eq!(normalize_command_name("|"), None);
610 assert_eq!(normalize_command_name("||"), None);
611 assert_eq!(normalize_command_name("&&"), None);
612 assert_eq!(normalize_command_name(";"), None);
613 assert_eq!(normalize_command_name("then"), None);
614 assert_eq!(normalize_command_name("fi"), None);
615 assert_eq!(normalize_command_name("done"), None);
616 assert_eq!(normalize_command_name("function"), None);
617 assert_eq!(normalize_command_name(""), None);
618 assert_eq!(normalize_command_name(" "), None);
619 }
620
621 #[test]
622 fn stats_sur_base_vide() {
623 let stats = compute(&[], &[]);
624 assert_eq!(stats.total, 0);
625 assert_eq!(stats.git_projects, 0);
626 assert_eq!(stats.failed, 0);
627 assert_eq!(stats.ignored_for_top_commands, 0);
628 assert!(stats.top_commands.is_empty());
629 assert!(stats.top_dirs.is_empty());
630 assert!(stats.top_projects.is_empty());
631 }
632
633 #[test]
634 fn stats_ignore_le_bruit_dans_le_top_commandes() {
635 let records = vec![
636 rec("git status", Some("/p/mnemo"), Some("/p/mnemo"), Some(0)),
637 rec(
638 "sudo apt update",
639 Some("/p/mnemo"),
640 Some("/p/mnemo"),
641 Some(0),
642 ),
643 rec("-", Some("/p/mnemo"), Some("/p/mnemo"), Some(0)),
644 rec("| grep x", Some("/p/mnemo"), Some("/p/mnemo"), Some(0)),
645 rec(
646 "# un commentaire",
647 Some("/p/mnemo"),
648 Some("/p/mnemo"),
649 Some(0),
650 ),
651 ];
652 let stats = compute(&records, &[]);
653
654 assert_eq!(stats.total, 5);
655 assert_eq!(stats.ignored_for_top_commands, 3);
657 let names: Vec<&str> = stats.top_commands.iter().map(|(n, _)| n.as_str()).collect();
658 assert!(names.contains(&"git"));
659 assert!(names.contains(&"apt"));
660 assert!(!names.contains(&"-"));
661 assert!(!names.contains(&"|"));
662 assert!(!names.contains(&"#"));
663 }
664
665 #[test]
666 fn stats_sur_base_remplie() {
667 let records = vec![
668 rec(
669 "cargo build",
670 Some("/home/u/proj/mnemo"),
671 Some("/home/u/proj/mnemo"),
672 Some(0),
673 ),
674 rec(
675 "cargo test",
676 Some("/home/u/proj/mnemo"),
677 Some("/home/u/proj/mnemo"),
678 Some(1),
679 ),
680 rec(
681 "git status",
682 Some("/home/u/proj/mnemo"),
683 Some("/home/u/proj/mnemo"),
684 Some(0),
685 ),
686 rec("ls -la", Some("/tmp"), None, Some(0)),
687 rec(
688 "cargo run",
689 Some("/home/u/proj/autre"),
690 Some("/home/u/proj/autre"),
691 Some(0),
692 ),
693 ];
694 let stats = compute(&records, &[]);
695
696 assert_eq!(stats.total, 5);
697 assert_eq!(stats.git_projects, 2);
698 assert_eq!(stats.failed, 1);
699 assert_eq!(
700 stats.top_commands.first().unwrap(),
701 &("cargo".to_string(), 3)
702 );
703 assert_eq!(
704 stats.top_projects.first().unwrap(),
705 &("mnemo".to_string(), 3)
706 );
707 }
708
709 #[test]
710 fn stats_respecte_la_config_ignored_commands() {
711 let records = vec![
712 rec(
713 "create_dir foo",
714 Some("/p/mnemo"),
715 Some("/p/mnemo"),
716 Some(0),
717 ),
718 rec(
719 "create_dir bar",
720 Some("/p/mnemo"),
721 Some("/p/mnemo"),
722 Some(0),
723 ),
724 rec("cargo build", Some("/p/mnemo"), Some("/p/mnemo"), Some(0)),
725 rec("git status", Some("/p/mnemo"), Some("/p/mnemo"), Some(0)),
726 ];
727 let ignored = vec!["create_dir".to_string()];
728 let stats = compute(&records, &ignored);
729
730 assert_eq!(stats.total, 4);
732 assert_eq!(stats.ignored_for_top_commands, 2);
734 let names: Vec<&str> = stats.top_commands.iter().map(|(n, _)| n.as_str()).collect();
735 assert!(names.contains(&"cargo"));
736 assert!(names.contains(&"git"));
737 assert!(!names.contains(&"create_dir"));
738 }
739
740 #[test]
741 fn ignored_commands_insensible_a_la_casse() {
742 let records = vec![rec("Create_Dir foo", None, None, Some(0))];
743 let stats = compute(&records, &["create_dir".to_string()]);
745 assert_eq!(stats.ignored_for_top_commands, 1);
746 assert!(stats.top_commands.is_empty());
747 }
748
749 #[test]
750 fn json_est_bien_forme() {
751 let records = vec![rec(
752 "cargo build",
753 Some("/home/u/proj/mnemo"),
754 Some("/home/u/proj/mnemo"),
755 Some(0),
756 )];
757 let stats = compute(&records, &[]);
758 let filters = Filters {
759 project: Some("mnemo".to_string()),
760 branch: None,
761 since: None,
762 };
763 let s = render_json(&stats, &filters, &["create_dir".to_string()]);
764 let parsed: serde_json::Value = serde_json::from_str(&s).unwrap();
765 assert_eq!(parsed["total_commands"], 1);
766 assert_eq!(parsed["filters"]["project"], "mnemo");
767 assert!(parsed["filters"]["branch"].is_null());
768 assert_eq!(parsed["top_commands"][0]["name"], "cargo");
769 assert_eq!(parsed["top_commands"][0]["count"], 1);
770 assert_eq!(parsed["top_directories"][0]["path"], "/home/u/proj/mnemo");
771 assert_eq!(parsed["ignored_commands_config"][0], "create_dir");
772 }
773
774 #[test]
775 fn nom_de_projet() {
776 assert_eq!(project_name("/home/u/proj/mnemo"), "mnemo");
777 assert_eq!(project_name("/home/u/proj/mnemo/"), "mnemo");
778 }
779
780 fn rec_shell(
781 command: &str,
782 shell: Option<&str>,
783 date: &str,
784 exit: Option<i64>,
785 ) -> CommandRecord {
786 CommandRecord {
787 id: 0,
788 command: command.to_string(),
789 cwd: None,
790 shell: shell.map(str::to_string),
791 hostname: None,
792 exit_code: exit,
793 created_at: date.to_string(),
794 git_root: None,
795 git_branch: None,
796 git_remote: None,
797 session_id: None,
798 }
799 }
800
801 #[test]
802 fn top_shells_et_taux_echec() {
803 let records = vec![
804 rec_shell("a", Some("bash"), "2026-06-10 10:00:00", Some(0)),
805 rec_shell("b", Some("bash"), "2026-06-10 11:00:00", Some(1)),
806 rec_shell("c", Some("zsh"), "2026-06-11 09:00:00", Some(0)),
807 rec_shell("d", None, "2026-06-11 09:30:00", Some(0)),
808 ];
809 let stats = compute(&records, &[]);
810 assert_eq!(stats.top_shells[0], ("bash".to_string(), 2));
811 assert_eq!(stats.failed, 1);
812 assert!((failure_rate(&stats) - 0.25).abs() < 1e-9);
813 assert_eq!(stats.daily.len(), 2);
815 }
816
817 #[test]
818 fn activity_window_remplit_les_jours_vides() {
819 let today = 1_781_784_000u64;
821 let daily = vec![("2026-06-18".to_string(), 3)];
822 let window = activity_window(&daily, today, 7);
823 assert_eq!(window.len(), 7);
824 assert_eq!(window.last().unwrap(), &("2026-06-18".to_string(), 3));
825 assert_eq!(window.first().unwrap().1, 0);
826 }
827}