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 dir = paths.probes_shell_init_dir();
81    if !fs.is_dir(&dir) {
82        return Ok(None);
83    }
84    let mut entries = fs.read_dir(&dir)?;
85    // Filenames start with `profile-<unix_ts>-…`, so a lexical sort
86    // is equivalent to chronological order (the timestamp segment is
87    // fixed-width as long as we stay below year 2286 — fine).
88    entries.sort_by(|a, b| a.name.cmp(&b.name));
89    let Some(latest) = entries
90        .into_iter()
91        .rfind(|e| e.is_file && e.name.starts_with("profile-") && e.name.ends_with(".tsv"))
92    else {
93        return Ok(None);
94    };
95    let content = fs.read_to_string(&latest.path)?;
96    Ok(Some(parse_profile(&latest.name, &content)))
97}
98
99/// Parse the textual content of a profile file. Tolerates missing
100/// preamble lines, unknown comments, and malformed rows (skipped).
101pub fn parse_profile(filename: &str, content: &str) -> Profile {
102    let mut shell = String::new();
103    let mut start_t: Option<f64> = None;
104    let mut end_t: Option<f64> = None;
105    let mut entries: Vec<ProfileEntry> = Vec::new();
106
107    for raw_line in content.lines() {
108        let line = raw_line.trim_end_matches('\r');
109        if line.is_empty() {
110            continue;
111        }
112        if let Some(rest) = line.strip_prefix('#') {
113            // Comment lines, format: `# key\tvalue` (the leading hash
114            // and one space are conventional).
115            let trimmed = rest.trim_start();
116            if let Some((key, val)) = trimmed.split_once('\t') {
117                match key {
118                    "shell" => shell = val.to_string(),
119                    "start_t" => start_t = val.parse::<f64>().ok(),
120                    "end_t" => end_t = val.parse::<f64>().ok(),
121                    _ => {} // unknown header — ignore
122                }
123            }
124            continue;
125        }
126        if let Some(entry) = parse_row(line) {
127            entries.push(entry);
128        }
129        // Otherwise: malformed row, silently dropped.
130    }
131
132    let total_duration_us = match (start_t, end_t) {
133        (Some(s), Some(e)) if e >= s => seconds_to_micros(e - s),
134        _ => 0,
135    };
136
137    Profile {
138        filename: filename.to_string(),
139        shell,
140        total_duration_us,
141        entries,
142    }
143}
144
145fn parse_row(line: &str) -> Option<ProfileEntry> {
146    let mut parts = line.splitn(7, '\t');
147    let phase = parts.next()?;
148    let pack = parts.next()?;
149    let handler = parts.next()?;
150    let target = parts.next()?;
151    let start = parts.next()?.parse::<f64>().ok()?;
152    let end = parts.next()?.parse::<f64>().ok()?;
153    let exit_status = parts.next()?.parse::<i32>().ok()?;
154    if !matches!(phase, "path" | "source") {
155        return None;
156    }
157    let duration_us = if end >= start {
158        seconds_to_micros(end - start)
159    } else {
160        0
161    };
162    Some(ProfileEntry {
163        phase: phase.to_string(),
164        pack: pack.to_string(),
165        handler: handler.to_string(),
166        target: target.to_string(),
167        duration_us,
168        exit_status,
169    })
170}
171
172fn seconds_to_micros(secs: f64) -> u64 {
173    if !secs.is_finite() || secs < 0.0 {
174        return 0;
175    }
176    (secs * 1_000_000.0).round() as u64
177}
178
179/// Prune `<data_dir>/probes/shell-init/` to the newest `keep` files
180/// (by filename). Returns the number of files removed. `keep == 0`
181/// is treated as "no pruning" — we don't want a stray miscalibrated
182/// config to wipe the whole profile history.
183pub fn rotate_profiles(fs: &dyn Fs, paths: &dyn Pather, keep: usize) -> Result<usize> {
184    if keep == 0 {
185        return Ok(0);
186    }
187    let dir = paths.probes_shell_init_dir();
188    if !fs.is_dir(&dir) {
189        return Ok(0);
190    }
191    let mut entries: Vec<_> = fs
192        .read_dir(&dir)?
193        .into_iter()
194        .filter(|e| e.is_file && e.name.starts_with("profile-") && e.name.ends_with(".tsv"))
195        .collect();
196    if entries.len() <= keep {
197        return Ok(0);
198    }
199    entries.sort_by(|a, b| a.name.cmp(&b.name));
200    let to_remove = entries.len() - keep;
201    let mut removed = 0;
202    for entry in entries.into_iter().take(to_remove) {
203        if fs.remove_file(&entry.path).is_ok() {
204            removed += 1;
205        }
206    }
207    Ok(removed)
208}
209
210/// Aggregate one profile by `(pack, handler)` for the rendered table.
211#[derive(Debug, Clone, Serialize)]
212pub struct GroupedProfile {
213    pub groups: Vec<ProfileGroup>,
214    pub user_total_us: u64,
215    pub framing_us: u64,
216    pub total_us: u64,
217}
218
219#[derive(Debug, Clone, Serialize)]
220pub struct ProfileGroup {
221    pub pack: String,
222    pub handler: String,
223    pub rows: Vec<ProfileEntry>,
224    pub group_total_us: u64,
225}
226
227/// Roll up a profile into per-(pack, handler) groups for display.
228///
229/// Groups are returned sorted by `(pack, handler)` so the rendered
230/// table ordering matches `dodot probe deployment-map` — eyeballing
231/// the two side-by-side should not require mental remapping. The init
232/// script emits PATH lines before shell sources (a phase-ordering
233/// concern of its own), so the raw entry order would otherwise show
234/// all `path` groups before any `shell` group, which is not what the
235/// user expects when reading a per-pack table.
236pub fn group_profile(profile: &Profile) -> GroupedProfile {
237    let user_total_us = profile.entries_duration_us();
238    let total_us = profile.total_duration_us.max(user_total_us);
239    let framing_us = total_us.saturating_sub(user_total_us);
240
241    let mut groups: Vec<ProfileGroup> = Vec::new();
242    for entry in &profile.entries {
243        let key = (&entry.pack, &entry.handler);
244        let pos = groups
245            .iter()
246            .position(|g| (&g.pack, &g.handler) == (key.0, key.1));
247        match pos {
248            Some(i) => {
249                groups[i].rows.push(entry.clone());
250                groups[i].group_total_us += entry.duration_us;
251            }
252            None => groups.push(ProfileGroup {
253                pack: entry.pack.clone(),
254                handler: entry.handler.clone(),
255                rows: vec![entry.clone()],
256                group_total_us: entry.duration_us,
257            }),
258        }
259    }
260
261    groups.sort_by(|a, b| a.pack.cmp(&b.pack).then(a.handler.cmp(&b.handler)));
262
263    GroupedProfile {
264        groups,
265        user_total_us,
266        framing_us,
267        total_us,
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::testing::TempEnvironment;
275
276    fn write_profile(env: &TempEnvironment, name: &str, content: &str) -> std::path::PathBuf {
277        let dir = env.paths.probes_shell_init_dir();
278        env.fs.mkdir_all(&dir).unwrap();
279        let path = dir.join(name);
280        env.fs.write_file(&path, content.as_bytes()).unwrap();
281        path
282    }
283
284    #[test]
285    fn parser_extracts_preamble_and_rows() {
286        let content = "# dodot shell-init profile v1\n\
287# shell\tbash 5.2\n\
288# start_t\t1714000000.000000\n\
289# init_script\t/x/dodot-init.sh\n\
290# columns\tphase\tpack\thandler\ttarget\tstart_t\tend_t\texit_status\n\
291path\tvim\tpath\t/x/bin\t1714000000.001000\t1714000000.001005\t0\n\
292source\tgit\tshell\t/x/aliases.sh\t1714000000.002000\t1714000000.005000\t0\n\
293# end_t\t1714000000.010000\n";
294        let p = parse_profile("profile-1714000000-1-1.tsv", content);
295        assert_eq!(p.shell, "bash 5.2");
296        assert_eq!(p.entries.len(), 2);
297        assert_eq!(p.entries[0].phase, "path");
298        assert_eq!(p.entries[0].duration_us, 5);
299        assert_eq!(p.entries[1].duration_us, 3000);
300        assert_eq!(p.total_duration_us, 10_000);
301    }
302
303    #[test]
304    fn parser_skips_malformed_rows() {
305        let content = "# columns\tphase\tpack\thandler\ttarget\tstart_t\tend_t\texit_status\n\
306junk\trow\twith\ttoo\tfew\tcols\n\
307path\tvim\tpath\t/x\t1.0\t1.001\t0\n\
308weird\tphase\twrong\t/x\t1.0\t1.001\t0\n";
309        let p = parse_profile("p.tsv", content);
310        assert_eq!(p.entries.len(), 1);
311        assert_eq!(p.entries[0].phase, "path");
312    }
313
314    #[test]
315    fn parser_handles_missing_end_marker() {
316        // Crashed shell: writes start_t and rows, but never reaches the
317        // epilogue. We still want a usable Profile.
318        let content = "# start_t\t1714000000.000000\n\
319source\tvim\tshell\t/x\t1714000000.001000\t1714000000.002000\t0\n";
320        let p = parse_profile("p.tsv", content);
321        assert_eq!(p.total_duration_us, 0); // no end_t → 0 total
322        assert_eq!(p.entries.len(), 1);
323        assert_eq!(p.entries[0].duration_us, 1000);
324    }
325
326    #[test]
327    fn read_latest_returns_none_when_dir_missing() {
328        let env = TempEnvironment::builder().build();
329        let r = read_latest_profile(env.fs.as_ref(), env.paths.as_ref()).unwrap();
330        assert!(r.is_none());
331    }
332
333    #[test]
334    fn read_latest_picks_highest_filename_lexicographically() {
335        let env = TempEnvironment::builder().build();
336        write_profile(&env, "profile-1000-1-1.tsv", "# shell\told\n");
337        write_profile(&env, "profile-2000-1-1.tsv", "# shell\tnew\n");
338        write_profile(&env, "profile-1500-1-1.tsv", "# shell\tmid\n");
339        let p = read_latest_profile(env.fs.as_ref(), env.paths.as_ref())
340            .unwrap()
341            .unwrap();
342        assert_eq!(p.shell, "new");
343        assert_eq!(p.filename, "profile-2000-1-1.tsv");
344    }
345
346    #[test]
347    fn rotate_keeps_newest_n() {
348        let env = TempEnvironment::builder().build();
349        for i in 0..10 {
350            write_profile(&env, &format!("profile-{i:04}-1-1.tsv"), "x");
351        }
352        let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 3).unwrap();
353        assert_eq!(removed, 7);
354        let remaining: Vec<String> = env
355            .fs
356            .read_dir(&env.paths.probes_shell_init_dir())
357            .unwrap()
358            .into_iter()
359            .map(|e| e.name)
360            .collect();
361        // The three highest-numbered (newest) files survive.
362        assert_eq!(
363            remaining,
364            vec![
365                "profile-0007-1-1.tsv".to_string(),
366                "profile-0008-1-1.tsv".to_string(),
367                "profile-0009-1-1.tsv".to_string(),
368            ]
369        );
370    }
371
372    #[test]
373    fn rotate_with_keep_zero_is_a_noop() {
374        // Defensive: a misconfigured keep_last_runs = 0 must not wipe
375        // the user's profile history. Treat as "no rotation".
376        let env = TempEnvironment::builder().build();
377        for i in 0..3 {
378            write_profile(&env, &format!("profile-{i}-1-1.tsv"), "x");
379        }
380        let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 0).unwrap();
381        assert_eq!(removed, 0);
382        let count = env
383            .fs
384            .read_dir(&env.paths.probes_shell_init_dir())
385            .unwrap()
386            .len();
387        assert_eq!(count, 3);
388    }
389
390    #[test]
391    fn rotate_below_threshold_is_a_noop() {
392        let env = TempEnvironment::builder().build();
393        write_profile(&env, "profile-1-1-1.tsv", "x");
394        let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 100).unwrap();
395        assert_eq!(removed, 0);
396    }
397
398    #[test]
399    fn rotate_ignores_non_profile_files() {
400        let env = TempEnvironment::builder().build();
401        let dir = env.paths.probes_shell_init_dir();
402        env.fs.mkdir_all(&dir).unwrap();
403        // Five profile files (more than `keep`), plus two non-profile
404        // files that the rotator must leave alone.
405        for i in 1..=5 {
406            env.fs
407                .write_file(&dir.join(format!("profile-{i}-1-1.tsv")), b"")
408                .unwrap();
409        }
410        env.fs
411            .write_file(&dir.join("README"), b"do not delete")
412            .unwrap();
413        env.fs
414            .write_file(&dir.join("notes.txt"), b"keep me")
415            .unwrap();
416
417        // keep=2 forces the pruning path (5 profiles → 2 should remain).
418        let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 2).unwrap();
419        assert_eq!(removed, 3);
420
421        // The two newest profiles survive.
422        assert!(env.fs.exists(&dir.join("profile-4-1-1.tsv")));
423        assert!(env.fs.exists(&dir.join("profile-5-1-1.tsv")));
424        // The three oldest are gone.
425        assert!(!env.fs.exists(&dir.join("profile-1-1-1.tsv")));
426        assert!(!env.fs.exists(&dir.join("profile-2-1-1.tsv")));
427        assert!(!env.fs.exists(&dir.join("profile-3-1-1.tsv")));
428
429        // Non-profile files are untouched.
430        assert!(env.fs.exists(&dir.join("README")));
431        assert!(env.fs.exists(&dir.join("notes.txt")));
432    }
433
434    #[test]
435    fn group_profile_aggregates_by_pack_handler() {
436        let p = Profile {
437            filename: "x".into(),
438            shell: "bash".into(),
439            total_duration_us: 10_000,
440            entries: vec![
441                ProfileEntry {
442                    phase: "source".into(),
443                    pack: "vim".into(),
444                    handler: "shell".into(),
445                    target: "/a".into(),
446                    duration_us: 100,
447                    exit_status: 0,
448                },
449                ProfileEntry {
450                    phase: "source".into(),
451                    pack: "vim".into(),
452                    handler: "shell".into(),
453                    target: "/b".into(),
454                    duration_us: 200,
455                    exit_status: 0,
456                },
457                ProfileEntry {
458                    phase: "path".into(),
459                    pack: "vim".into(),
460                    handler: "path".into(),
461                    target: "/bin".into(),
462                    duration_us: 5,
463                    exit_status: 0,
464                },
465            ],
466        };
467        let g = group_profile(&p);
468        assert_eq!(g.groups.len(), 2);
469        // Groups are sorted by (pack, handler), not by emission order:
470        // "path" comes before "shell" alphabetically within `vim`.
471        assert_eq!(g.groups[0].pack, "vim");
472        assert_eq!(g.groups[0].handler, "path");
473        assert_eq!(g.groups[0].group_total_us, 5);
474        assert_eq!(g.groups[1].handler, "shell");
475        assert_eq!(g.groups[1].group_total_us, 300);
476        assert_eq!(g.user_total_us, 305);
477        assert_eq!(g.total_us, 10_000);
478        assert_eq!(g.framing_us, 9_695);
479    }
480
481    #[test]
482    fn group_profile_sorts_across_packs() {
483        // Entries arrive in deliberately scrambled order; the result
484        // must still be (pack, handler)-sorted.
485        let p = Profile {
486            filename: "x".into(),
487            shell: "bash".into(),
488            total_duration_us: 0,
489            entries: vec![
490                entry("vim", "shell", "/a", 1),
491                entry("git", "symlink", "/b", 1),
492                entry("vim", "path", "/c", 1),
493                entry("git", "shell", "/d", 1),
494            ],
495        };
496        let g = group_profile(&p);
497        let keys: Vec<(String, String)> = g
498            .groups
499            .iter()
500            .map(|gp| (gp.pack.clone(), gp.handler.clone()))
501            .collect();
502        assert_eq!(
503            keys,
504            vec![
505                ("git".into(), "shell".into()),
506                ("git".into(), "symlink".into()),
507                ("vim".into(), "path".into()),
508                ("vim".into(), "shell".into()),
509            ]
510        );
511    }
512
513    fn entry(pack: &str, handler: &str, target: &str, dur_us: u64) -> ProfileEntry {
514        ProfileEntry {
515            phase: "source".into(),
516            pack: pack.into(),
517            handler: handler.into(),
518            target: target.into(),
519            duration_us: dur_us,
520            exit_status: 0,
521        }
522    }
523
524    #[test]
525    fn group_profile_clamps_framing_when_total_below_entries() {
526        // If `total_duration_us` is missing (parse left it at 0) but
527        // we have entries, framing_us must be 0 — not negative.
528        let p = Profile {
529            filename: "x".into(),
530            shell: "".into(),
531            total_duration_us: 0,
532            entries: vec![ProfileEntry {
533                phase: "source".into(),
534                pack: "vim".into(),
535                handler: "shell".into(),
536                target: "/a".into(),
537                duration_us: 500,
538                exit_status: 0,
539            }],
540        };
541        let g = group_profile(&p);
542        assert_eq!(g.user_total_us, 500);
543        assert_eq!(g.total_us, 500);
544        assert_eq!(g.framing_us, 0);
545    }
546}