Skip to main content

dodot_lib/commands/
probe.rs

1//! `probe` command family — introspection subcommands.
2//!
3//! Today there are three: `deployment-map`, `show-data-dir`, and the
4//! bare `probe` (which renders a summary pointing at the others). A
5//! later phase adds `shell-init` (timing reports). See
6//! `docs/proposals/profiling.lex`.
7//!
8//! All variants serialize through a single `ProbeResult` enum that is
9//! `#[serde(tag = "kind")]`-tagged; the matching Jinja template
10//! branches on `kind` to pick the right section.
11
12use serde::Serialize;
13
14use crate::packs::orchestration::ExecutionContext;
15use crate::probe::{
16    aggregate_profiles, collect_data_dir_tree, collect_deployment_map, group_profile,
17    parse_unix_ts_from_filename, read_last_up_marker, read_latest_profile, read_recent_profiles,
18    summarize_history, AggregatedTarget, DeploymentMapEntry, GroupedProfile, HistoryEntry,
19    TreeNode,
20};
21use crate::Result;
22
23/// Default max depth for `probe show-data-dir`. Enough to show
24/// `packs / <pack> / <handler> / <entry>` without scrolling off
25/// screen for reasonable installs; deeper subtrees are summarised.
26pub const DEFAULT_SHOW_DATA_DIR_DEPTH: usize = 4;
27
28/// Display-shaped deployment-map row. Paths are pre-shortened to
29/// `~/…` where they live under HOME so the rendered table stays
30/// narrow; the machine-readable TSV on disk keeps absolute paths.
31#[derive(Debug, Clone, Serialize)]
32pub struct DeploymentDisplayEntry {
33    pub pack: String,
34    pub handler: String,
35    pub kind: String,
36    /// Pre-shortened (`~/…`) absolute source path; empty for
37    /// non-symlink entries (sentinels, rendered files).
38    pub source: String,
39    /// Pre-shortened absolute datastore path.
40    pub datastore: String,
41}
42
43/// One line of tree output, pre-flattened for the template.
44///
45/// The template for a tree is annoying to write directly in Jinja
46/// (indentation, prefix characters, etc.), so we flatten the tree to
47/// a list of `(indent, name, annotation)` triples here.
48#[derive(Debug, Clone, Serialize)]
49pub struct TreeLine {
50    /// Indent prefix (e.g. `"  │  ├─ "`).
51    pub prefix: String,
52    /// The node's display name (basename for non-root nodes).
53    pub name: String,
54    /// A dim-styled annotation shown after the name (size, link target,
55    /// truncation count). Empty when the node has nothing extra to say.
56    pub annotation: String,
57}
58
59/// Result of any `probe` invocation. Serialises with a `kind` tag so
60/// the Jinja template can dispatch on it.
61#[derive(Debug, Clone, Serialize)]
62#[serde(tag = "kind", rename_all = "kebab-case")]
63pub enum ProbeResult {
64    /// `dodot probe` with no subcommand — a summary pointing the user
65    /// at the real subcommands.
66    Summary {
67        data_dir: String,
68        available: Vec<ProbeSubcommandInfo>,
69    },
70    /// `dodot probe deployment-map` — the source↔deployed map.
71    DeploymentMap {
72        data_dir: String,
73        map_path: String,
74        entries: Vec<DeploymentDisplayEntry>,
75    },
76    /// `dodot probe show-data-dir` — a bounded tree view of
77    /// `<data_dir>`.
78    ShowDataDir {
79        data_dir: String,
80        /// Flattened, template-ready lines.
81        lines: Vec<TreeLine>,
82        total_nodes: usize,
83        /// Size in bytes of the whole tree (symlinks counted by their
84        /// link-entry size).
85        total_size: u64,
86    },
87    /// `dodot probe shell-init` — the most recent shell-startup profile,
88    /// grouped by pack and handler.
89    ShellInit(ShellInitView),
90    /// `dodot probe shell-init --runs N` — per-target percentile stats
91    /// across the last N runs.
92    ShellInitAggregate(ShellInitAggregateView),
93    /// `dodot probe shell-init --history` — one summary line per recent
94    /// run, newest first (matches every other dated listing in the tool;
95    /// the user can pipe through `tac` if they want the inverse).
96    ShellInitHistory(ShellInitHistoryView),
97    /// `dodot probe shell-init <pack>[/<file>]` — drill-down view of
98    /// one target (or one pack) across recent runs. Emits per-run
99    /// duration, exit status, and captured stderr (when any) so the
100    /// user can pinpoint *what* a failing source file printed.
101    ShellInitFilter(ShellInitFilterView),
102    /// `dodot probe shell-init --errors-only` — every target with at
103    /// least one non-zero exit across the examined window, grouped by
104    /// target and sorted by failure count (most-broken first).
105    ShellInitErrors(ShellInitErrorsView),
106}
107
108/// Display payload for `--runs N`.
109#[derive(Debug, Clone, Serialize)]
110pub struct ShellInitAggregateView {
111    /// How many profiles were actually loaded (may be smaller than the
112    /// requested N if there aren't enough on disk yet).
113    pub runs: usize,
114    /// User-requested N (echoed back so the renderer can say
115    /// "showing 4 of last 10 requested").
116    pub requested_runs: usize,
117    pub profiling_enabled: bool,
118    pub profiles_dir: String,
119    pub rows: Vec<ShellInitAggregateRow>,
120    /// True when the newest aggregated profile was captured before the
121    /// most recent `dodot up`. The renderer prints a freshness banner
122    /// in that case so the user knows to open a new shell.
123    pub stale: bool,
124    /// `YYYY-MM-DD HH:MM` capture time of the newest aggregated
125    /// profile; empty when no profiles were loaded.
126    pub latest_profile_when: String,
127    /// `YYYY-MM-DD HH:MM` of the most recent `dodot up`; empty when
128    /// `up` has never run on this machine.
129    pub last_up_when: String,
130}
131
132/// One per-target aggregate row, durations pre-humanised for the
133/// template.
134#[derive(Debug, Clone, Serialize)]
135pub struct ShellInitAggregateRow {
136    pub pack: String,
137    pub handler: String,
138    pub target: String,
139    pub p50_label: String,
140    pub p95_label: String,
141    pub max_label: String,
142    pub p50_us: u64,
143    pub p95_us: u64,
144    pub max_us: u64,
145    /// e.g. `"7/10"` — formatted at the lib so JSON consumers and the
146    /// template both render identically.
147    pub seen_label: String,
148    pub runs_seen: usize,
149    pub runs_total: usize,
150}
151
152/// Display payload for `--history`.
153#[derive(Debug, Clone, Serialize)]
154pub struct ShellInitHistoryView {
155    pub profiling_enabled: bool,
156    pub profiles_dir: String,
157    pub rows: Vec<ShellInitHistoryRow>,
158    /// True when the newest row was captured before the most recent
159    /// `dodot up`. Older rows in the history are obviously older —
160    /// they're not flagged individually.
161    pub stale: bool,
162    /// `YYYY-MM-DD HH:MM` capture time of the newest history row;
163    /// empty when no profiles exist.
164    pub latest_profile_when: String,
165    /// `YYYY-MM-DD HH:MM` of the most recent `dodot up`; empty when
166    /// `up` has never run on this machine.
167    pub last_up_when: String,
168}
169
170/// One per-run row in `--history`.
171#[derive(Debug, Clone, Serialize)]
172pub struct ShellInitHistoryRow {
173    /// Filename of the underlying TSV — useful for cross-reference and
174    /// keeps history rows traceable to the on-disk artefact.
175    pub filename: String,
176    /// Unix timestamp parsed from the filename (or `0` when the
177    /// filename doesn't follow the expected pattern). Surfaced in JSON
178    /// so machine consumers can do their own date math without
179    /// re-parsing `filename`.
180    pub unix_ts: u64,
181    /// Compact `YYYY-MM-DD HH:MM` formatted from the unix timestamp in
182    /// the filename. Empty when the timestamp couldn't be parsed.
183    pub when: String,
184    pub shell: String,
185    pub total_label: String,
186    pub user_total_label: String,
187    pub total_us: u64,
188    pub user_total_us: u64,
189    pub failed_entries: usize,
190    pub entry_count: usize,
191}
192
193/// Display payload for `probe shell-init`. Pulled into its own struct
194/// so the JSON view stays clean and the variant constructor in
195/// `shell_init()` reads naturally.
196#[derive(Debug, Clone, Serialize)]
197pub struct ShellInitView {
198    /// Source filename of the report (for "which run is this?" UX).
199    /// Empty when no profile has been written yet.
200    pub filename: String,
201    /// Shell label as recorded in the preamble (e.g. `bash 5.3.9`).
202    pub shell: String,
203    /// True when the profiling wrapper is enabled in config.
204    pub profiling_enabled: bool,
205    /// True when the directory exists and contained a parseable file.
206    pub has_profile: bool,
207    /// Pre-grouped rows for the template; empty when `has_profile` is
208    /// false.
209    pub groups: Vec<ShellInitGroup>,
210    pub user_total_us: u64,
211    pub framing_us: u64,
212    pub total_us: u64,
213    /// Where the profiles live on disk (so the user can `ls` it).
214    pub profiles_dir: String,
215    /// True when the displayed profile was captured before the most
216    /// recent `dodot up`. The renderer prints a freshness banner so
217    /// the user knows the timings reflect a pre-up shell.
218    pub stale: bool,
219    /// `YYYY-MM-DD HH:MM` capture time of the displayed profile;
220    /// empty when no profile is available.
221    pub profile_when: String,
222    /// `YYYY-MM-DD HH:MM` of the most recent `dodot up`; empty when
223    /// `up` has never run on this machine.
224    pub last_up_when: String,
225}
226
227/// Display row for one entry in a shell-init group.
228#[derive(Debug, Clone, Serialize)]
229pub struct ShellInitRow {
230    pub target: String,
231    pub duration_us: u64,
232    pub duration_label: String,
233    pub exit_status: i32,
234    /// `"deployed"` (success — rendered green) or `"error"` (non-zero
235    /// source exit). These map directly to existing styles in
236    /// `crate::render`'s theme; using fresh names here would require
237    /// theme additions for no UX gain.
238    pub status_class: &'static str,
239}
240
241/// Display group: one (pack, handler) bucket of shell-init rows.
242#[derive(Debug, Clone, Serialize)]
243pub struct ShellInitGroup {
244    pub pack: String,
245    pub handler: String,
246    pub rows: Vec<ShellInitRow>,
247    pub group_total_us: u64,
248    pub group_total_label: String,
249}
250
251/// Display payload for the filtered drill-down view.
252///
253/// Renders per-run history of one target (when the filter narrows to a
254/// single file) or every target in a pack across recent runs. Emits the
255/// captured stderr inline so the user can see exactly what each failing
256/// source printed without leaving the terminal.
257#[derive(Debug, Clone, Serialize)]
258pub struct ShellInitFilterView {
259    pub profiling_enabled: bool,
260    pub profiles_dir: String,
261    /// Filter as the user typed it — echoed in the header.
262    pub filter: String,
263    /// Pack portion of the filter (always set).
264    pub filter_pack: String,
265    /// Filename portion of the filter, if any (the part after `/`).
266    pub filter_filename: Option<String>,
267    /// Number of profiles examined.
268    pub runs_examined: usize,
269    /// One block per matching target. When the filter is a specific
270    /// file, this contains at most one block. When it's a pack-only
271    /// filter, one block per target seen in the pack across the
272    /// examined runs.
273    pub targets: Vec<ShellInitFilterTarget>,
274    pub stale: bool,
275    pub latest_profile_when: String,
276    pub last_up_when: String,
277}
278
279/// One target's runs across the examined window.
280#[derive(Debug, Clone, Serialize)]
281pub struct ShellInitFilterTarget {
282    /// Full source path as recorded in the profile.
283    pub target: String,
284    /// Basename for header display.
285    pub display_target: String,
286    /// Pack the target belongs to.
287    pub pack: String,
288    /// Handler (`shell` for sourced files, `path` for PATH exports).
289    pub handler: String,
290    /// Per-run rows, newest first.
291    pub runs: Vec<ShellInitFilterRun>,
292    /// How many of `runs` had a non-zero exit status.
293    pub failure_count: usize,
294}
295
296/// Display payload for `--errors-only`. Same shape as the filter view
297/// minus the user-typed filter string — the implicit filter is "non-
298/// zero exit, any pack, any target".
299#[derive(Debug, Clone, Serialize)]
300pub struct ShellInitErrorsView {
301    pub profiling_enabled: bool,
302    pub profiles_dir: String,
303    pub runs_examined: usize,
304    /// Targets with at least one failed run in the window, sorted by
305    /// failure count desc (then by pack/target asc as a tiebreaker so
306    /// the order is stable across runs with the same counts).
307    pub targets: Vec<ShellInitFilterTarget>,
308    pub stale: bool,
309    pub latest_profile_when: String,
310    pub last_up_when: String,
311}
312
313/// One per-run row inside a target block.
314#[derive(Debug, Clone, Serialize)]
315pub struct ShellInitFilterRun {
316    /// `YYYY-MM-DD HH:MM` of the run.
317    pub when: String,
318    /// Pre-humanised duration label (e.g. `"83 µs"`).
319    pub duration_label: String,
320    pub duration_us: u64,
321    pub exit_status: i32,
322    /// `"deployed"` (success) or `"error"` (non-zero exit) — maps to
323    /// the same theme styles used by the unfiltered view.
324    pub status_class: &'static str,
325    /// Captured stderr split into individual lines. Empty when the
326    /// source printed nothing to stderr in this run. Pre-split because
327    /// the template engine doesn't expose a `.split()` filter, and
328    /// rendering each line with its own indent is cleaner than fighting
329    /// the template language.
330    pub stderr_lines: Vec<String>,
331    /// Source TSV filename, for cross-reference.
332    pub profile_filename: String,
333}
334
335/// One entry in the `probe` summary listing.
336#[derive(Debug, Clone, Serialize)]
337pub struct ProbeSubcommandInfo {
338    pub name: &'static str,
339    pub description: &'static str,
340}
341
342/// The full list of probe subcommands, used by the summary view.
343/// Keeping them in one array keeps the CLI registration, clap
344/// registration, and summary output trivially in sync.
345pub const PROBE_SUBCOMMANDS: &[ProbeSubcommandInfo] = &[
346    ProbeSubcommandInfo {
347        name: "deployment-map",
348        description: "Source↔deployed map — what dodot linked where.",
349    },
350    ProbeSubcommandInfo {
351        name: "shell-init",
352        description: "Per-source timings for the most recent shell startup.",
353    },
354    ProbeSubcommandInfo {
355        name: "show-data-dir",
356        description: "Tree of dodot's data directory, with sizes.",
357    },
358];
359
360// ── Entry points ────────────────────────────────────────────────────
361
362/// Render the bare `dodot probe` summary.
363pub fn summary(ctx: &ExecutionContext) -> Result<ProbeResult> {
364    Ok(ProbeResult::Summary {
365        data_dir: ctx.paths.data_dir().display().to_string(),
366        available: PROBE_SUBCOMMANDS.to_vec(),
367    })
368}
369
370/// Render the deployment map for display.
371///
372/// Reads the current datastore state (not the on-disk TSV) so the
373/// output is always fresh even if the user never ran `dodot up`.
374pub fn deployment_map(ctx: &ExecutionContext) -> Result<ProbeResult> {
375    let raw = collect_deployment_map(ctx.fs.as_ref(), ctx.paths.as_ref())?;
376    let home = ctx.paths.home_dir();
377    let entries = raw
378        .into_iter()
379        .map(|e| into_display_entry(e, home))
380        .collect();
381
382    Ok(ProbeResult::DeploymentMap {
383        data_dir: ctx.paths.data_dir().display().to_string(),
384        map_path: ctx.paths.deployment_map_path().display().to_string(),
385        entries,
386    })
387}
388
389/// Render the most recent shell-init profile.
390///
391/// When no profile has been written yet (fresh install, or profiling
392/// disabled, or the user hasn't started a shell since the last `up`),
393/// returns a "no data" view with `has_profile = false`. The template
394/// uses that flag to print a hint instead of an empty table.
395pub fn shell_init(ctx: &ExecutionContext) -> Result<ProbeResult> {
396    let root_config = ctx.config_manager.root_config()?;
397    let profiling_enabled = root_config.profiling.enabled;
398
399    let profile_opt = read_latest_profile(ctx.fs.as_ref(), ctx.paths.as_ref())?;
400    let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
401    let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
402    let last_up_when = last_up_ts.map(format_unix_ts).unwrap_or_default();
403
404    let view = match profile_opt {
405        Some(profile) => {
406            let grouped = group_profile(&profile);
407            let profile_ts = parse_unix_ts_from_filename(&profile.filename);
408            let stale = is_stale(profile_ts, last_up_ts);
409            ShellInitView {
410                filename: profile.filename.clone(),
411                shell: profile.shell.clone(),
412                profiling_enabled,
413                has_profile: true,
414                groups: shell_init_groups(&grouped),
415                user_total_us: grouped.user_total_us,
416                framing_us: grouped.framing_us,
417                total_us: grouped.total_us,
418                profiles_dir,
419                stale,
420                profile_when: format_unix_ts(profile_ts),
421                last_up_when,
422            }
423        }
424        None => ShellInitView {
425            filename: String::new(),
426            shell: String::new(),
427            profiling_enabled,
428            has_profile: false,
429            groups: Vec::new(),
430            user_total_us: 0,
431            framing_us: 0,
432            total_us: 0,
433            profiles_dir,
434            stale: false,
435            profile_when: String::new(),
436            last_up_when,
437        },
438    };
439
440    Ok(ProbeResult::ShellInit(view))
441}
442
443/// Decide whether a profile timestamp predates the last `dodot up`.
444/// Returns false when either timestamp is unknown — we never warn on
445/// guesswork, only when we have both reference points.
446fn is_stale(profile_ts: u64, last_up_ts: Option<u64>) -> bool {
447    matches!(last_up_ts, Some(last) if profile_ts > 0 && profile_ts < last)
448}
449
450fn shell_init_groups(grouped: &GroupedProfile) -> Vec<ShellInitGroup> {
451    grouped
452        .groups
453        .iter()
454        .map(|g| ShellInitGroup {
455            pack: g.pack.clone(),
456            handler: g.handler.clone(),
457            rows: g
458                .rows
459                .iter()
460                .map(|r| ShellInitRow {
461                    target: short_target(&r.target),
462                    duration_us: r.duration_us,
463                    duration_label: humanize_us(r.duration_us),
464                    exit_status: r.exit_status,
465                    status_class: if r.exit_status == 0 {
466                        "deployed"
467                    } else {
468                        "error"
469                    },
470                })
471                .collect(),
472            group_total_us: g.group_total_us,
473            group_total_label: humanize_us(g.group_total_us),
474        })
475        .collect()
476}
477
478/// Display-friendly basename for a target path. The fully-qualified
479/// path is in the on-disk profile already; the rendered table is
480/// narrow.
481fn short_target(target: &str) -> String {
482    std::path::Path::new(target)
483        .file_name()
484        .map(|n| n.to_string_lossy().into_owned())
485        .unwrap_or_else(|| target.to_string())
486}
487
488/// Compact human duration: "0 µs" / "1.2 ms" / "350 ms" / "1.4 s".
489pub fn humanize_us(us: u64) -> String {
490    if us < 1_000 {
491        format!("{us} µs")
492    } else if us < 1_000_000 {
493        format!("{:.1} ms", us as f64 / 1_000.0)
494    } else {
495        format!("{:.2} s", us as f64 / 1_000_000.0)
496    }
497}
498
499/// Aggregate the last `runs` profiles into per-target percentile stats.
500///
501/// The CLI applies a default of 10 when the user passes `--runs`
502/// without a value (see `clap`'s `default_missing_value` in
503/// `dodot-cli/src/main.rs`); this function takes the resolved count
504/// directly so it stays useful from external callers (tests, custom
505/// harnesses) that pick their own N.
506pub fn shell_init_aggregate(ctx: &ExecutionContext, runs: usize) -> Result<ProbeResult> {
507    let root_config = ctx.config_manager.root_config()?;
508    let profiling_enabled = root_config.profiling.enabled;
509    let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
510    // read_recent_profiles returns newest-first, so the first entry's
511    // filename is the most recent capture.
512    let latest_profile_ts = profiles
513        .first()
514        .map(|p| parse_unix_ts_from_filename(&p.filename))
515        .unwrap_or(0);
516    let view = aggregate_profiles(&profiles);
517    let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
518    let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
519
520    Ok(ProbeResult::ShellInitAggregate(ShellInitAggregateView {
521        runs: view.runs,
522        requested_runs: runs,
523        profiling_enabled,
524        profiles_dir,
525        rows: view.targets.into_iter().map(into_aggregate_row).collect(),
526        stale: is_stale(latest_profile_ts, last_up_ts),
527        latest_profile_when: format_unix_ts(latest_profile_ts),
528        last_up_when: last_up_ts.map(format_unix_ts).unwrap_or_default(),
529    }))
530}
531
532fn into_aggregate_row(t: AggregatedTarget) -> ShellInitAggregateRow {
533    ShellInitAggregateRow {
534        pack: t.pack,
535        handler: t.handler,
536        target: short_target(&t.target),
537        p50_label: humanize_us(t.p50_us),
538        p95_label: humanize_us(t.p95_us),
539        max_label: humanize_us(t.max_us),
540        p50_us: t.p50_us,
541        p95_us: t.p95_us,
542        max_us: t.max_us,
543        seen_label: format!("{}/{}", t.runs_seen, t.runs_total),
544        runs_seen: t.runs_seen,
545        runs_total: t.runs_total,
546    }
547}
548
549/// Default cap on the number of history rows emitted, so a user with
550/// hundreds of profiles doesn't get a page-filling race down their
551/// terminal.
552pub const DEFAULT_HISTORY_LIMIT: usize = 50;
553
554/// Default window for the filtered drill-down view. Wider than
555/// `RUNTIME_FAILURE_WINDOW` (used by `status`) so a user looking at
556/// `dodot probe shell-init <file>` gets enough history to see whether
557/// the failure is recurring or one-off, but bounded so the rendered
558/// output stays readable.
559pub const DEFAULT_FILTER_RUNS: usize = 20;
560
561/// Match a profile target path against a filename-or-subpath filter.
562///
563/// Returns true when:
564/// - the filter is a bare basename (`env.sh`) and `target`'s last path
565///   component equals it, or
566/// - the filter is a subpath (`subdir/env.sh`) and `target` ends with
567///   that subpath at a path boundary.
568///
569/// The boundary check (`/{filter}` suffix) prevents `env.sh` from
570/// matching `nvenv.sh` or other filenames that happen to end with the
571/// same characters.
572fn target_matches_filter(target: &str, filter: &str) -> bool {
573    if !filter.contains('/') {
574        return std::path::Path::new(target)
575            .file_name()
576            .is_some_and(|s| s == std::ffi::OsStr::new(filter));
577    }
578    // Subpath form: must end at a path boundary so `dir/env.sh` doesn't
579    // accidentally match `otherdir/env.sh`.
580    target.ends_with(&format!("/{filter}")) || target == filter
581}
582
583/// Render the filtered drill-down view for a `<pack>[/<file>]` filter.
584///
585/// `runs` controls how many recent profiles are examined; the caller
586/// passes [`DEFAULT_FILTER_RUNS`] unless it has a specific reason to
587/// look further or fewer.
588pub fn shell_init_filter(ctx: &ExecutionContext, filter: &str, runs: usize) -> Result<ProbeResult> {
589    let root_config = ctx.config_manager.root_config()?;
590    let profiling_enabled = root_config.profiling.enabled;
591    let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
592    let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
593    let last_up_when = last_up_ts.map(format_unix_ts).unwrap_or_default();
594
595    // Filter parsing: `pack` or `pack/file`. Trim a leading `./` and a
596    // trailing `/` defensively so users can paste tab-completed paths.
597    let trimmed = filter.trim().trim_start_matches("./").trim_end_matches('/');
598    let (filter_pack, filter_filename) = match trimmed.split_once('/') {
599        Some((p, f)) if !p.is_empty() && !f.is_empty() => (p.to_string(), Some(f.to_string())),
600        _ => (trimmed.to_string(), None),
601    };
602
603    let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
604    let latest_profile_ts = profiles
605        .first()
606        .map(|p| parse_unix_ts_from_filename(&p.filename))
607        .unwrap_or(0);
608
609    // Bucket per `(pack, handler, target)`. Order: targets sorted by
610    // path so output is stable; runs within each target stay newest-
611    // first (matching the input slice order).
612    use std::collections::BTreeMap;
613    let mut buckets: BTreeMap<(String, String, String), Vec<ShellInitFilterRun>> = BTreeMap::new();
614
615    for profile in &profiles {
616        let when = format_unix_ts(parse_unix_ts_from_filename(&profile.filename));
617        for entry in &profile.entries {
618            if entry.pack != filter_pack {
619                continue;
620            }
621            if let Some(name) = &filter_filename {
622                if !target_matches_filter(&entry.target, name) {
623                    continue;
624                }
625            }
626            let stderr_lines: Vec<String> = profile
627                .errors
628                .iter()
629                .find(|er| er.target == entry.target)
630                .map(|er| {
631                    er.message
632                        .trim_end()
633                        .lines()
634                        .map(|s| s.to_string())
635                        .collect()
636                })
637                .unwrap_or_default();
638            buckets
639                .entry((
640                    entry.pack.clone(),
641                    entry.handler.clone(),
642                    entry.target.clone(),
643                ))
644                .or_default()
645                .push(ShellInitFilterRun {
646                    when: when.clone(),
647                    duration_us: entry.duration_us,
648                    duration_label: humanize_us(entry.duration_us),
649                    exit_status: entry.exit_status,
650                    status_class: if entry.exit_status == 0 {
651                        "deployed"
652                    } else {
653                        "error"
654                    },
655                    stderr_lines,
656                    profile_filename: profile.filename.clone(),
657                });
658        }
659    }
660
661    let targets: Vec<ShellInitFilterTarget> = buckets
662        .into_iter()
663        .map(|((pack, handler, target), runs_vec)| {
664            let display_target = std::path::Path::new(&target)
665                .file_name()
666                .map(|s| s.to_string_lossy().into_owned())
667                .unwrap_or_else(|| target.clone());
668            let failure_count = runs_vec.iter().filter(|r| r.exit_status != 0).count();
669            ShellInitFilterTarget {
670                target,
671                display_target,
672                pack,
673                handler,
674                runs: runs_vec,
675                failure_count,
676            }
677        })
678        .collect();
679
680    Ok(ProbeResult::ShellInitFilter(ShellInitFilterView {
681        profiling_enabled,
682        profiles_dir,
683        filter: filter.trim().to_string(),
684        filter_pack,
685        filter_filename,
686        runs_examined: profiles.len(),
687        targets,
688        stale: is_stale(latest_profile_ts, last_up_ts),
689        latest_profile_when: format_unix_ts(latest_profile_ts),
690        last_up_when,
691    }))
692}
693
694/// Render the cross-history errors view.
695///
696/// Scans the last `runs` profiles, keeps only entries with non-zero
697/// exit status, groups them by target, and orders by failure count
698/// (most-broken first). The `runs` parameter follows the same window
699/// convention as [`shell_init_filter`].
700pub fn shell_init_errors(ctx: &ExecutionContext, runs: usize) -> Result<ProbeResult> {
701    let root_config = ctx.config_manager.root_config()?;
702    let profiling_enabled = root_config.profiling.enabled;
703    let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
704    let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
705    let last_up_when = last_up_ts.map(format_unix_ts).unwrap_or_default();
706
707    let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
708    let latest_profile_ts = profiles
709        .first()
710        .map(|p| parse_unix_ts_from_filename(&p.filename))
711        .unwrap_or(0);
712
713    use std::collections::BTreeMap;
714    let mut buckets: BTreeMap<(String, String, String), Vec<ShellInitFilterRun>> = BTreeMap::new();
715
716    for profile in &profiles {
717        let when = format_unix_ts(parse_unix_ts_from_filename(&profile.filename));
718        for entry in &profile.entries {
719            // Errors-only: skip clean runs entirely.
720            if entry.exit_status == 0 {
721                continue;
722            }
723            let stderr_lines: Vec<String> = profile
724                .errors
725                .iter()
726                .find(|er| er.target == entry.target)
727                .map(|er| {
728                    er.message
729                        .trim_end()
730                        .lines()
731                        .map(|s| s.to_string())
732                        .collect()
733                })
734                .unwrap_or_default();
735            buckets
736                .entry((
737                    entry.pack.clone(),
738                    entry.handler.clone(),
739                    entry.target.clone(),
740                ))
741                .or_default()
742                .push(ShellInitFilterRun {
743                    when: when.clone(),
744                    duration_us: entry.duration_us,
745                    duration_label: humanize_us(entry.duration_us),
746                    exit_status: entry.exit_status,
747                    status_class: "error",
748                    stderr_lines,
749                    profile_filename: profile.filename.clone(),
750                });
751        }
752    }
753
754    let mut targets: Vec<ShellInitFilterTarget> = buckets
755        .into_iter()
756        .map(|((pack, handler, target), runs_vec)| {
757            let display_target = std::path::Path::new(&target)
758                .file_name()
759                .map(|s| s.to_string_lossy().into_owned())
760                .unwrap_or_else(|| target.clone());
761            let failure_count = runs_vec.len();
762            ShellInitFilterTarget {
763                target,
764                display_target,
765                pack,
766                handler,
767                runs: runs_vec,
768                failure_count,
769            }
770        })
771        .collect();
772
773    // Sort: most-broken first, with a stable (pack, handler, target)
774    // tiebreaker so two targets with the same failure count don't swap
775    // positions across runs.
776    targets.sort_by(|a, b| {
777        b.failure_count
778            .cmp(&a.failure_count)
779            .then_with(|| a.pack.cmp(&b.pack))
780            .then_with(|| a.handler.cmp(&b.handler))
781            .then_with(|| a.target.cmp(&b.target))
782    });
783
784    Ok(ProbeResult::ShellInitErrors(ShellInitErrorsView {
785        profiling_enabled,
786        profiles_dir,
787        runs_examined: profiles.len(),
788        targets,
789        stale: is_stale(latest_profile_ts, last_up_ts),
790        latest_profile_when: format_unix_ts(latest_profile_ts),
791        last_up_when,
792    }))
793}
794
795/// Render the per-run history view (one summary line per profile).
796pub fn shell_init_history(ctx: &ExecutionContext, limit: usize) -> Result<ProbeResult> {
797    let root_config = ctx.config_manager.root_config()?;
798    let profiling_enabled = root_config.profiling.enabled;
799    let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), limit)?;
800    // `read_recent_profiles` already returns newest-first, which is the
801    // order users expect for a history listing (most recent at the top
802    // of the table). Don't reverse.
803    let latest_profile_ts = profiles
804        .first()
805        .map(|p| parse_unix_ts_from_filename(&p.filename))
806        .unwrap_or(0);
807    let history = summarize_history(&profiles);
808    let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
809    let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
810
811    Ok(ProbeResult::ShellInitHistory(ShellInitHistoryView {
812        profiling_enabled,
813        profiles_dir,
814        rows: history.into_iter().map(into_history_row).collect(),
815        stale: is_stale(latest_profile_ts, last_up_ts),
816        latest_profile_when: format_unix_ts(latest_profile_ts),
817        last_up_when: last_up_ts.map(format_unix_ts).unwrap_or_default(),
818    }))
819}
820
821fn into_history_row(h: HistoryEntry) -> ShellInitHistoryRow {
822    ShellInitHistoryRow {
823        filename: h.filename,
824        unix_ts: h.unix_ts,
825        when: format_unix_ts(h.unix_ts),
826        shell: h.shell,
827        total_label: humanize_us(h.total_us),
828        user_total_label: humanize_us(h.user_total_us),
829        total_us: h.total_us,
830        user_total_us: h.user_total_us,
831        failed_entries: h.failed_entries,
832        entry_count: h.entry_count,
833    }
834}
835
836/// Format a unix timestamp as `YYYY-MM-DD HH:MM` in UTC. Returns an
837/// empty string for `0` (parse-failure sentinel) so the renderer can
838/// just print a blank cell.
839///
840/// Does the calendar math by hand to avoid pulling a dep — chrono is
841/// overkill for one display string. Algorithm: Howard Hinnant's
842/// civil_from_days.
843pub fn format_unix_ts(ts: u64) -> String {
844    // 0 is the parse-failure sentinel from `parse_unix_ts_from_filename`;
845    // anything past year 9999 is also nonsense in a shell-startup
846    // profile (the file format itself is the giveaway). Returning an
847    // empty string keeps the renderer predictable even in the face of
848    // a tampered-with filename, and bounds the i64 cast on `days`
849    // safely below i64::MAX regardless of input.
850    const MAX_REASONABLE_TS: u64 = 253_402_300_799; // 9999-12-31T23:59:59 UTC.
851    if ts == 0 || ts > MAX_REASONABLE_TS {
852        return String::new();
853    }
854    let secs_per_day: u64 = 86_400;
855    let days = (ts / secs_per_day) as i64; // safe: ts < 2.5e11 → days < 3e6
856    let secs_of_day = ts % secs_per_day;
857    let hour = secs_of_day / 3600;
858    let minute = (secs_of_day % 3600) / 60;
859    let (y, m, d) = civil_from_days(days);
860    format!("{y:04}-{m:02}-{d:02} {hour:02}:{minute:02}")
861}
862
863/// Howard Hinnant's `civil_from_days`: convert days since 1970-01-01
864/// (UTC) into `(year, month, day)`. Public-domain algorithm.
865fn civil_from_days(z: i64) -> (i32, u32, u32) {
866    let z = z + 719468;
867    let era = if z >= 0 { z } else { z - 146096 } / 146097;
868    let doe = (z - era * 146097) as u64; // [0, 146096]
869    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
870    let y = yoe as i64 + era * 400;
871    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
872    let mp = (5 * doy + 2) / 153; // [0, 11]
873    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
874    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
875    let y = if m <= 2 { y + 1 } else { y };
876    (y as i32, m as u32, d as u32)
877}
878
879/// Render the data-dir tree.
880pub fn show_data_dir(ctx: &ExecutionContext, max_depth: usize) -> Result<ProbeResult> {
881    let tree = collect_data_dir_tree(ctx.fs.as_ref(), ctx.paths.as_ref(), max_depth)?;
882    let total_nodes = tree.count_nodes();
883    let total_size = tree.total_size();
884    let mut lines = Vec::new();
885    flatten_tree(&tree, "", true, &mut lines, true);
886    Ok(ProbeResult::ShowDataDir {
887        data_dir: ctx.paths.data_dir().display().to_string(),
888        lines,
889        total_nodes,
890        total_size,
891    })
892}
893
894// ── Display helpers ─────────────────────────────────────────────────
895
896fn into_display_entry(e: DeploymentMapEntry, home: &std::path::Path) -> DeploymentDisplayEntry {
897    DeploymentDisplayEntry {
898        pack: e.pack,
899        handler: e.handler,
900        kind: e.kind.as_str().into(),
901        source: if e.source.as_os_str().is_empty() {
902            // Sentinel / rendered file — no source file backs this entry.
903            // Show an em dash so the column stays populated.
904            "—".into()
905        } else {
906            display_path(&e.source, home)
907        },
908        datastore: display_path(&e.datastore, home),
909    }
910}
911
912fn display_path(p: &std::path::Path, home: &std::path::Path) -> String {
913    if let Ok(rel) = p.strip_prefix(home) {
914        format!("~/{}", rel.display())
915    } else {
916        p.display().to_string()
917    }
918}
919
920/// Flatten a `TreeNode` into [`TreeLine`]s with box-drawing prefixes.
921///
922/// `prefix` is the continuation prefix applied to this node's
923/// descendants (e.g. `"│  "` if this node has more siblings below,
924/// `"   "` if it's the last). `is_last` controls the branch glyph for
925/// this node itself (`"└─ "` vs `"├─ "`). `is_root` skips the branch
926/// glyph on the topmost call so the root displays flush-left.
927fn flatten_tree(
928    node: &TreeNode,
929    prefix: &str,
930    is_last: bool,
931    out: &mut Vec<TreeLine>,
932    is_root: bool,
933) {
934    let branch = if is_root {
935        String::new()
936    } else if is_last {
937        "└─ ".to_string()
938    } else {
939        "├─ ".to_string()
940    };
941    let line_prefix = format!("{prefix}{branch}");
942    out.push(TreeLine {
943        prefix: line_prefix,
944        name: node.name.clone(),
945        annotation: annotate(node),
946    });
947
948    if node.children.is_empty() {
949        return;
950    }
951
952    // Extend the prefix for children. The root contributes no prefix
953    // characters; a last-child contributes "   "; an inner child
954    // contributes "│  ".
955    let child_prefix = if is_root {
956        String::new()
957    } else if is_last {
958        format!("{prefix}   ")
959    } else {
960        format!("{prefix}│  ")
961    };
962
963    let last_idx = node.children.len() - 1;
964    for (i, child) in node.children.iter().enumerate() {
965        flatten_tree(child, &child_prefix, i == last_idx, out, false);
966    }
967}
968
969fn annotate(node: &TreeNode) -> String {
970    match node.kind {
971        "dir" => match node.truncated_count {
972            Some(n) if n > 0 => format!("(… {n} more)"),
973            _ => String::new(),
974        },
975        "file" => match node.size {
976            Some(n) => humanize_bytes(n),
977            None => String::new(),
978        },
979        "symlink" => match &node.link_target {
980            Some(t) => format!("→ {t}"),
981            None => "→ (broken)".into(),
982        },
983        _ => String::new(),
984    }
985}
986
987/// Compact byte sizing: "512 B", "1.2 KB", "3.4 MB".
988///
989/// KB = 1024 bytes. No fractional KB below 1024.
990pub fn humanize_bytes(n: u64) -> String {
991    const KB: u64 = 1024;
992    const MB: u64 = KB * 1024;
993    const GB: u64 = MB * 1024;
994    if n < KB {
995        format!("{n} B")
996    } else if n < MB {
997        format!("{:.1} KB", n as f64 / KB as f64)
998    } else if n < GB {
999        format!("{:.1} MB", n as f64 / MB as f64)
1000    } else {
1001        format!("{:.1} GB", n as f64 / GB as f64)
1002    }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008    use crate::probe::{DeploymentKind, DeploymentMapEntry};
1009    use std::path::PathBuf;
1010
1011    fn home() -> PathBuf {
1012        PathBuf::from("/home/alice")
1013    }
1014
1015    #[test]
1016    fn display_path_shortens_home() {
1017        assert_eq!(
1018            display_path(&PathBuf::from("/home/alice/dotfiles/vim/rc"), &home()),
1019            "~/dotfiles/vim/rc"
1020        );
1021    }
1022
1023    #[test]
1024    fn display_path_keeps_paths_outside_home() {
1025        assert_eq!(
1026            display_path(&PathBuf::from("/opt/data"), &home()),
1027            "/opt/data"
1028        );
1029    }
1030
1031    #[test]
1032    fn humanize_bytes_boundaries() {
1033        assert_eq!(humanize_bytes(0), "0 B");
1034        assert_eq!(humanize_bytes(1023), "1023 B");
1035        assert_eq!(humanize_bytes(1024), "1.0 KB");
1036        assert_eq!(humanize_bytes(1024 * 1024), "1.0 MB");
1037        assert_eq!(humanize_bytes(1024 * 1024 * 1024), "1.0 GB");
1038    }
1039
1040    #[test]
1041    fn format_unix_ts_handles_zero_and_out_of_range() {
1042        // Sentinel for parse-failure → empty, not a date.
1043        assert_eq!(format_unix_ts(0), "");
1044        // Real timestamp → formatted.
1045        assert_eq!(format_unix_ts(1_714_000_000), "2024-04-24 23:06");
1046        // Past year 9999 → empty (defensive ceiling so a tampered
1047        // filename doesn't produce a nonsense date or risk overflow
1048        // during the i64 cast on `days`).
1049        assert_eq!(format_unix_ts(u64::MAX), "");
1050        assert_eq!(format_unix_ts(253_402_300_800), ""); // 1s past year 9999.
1051                                                         // Right below the ceiling still renders.
1052        assert_eq!(format_unix_ts(253_402_300_799), "9999-12-31 23:59");
1053    }
1054
1055    #[test]
1056    fn into_display_entry_handles_sentinel_source() {
1057        let entry = DeploymentMapEntry {
1058            pack: "nvim".into(),
1059            handler: "install".into(),
1060            kind: DeploymentKind::File,
1061            source: PathBuf::new(),
1062            datastore: PathBuf::from("/home/alice/.local/share/dodot/packs/nvim/install/sent"),
1063        };
1064        let display = into_display_entry(entry, &home());
1065        assert_eq!(display.source, "—");
1066        assert!(display.datastore.starts_with("~/"));
1067    }
1068
1069    #[test]
1070    fn tree_flattening_produces_branch_glyphs() {
1071        // Fabricate a small tree:
1072        //   root
1073        //   ├─ a
1074        //   │  └─ aa
1075        //   └─ b
1076        let tree = TreeNode {
1077            name: "root".into(),
1078            path: PathBuf::from("/root"),
1079            kind: "dir",
1080            size: None,
1081            link_target: None,
1082            truncated_count: None,
1083            children: vec![
1084                TreeNode {
1085                    name: "a".into(),
1086                    path: PathBuf::from("/root/a"),
1087                    kind: "dir",
1088                    size: None,
1089                    link_target: None,
1090                    truncated_count: None,
1091                    children: vec![TreeNode {
1092                        name: "aa".into(),
1093                        path: PathBuf::from("/root/a/aa"),
1094                        kind: "file",
1095                        size: Some(10),
1096                        link_target: None,
1097                        truncated_count: None,
1098                        children: Vec::new(),
1099                    }],
1100                },
1101                TreeNode {
1102                    name: "b".into(),
1103                    path: PathBuf::from("/root/b"),
1104                    kind: "file",
1105                    size: Some(42),
1106                    link_target: None,
1107                    truncated_count: None,
1108                    children: Vec::new(),
1109                },
1110            ],
1111        };
1112        let mut lines = Vec::new();
1113        flatten_tree(&tree, "", true, &mut lines, true);
1114        assert_eq!(lines.len(), 4);
1115        assert_eq!(lines[0].name, "root");
1116        assert_eq!(lines[0].prefix, ""); // root is flush-left
1117        assert_eq!(lines[1].name, "a");
1118        assert!(lines[1].prefix.ends_with("├─ "));
1119        assert_eq!(lines[2].name, "aa");
1120        assert!(lines[2].prefix.ends_with("└─ "));
1121        assert!(lines[2].prefix.starts_with("│")); // parent is not last
1122        assert_eq!(lines[3].name, "b");
1123        assert!(lines[3].prefix.ends_with("└─ "));
1124        assert_eq!(lines[3].annotation, "42 B");
1125    }
1126
1127    #[test]
1128    fn annotate_symlink_with_target() {
1129        let node = TreeNode {
1130            name: "link".into(),
1131            path: PathBuf::from("/x"),
1132            kind: "symlink",
1133            size: Some(20),
1134            link_target: Some("/target".into()),
1135            truncated_count: None,
1136            children: Vec::new(),
1137        };
1138        assert_eq!(annotate(&node), "→ /target");
1139    }
1140
1141    #[test]
1142    fn annotate_broken_symlink() {
1143        let node = TreeNode {
1144            name: "link".into(),
1145            path: PathBuf::from("/x"),
1146            kind: "symlink",
1147            size: Some(20),
1148            link_target: None,
1149            truncated_count: None,
1150            children: Vec::new(),
1151        };
1152        assert_eq!(annotate(&node), "→ (broken)");
1153    }
1154
1155    #[test]
1156    fn annotate_truncated_dir() {
1157        let node = TreeNode {
1158            name: "deep".into(),
1159            path: PathBuf::from("/x"),
1160            kind: "dir",
1161            size: None,
1162            link_target: None,
1163            truncated_count: Some(7),
1164            children: Vec::new(),
1165        };
1166        assert_eq!(annotate(&node), "(… 7 more)");
1167    }
1168
1169    #[test]
1170    fn probe_result_deployment_map_serialises_with_kind_tag() {
1171        let result = ProbeResult::DeploymentMap {
1172            data_dir: "/d".into(),
1173            map_path: "/d/deployment-map.tsv".into(),
1174            entries: Vec::new(),
1175        };
1176        let json = serde_json::to_value(&result).unwrap();
1177        assert_eq!(json["kind"], "deployment-map");
1178        assert!(json["entries"].is_array());
1179    }
1180
1181    #[test]
1182    fn probe_result_show_data_dir_serialises_with_kind_tag() {
1183        let result = ProbeResult::ShowDataDir {
1184            data_dir: "/d".into(),
1185            lines: Vec::new(),
1186            total_nodes: 1,
1187            total_size: 0,
1188        };
1189        let json = serde_json::to_value(&result).unwrap();
1190        assert_eq!(json["kind"], "show-data-dir");
1191        assert_eq!(json["total_nodes"], 1);
1192    }
1193
1194    #[test]
1195    fn probe_subcommands_list_matches_variants() {
1196        // Failsafe: if we add a probe subcommand to the enum we should
1197        // add it to the summary list too. This assertion catches the
1198        // former getting ahead of the latter.
1199        let names: Vec<&str> = PROBE_SUBCOMMANDS.iter().map(|s| s.name).collect();
1200        assert!(names.contains(&"deployment-map"));
1201        assert!(names.contains(&"show-data-dir"));
1202    }
1203}