1use serde::Serialize;
25use tracing::warn;
26
27use crate::fs::Fs;
28use crate::paths::Pather;
29use crate::Result;
30
31#[derive(Debug, Clone, PartialEq, Serialize)]
33pub struct ProfileEntry {
34 pub phase: String,
36 pub pack: String,
37 pub handler: String,
38 pub target: String,
39 pub duration_us: u64,
42 pub exit_status: i32,
45}
46
47#[derive(Debug, Clone, PartialEq, Serialize)]
52pub struct ProfileErrorRecord {
53 pub target: String,
54 pub exit_status: i32,
55 pub message: String,
58}
59
60#[derive(Debug, Clone, Serialize)]
62pub struct Profile {
63 pub filename: String,
66 pub shell: String,
68 pub total_duration_us: u64,
71 pub entries: Vec<ProfileEntry>,
72 #[serde(default)]
77 pub errors: Vec<ProfileErrorRecord>,
78}
79
80impl Profile {
81 pub fn entries_duration_us(&self) -> u64 {
85 self.entries.iter().map(|e| e.duration_us).sum()
86 }
87
88 pub fn framing_duration_us(&self) -> u64 {
92 self.total_duration_us
93 .saturating_sub(self.entries_duration_us())
94 }
95}
96
97pub fn read_latest_profile(fs: &dyn Fs, paths: &dyn Pather) -> Result<Option<Profile>> {
100 let mut profiles = read_recent_profiles(fs, paths, 1)?;
101 Ok(profiles.pop())
102}
103
104pub fn read_recent_profiles(fs: &dyn Fs, paths: &dyn Pather, limit: usize) -> Result<Vec<Profile>> {
116 let dir = paths.probes_shell_init_dir();
117 if !fs.is_dir(&dir) || limit == 0 {
118 return Ok(Vec::new());
119 }
120 let entries: Vec<_> = fs
121 .read_dir(&dir)?
122 .into_iter()
123 .rev()
124 .filter(|e| e.is_file && e.name.starts_with("profile-") && e.name.ends_with(".tsv"))
125 .take(limit)
126 .collect();
127
128 let mut profiles = Vec::with_capacity(entries.len());
129 for entry in entries {
130 let content = fs.read_to_string(&entry.path)?;
131 let mut profile = parse_profile(&entry.name, &content);
132 let errors_path = errors_log_path_for(&entry.path);
140 if fs.exists(&errors_path) {
141 match fs.read_to_string(&errors_path) {
142 Ok(err_content) => profile.errors = parse_errors_log(&err_content),
143 Err(e) => warn!(
144 path = %errors_path.display(),
145 error = %e,
146 "errors-log sidecar exists but could not be read; treating as empty"
147 ),
148 }
149 }
150 profiles.push(profile);
151 }
152 Ok(profiles)
153}
154
155fn errors_log_path_for(profile_path: &std::path::Path) -> std::path::PathBuf {
161 let s = profile_path.to_string_lossy();
162 if let Some(stem) = s.strip_suffix(".tsv") {
163 std::path::PathBuf::from(format!("{stem}.errors.log"))
164 } else {
165 std::path::PathBuf::from(format!("{s}.errors.log"))
166 }
167}
168
169pub fn parse_errors_log(content: &str) -> Vec<ProfileErrorRecord> {
187 let mut out: Vec<ProfileErrorRecord> = Vec::new();
188 let mut current: Option<(String, i32, String)> = None;
189
190 let flush = |slot: &mut Option<(String, i32, String)>, out: &mut Vec<ProfileErrorRecord>| {
191 if let Some((target, exit_status, mut message)) = slot.take() {
192 if message.ends_with('\n') {
195 message.pop();
196 }
197 out.push(ProfileErrorRecord {
198 target,
199 exit_status,
200 message,
201 });
202 }
203 };
204
205 for raw_line in content.lines() {
206 let line = raw_line.trim_end_matches('\r');
207 if let Some(rest) = line.strip_prefix("@@\t") {
208 flush(&mut current, &mut out);
210 let mut parts = rest.splitn(2, '\t');
211 let Some(target) = parts.next() else { continue };
212 let Some(exit_str) = parts.next() else {
213 continue;
214 };
215 let Ok(exit_status) = exit_str.parse::<i32>() else {
216 continue;
217 };
218 current = Some((target.to_string(), exit_status, String::new()));
219 continue;
220 }
221 if line.starts_with('#') && current.is_none() {
222 continue;
224 }
225 if let Some((_, _, ref mut message)) = current {
226 message.push_str(line);
227 message.push('\n');
228 }
229 }
232 flush(&mut current, &mut out);
233 out
234}
235
236pub fn parse_profile(filename: &str, content: &str) -> Profile {
239 let mut shell = String::new();
240 let mut start_t: Option<f64> = None;
241 let mut end_t: Option<f64> = None;
242 let mut entries: Vec<ProfileEntry> = Vec::new();
243
244 for raw_line in content.lines() {
245 let line = raw_line.trim_end_matches('\r');
246 if line.is_empty() {
247 continue;
248 }
249 if let Some(rest) = line.strip_prefix('#') {
250 let trimmed = rest.trim_start();
253 if let Some((key, val)) = trimmed.split_once('\t') {
254 match key {
255 "shell" => shell = val.to_string(),
256 "start_t" => start_t = val.parse::<f64>().ok(),
257 "end_t" => end_t = val.parse::<f64>().ok(),
258 _ => {} }
260 }
261 continue;
262 }
263 if let Some(entry) = parse_row(line) {
264 entries.push(entry);
265 }
266 }
268
269 let total_duration_us = match (start_t, end_t) {
270 (Some(s), Some(e)) if e >= s => seconds_to_micros(e - s),
271 _ => 0,
272 };
273
274 Profile {
275 filename: filename.to_string(),
276 shell,
277 total_duration_us,
278 entries,
279 errors: Vec::new(),
280 }
281}
282
283fn parse_row(line: &str) -> Option<ProfileEntry> {
284 let mut parts = line.splitn(7, '\t');
285 let phase = parts.next()?;
286 let pack = parts.next()?;
287 let handler = parts.next()?;
288 let target = parts.next()?;
289 let start = parts.next()?.parse::<f64>().ok()?;
290 let end = parts.next()?.parse::<f64>().ok()?;
291 let exit_status = parts.next()?.parse::<i32>().ok()?;
292 if !matches!(phase, "path" | "source") {
293 return None;
294 }
295 let duration_us = if end >= start {
296 seconds_to_micros(end - start)
297 } else {
298 0
299 };
300 Some(ProfileEntry {
301 phase: phase.to_string(),
302 pack: pack.to_string(),
303 handler: handler.to_string(),
304 target: target.to_string(),
305 duration_us,
306 exit_status,
307 })
308}
309
310fn seconds_to_micros(secs: f64) -> u64 {
311 if !secs.is_finite() || secs < 0.0 {
312 return 0;
313 }
314 (secs * 1_000_000.0).round() as u64
315}
316
317pub fn rotate_profiles(fs: &dyn Fs, paths: &dyn Pather, keep: usize) -> Result<usize> {
322 if keep == 0 {
323 return Ok(0);
324 }
325 let dir = paths.probes_shell_init_dir();
326 if !fs.is_dir(&dir) {
327 return Ok(0);
328 }
329 let entries: Vec<_> = fs
333 .read_dir(&dir)?
334 .into_iter()
335 .filter(|e| e.is_file && e.name.starts_with("profile-") && e.name.ends_with(".tsv"))
336 .collect();
337 if entries.len() <= keep {
338 return Ok(0);
339 }
340 let to_remove = entries.len() - keep;
341 let mut removed = 0;
342 for entry in entries.into_iter().take(to_remove) {
343 if fs.remove_file(&entry.path).is_ok() {
344 removed += 1;
345 }
346 let sidecar = errors_log_path_for(&entry.path);
349 let _ = fs.remove_file(&sidecar);
350 }
351 Ok(removed)
352}
353
354#[derive(Debug, Clone, Serialize)]
356pub struct GroupedProfile {
357 pub groups: Vec<ProfileGroup>,
358 pub user_total_us: u64,
359 pub framing_us: u64,
360 pub total_us: u64,
361}
362
363#[derive(Debug, Clone, Serialize)]
364pub struct ProfileGroup {
365 pub pack: String,
366 pub handler: String,
367 pub rows: Vec<ProfileEntry>,
368 pub group_total_us: u64,
369}
370
371pub fn group_profile(profile: &Profile) -> GroupedProfile {
381 let user_total_us = profile.entries_duration_us();
382 let total_us = profile.total_duration_us.max(user_total_us);
383 let framing_us = total_us.saturating_sub(user_total_us);
384
385 let mut groups: Vec<ProfileGroup> = Vec::new();
386 for entry in &profile.entries {
387 let key = (&entry.pack, &entry.handler);
388 let pos = groups
389 .iter()
390 .position(|g| (&g.pack, &g.handler) == (key.0, key.1));
391 match pos {
392 Some(i) => {
393 groups[i].rows.push(entry.clone());
394 groups[i].group_total_us += entry.duration_us;
395 }
396 None => groups.push(ProfileGroup {
397 pack: entry.pack.clone(),
398 handler: entry.handler.clone(),
399 rows: vec![entry.clone()],
400 group_total_us: entry.duration_us,
401 }),
402 }
403 }
404
405 groups.sort_by(|a, b| a.pack.cmp(&b.pack).then(a.handler.cmp(&b.handler)));
406
407 GroupedProfile {
408 groups,
409 user_total_us,
410 framing_us,
411 total_us,
412 }
413}
414
415#[derive(Debug, Clone, Serialize)]
419pub struct AggregatedTarget {
420 pub pack: String,
421 pub handler: String,
422 pub target: String,
423 pub p50_us: u64,
424 pub p95_us: u64,
425 pub max_us: u64,
426 pub runs_seen: usize,
430 pub runs_total: usize,
431}
432
433#[derive(Debug, Clone, Serialize)]
436pub struct AggregatedView {
437 pub runs: usize,
438 pub targets: Vec<AggregatedTarget>,
439}
440
441pub fn aggregate_profiles(profiles: &[Profile]) -> AggregatedView {
446 use std::collections::BTreeMap;
447
448 let mut buckets: BTreeMap<(String, String, String), Vec<u64>> = BTreeMap::new();
450 for p in profiles {
451 for e in &p.entries {
452 buckets
453 .entry((e.pack.clone(), e.handler.clone(), e.target.clone()))
454 .or_default()
455 .push(e.duration_us);
456 }
457 }
458
459 let runs_total = profiles.len();
460 let targets = buckets
461 .into_iter()
462 .map(|((pack, handler, target), mut durs)| {
463 durs.sort_unstable();
464 AggregatedTarget {
465 pack,
466 handler,
467 target,
468 p50_us: percentile(&durs, 50),
469 p95_us: percentile(&durs, 95),
470 max_us: *durs.last().unwrap_or(&0),
471 runs_seen: durs.len(),
472 runs_total,
473 }
474 })
475 .collect();
476
477 AggregatedView {
478 runs: runs_total,
479 targets,
480 }
481}
482
483fn percentile(sorted: &[u64], pct: u8) -> u64 {
489 if sorted.is_empty() {
490 return 0;
491 }
492 let n = sorted.len();
493 let rank = ((pct as f64 / 100.0) * n as f64).ceil() as usize;
496 let idx = rank.saturating_sub(1).min(n - 1);
497 sorted[idx]
498}
499
500#[derive(Debug, Clone, Serialize)]
504pub struct HistoryEntry {
505 pub filename: String,
506 pub unix_ts: u64,
509 pub shell: String,
510 pub total_us: u64,
511 pub user_total_us: u64,
512 pub failed_entries: usize,
515 pub entry_count: usize,
516}
517
518pub fn summarize_history(profiles: &[Profile]) -> Vec<HistoryEntry> {
521 profiles.iter().map(history_entry_from).collect()
522}
523
524fn history_entry_from(profile: &Profile) -> HistoryEntry {
525 HistoryEntry {
526 filename: profile.filename.clone(),
527 unix_ts: parse_unix_ts_from_filename(&profile.filename),
528 shell: profile.shell.clone(),
529 total_us: profile.total_duration_us,
530 user_total_us: profile.entries_duration_us(),
531 failed_entries: profile
532 .entries
533 .iter()
534 .filter(|e| e.exit_status != 0)
535 .count(),
536 entry_count: profile.entries.len(),
537 }
538}
539
540pub(crate) fn parse_unix_ts_from_filename(filename: &str) -> u64 {
544 filename
545 .strip_prefix("profile-")
546 .and_then(|rest| rest.split('-').next())
547 .and_then(|s| s.parse::<u64>().ok())
548 .unwrap_or(0)
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use crate::testing::TempEnvironment;
555
556 fn write_profile(env: &TempEnvironment, name: &str, content: &str) -> std::path::PathBuf {
557 let dir = env.paths.probes_shell_init_dir();
558 env.fs.mkdir_all(&dir).unwrap();
559 let path = dir.join(name);
560 env.fs.write_file(&path, content.as_bytes()).unwrap();
561 path
562 }
563
564 #[test]
565 fn parser_extracts_preamble_and_rows() {
566 let content = "# dodot shell-init profile v1\n\
567# shell\tbash 5.2\n\
568# start_t\t1714000000.000000\n\
569# init_script\t/x/dodot-init.sh\n\
570# columns\tphase\tpack\thandler\ttarget\tstart_t\tend_t\texit_status\n\
571path\tvim\tpath\t/x/bin\t1714000000.001000\t1714000000.001005\t0\n\
572source\tgit\tshell\t/x/aliases.sh\t1714000000.002000\t1714000000.005000\t0\n\
573# end_t\t1714000000.010000\n";
574 let p = parse_profile("profile-1714000000-1-1.tsv", content);
575 assert_eq!(p.shell, "bash 5.2");
576 assert_eq!(p.entries.len(), 2);
577 assert_eq!(p.entries[0].phase, "path");
578 assert_eq!(p.entries[0].duration_us, 5);
579 assert_eq!(p.entries[1].duration_us, 3000);
580 assert_eq!(p.total_duration_us, 10_000);
581 }
582
583 #[test]
584 fn parser_skips_malformed_rows() {
585 let content = "# columns\tphase\tpack\thandler\ttarget\tstart_t\tend_t\texit_status\n\
586junk\trow\twith\ttoo\tfew\tcols\n\
587path\tvim\tpath\t/x\t1.0\t1.001\t0\n\
588weird\tphase\twrong\t/x\t1.0\t1.001\t0\n";
589 let p = parse_profile("p.tsv", content);
590 assert_eq!(p.entries.len(), 1);
591 assert_eq!(p.entries[0].phase, "path");
592 }
593
594 #[test]
595 fn parser_handles_missing_end_marker() {
596 let content = "# start_t\t1714000000.000000\n\
599source\tvim\tshell\t/x\t1714000000.001000\t1714000000.002000\t0\n";
600 let p = parse_profile("p.tsv", content);
601 assert_eq!(p.total_duration_us, 0); assert_eq!(p.entries.len(), 1);
603 assert_eq!(p.entries[0].duration_us, 1000);
604 }
605
606 #[test]
607 fn read_latest_returns_none_when_dir_missing() {
608 let env = TempEnvironment::builder().build();
609 let r = read_latest_profile(env.fs.as_ref(), env.paths.as_ref()).unwrap();
610 assert!(r.is_none());
611 }
612
613 #[test]
614 fn read_latest_picks_highest_filename_lexicographically() {
615 let env = TempEnvironment::builder().build();
616 write_profile(&env, "profile-1000-1-1.tsv", "# shell\told\n");
617 write_profile(&env, "profile-2000-1-1.tsv", "# shell\tnew\n");
618 write_profile(&env, "profile-1500-1-1.tsv", "# shell\tmid\n");
619 let p = read_latest_profile(env.fs.as_ref(), env.paths.as_ref())
620 .unwrap()
621 .unwrap();
622 assert_eq!(p.shell, "new");
623 assert_eq!(p.filename, "profile-2000-1-1.tsv");
624 }
625
626 #[test]
627 fn rotate_keeps_newest_n() {
628 let env = TempEnvironment::builder().build();
629 for i in 0..10 {
630 write_profile(&env, &format!("profile-{i:04}-1-1.tsv"), "x");
631 }
632 let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 3).unwrap();
633 assert_eq!(removed, 7);
634 let remaining: Vec<String> = env
635 .fs
636 .read_dir(&env.paths.probes_shell_init_dir())
637 .unwrap()
638 .into_iter()
639 .map(|e| e.name)
640 .collect();
641 assert_eq!(
643 remaining,
644 vec![
645 "profile-0007-1-1.tsv".to_string(),
646 "profile-0008-1-1.tsv".to_string(),
647 "profile-0009-1-1.tsv".to_string(),
648 ]
649 );
650 }
651
652 #[test]
653 fn rotate_with_keep_zero_is_a_noop() {
654 let env = TempEnvironment::builder().build();
657 for i in 0..3 {
658 write_profile(&env, &format!("profile-{i}-1-1.tsv"), "x");
659 }
660 let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 0).unwrap();
661 assert_eq!(removed, 0);
662 let count = env
663 .fs
664 .read_dir(&env.paths.probes_shell_init_dir())
665 .unwrap()
666 .len();
667 assert_eq!(count, 3);
668 }
669
670 #[test]
671 fn rotate_below_threshold_is_a_noop() {
672 let env = TempEnvironment::builder().build();
673 write_profile(&env, "profile-1-1-1.tsv", "x");
674 let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 100).unwrap();
675 assert_eq!(removed, 0);
676 }
677
678 #[test]
679 fn rotate_ignores_non_profile_files() {
680 let env = TempEnvironment::builder().build();
681 let dir = env.paths.probes_shell_init_dir();
682 env.fs.mkdir_all(&dir).unwrap();
683 for i in 1..=5 {
686 env.fs
687 .write_file(&dir.join(format!("profile-{i}-1-1.tsv")), b"")
688 .unwrap();
689 }
690 env.fs
691 .write_file(&dir.join("README"), b"do not delete")
692 .unwrap();
693 env.fs
694 .write_file(&dir.join("notes.txt"), b"keep me")
695 .unwrap();
696
697 let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 2).unwrap();
699 assert_eq!(removed, 3);
700
701 assert!(env.fs.exists(&dir.join("profile-4-1-1.tsv")));
703 assert!(env.fs.exists(&dir.join("profile-5-1-1.tsv")));
704 assert!(!env.fs.exists(&dir.join("profile-1-1-1.tsv")));
706 assert!(!env.fs.exists(&dir.join("profile-2-1-1.tsv")));
707 assert!(!env.fs.exists(&dir.join("profile-3-1-1.tsv")));
708
709 assert!(env.fs.exists(&dir.join("README")));
711 assert!(env.fs.exists(&dir.join("notes.txt")));
712 }
713
714 #[test]
715 fn group_profile_aggregates_by_pack_handler() {
716 let p = Profile {
717 filename: "x".into(),
718 shell: "bash".into(),
719 total_duration_us: 10_000,
720 errors: Vec::new(),
721 entries: vec![
722 ProfileEntry {
723 phase: "source".into(),
724 pack: "vim".into(),
725 handler: "shell".into(),
726 target: "/a".into(),
727 duration_us: 100,
728 exit_status: 0,
729 },
730 ProfileEntry {
731 phase: "source".into(),
732 pack: "vim".into(),
733 handler: "shell".into(),
734 target: "/b".into(),
735 duration_us: 200,
736 exit_status: 0,
737 },
738 ProfileEntry {
739 phase: "path".into(),
740 pack: "vim".into(),
741 handler: "path".into(),
742 target: "/bin".into(),
743 duration_us: 5,
744 exit_status: 0,
745 },
746 ],
747 };
748 let g = group_profile(&p);
749 assert_eq!(g.groups.len(), 2);
750 assert_eq!(g.groups[0].pack, "vim");
753 assert_eq!(g.groups[0].handler, "path");
754 assert_eq!(g.groups[0].group_total_us, 5);
755 assert_eq!(g.groups[1].handler, "shell");
756 assert_eq!(g.groups[1].group_total_us, 300);
757 assert_eq!(g.user_total_us, 305);
758 assert_eq!(g.total_us, 10_000);
759 assert_eq!(g.framing_us, 9_695);
760 }
761
762 #[test]
763 fn group_profile_sorts_across_packs() {
764 let p = Profile {
767 filename: "x".into(),
768 shell: "bash".into(),
769 total_duration_us: 0,
770 errors: Vec::new(),
771 entries: vec![
772 entry("vim", "shell", "/a", 1),
773 entry("git", "symlink", "/b", 1),
774 entry("vim", "path", "/c", 1),
775 entry("git", "shell", "/d", 1),
776 ],
777 };
778 let g = group_profile(&p);
779 let keys: Vec<(String, String)> = g
780 .groups
781 .iter()
782 .map(|gp| (gp.pack.clone(), gp.handler.clone()))
783 .collect();
784 assert_eq!(
785 keys,
786 vec![
787 ("git".into(), "shell".into()),
788 ("git".into(), "symlink".into()),
789 ("vim".into(), "path".into()),
790 ("vim".into(), "shell".into()),
791 ]
792 );
793 }
794
795 fn entry(pack: &str, handler: &str, target: &str, dur_us: u64) -> ProfileEntry {
796 ProfileEntry {
797 phase: "source".into(),
798 pack: pack.into(),
799 handler: handler.into(),
800 target: target.into(),
801 duration_us: dur_us,
802 exit_status: 0,
803 }
804 }
805
806 #[test]
807 fn group_profile_clamps_framing_when_total_below_entries() {
808 let p = Profile {
811 filename: "x".into(),
812 shell: "".into(),
813 total_duration_us: 0,
814 errors: Vec::new(),
815 entries: vec![ProfileEntry {
816 phase: "source".into(),
817 pack: "vim".into(),
818 handler: "shell".into(),
819 target: "/a".into(),
820 duration_us: 500,
821 exit_status: 0,
822 }],
823 };
824 let g = group_profile(&p);
825 assert_eq!(g.user_total_us, 500);
826 assert_eq!(g.total_us, 500);
827 assert_eq!(g.framing_us, 0);
828 }
829
830 #[test]
833 fn read_recent_returns_newest_first_capped_at_limit() {
834 let env = TempEnvironment::builder().build();
835 for i in 1..=5 {
836 write_profile(
837 &env,
838 &format!("profile-{i}-1-1.tsv"),
839 "# columns\tphase\tpack\thandler\ttarget\tstart_t\tend_t\texit_status\n",
840 );
841 }
842 let recent = read_recent_profiles(env.fs.as_ref(), env.paths.as_ref(), 3).unwrap();
843 let names: Vec<&str> = recent.iter().map(|p| p.filename.as_str()).collect();
844 assert_eq!(
845 names,
846 vec![
847 "profile-5-1-1.tsv",
848 "profile-4-1-1.tsv",
849 "profile-3-1-1.tsv",
850 ]
851 );
852 }
853
854 #[test]
855 fn read_recent_with_limit_zero_returns_empty() {
856 let env = TempEnvironment::builder().build();
857 write_profile(&env, "profile-1-1-1.tsv", "x");
858 let recent = read_recent_profiles(env.fs.as_ref(), env.paths.as_ref(), 0).unwrap();
859 assert!(recent.is_empty());
860 }
861
862 #[test]
863 fn read_recent_handles_fewer_files_than_limit() {
864 let env = TempEnvironment::builder().build();
865 write_profile(&env, "profile-1-1-1.tsv", "");
866 let recent = read_recent_profiles(env.fs.as_ref(), env.paths.as_ref(), 100).unwrap();
867 assert_eq!(recent.len(), 1);
868 }
869
870 #[test]
873 fn percentile_nearest_rank_basic_cases() {
874 let v: Vec<u64> = (1..=10).collect();
877 assert_eq!(percentile(&v, 50), 5);
878 assert_eq!(percentile(&v, 95), 10);
879 assert_eq!(percentile(&[42], 50), 42);
881 assert_eq!(percentile(&[42], 95), 42);
882 assert_eq!(percentile(&[], 50), 0);
884 }
885
886 #[test]
887 fn aggregate_profiles_buckets_by_pack_handler_target() {
888 let p1 = Profile {
889 filename: "profile-1-1-1.tsv".into(),
890 shell: "bash".into(),
891 total_duration_us: 0,
892 errors: Vec::new(),
893 entries: vec![
894 entry("vim", "shell", "/a", 100),
895 entry("vim", "shell", "/b", 200),
896 ],
897 };
898 let p2 = Profile {
899 filename: "profile-2-1-1.tsv".into(),
900 shell: "bash".into(),
901 total_duration_us: 0,
902 errors: Vec::new(),
903 entries: vec![
904 entry("vim", "shell", "/a", 110),
905 entry("vim", "shell", "/b", 250),
906 ],
907 };
908 let p3 = Profile {
909 filename: "profile-3-1-1.tsv".into(),
910 shell: "bash".into(),
911 total_duration_us: 0,
912 errors: Vec::new(),
913 entries: vec![entry("vim", "shell", "/a", 120)],
914 };
916 let agg = aggregate_profiles(&[p1, p2, p3]);
917 assert_eq!(agg.runs, 3);
918 assert_eq!(agg.targets.len(), 2);
919 let a = agg.targets.iter().find(|t| t.target == "/a").unwrap();
920 assert_eq!(a.runs_seen, 3);
921 assert_eq!(a.runs_total, 3);
922 assert_eq!(a.p50_us, 110); assert_eq!(a.max_us, 120);
924 let b = agg.targets.iter().find(|t| t.target == "/b").unwrap();
925 assert_eq!(b.runs_seen, 2);
926 assert_eq!(b.runs_total, 3);
927 assert_eq!(b.max_us, 250);
928 }
929
930 #[test]
931 fn aggregate_empty_profiles_returns_empty_view() {
932 let agg = aggregate_profiles(&[]);
933 assert_eq!(agg.runs, 0);
934 assert!(agg.targets.is_empty());
935 }
936
937 #[test]
938 fn aggregate_targets_sort_by_pack_handler_target() {
939 let p = Profile {
941 filename: "p".into(),
942 shell: "".into(),
943 total_duration_us: 0,
944 errors: Vec::new(),
945 entries: vec![
946 entry("vim", "shell", "/z", 1),
947 entry("git", "shell", "/a", 1),
948 entry("vim", "path", "/x", 1),
949 entry("git", "shell", "/y", 1),
950 ],
951 };
952 let agg = aggregate_profiles(&[p]);
953 let keys: Vec<(&str, &str, &str)> = agg
954 .targets
955 .iter()
956 .map(|t| (t.pack.as_str(), t.handler.as_str(), t.target.as_str()))
957 .collect();
958 assert_eq!(
959 keys,
960 vec![
961 ("git", "shell", "/a"),
962 ("git", "shell", "/y"),
963 ("vim", "path", "/x"),
964 ("vim", "shell", "/z"),
965 ]
966 );
967 }
968
969 #[test]
972 fn summarize_history_pulls_basic_metrics_per_run() {
973 let p1 = Profile {
974 filename: "profile-1714000000-12-34.tsv".into(),
975 shell: "bash 5.3".into(),
976 total_duration_us: 500,
977 errors: Vec::new(),
978 entries: vec![
979 entry("vim", "shell", "/a", 100),
980 ProfileEntry {
981 phase: "source".into(),
982 pack: "gh".into(),
983 handler: "shell".into(),
984 target: "/x".into(),
985 duration_us: 50,
986 exit_status: 1, },
988 ],
989 };
990 let h = summarize_history(&[p1]);
991 assert_eq!(h.len(), 1);
992 assert_eq!(h[0].unix_ts, 1714000000);
993 assert_eq!(h[0].shell, "bash 5.3");
994 assert_eq!(h[0].total_us, 500);
995 assert_eq!(h[0].user_total_us, 150);
996 assert_eq!(h[0].failed_entries, 1);
997 assert_eq!(h[0].entry_count, 2);
998 }
999
1000 #[test]
1001 fn parse_unix_ts_handles_unparseable_filenames() {
1002 assert_eq!(
1005 parse_unix_ts_from_filename("profile-1714000000-1-1.tsv"),
1006 1714000000
1007 );
1008 assert_eq!(parse_unix_ts_from_filename("garbage.txt"), 0);
1009 assert_eq!(parse_unix_ts_from_filename("profile-notanum-1-1.tsv"), 0);
1010 }
1011
1012 #[test]
1015 fn parse_errors_log_handles_single_record() {
1016 let content = "# dodot shell-init errors v1\n\
1017@@\t/p/aliases.sh\t1\n\
1018zsh: parse error near unexpected token\n\
1019";
1020 let recs = parse_errors_log(content);
1021 assert_eq!(recs.len(), 1);
1022 assert_eq!(recs[0].target, "/p/aliases.sh");
1023 assert_eq!(recs[0].exit_status, 1);
1024 assert_eq!(recs[0].message, "zsh: parse error near unexpected token");
1025 }
1026
1027 #[test]
1028 fn parse_errors_log_handles_multiple_records_with_multiline_stderr() {
1029 let content = "# dodot shell-init errors v1\n\
1030@@\t/p/a.sh\t2\n\
1031line one\n\
1032line two\n\
1033\n\
1034@@\t/p/b.sh\t0\n\
1035warning: deprecated\n\
1036";
1037 let recs = parse_errors_log(content);
1038 assert_eq!(recs.len(), 2);
1039 assert_eq!(recs[0].target, "/p/a.sh");
1040 assert_eq!(recs[0].exit_status, 2);
1041 assert_eq!(recs[0].message, "line one\nline two\n");
1042 assert_eq!(recs[1].target, "/p/b.sh");
1043 assert_eq!(recs[1].exit_status, 0); assert_eq!(recs[1].message, "warning: deprecated");
1045 }
1046
1047 #[test]
1048 fn parse_errors_log_skips_malformed_headers() {
1049 let content = "@@\t/p/a.sh\n\
1051some line\n\
1052@@\t/p/b.sh\tnotanint\n\
1053some line\n\
1054@@\t/p/c.sh\t3\n\
1055real error\n\
1056";
1057 let recs = parse_errors_log(content);
1058 assert_eq!(recs.len(), 1);
1059 assert_eq!(recs[0].target, "/p/c.sh");
1060 assert_eq!(recs[0].exit_status, 3);
1061 }
1062
1063 #[test]
1064 fn parse_errors_log_returns_empty_for_empty_or_header_only() {
1065 assert!(parse_errors_log("").is_empty());
1066 assert!(parse_errors_log("# dodot shell-init errors v1\n").is_empty());
1067 }
1068
1069 #[test]
1070 fn read_recent_loads_sibling_errors_log_when_present() {
1071 let env = TempEnvironment::builder().build();
1072 write_profile(
1074 &env,
1075 "profile-1000-1-1.tsv",
1076 "# shell\tbash\n\
1077# start_t\t1.0\n\
1078source\tvim\tshell\t/p/aliases.sh\t1.0\t1.001\t1\n\
1079# end_t\t1.002\n",
1080 );
1081 let dir = env.paths.probes_shell_init_dir();
1083 env.fs
1084 .write_file(
1085 &dir.join("profile-1000-1-1.errors.log"),
1086 b"# dodot shell-init errors v1\n@@\t/p/aliases.sh\t1\nboom\n",
1087 )
1088 .unwrap();
1089
1090 let profiles = read_recent_profiles(env.fs.as_ref(), env.paths.as_ref(), 5).unwrap();
1091 assert_eq!(profiles.len(), 1);
1092 assert_eq!(profiles[0].errors.len(), 1);
1093 assert_eq!(profiles[0].errors[0].target, "/p/aliases.sh");
1094 assert_eq!(profiles[0].errors[0].message, "boom");
1095 }
1096
1097 #[test]
1098 fn read_recent_with_no_sibling_errors_log_yields_empty_errors() {
1099 let env = TempEnvironment::builder().build();
1102 write_profile(
1103 &env,
1104 "profile-1000-1-1.tsv",
1105 "# shell\tbash\n# start_t\t1.0\n# end_t\t1.001\n",
1106 );
1107 let profiles = read_recent_profiles(env.fs.as_ref(), env.paths.as_ref(), 5).unwrap();
1108 assert_eq!(profiles.len(), 1);
1109 assert!(profiles[0].errors.is_empty());
1110 }
1111}