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;
25use tracing::warn;
26
27use crate::fs::Fs;
28use crate::paths::Pather;
29use crate::Result;
30
31/// One parsed entry row from a profile TSV.
32#[derive(Debug, Clone, PartialEq, Serialize)]
33pub struct ProfileEntry {
34    /// `"path"` for an export, `"source"` for a sourced shell file.
35    pub phase: String,
36    pub pack: String,
37    pub handler: String,
38    pub target: String,
39    /// Microseconds the entry took to execute (computed from
40    /// `end_t - start_t` at parse time).
41    pub duration_us: u64,
42    /// Exit status reported by the source. Always `0` for `path`
43    /// entries (PATH export can't fail meaningfully).
44    pub exit_status: i32,
45}
46
47/// One captured stderr record from a sourced shell file. The shell
48/// wrapper writes one record per source whose stderr was non-empty,
49/// regardless of exit status (warnings emitted on a successful source
50/// are surfaced too).
51#[derive(Debug, Clone, PartialEq, Serialize)]
52pub struct ProfileErrorRecord {
53    pub target: String,
54    pub exit_status: i32,
55    /// Captured stderr verbatim, with trailing newline preserved if
56    /// present in the original. May be multiple lines.
57    pub message: String,
58}
59
60/// A whole profile file, post-parse.
61#[derive(Debug, Clone, Serialize)]
62pub struct Profile {
63    /// Source filename (basename only). Useful for showing "which
64    /// run am I looking at" in the rendered report.
65    pub filename: String,
66    /// `bash 5.3.9` etc; empty if the preamble was missing.
67    pub shell: String,
68    /// Whole-script wall time in microseconds, from `# start_t` to
69    /// `# end_t`. `0` if either marker is missing (e.g. crashed shell).
70    pub total_duration_us: u64,
71    pub entries: Vec<ProfileEntry>,
72    /// Stderr records loaded from the sibling `*.errors.log` file, if
73    /// any. Empty when no errors were captured for this run, when the
74    /// log is missing, or when running with an older profile that
75    /// pre-dates the errors-log feature.
76    #[serde(default)]
77    pub errors: Vec<ProfileErrorRecord>,
78}
79
80impl Profile {
81    /// Convenience: total time spent in the entry rows. Lets the
82    /// renderer show "user-sourced time" vs "dodot framing" by
83    /// subtracting this from `total_duration_us`.
84    pub fn entries_duration_us(&self) -> u64 {
85        self.entries.iter().map(|e| e.duration_us).sum()
86    }
87
88    /// `total_duration_us - entries_duration_us`, saturating at zero.
89    /// Represents the shell-side overhead of dodot's wrapper itself
90    /// (and any work between wrapper invocations).
91    pub fn framing_duration_us(&self) -> u64 {
92        self.total_duration_us
93            .saturating_sub(self.entries_duration_us())
94    }
95}
96
97/// Read the most recently written profile under `<data_dir>/probes/shell-init/`,
98/// or `None` if the directory is empty / missing.
99pub 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
104/// Read up to `limit` most recent profiles, newest first.
105///
106/// Profiles are returned in reverse chronological order (newest first).
107/// The cap exists because callers know how much they need — `--runs 5`
108/// asks for five — and the directory may have hundreds of files.
109///
110/// Implementation: `Fs::read_dir` already returns entries sorted by
111/// name, and `profile-<unix_ts>-…` is fixed-prefix monotonic, so
112/// lexical-ascending == chronological-ascending. We `.rev()` the
113/// iterator to walk newest-first, filter, and `take(limit)` so we
114/// only allocate the rows we'll actually return.
115pub 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        // Sibling errors log: same path with `.tsv` swapped for
133        // `.errors.log`. Missing file is normal (no errors captured)
134        // and is silently treated as empty. A read failure on a file
135        // that does exist is suspicious — likely permissions or
136        // corruption — so we log it instead of dropping silently. We
137        // still don't fail the whole call: callers want as much profile
138        // data as possible even with one bad sidecar.
139        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
155/// Sibling errors-log path for a profile TSV.
156///
157/// Mirrors the shell wrapper: `${prof_file%.tsv}.errors.log`. Falls back
158/// to appending `.errors.log` if the input doesn't end in `.tsv` (a
159/// belt-and-braces guard for filenames the wrapper would never emit).
160fn 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
169/// Parse the textual content of an errors log. Format:
170///
171/// ```text
172/// # dodot shell-init errors v1
173/// @@\t<target>\t<exit_status>
174/// <stderr line 1>
175/// <stderr line 2>
176/// @@\t<target>\t<exit_status>
177/// <stderr line 1>
178/// ```
179///
180/// Lines starting with `# ` are headers (ignored). A line beginning with
181/// `@@\t` opens a new record; subsequent lines until the next `@@\t`
182/// (or EOF) are the captured stderr. Records with malformed headers are
183/// skipped. The trailing newline written after each record by the
184/// shell wrapper appears as a blank line; we strip exactly one trailing
185/// newline from each record's `message` to keep round-tripping clean.
186pub 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            // Drop exactly one trailing newline (the wrapper's record
193            // terminator). Preserve any extra blank lines inside.
194            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            // New record header. Flush any in-progress record first.
209            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            // Top-of-file version banner; ignore.
223            continue;
224        }
225        if let Some((_, _, ref mut message)) = current {
226            message.push_str(line);
227            message.push('\n');
228        }
229        // Lines outside any record (e.g. trailing junk before the first
230        // `@@`) are silently dropped.
231    }
232    flush(&mut current, &mut out);
233    out
234}
235
236/// Parse the textual content of a profile file. Tolerates missing
237/// preamble lines, unknown comments, and malformed rows (skipped).
238pub 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            // Comment lines, format: `# key\tvalue` (the leading hash
251            // and one space are conventional).
252            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                    _ => {} // unknown header — ignore
259                }
260            }
261            continue;
262        }
263        if let Some(entry) = parse_row(line) {
264            entries.push(entry);
265        }
266        // Otherwise: malformed row, silently dropped.
267    }
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
317/// Prune `<data_dir>/probes/shell-init/` to the newest `keep` files
318/// (by filename). Returns the number of files removed. `keep == 0`
319/// is treated as "no pruning" — we don't want a stray miscalibrated
320/// config to wipe the whole profile history.
321pub 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    // `Fs::read_dir` returns entries already sorted by name, and
330    // `profile-<unix_ts>-…` is fixed-prefix monotonic, so the result
331    // is chronological-ascending; oldest entries are at the front.
332    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        // Best-effort: remove the sibling errors log too, so a long-
347        // running install doesn't accumulate orphan `.errors.log` files.
348        let sidecar = errors_log_path_for(&entry.path);
349        let _ = fs.remove_file(&sidecar);
350    }
351    Ok(removed)
352}
353
354/// Aggregate one profile by `(pack, handler)` for the rendered table.
355#[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
371/// Roll up a profile into per-(pack, handler) groups for display.
372///
373/// Groups are returned sorted by `(pack, handler)` so the rendered
374/// table ordering matches `dodot probe deployment-map` — eyeballing
375/// the two side-by-side should not require mental remapping. The init
376/// script emits PATH lines before shell sources (a phase-ordering
377/// concern of its own), so the raw entry order would otherwise show
378/// all `path` groups before any `shell` group, which is not what the
379/// user expects when reading a per-pack table.
380pub 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// ── Multi-run aggregation (`probe shell-init --runs N`) ───────────────
416
417/// Per-target stats across N runs.
418#[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    /// How many of the considered runs had this target. Surfaced because
427    /// targets can appear/disappear across deploys; "p95 from 2/10 runs"
428    /// is a different signal than "p95 from 10/10".
429    pub runs_seen: usize,
430    pub runs_total: usize,
431}
432
433/// Aggregate output, one row per `(pack, handler, target)` keyed by all
434/// the profiles passed in.
435#[derive(Debug, Clone, Serialize)]
436pub struct AggregatedView {
437    pub runs: usize,
438    pub targets: Vec<AggregatedTarget>,
439}
440
441/// Roll up a slice of profiles into per-target percentile stats.
442///
443/// Targets are returned sorted by `(pack, handler, target)` for
444/// readability (matches `group_profile`'s ordering convention).
445pub fn aggregate_profiles(profiles: &[Profile]) -> AggregatedView {
446    use std::collections::BTreeMap;
447
448    // Bucket durations by (pack, handler, target).
449    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
483/// Nearest-rank percentile (no interpolation): the smallest value at
484/// or above the cumulative-frequency threshold. For p95 over 1 sample
485/// returns that sample; over 10 samples returns the 10th (max).
486///
487/// `sorted` must be sorted ascending. Returns 0 for an empty slice.
488fn percentile(sorted: &[u64], pct: u8) -> u64 {
489    if sorted.is_empty() {
490        return 0;
491    }
492    let n = sorted.len();
493    // ceil(p/100 * n) maps to a 1-indexed rank; subtract 1 for 0-indexed.
494    // Saturate at n-1 to handle any rounding edge cases.
495    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// ── History (`probe shell-init --history`) ────────────────────────────
501
502/// One row in the history view: a single run's headline metrics.
503#[derive(Debug, Clone, Serialize)]
504pub struct HistoryEntry {
505    pub filename: String,
506    /// Best-effort unix timestamp parsed from the filename. `0` if the
507    /// filename doesn't match `profile-<unix_ts>-…`.
508    pub unix_ts: u64,
509    pub shell: String,
510    pub total_us: u64,
511    pub user_total_us: u64,
512    /// Count of entries with non-zero exit_status — surfaces silent
513    /// breakage at a glance.
514    pub failed_entries: usize,
515    pub entry_count: usize,
516}
517
518/// Build a history view from a slice of profiles (newest-first input,
519/// preserved order in the output — caller decides cadence).
520pub 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
540/// Extract the leading `<unix_ts>` from `profile-<unix_ts>-<pid>-<rand>.tsv`,
541/// returning `0` for any unparseable filename. The renderer formats this
542/// into a date string; storing it as an integer keeps JSON output stable.
543pub(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        // Crashed shell: writes start_t and rows, but never reaches the
597        // epilogue. We still want a usable Profile.
598        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); // no end_t → 0 total
602        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        // The three highest-numbered (newest) files survive.
642        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        // Defensive: a misconfigured keep_last_runs = 0 must not wipe
655        // the user's profile history. Treat as "no rotation".
656        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        // Five profile files (more than `keep`), plus two non-profile
684        // files that the rotator must leave alone.
685        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        // keep=2 forces the pruning path (5 profiles → 2 should remain).
698        let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 2).unwrap();
699        assert_eq!(removed, 3);
700
701        // The two newest profiles survive.
702        assert!(env.fs.exists(&dir.join("profile-4-1-1.tsv")));
703        assert!(env.fs.exists(&dir.join("profile-5-1-1.tsv")));
704        // The three oldest are gone.
705        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        // Non-profile files are untouched.
710        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        // Groups are sorted by (pack, handler), not by emission order:
751        // "path" comes before "shell" alphabetically within `vim`.
752        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        // Entries arrive in deliberately scrambled order; the result
765        // must still be (pack, handler)-sorted.
766        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        // If `total_duration_us` is missing (parse left it at 0) but
809        // we have entries, framing_us must be 0 — not negative.
810        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    // ── read_recent_profiles ──────────────────────────────────────
831
832    #[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    // ── percentile + aggregate ────────────────────────────────────
871
872    #[test]
873    fn percentile_nearest_rank_basic_cases() {
874        // Ten samples 1..=10. p50 = 5 (lower-median, nearest-rank
875        // doesn't interpolate); p95 = 10 (max-ish, since ceil(0.95*10)=10).
876        let v: Vec<u64> = (1..=10).collect();
877        assert_eq!(percentile(&v, 50), 5);
878        assert_eq!(percentile(&v, 95), 10);
879        // Single sample → all percentiles return it.
880        assert_eq!(percentile(&[42], 50), 42);
881        assert_eq!(percentile(&[42], 95), 42);
882        // Empty slice safely returns 0.
883        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            // /b absent in this run (sparse target presence).
915        };
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); // sorted: 100,110,120 → idx ceil(0.5*3)-1=1
923        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        // Inputs scrambled; output must be (pack, handler, target)-sorted.
940        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    // ── history ───────────────────────────────────────────────────
970
971    #[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, // hidden failure
987                },
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        // Best-effort: an unrecognised filename returns 0 rather than
1003        // crashing the history view.
1004        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    // ── errors log parsing ────────────────────────────────────────
1013
1014    #[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); // success with warnings
1044        assert_eq!(recs[1].message, "warning: deprecated");
1045    }
1046
1047    #[test]
1048    fn parse_errors_log_skips_malformed_headers() {
1049        // A header missing the exit status, and one with non-integer exit.
1050        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        // A profile with one source entry...
1073        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        // ...and a sibling errors log containing one record.
1082        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        // Older profiles (pre-feature) and clean runs both have no
1100        // sibling log; we treat both as "no errors captured".
1101        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}