1use serde::Serialize;
25
26use crate::fs::Fs;
27use crate::paths::Pather;
28use crate::Result;
29
30#[derive(Debug, Clone, PartialEq, Serialize)]
32pub struct ProfileEntry {
33 pub phase: String,
35 pub pack: String,
36 pub handler: String,
37 pub target: String,
38 pub duration_us: u64,
41 pub exit_status: i32,
44}
45
46#[derive(Debug, Clone, Serialize)]
48pub struct Profile {
49 pub filename: String,
52 pub shell: String,
54 pub total_duration_us: u64,
57 pub entries: Vec<ProfileEntry>,
58}
59
60impl Profile {
61 pub fn entries_duration_us(&self) -> u64 {
65 self.entries.iter().map(|e| e.duration_us).sum()
66 }
67
68 pub fn framing_duration_us(&self) -> u64 {
72 self.total_duration_us
73 .saturating_sub(self.entries_duration_us())
74 }
75}
76
77pub fn read_latest_profile(fs: &dyn Fs, paths: &dyn Pather) -> Result<Option<Profile>> {
80 let mut profiles = read_recent_profiles(fs, paths, 1)?;
81 Ok(profiles.pop())
82}
83
84pub fn read_recent_profiles(fs: &dyn Fs, paths: &dyn Pather, limit: usize) -> Result<Vec<Profile>> {
96 let dir = paths.probes_shell_init_dir();
97 if !fs.is_dir(&dir) || limit == 0 {
98 return Ok(Vec::new());
99 }
100 let entries: Vec<_> = fs
101 .read_dir(&dir)?
102 .into_iter()
103 .rev()
104 .filter(|e| e.is_file && e.name.starts_with("profile-") && e.name.ends_with(".tsv"))
105 .take(limit)
106 .collect();
107
108 let mut profiles = Vec::with_capacity(entries.len());
109 for entry in entries {
110 let content = fs.read_to_string(&entry.path)?;
111 profiles.push(parse_profile(&entry.name, &content));
112 }
113 Ok(profiles)
114}
115
116pub fn parse_profile(filename: &str, content: &str) -> Profile {
119 let mut shell = String::new();
120 let mut start_t: Option<f64> = None;
121 let mut end_t: Option<f64> = None;
122 let mut entries: Vec<ProfileEntry> = Vec::new();
123
124 for raw_line in content.lines() {
125 let line = raw_line.trim_end_matches('\r');
126 if line.is_empty() {
127 continue;
128 }
129 if let Some(rest) = line.strip_prefix('#') {
130 let trimmed = rest.trim_start();
133 if let Some((key, val)) = trimmed.split_once('\t') {
134 match key {
135 "shell" => shell = val.to_string(),
136 "start_t" => start_t = val.parse::<f64>().ok(),
137 "end_t" => end_t = val.parse::<f64>().ok(),
138 _ => {} }
140 }
141 continue;
142 }
143 if let Some(entry) = parse_row(line) {
144 entries.push(entry);
145 }
146 }
148
149 let total_duration_us = match (start_t, end_t) {
150 (Some(s), Some(e)) if e >= s => seconds_to_micros(e - s),
151 _ => 0,
152 };
153
154 Profile {
155 filename: filename.to_string(),
156 shell,
157 total_duration_us,
158 entries,
159 }
160}
161
162fn parse_row(line: &str) -> Option<ProfileEntry> {
163 let mut parts = line.splitn(7, '\t');
164 let phase = parts.next()?;
165 let pack = parts.next()?;
166 let handler = parts.next()?;
167 let target = parts.next()?;
168 let start = parts.next()?.parse::<f64>().ok()?;
169 let end = parts.next()?.parse::<f64>().ok()?;
170 let exit_status = parts.next()?.parse::<i32>().ok()?;
171 if !matches!(phase, "path" | "source") {
172 return None;
173 }
174 let duration_us = if end >= start {
175 seconds_to_micros(end - start)
176 } else {
177 0
178 };
179 Some(ProfileEntry {
180 phase: phase.to_string(),
181 pack: pack.to_string(),
182 handler: handler.to_string(),
183 target: target.to_string(),
184 duration_us,
185 exit_status,
186 })
187}
188
189fn seconds_to_micros(secs: f64) -> u64 {
190 if !secs.is_finite() || secs < 0.0 {
191 return 0;
192 }
193 (secs * 1_000_000.0).round() as u64
194}
195
196pub fn rotate_profiles(fs: &dyn Fs, paths: &dyn Pather, keep: usize) -> Result<usize> {
201 if keep == 0 {
202 return Ok(0);
203 }
204 let dir = paths.probes_shell_init_dir();
205 if !fs.is_dir(&dir) {
206 return Ok(0);
207 }
208 let entries: Vec<_> = fs
212 .read_dir(&dir)?
213 .into_iter()
214 .filter(|e| e.is_file && e.name.starts_with("profile-") && e.name.ends_with(".tsv"))
215 .collect();
216 if entries.len() <= keep {
217 return Ok(0);
218 }
219 let to_remove = entries.len() - keep;
220 let mut removed = 0;
221 for entry in entries.into_iter().take(to_remove) {
222 if fs.remove_file(&entry.path).is_ok() {
223 removed += 1;
224 }
225 }
226 Ok(removed)
227}
228
229#[derive(Debug, Clone, Serialize)]
231pub struct GroupedProfile {
232 pub groups: Vec<ProfileGroup>,
233 pub user_total_us: u64,
234 pub framing_us: u64,
235 pub total_us: u64,
236}
237
238#[derive(Debug, Clone, Serialize)]
239pub struct ProfileGroup {
240 pub pack: String,
241 pub handler: String,
242 pub rows: Vec<ProfileEntry>,
243 pub group_total_us: u64,
244}
245
246pub fn group_profile(profile: &Profile) -> GroupedProfile {
256 let user_total_us = profile.entries_duration_us();
257 let total_us = profile.total_duration_us.max(user_total_us);
258 let framing_us = total_us.saturating_sub(user_total_us);
259
260 let mut groups: Vec<ProfileGroup> = Vec::new();
261 for entry in &profile.entries {
262 let key = (&entry.pack, &entry.handler);
263 let pos = groups
264 .iter()
265 .position(|g| (&g.pack, &g.handler) == (key.0, key.1));
266 match pos {
267 Some(i) => {
268 groups[i].rows.push(entry.clone());
269 groups[i].group_total_us += entry.duration_us;
270 }
271 None => groups.push(ProfileGroup {
272 pack: entry.pack.clone(),
273 handler: entry.handler.clone(),
274 rows: vec![entry.clone()],
275 group_total_us: entry.duration_us,
276 }),
277 }
278 }
279
280 groups.sort_by(|a, b| a.pack.cmp(&b.pack).then(a.handler.cmp(&b.handler)));
281
282 GroupedProfile {
283 groups,
284 user_total_us,
285 framing_us,
286 total_us,
287 }
288}
289
290#[derive(Debug, Clone, Serialize)]
294pub struct AggregatedTarget {
295 pub pack: String,
296 pub handler: String,
297 pub target: String,
298 pub p50_us: u64,
299 pub p95_us: u64,
300 pub max_us: u64,
301 pub runs_seen: usize,
305 pub runs_total: usize,
306}
307
308#[derive(Debug, Clone, Serialize)]
311pub struct AggregatedView {
312 pub runs: usize,
313 pub targets: Vec<AggregatedTarget>,
314}
315
316pub fn aggregate_profiles(profiles: &[Profile]) -> AggregatedView {
321 use std::collections::BTreeMap;
322
323 let mut buckets: BTreeMap<(String, String, String), Vec<u64>> = BTreeMap::new();
325 for p in profiles {
326 for e in &p.entries {
327 buckets
328 .entry((e.pack.clone(), e.handler.clone(), e.target.clone()))
329 .or_default()
330 .push(e.duration_us);
331 }
332 }
333
334 let runs_total = profiles.len();
335 let targets = buckets
336 .into_iter()
337 .map(|((pack, handler, target), mut durs)| {
338 durs.sort_unstable();
339 AggregatedTarget {
340 pack,
341 handler,
342 target,
343 p50_us: percentile(&durs, 50),
344 p95_us: percentile(&durs, 95),
345 max_us: *durs.last().unwrap_or(&0),
346 runs_seen: durs.len(),
347 runs_total,
348 }
349 })
350 .collect();
351
352 AggregatedView {
353 runs: runs_total,
354 targets,
355 }
356}
357
358fn percentile(sorted: &[u64], pct: u8) -> u64 {
364 if sorted.is_empty() {
365 return 0;
366 }
367 let n = sorted.len();
368 let rank = ((pct as f64 / 100.0) * n as f64).ceil() as usize;
371 let idx = rank.saturating_sub(1).min(n - 1);
372 sorted[idx]
373}
374
375#[derive(Debug, Clone, Serialize)]
379pub struct HistoryEntry {
380 pub filename: String,
381 pub unix_ts: u64,
384 pub shell: String,
385 pub total_us: u64,
386 pub user_total_us: u64,
387 pub failed_entries: usize,
390 pub entry_count: usize,
391}
392
393pub fn summarize_history(profiles: &[Profile]) -> Vec<HistoryEntry> {
396 profiles.iter().map(history_entry_from).collect()
397}
398
399fn history_entry_from(profile: &Profile) -> HistoryEntry {
400 HistoryEntry {
401 filename: profile.filename.clone(),
402 unix_ts: parse_unix_ts_from_filename(&profile.filename),
403 shell: profile.shell.clone(),
404 total_us: profile.total_duration_us,
405 user_total_us: profile.entries_duration_us(),
406 failed_entries: profile
407 .entries
408 .iter()
409 .filter(|e| e.exit_status != 0)
410 .count(),
411 entry_count: profile.entries.len(),
412 }
413}
414
415fn parse_unix_ts_from_filename(filename: &str) -> u64 {
419 filename
420 .strip_prefix("profile-")
421 .and_then(|rest| rest.split('-').next())
422 .and_then(|s| s.parse::<u64>().ok())
423 .unwrap_or(0)
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429 use crate::testing::TempEnvironment;
430
431 fn write_profile(env: &TempEnvironment, name: &str, content: &str) -> std::path::PathBuf {
432 let dir = env.paths.probes_shell_init_dir();
433 env.fs.mkdir_all(&dir).unwrap();
434 let path = dir.join(name);
435 env.fs.write_file(&path, content.as_bytes()).unwrap();
436 path
437 }
438
439 #[test]
440 fn parser_extracts_preamble_and_rows() {
441 let content = "# dodot shell-init profile v1\n\
442# shell\tbash 5.2\n\
443# start_t\t1714000000.000000\n\
444# init_script\t/x/dodot-init.sh\n\
445# columns\tphase\tpack\thandler\ttarget\tstart_t\tend_t\texit_status\n\
446path\tvim\tpath\t/x/bin\t1714000000.001000\t1714000000.001005\t0\n\
447source\tgit\tshell\t/x/aliases.sh\t1714000000.002000\t1714000000.005000\t0\n\
448# end_t\t1714000000.010000\n";
449 let p = parse_profile("profile-1714000000-1-1.tsv", content);
450 assert_eq!(p.shell, "bash 5.2");
451 assert_eq!(p.entries.len(), 2);
452 assert_eq!(p.entries[0].phase, "path");
453 assert_eq!(p.entries[0].duration_us, 5);
454 assert_eq!(p.entries[1].duration_us, 3000);
455 assert_eq!(p.total_duration_us, 10_000);
456 }
457
458 #[test]
459 fn parser_skips_malformed_rows() {
460 let content = "# columns\tphase\tpack\thandler\ttarget\tstart_t\tend_t\texit_status\n\
461junk\trow\twith\ttoo\tfew\tcols\n\
462path\tvim\tpath\t/x\t1.0\t1.001\t0\n\
463weird\tphase\twrong\t/x\t1.0\t1.001\t0\n";
464 let p = parse_profile("p.tsv", content);
465 assert_eq!(p.entries.len(), 1);
466 assert_eq!(p.entries[0].phase, "path");
467 }
468
469 #[test]
470 fn parser_handles_missing_end_marker() {
471 let content = "# start_t\t1714000000.000000\n\
474source\tvim\tshell\t/x\t1714000000.001000\t1714000000.002000\t0\n";
475 let p = parse_profile("p.tsv", content);
476 assert_eq!(p.total_duration_us, 0); assert_eq!(p.entries.len(), 1);
478 assert_eq!(p.entries[0].duration_us, 1000);
479 }
480
481 #[test]
482 fn read_latest_returns_none_when_dir_missing() {
483 let env = TempEnvironment::builder().build();
484 let r = read_latest_profile(env.fs.as_ref(), env.paths.as_ref()).unwrap();
485 assert!(r.is_none());
486 }
487
488 #[test]
489 fn read_latest_picks_highest_filename_lexicographically() {
490 let env = TempEnvironment::builder().build();
491 write_profile(&env, "profile-1000-1-1.tsv", "# shell\told\n");
492 write_profile(&env, "profile-2000-1-1.tsv", "# shell\tnew\n");
493 write_profile(&env, "profile-1500-1-1.tsv", "# shell\tmid\n");
494 let p = read_latest_profile(env.fs.as_ref(), env.paths.as_ref())
495 .unwrap()
496 .unwrap();
497 assert_eq!(p.shell, "new");
498 assert_eq!(p.filename, "profile-2000-1-1.tsv");
499 }
500
501 #[test]
502 fn rotate_keeps_newest_n() {
503 let env = TempEnvironment::builder().build();
504 for i in 0..10 {
505 write_profile(&env, &format!("profile-{i:04}-1-1.tsv"), "x");
506 }
507 let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 3).unwrap();
508 assert_eq!(removed, 7);
509 let remaining: Vec<String> = env
510 .fs
511 .read_dir(&env.paths.probes_shell_init_dir())
512 .unwrap()
513 .into_iter()
514 .map(|e| e.name)
515 .collect();
516 assert_eq!(
518 remaining,
519 vec![
520 "profile-0007-1-1.tsv".to_string(),
521 "profile-0008-1-1.tsv".to_string(),
522 "profile-0009-1-1.tsv".to_string(),
523 ]
524 );
525 }
526
527 #[test]
528 fn rotate_with_keep_zero_is_a_noop() {
529 let env = TempEnvironment::builder().build();
532 for i in 0..3 {
533 write_profile(&env, &format!("profile-{i}-1-1.tsv"), "x");
534 }
535 let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 0).unwrap();
536 assert_eq!(removed, 0);
537 let count = env
538 .fs
539 .read_dir(&env.paths.probes_shell_init_dir())
540 .unwrap()
541 .len();
542 assert_eq!(count, 3);
543 }
544
545 #[test]
546 fn rotate_below_threshold_is_a_noop() {
547 let env = TempEnvironment::builder().build();
548 write_profile(&env, "profile-1-1-1.tsv", "x");
549 let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 100).unwrap();
550 assert_eq!(removed, 0);
551 }
552
553 #[test]
554 fn rotate_ignores_non_profile_files() {
555 let env = TempEnvironment::builder().build();
556 let dir = env.paths.probes_shell_init_dir();
557 env.fs.mkdir_all(&dir).unwrap();
558 for i in 1..=5 {
561 env.fs
562 .write_file(&dir.join(format!("profile-{i}-1-1.tsv")), b"")
563 .unwrap();
564 }
565 env.fs
566 .write_file(&dir.join("README"), b"do not delete")
567 .unwrap();
568 env.fs
569 .write_file(&dir.join("notes.txt"), b"keep me")
570 .unwrap();
571
572 let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 2).unwrap();
574 assert_eq!(removed, 3);
575
576 assert!(env.fs.exists(&dir.join("profile-4-1-1.tsv")));
578 assert!(env.fs.exists(&dir.join("profile-5-1-1.tsv")));
579 assert!(!env.fs.exists(&dir.join("profile-1-1-1.tsv")));
581 assert!(!env.fs.exists(&dir.join("profile-2-1-1.tsv")));
582 assert!(!env.fs.exists(&dir.join("profile-3-1-1.tsv")));
583
584 assert!(env.fs.exists(&dir.join("README")));
586 assert!(env.fs.exists(&dir.join("notes.txt")));
587 }
588
589 #[test]
590 fn group_profile_aggregates_by_pack_handler() {
591 let p = Profile {
592 filename: "x".into(),
593 shell: "bash".into(),
594 total_duration_us: 10_000,
595 entries: vec![
596 ProfileEntry {
597 phase: "source".into(),
598 pack: "vim".into(),
599 handler: "shell".into(),
600 target: "/a".into(),
601 duration_us: 100,
602 exit_status: 0,
603 },
604 ProfileEntry {
605 phase: "source".into(),
606 pack: "vim".into(),
607 handler: "shell".into(),
608 target: "/b".into(),
609 duration_us: 200,
610 exit_status: 0,
611 },
612 ProfileEntry {
613 phase: "path".into(),
614 pack: "vim".into(),
615 handler: "path".into(),
616 target: "/bin".into(),
617 duration_us: 5,
618 exit_status: 0,
619 },
620 ],
621 };
622 let g = group_profile(&p);
623 assert_eq!(g.groups.len(), 2);
624 assert_eq!(g.groups[0].pack, "vim");
627 assert_eq!(g.groups[0].handler, "path");
628 assert_eq!(g.groups[0].group_total_us, 5);
629 assert_eq!(g.groups[1].handler, "shell");
630 assert_eq!(g.groups[1].group_total_us, 300);
631 assert_eq!(g.user_total_us, 305);
632 assert_eq!(g.total_us, 10_000);
633 assert_eq!(g.framing_us, 9_695);
634 }
635
636 #[test]
637 fn group_profile_sorts_across_packs() {
638 let p = Profile {
641 filename: "x".into(),
642 shell: "bash".into(),
643 total_duration_us: 0,
644 entries: vec![
645 entry("vim", "shell", "/a", 1),
646 entry("git", "symlink", "/b", 1),
647 entry("vim", "path", "/c", 1),
648 entry("git", "shell", "/d", 1),
649 ],
650 };
651 let g = group_profile(&p);
652 let keys: Vec<(String, String)> = g
653 .groups
654 .iter()
655 .map(|gp| (gp.pack.clone(), gp.handler.clone()))
656 .collect();
657 assert_eq!(
658 keys,
659 vec![
660 ("git".into(), "shell".into()),
661 ("git".into(), "symlink".into()),
662 ("vim".into(), "path".into()),
663 ("vim".into(), "shell".into()),
664 ]
665 );
666 }
667
668 fn entry(pack: &str, handler: &str, target: &str, dur_us: u64) -> ProfileEntry {
669 ProfileEntry {
670 phase: "source".into(),
671 pack: pack.into(),
672 handler: handler.into(),
673 target: target.into(),
674 duration_us: dur_us,
675 exit_status: 0,
676 }
677 }
678
679 #[test]
680 fn group_profile_clamps_framing_when_total_below_entries() {
681 let p = Profile {
684 filename: "x".into(),
685 shell: "".into(),
686 total_duration_us: 0,
687 entries: vec![ProfileEntry {
688 phase: "source".into(),
689 pack: "vim".into(),
690 handler: "shell".into(),
691 target: "/a".into(),
692 duration_us: 500,
693 exit_status: 0,
694 }],
695 };
696 let g = group_profile(&p);
697 assert_eq!(g.user_total_us, 500);
698 assert_eq!(g.total_us, 500);
699 assert_eq!(g.framing_us, 0);
700 }
701
702 #[test]
705 fn read_recent_returns_newest_first_capped_at_limit() {
706 let env = TempEnvironment::builder().build();
707 for i in 1..=5 {
708 write_profile(
709 &env,
710 &format!("profile-{i}-1-1.tsv"),
711 "# columns\tphase\tpack\thandler\ttarget\tstart_t\tend_t\texit_status\n",
712 );
713 }
714 let recent = read_recent_profiles(env.fs.as_ref(), env.paths.as_ref(), 3).unwrap();
715 let names: Vec<&str> = recent.iter().map(|p| p.filename.as_str()).collect();
716 assert_eq!(
717 names,
718 vec![
719 "profile-5-1-1.tsv",
720 "profile-4-1-1.tsv",
721 "profile-3-1-1.tsv",
722 ]
723 );
724 }
725
726 #[test]
727 fn read_recent_with_limit_zero_returns_empty() {
728 let env = TempEnvironment::builder().build();
729 write_profile(&env, "profile-1-1-1.tsv", "x");
730 let recent = read_recent_profiles(env.fs.as_ref(), env.paths.as_ref(), 0).unwrap();
731 assert!(recent.is_empty());
732 }
733
734 #[test]
735 fn read_recent_handles_fewer_files_than_limit() {
736 let env = TempEnvironment::builder().build();
737 write_profile(&env, "profile-1-1-1.tsv", "");
738 let recent = read_recent_profiles(env.fs.as_ref(), env.paths.as_ref(), 100).unwrap();
739 assert_eq!(recent.len(), 1);
740 }
741
742 #[test]
745 fn percentile_nearest_rank_basic_cases() {
746 let v: Vec<u64> = (1..=10).collect();
749 assert_eq!(percentile(&v, 50), 5);
750 assert_eq!(percentile(&v, 95), 10);
751 assert_eq!(percentile(&[42], 50), 42);
753 assert_eq!(percentile(&[42], 95), 42);
754 assert_eq!(percentile(&[], 50), 0);
756 }
757
758 #[test]
759 fn aggregate_profiles_buckets_by_pack_handler_target() {
760 let p1 = Profile {
761 filename: "profile-1-1-1.tsv".into(),
762 shell: "bash".into(),
763 total_duration_us: 0,
764 entries: vec![
765 entry("vim", "shell", "/a", 100),
766 entry("vim", "shell", "/b", 200),
767 ],
768 };
769 let p2 = Profile {
770 filename: "profile-2-1-1.tsv".into(),
771 shell: "bash".into(),
772 total_duration_us: 0,
773 entries: vec![
774 entry("vim", "shell", "/a", 110),
775 entry("vim", "shell", "/b", 250),
776 ],
777 };
778 let p3 = Profile {
779 filename: "profile-3-1-1.tsv".into(),
780 shell: "bash".into(),
781 total_duration_us: 0,
782 entries: vec![entry("vim", "shell", "/a", 120)],
783 };
785 let agg = aggregate_profiles(&[p1, p2, p3]);
786 assert_eq!(agg.runs, 3);
787 assert_eq!(agg.targets.len(), 2);
788 let a = agg.targets.iter().find(|t| t.target == "/a").unwrap();
789 assert_eq!(a.runs_seen, 3);
790 assert_eq!(a.runs_total, 3);
791 assert_eq!(a.p50_us, 110); assert_eq!(a.max_us, 120);
793 let b = agg.targets.iter().find(|t| t.target == "/b").unwrap();
794 assert_eq!(b.runs_seen, 2);
795 assert_eq!(b.runs_total, 3);
796 assert_eq!(b.max_us, 250);
797 }
798
799 #[test]
800 fn aggregate_empty_profiles_returns_empty_view() {
801 let agg = aggregate_profiles(&[]);
802 assert_eq!(agg.runs, 0);
803 assert!(agg.targets.is_empty());
804 }
805
806 #[test]
807 fn aggregate_targets_sort_by_pack_handler_target() {
808 let p = Profile {
810 filename: "p".into(),
811 shell: "".into(),
812 total_duration_us: 0,
813 entries: vec![
814 entry("vim", "shell", "/z", 1),
815 entry("git", "shell", "/a", 1),
816 entry("vim", "path", "/x", 1),
817 entry("git", "shell", "/y", 1),
818 ],
819 };
820 let agg = aggregate_profiles(&[p]);
821 let keys: Vec<(&str, &str, &str)> = agg
822 .targets
823 .iter()
824 .map(|t| (t.pack.as_str(), t.handler.as_str(), t.target.as_str()))
825 .collect();
826 assert_eq!(
827 keys,
828 vec![
829 ("git", "shell", "/a"),
830 ("git", "shell", "/y"),
831 ("vim", "path", "/x"),
832 ("vim", "shell", "/z"),
833 ]
834 );
835 }
836
837 #[test]
840 fn summarize_history_pulls_basic_metrics_per_run() {
841 let p1 = Profile {
842 filename: "profile-1714000000-12-34.tsv".into(),
843 shell: "bash 5.3".into(),
844 total_duration_us: 500,
845 entries: vec![
846 entry("vim", "shell", "/a", 100),
847 ProfileEntry {
848 phase: "source".into(),
849 pack: "gh".into(),
850 handler: "shell".into(),
851 target: "/x".into(),
852 duration_us: 50,
853 exit_status: 1, },
855 ],
856 };
857 let h = summarize_history(&[p1]);
858 assert_eq!(h.len(), 1);
859 assert_eq!(h[0].unix_ts, 1714000000);
860 assert_eq!(h[0].shell, "bash 5.3");
861 assert_eq!(h[0].total_us, 500);
862 assert_eq!(h[0].user_total_us, 150);
863 assert_eq!(h[0].failed_entries, 1);
864 assert_eq!(h[0].entry_count, 2);
865 }
866
867 #[test]
868 fn parse_unix_ts_handles_unparseable_filenames() {
869 assert_eq!(
872 parse_unix_ts_from_filename("profile-1714000000-1-1.tsv"),
873 1714000000
874 );
875 assert_eq!(parse_unix_ts_from_filename("garbage.txt"), 0);
876 assert_eq!(parse_unix_ts_from_filename("profile-notanum-1-1.tsv"), 0);
877 }
878}