Skip to main content

dodot_lib/probe/
shell_init.rs

1//! Shell-init profile reader, aggregator, and rotation.
2//!
3//! Profile files are written by the timing wrapper that
4//! [`crate::shell::generate_init_script`] embeds in `dodot-init.sh`.
5//! Each shell startup emits one TSV under
6//! `<data_dir>/probes/shell-init/profile-<unix_ts>-<pid>-<rand>.tsv`
7//! with the shape:
8//!
9//! ```text
10//! # dodot shell-init profile v1
11//! # shell\tbash 5.3.9(1)-release
12//! # start_t\t1714000000.123456
13//! # init_script\t/home/alice/.local/share/dodot/shell/dodot-init.sh
14//! # columns\tphase\tpack\thandler\ttarget\tstart_t\tend_t\texit_status
15//! path\tvim\tpath\t/home/alice/dotfiles/vim/bin\t1714000000.123500\t1714000000.123502\t0
16//! source\tvim\tshell\t/home/alice/dotfiles/vim/aliases.sh\t1714000000.123600\t1714000000.124900\t0
17//! # end_t\t1714000000.125100
18//! ```
19//!
20//! Both the reader and the rotator are tolerant of malformed input —
21//! a partial write from a crashed shell should leave the next `dodot
22//! probe shell-init` working, just with a short report.
23
24use serde::Serialize;
25
26use crate::fs::Fs;
27use crate::paths::Pather;
28use crate::Result;
29
30/// One parsed entry row from a profile TSV.
31#[derive(Debug, Clone, PartialEq, Serialize)]
32pub struct ProfileEntry {
33    /// `"path"` for an export, `"source"` for a sourced shell file.
34    pub phase: String,
35    pub pack: String,
36    pub handler: String,
37    pub target: String,
38    /// Microseconds the entry took to execute (computed from
39    /// `end_t - start_t` at parse time).
40    pub duration_us: u64,
41    /// Exit status reported by the source. Always `0` for `path`
42    /// entries (PATH export can't fail meaningfully).
43    pub exit_status: i32,
44}
45
46/// A whole profile file, post-parse.
47#[derive(Debug, Clone, Serialize)]
48pub struct Profile {
49    /// Source filename (basename only). Useful for showing "which
50    /// run am I looking at" in the rendered report.
51    pub filename: String,
52    /// `bash 5.3.9` etc; empty if the preamble was missing.
53    pub shell: String,
54    /// Whole-script wall time in microseconds, from `# start_t` to
55    /// `# end_t`. `0` if either marker is missing (e.g. crashed shell).
56    pub total_duration_us: u64,
57    pub entries: Vec<ProfileEntry>,
58}
59
60impl Profile {
61    /// Convenience: total time spent in the entry rows. Lets the
62    /// renderer show "user-sourced time" vs "dodot framing" by
63    /// subtracting this from `total_duration_us`.
64    pub fn entries_duration_us(&self) -> u64 {
65        self.entries.iter().map(|e| e.duration_us).sum()
66    }
67
68    /// `total_duration_us - entries_duration_us`, saturating at zero.
69    /// Represents the shell-side overhead of dodot's wrapper itself
70    /// (and any work between wrapper invocations).
71    pub fn framing_duration_us(&self) -> u64 {
72        self.total_duration_us
73            .saturating_sub(self.entries_duration_us())
74    }
75}
76
77/// Read the most recently written profile under `<data_dir>/probes/shell-init/`,
78/// or `None` if the directory is empty / missing.
79pub 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
84/// Read up to `limit` most recent profiles, newest first.
85///
86/// Profiles are returned in reverse chronological order (newest first).
87/// The cap exists because callers know how much they need — `--runs 5`
88/// asks for five — and the directory may have hundreds of files.
89///
90/// Implementation: `Fs::read_dir` already returns entries sorted by
91/// name, and `profile-<unix_ts>-…` is fixed-prefix monotonic, so
92/// lexical-ascending == chronological-ascending. We `.rev()` the
93/// iterator to walk newest-first, filter, and `take(limit)` so we
94/// only allocate the rows we'll actually return.
95pub 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
116/// Parse the textual content of a profile file. Tolerates missing
117/// preamble lines, unknown comments, and malformed rows (skipped).
118pub 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            // Comment lines, format: `# key\tvalue` (the leading hash
131            // and one space are conventional).
132            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                    _ => {} // unknown header — ignore
139                }
140            }
141            continue;
142        }
143        if let Some(entry) = parse_row(line) {
144            entries.push(entry);
145        }
146        // Otherwise: malformed row, silently dropped.
147    }
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
196/// Prune `<data_dir>/probes/shell-init/` to the newest `keep` files
197/// (by filename). Returns the number of files removed. `keep == 0`
198/// is treated as "no pruning" — we don't want a stray miscalibrated
199/// config to wipe the whole profile history.
200pub 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    // `Fs::read_dir` returns entries already sorted by name, and
209    // `profile-<unix_ts>-…` is fixed-prefix monotonic, so the result
210    // is chronological-ascending; oldest entries are at the front.
211    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/// Aggregate one profile by `(pack, handler)` for the rendered table.
230#[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
246/// Roll up a profile into per-(pack, handler) groups for display.
247///
248/// Groups are returned sorted by `(pack, handler)` so the rendered
249/// table ordering matches `dodot probe deployment-map` — eyeballing
250/// the two side-by-side should not require mental remapping. The init
251/// script emits PATH lines before shell sources (a phase-ordering
252/// concern of its own), so the raw entry order would otherwise show
253/// all `path` groups before any `shell` group, which is not what the
254/// user expects when reading a per-pack table.
255pub 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// ── Multi-run aggregation (`probe shell-init --runs N`) ───────────────
291
292/// Per-target stats across N runs.
293#[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    /// How many of the considered runs had this target. Surfaced because
302    /// targets can appear/disappear across deploys; "p95 from 2/10 runs"
303    /// is a different signal than "p95 from 10/10".
304    pub runs_seen: usize,
305    pub runs_total: usize,
306}
307
308/// Aggregate output, one row per `(pack, handler, target)` keyed by all
309/// the profiles passed in.
310#[derive(Debug, Clone, Serialize)]
311pub struct AggregatedView {
312    pub runs: usize,
313    pub targets: Vec<AggregatedTarget>,
314}
315
316/// Roll up a slice of profiles into per-target percentile stats.
317///
318/// Targets are returned sorted by `(pack, handler, target)` for
319/// readability (matches `group_profile`'s ordering convention).
320pub fn aggregate_profiles(profiles: &[Profile]) -> AggregatedView {
321    use std::collections::BTreeMap;
322
323    // Bucket durations by (pack, handler, target).
324    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
358/// Nearest-rank percentile (no interpolation): the smallest value at
359/// or above the cumulative-frequency threshold. For p95 over 1 sample
360/// returns that sample; over 10 samples returns the 10th (max).
361///
362/// `sorted` must be sorted ascending. Returns 0 for an empty slice.
363fn percentile(sorted: &[u64], pct: u8) -> u64 {
364    if sorted.is_empty() {
365        return 0;
366    }
367    let n = sorted.len();
368    // ceil(p/100 * n) maps to a 1-indexed rank; subtract 1 for 0-indexed.
369    // Saturate at n-1 to handle any rounding edge cases.
370    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// ── History (`probe shell-init --history`) ────────────────────────────
376
377/// One row in the history view: a single run's headline metrics.
378#[derive(Debug, Clone, Serialize)]
379pub struct HistoryEntry {
380    pub filename: String,
381    /// Best-effort unix timestamp parsed from the filename. `0` if the
382    /// filename doesn't match `profile-<unix_ts>-…`.
383    pub unix_ts: u64,
384    pub shell: String,
385    pub total_us: u64,
386    pub user_total_us: u64,
387    /// Count of entries with non-zero exit_status — surfaces silent
388    /// breakage at a glance.
389    pub failed_entries: usize,
390    pub entry_count: usize,
391}
392
393/// Build a history view from a slice of profiles (newest-first input,
394/// preserved order in the output — caller decides cadence).
395pub 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
415/// Extract the leading `<unix_ts>` from `profile-<unix_ts>-<pid>-<rand>.tsv`,
416/// returning `0` for any unparseable filename. The renderer formats this
417/// into a date string; storing it as an integer keeps JSON output stable.
418fn 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        // Crashed shell: writes start_t and rows, but never reaches the
472        // epilogue. We still want a usable Profile.
473        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); // no end_t → 0 total
477        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        // The three highest-numbered (newest) files survive.
517        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        // Defensive: a misconfigured keep_last_runs = 0 must not wipe
530        // the user's profile history. Treat as "no rotation".
531        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        // Five profile files (more than `keep`), plus two non-profile
559        // files that the rotator must leave alone.
560        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        // keep=2 forces the pruning path (5 profiles → 2 should remain).
573        let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 2).unwrap();
574        assert_eq!(removed, 3);
575
576        // The two newest profiles survive.
577        assert!(env.fs.exists(&dir.join("profile-4-1-1.tsv")));
578        assert!(env.fs.exists(&dir.join("profile-5-1-1.tsv")));
579        // The three oldest are gone.
580        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        // Non-profile files are untouched.
585        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        // Groups are sorted by (pack, handler), not by emission order:
625        // "path" comes before "shell" alphabetically within `vim`.
626        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        // Entries arrive in deliberately scrambled order; the result
639        // must still be (pack, handler)-sorted.
640        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        // If `total_duration_us` is missing (parse left it at 0) but
682        // we have entries, framing_us must be 0 — not negative.
683        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    // ── read_recent_profiles ──────────────────────────────────────
703
704    #[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    // ── percentile + aggregate ────────────────────────────────────
743
744    #[test]
745    fn percentile_nearest_rank_basic_cases() {
746        // Ten samples 1..=10. p50 = 5 (lower-median, nearest-rank
747        // doesn't interpolate); p95 = 10 (max-ish, since ceil(0.95*10)=10).
748        let v: Vec<u64> = (1..=10).collect();
749        assert_eq!(percentile(&v, 50), 5);
750        assert_eq!(percentile(&v, 95), 10);
751        // Single sample → all percentiles return it.
752        assert_eq!(percentile(&[42], 50), 42);
753        assert_eq!(percentile(&[42], 95), 42);
754        // Empty slice safely returns 0.
755        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            // /b absent in this run (sparse target presence).
784        };
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); // sorted: 100,110,120 → idx ceil(0.5*3)-1=1
792        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        // Inputs scrambled; output must be (pack, handler, target)-sorted.
809        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    // ── history ───────────────────────────────────────────────────
838
839    #[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, // hidden failure
854                },
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        // Best-effort: an unrecognised filename returns 0 rather than
870        // crashing the history view.
871        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}