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    /// `dodot probe app <pack>` — advisory introspection of macOS
107    /// app-support paths for a single pack: which folder names this
108    /// pack will route to, whether they exist, matching homebrew cask
109    /// metadata, and `.app` bundle / bundle-id pairs from Spotlight.
110    /// See `docs/proposals/macos-paths.lex` §8.4.
111    App(AppProbeView),
112}
113
114/// Display payload for `dodot probe app <pack>`.
115#[derive(Debug, Clone, Serialize)]
116pub struct AppProbeView {
117    pub pack: String,
118    /// Whether the host platform supports the macOS-only probes
119    /// (homebrew cask + Spotlight). On Linux this is `false` and the
120    /// `entries` list reflects only the deterministic info available
121    /// from the resolver — no cask/bundle data.
122    pub macos: bool,
123    /// One row per app-folder name this pack would route to. May be
124    /// empty for a pack with no `_app/`/`force_app`/`app_aliases`
125    /// entries.
126    pub entries: Vec<AppProbeEntry>,
127    /// Sibling-adoption suggestions surfaced from the matching cask's
128    /// zap stanza (e.g. `~/Library/Preferences/<bundle>.plist`).
129    pub suggested_adoptions: Vec<String>,
130}
131
132/// One row per app-support folder a pack will deploy to.
133#[derive(Debug, Clone, Serialize)]
134pub struct AppProbeEntry {
135    /// The destination folder name, e.g. `"Code"`.
136    pub folder: String,
137    /// `<app_support_dir>/<folder>/` path. Always populated, even when
138    /// the folder doesn't exist on disk — the renderer shortens to
139    /// `~/...` for display.
140    pub target_path: String,
141    /// Whether `target_path` exists on the local filesystem.
142    pub target_exists: bool,
143    /// Source rule that produced this folder: `"alias"`, `"force_app"`,
144    /// or `"_app/"`. Drives display.
145    pub source_rule: String,
146    /// Matching homebrew cask token, when found. Always an
147    /// *installed* cask (matching only iterates `brew list --cask
148    /// --versions`); a `Some` value implies "installed". A `None`
149    /// value means either no installed cask declared this folder in
150    /// its zap stanza, or we're not on macOS.
151    pub cask: Option<String>,
152    /// `.app` bundle name derived from cask metadata, e.g.
153    /// `"Visual Studio Code.app"`.
154    pub app_bundle: Option<String>,
155    /// `kMDItemCFBundleIdentifier` for the `.app` bundle, when
156    /// resolvable via `mdls`.
157    pub bundle_id: Option<String>,
158}
159
160/// Display payload for `--runs N`.
161#[derive(Debug, Clone, Serialize)]
162pub struct ShellInitAggregateView {
163    /// How many profiles were actually loaded (may be smaller than the
164    /// requested N if there aren't enough on disk yet).
165    pub runs: usize,
166    /// User-requested N (echoed back so the renderer can say
167    /// "showing 4 of last 10 requested").
168    pub requested_runs: usize,
169    pub profiling_enabled: bool,
170    pub profiles_dir: String,
171    pub rows: Vec<ShellInitAggregateRow>,
172    /// True when the newest aggregated profile was captured before the
173    /// most recent `dodot up`. The renderer prints a freshness banner
174    /// in that case so the user knows to open a new shell.
175    pub stale: bool,
176    /// `YYYY-MM-DD HH:MM` capture time of the newest aggregated
177    /// profile; empty when no profiles were loaded.
178    pub latest_profile_when: String,
179    /// `YYYY-MM-DD HH:MM` of the most recent `dodot up`; empty when
180    /// `up` has never run on this machine.
181    pub last_up_when: String,
182}
183
184/// One per-target aggregate row, durations pre-humanised for the
185/// template.
186#[derive(Debug, Clone, Serialize)]
187pub struct ShellInitAggregateRow {
188    pub pack: String,
189    pub handler: String,
190    pub target: String,
191    pub p50_label: String,
192    pub p95_label: String,
193    pub max_label: String,
194    pub p50_us: u64,
195    pub p95_us: u64,
196    pub max_us: u64,
197    /// e.g. `"7/10"` — formatted at the lib so JSON consumers and the
198    /// template both render identically.
199    pub seen_label: String,
200    pub runs_seen: usize,
201    pub runs_total: usize,
202}
203
204/// Display payload for `--history`.
205#[derive(Debug, Clone, Serialize)]
206pub struct ShellInitHistoryView {
207    pub profiling_enabled: bool,
208    pub profiles_dir: String,
209    pub rows: Vec<ShellInitHistoryRow>,
210    /// True when the newest row was captured before the most recent
211    /// `dodot up`. Older rows in the history are obviously older —
212    /// they're not flagged individually.
213    pub stale: bool,
214    /// `YYYY-MM-DD HH:MM` capture time of the newest history row;
215    /// empty when no profiles exist.
216    pub latest_profile_when: String,
217    /// `YYYY-MM-DD HH:MM` of the most recent `dodot up`; empty when
218    /// `up` has never run on this machine.
219    pub last_up_when: String,
220}
221
222/// One per-run row in `--history`.
223#[derive(Debug, Clone, Serialize)]
224pub struct ShellInitHistoryRow {
225    /// Filename of the underlying TSV — useful for cross-reference and
226    /// keeps history rows traceable to the on-disk artefact.
227    pub filename: String,
228    /// Unix timestamp parsed from the filename (or `0` when the
229    /// filename doesn't follow the expected pattern). Surfaced in JSON
230    /// so machine consumers can do their own date math without
231    /// re-parsing `filename`.
232    pub unix_ts: u64,
233    /// Compact `YYYY-MM-DD HH:MM` formatted from the unix timestamp in
234    /// the filename. Empty when the timestamp couldn't be parsed.
235    pub when: String,
236    pub shell: String,
237    pub total_label: String,
238    pub user_total_label: String,
239    pub total_us: u64,
240    pub user_total_us: u64,
241    pub failed_entries: usize,
242    pub entry_count: usize,
243}
244
245/// Display payload for `probe shell-init`. Pulled into its own struct
246/// so the JSON view stays clean and the variant constructor in
247/// `shell_init()` reads naturally.
248#[derive(Debug, Clone, Serialize)]
249pub struct ShellInitView {
250    /// Source filename of the report (for "which run is this?" UX).
251    /// Empty when no profile has been written yet.
252    pub filename: String,
253    /// Shell label as recorded in the preamble (e.g. `bash 5.3.9`).
254    pub shell: String,
255    /// True when the profiling wrapper is enabled in config.
256    pub profiling_enabled: bool,
257    /// True when the directory exists and contained a parseable file.
258    pub has_profile: bool,
259    /// Pre-grouped rows for the template; empty when `has_profile` is
260    /// false.
261    pub groups: Vec<ShellInitGroup>,
262    pub user_total_us: u64,
263    pub framing_us: u64,
264    pub total_us: u64,
265    /// Where the profiles live on disk (so the user can `ls` it).
266    pub profiles_dir: String,
267    /// True when the displayed profile was captured before the most
268    /// recent `dodot up`. The renderer prints a freshness banner so
269    /// the user knows the timings reflect a pre-up shell.
270    pub stale: bool,
271    /// `YYYY-MM-DD HH:MM` capture time of the displayed profile;
272    /// empty when no profile is available.
273    pub profile_when: String,
274    /// `YYYY-MM-DD HH:MM` of the most recent `dodot up`; empty when
275    /// `up` has never run on this machine.
276    pub last_up_when: String,
277}
278
279/// Display row for one entry in a shell-init group.
280#[derive(Debug, Clone, Serialize)]
281pub struct ShellInitRow {
282    pub target: String,
283    pub duration_us: u64,
284    pub duration_label: String,
285    pub exit_status: i32,
286    /// `"deployed"` (success — rendered green) or `"error"` (non-zero
287    /// source exit). These map directly to existing styles in
288    /// `crate::render`'s theme; using fresh names here would require
289    /// theme additions for no UX gain.
290    pub status_class: &'static str,
291}
292
293/// Display group: one (pack, handler) bucket of shell-init rows.
294#[derive(Debug, Clone, Serialize)]
295pub struct ShellInitGroup {
296    pub pack: String,
297    pub handler: String,
298    pub rows: Vec<ShellInitRow>,
299    pub group_total_us: u64,
300    pub group_total_label: String,
301}
302
303/// Display payload for the filtered drill-down view.
304///
305/// Renders per-run history of one target (when the filter narrows to a
306/// single file) or every target in a pack across recent runs. Emits the
307/// captured stderr inline so the user can see exactly what each failing
308/// source printed without leaving the terminal.
309#[derive(Debug, Clone, Serialize)]
310pub struct ShellInitFilterView {
311    pub profiling_enabled: bool,
312    pub profiles_dir: String,
313    /// Filter as the user typed it — echoed in the header.
314    pub filter: String,
315    /// Pack portion of the filter (always set).
316    pub filter_pack: String,
317    /// Filename portion of the filter, if any (the part after `/`).
318    pub filter_filename: Option<String>,
319    /// Number of profiles examined.
320    pub runs_examined: usize,
321    /// One block per matching target. When the filter is a specific
322    /// file, this contains at most one block. When it's a pack-only
323    /// filter, one block per target seen in the pack across the
324    /// examined runs.
325    pub targets: Vec<ShellInitFilterTarget>,
326    pub stale: bool,
327    pub latest_profile_when: String,
328    pub last_up_when: String,
329}
330
331/// One target's runs across the examined window.
332#[derive(Debug, Clone, Serialize)]
333pub struct ShellInitFilterTarget {
334    /// Full source path as recorded in the profile.
335    pub target: String,
336    /// Basename for header display.
337    pub display_target: String,
338    /// Pack the target belongs to.
339    pub pack: String,
340    /// Handler (`shell` for sourced files, `path` for PATH exports).
341    pub handler: String,
342    /// Per-run rows, newest first.
343    pub runs: Vec<ShellInitFilterRun>,
344    /// How many of `runs` had a non-zero exit status.
345    pub failure_count: usize,
346}
347
348/// Display payload for `--errors-only`. Same shape as the filter view
349/// minus the user-typed filter string — the implicit filter is "non-
350/// zero exit, any pack, any target".
351#[derive(Debug, Clone, Serialize)]
352pub struct ShellInitErrorsView {
353    pub profiling_enabled: bool,
354    pub profiles_dir: String,
355    pub runs_examined: usize,
356    /// Targets with at least one failed run in the window, sorted by
357    /// failure count desc (then by pack/target asc as a tiebreaker so
358    /// the order is stable across runs with the same counts).
359    pub targets: Vec<ShellInitFilterTarget>,
360    pub stale: bool,
361    pub latest_profile_when: String,
362    pub last_up_when: String,
363}
364
365/// One per-run row inside a target block.
366#[derive(Debug, Clone, Serialize)]
367pub struct ShellInitFilterRun {
368    /// `YYYY-MM-DD HH:MM` of the run.
369    pub when: String,
370    /// Pre-humanised duration label (e.g. `"83 µs"`).
371    pub duration_label: String,
372    pub duration_us: u64,
373    pub exit_status: i32,
374    /// `"deployed"` (success) or `"error"` (non-zero exit) — maps to
375    /// the same theme styles used by the unfiltered view.
376    pub status_class: &'static str,
377    /// Captured stderr split into individual lines. Empty when the
378    /// source printed nothing to stderr in this run. Pre-split because
379    /// the template engine doesn't expose a `.split()` filter, and
380    /// rendering each line with its own indent is cleaner than fighting
381    /// the template language.
382    pub stderr_lines: Vec<String>,
383    /// Source TSV filename, for cross-reference.
384    pub profile_filename: String,
385}
386
387/// One entry in the `probe` summary listing.
388#[derive(Debug, Clone, Serialize)]
389pub struct ProbeSubcommandInfo {
390    pub name: &'static str,
391    pub description: &'static str,
392}
393
394/// The full list of probe subcommands, used by the summary view.
395/// Keeping them in one array keeps the CLI registration, clap
396/// registration, and summary output trivially in sync.
397pub const PROBE_SUBCOMMANDS: &[ProbeSubcommandInfo] = &[
398    ProbeSubcommandInfo {
399        name: "deployment-map",
400        description: "Source↔deployed map — what dodot linked where.",
401    },
402    ProbeSubcommandInfo {
403        name: "shell-init",
404        description: "Per-source timings for the most recent shell startup.",
405    },
406    ProbeSubcommandInfo {
407        name: "show-data-dir",
408        description: "Tree of dodot's data directory, with sizes.",
409    },
410];
411
412// ── Entry points ────────────────────────────────────────────────────
413
414/// Render the bare `dodot probe` summary.
415pub fn summary(ctx: &ExecutionContext) -> Result<ProbeResult> {
416    Ok(ProbeResult::Summary {
417        data_dir: ctx.paths.data_dir().display().to_string(),
418        available: PROBE_SUBCOMMANDS.to_vec(),
419    })
420}
421
422/// Render the deployment map for display.
423///
424/// Reads the current datastore state (not the on-disk TSV) so the
425/// output is always fresh even if the user never ran `dodot up`.
426pub fn deployment_map(ctx: &ExecutionContext) -> Result<ProbeResult> {
427    let raw = collect_deployment_map(ctx.fs.as_ref(), ctx.paths.as_ref())?;
428    let home = ctx.paths.home_dir();
429    let entries = raw
430        .into_iter()
431        .map(|e| into_display_entry(e, home))
432        .collect();
433
434    Ok(ProbeResult::DeploymentMap {
435        data_dir: ctx.paths.data_dir().display().to_string(),
436        map_path: ctx.paths.deployment_map_path().display().to_string(),
437        entries,
438    })
439}
440
441/// Render the most recent shell-init profile.
442///
443/// When no profile has been written yet (fresh install, or profiling
444/// disabled, or the user hasn't started a shell since the last `up`),
445/// returns a "no data" view with `has_profile = false`. The template
446/// uses that flag to print a hint instead of an empty table.
447pub fn shell_init(ctx: &ExecutionContext) -> Result<ProbeResult> {
448    let root_config = ctx.config_manager.root_config()?;
449    let profiling_enabled = root_config.profiling.enabled;
450
451    let profile_opt = read_latest_profile(ctx.fs.as_ref(), ctx.paths.as_ref())?;
452    let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
453    let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
454    let last_up_when = last_up_ts.map(format_unix_ts).unwrap_or_default();
455
456    let view = match profile_opt {
457        Some(profile) => {
458            let grouped = group_profile(&profile);
459            let profile_ts = parse_unix_ts_from_filename(&profile.filename);
460            let stale = is_stale(profile_ts, last_up_ts);
461            ShellInitView {
462                filename: profile.filename.clone(),
463                shell: profile.shell.clone(),
464                profiling_enabled,
465                has_profile: true,
466                groups: shell_init_groups(&grouped),
467                user_total_us: grouped.user_total_us,
468                framing_us: grouped.framing_us,
469                total_us: grouped.total_us,
470                profiles_dir,
471                stale,
472                profile_when: format_unix_ts(profile_ts),
473                last_up_when,
474            }
475        }
476        None => ShellInitView {
477            filename: String::new(),
478            shell: String::new(),
479            profiling_enabled,
480            has_profile: false,
481            groups: Vec::new(),
482            user_total_us: 0,
483            framing_us: 0,
484            total_us: 0,
485            profiles_dir,
486            stale: false,
487            profile_when: String::new(),
488            last_up_when,
489        },
490    };
491
492    Ok(ProbeResult::ShellInit(view))
493}
494
495/// Decide whether a profile timestamp predates the last `dodot up`.
496/// Returns false when either timestamp is unknown — we never warn on
497/// guesswork, only when we have both reference points.
498fn is_stale(profile_ts: u64, last_up_ts: Option<u64>) -> bool {
499    matches!(last_up_ts, Some(last) if profile_ts > 0 && profile_ts < last)
500}
501
502fn shell_init_groups(grouped: &GroupedProfile) -> Vec<ShellInitGroup> {
503    grouped
504        .groups
505        .iter()
506        .map(|g| ShellInitGroup {
507            pack: g.pack.clone(),
508            handler: g.handler.clone(),
509            rows: g
510                .rows
511                .iter()
512                .map(|r| ShellInitRow {
513                    target: short_target(&r.target),
514                    duration_us: r.duration_us,
515                    duration_label: humanize_us(r.duration_us),
516                    exit_status: r.exit_status,
517                    status_class: if r.exit_status == 0 {
518                        "deployed"
519                    } else {
520                        "error"
521                    },
522                })
523                .collect(),
524            group_total_us: g.group_total_us,
525            group_total_label: humanize_us(g.group_total_us),
526        })
527        .collect()
528}
529
530/// Display-friendly basename for a target path. The fully-qualified
531/// path is in the on-disk profile already; the rendered table is
532/// narrow.
533fn short_target(target: &str) -> String {
534    std::path::Path::new(target)
535        .file_name()
536        .map(|n| n.to_string_lossy().into_owned())
537        .unwrap_or_else(|| target.to_string())
538}
539
540/// Compact human duration: "0 µs" / "1.2 ms" / "350 ms" / "1.4 s".
541pub fn humanize_us(us: u64) -> String {
542    if us < 1_000 {
543        format!("{us} µs")
544    } else if us < 1_000_000 {
545        format!("{:.1} ms", us as f64 / 1_000.0)
546    } else {
547        format!("{:.2} s", us as f64 / 1_000_000.0)
548    }
549}
550
551/// Aggregate the last `runs` profiles into per-target percentile stats.
552///
553/// The CLI applies a default of 10 when the user passes `--runs`
554/// without a value (see `clap`'s `default_missing_value` in
555/// `dodot-cli/src/main.rs`); this function takes the resolved count
556/// directly so it stays useful from external callers (tests, custom
557/// harnesses) that pick their own N.
558pub fn shell_init_aggregate(ctx: &ExecutionContext, runs: usize) -> Result<ProbeResult> {
559    let root_config = ctx.config_manager.root_config()?;
560    let profiling_enabled = root_config.profiling.enabled;
561    let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
562    // read_recent_profiles returns newest-first, so the first entry's
563    // filename is the most recent capture.
564    let latest_profile_ts = profiles
565        .first()
566        .map(|p| parse_unix_ts_from_filename(&p.filename))
567        .unwrap_or(0);
568    let view = aggregate_profiles(&profiles);
569    let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
570    let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
571
572    Ok(ProbeResult::ShellInitAggregate(ShellInitAggregateView {
573        runs: view.runs,
574        requested_runs: runs,
575        profiling_enabled,
576        profiles_dir,
577        rows: view.targets.into_iter().map(into_aggregate_row).collect(),
578        stale: is_stale(latest_profile_ts, last_up_ts),
579        latest_profile_when: format_unix_ts(latest_profile_ts),
580        last_up_when: last_up_ts.map(format_unix_ts).unwrap_or_default(),
581    }))
582}
583
584fn into_aggregate_row(t: AggregatedTarget) -> ShellInitAggregateRow {
585    ShellInitAggregateRow {
586        pack: t.pack,
587        handler: t.handler,
588        target: short_target(&t.target),
589        p50_label: humanize_us(t.p50_us),
590        p95_label: humanize_us(t.p95_us),
591        max_label: humanize_us(t.max_us),
592        p50_us: t.p50_us,
593        p95_us: t.p95_us,
594        max_us: t.max_us,
595        seen_label: format!("{}/{}", t.runs_seen, t.runs_total),
596        runs_seen: t.runs_seen,
597        runs_total: t.runs_total,
598    }
599}
600
601/// Default cap on the number of history rows emitted, so a user with
602/// hundreds of profiles doesn't get a page-filling race down their
603/// terminal.
604pub const DEFAULT_HISTORY_LIMIT: usize = 50;
605
606/// Default window for the filtered drill-down view. Wider than
607/// `RUNTIME_FAILURE_WINDOW` (used by `status`) so a user looking at
608/// `dodot probe shell-init <file>` gets enough history to see whether
609/// the failure is recurring or one-off, but bounded so the rendered
610/// output stays readable.
611pub const DEFAULT_FILTER_RUNS: usize = 20;
612
613/// Match a profile target path against a filename-or-subpath filter.
614///
615/// Returns true when:
616/// - the filter is a bare basename (`env.sh`) and `target`'s last path
617///   component equals it, or
618/// - the filter is a subpath (`subdir/env.sh`) and `target` ends with
619///   that subpath at a path boundary.
620///
621/// The boundary check (`/{filter}` suffix) prevents `env.sh` from
622/// matching `nvenv.sh` or other filenames that happen to end with the
623/// same characters.
624fn target_matches_filter(target: &str, filter: &str) -> bool {
625    if !filter.contains('/') {
626        return std::path::Path::new(target)
627            .file_name()
628            .is_some_and(|s| s == std::ffi::OsStr::new(filter));
629    }
630    // Subpath form: must end at a path boundary so `dir/env.sh` doesn't
631    // accidentally match `otherdir/env.sh`.
632    target.ends_with(&format!("/{filter}")) || target == filter
633}
634
635/// Render the filtered drill-down view for a `<pack>[/<file>]` filter.
636///
637/// `runs` controls how many recent profiles are examined; the caller
638/// passes [`DEFAULT_FILTER_RUNS`] unless it has a specific reason to
639/// look further or fewer.
640pub fn shell_init_filter(ctx: &ExecutionContext, filter: &str, runs: usize) -> Result<ProbeResult> {
641    let root_config = ctx.config_manager.root_config()?;
642    let profiling_enabled = root_config.profiling.enabled;
643    let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
644    let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
645    let last_up_when = last_up_ts.map(format_unix_ts).unwrap_or_default();
646
647    // Filter parsing: `pack` or `pack/file`. Trim a leading `./` and a
648    // trailing `/` defensively so users can paste tab-completed paths.
649    let trimmed = filter.trim().trim_start_matches("./").trim_end_matches('/');
650    let (filter_pack, filter_filename) = match trimmed.split_once('/') {
651        Some((p, f)) if !p.is_empty() && !f.is_empty() => (p.to_string(), Some(f.to_string())),
652        _ => (trimmed.to_string(), None),
653    };
654
655    let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
656    let latest_profile_ts = profiles
657        .first()
658        .map(|p| parse_unix_ts_from_filename(&p.filename))
659        .unwrap_or(0);
660
661    // Bucket per `(pack, handler, target)`. Order: targets sorted by
662    // path so output is stable; runs within each target stay newest-
663    // first (matching the input slice order).
664    use std::collections::BTreeMap;
665    let mut buckets: BTreeMap<(String, String, String), Vec<ShellInitFilterRun>> = BTreeMap::new();
666
667    for profile in &profiles {
668        let when = format_unix_ts(parse_unix_ts_from_filename(&profile.filename));
669        for entry in &profile.entries {
670            if entry.pack != filter_pack {
671                continue;
672            }
673            if let Some(name) = &filter_filename {
674                if !target_matches_filter(&entry.target, name) {
675                    continue;
676                }
677            }
678            let stderr_lines: Vec<String> = profile
679                .errors
680                .iter()
681                .find(|er| er.target == entry.target)
682                .map(|er| {
683                    er.message
684                        .trim_end()
685                        .lines()
686                        .map(|s| s.to_string())
687                        .collect()
688                })
689                .unwrap_or_default();
690            buckets
691                .entry((
692                    entry.pack.clone(),
693                    entry.handler.clone(),
694                    entry.target.clone(),
695                ))
696                .or_default()
697                .push(ShellInitFilterRun {
698                    when: when.clone(),
699                    duration_us: entry.duration_us,
700                    duration_label: humanize_us(entry.duration_us),
701                    exit_status: entry.exit_status,
702                    status_class: if entry.exit_status == 0 {
703                        "deployed"
704                    } else {
705                        "error"
706                    },
707                    stderr_lines,
708                    profile_filename: profile.filename.clone(),
709                });
710        }
711    }
712
713    let targets: Vec<ShellInitFilterTarget> = buckets
714        .into_iter()
715        .map(|((pack, handler, target), runs_vec)| {
716            let display_target = std::path::Path::new(&target)
717                .file_name()
718                .map(|s| s.to_string_lossy().into_owned())
719                .unwrap_or_else(|| target.clone());
720            let failure_count = runs_vec.iter().filter(|r| r.exit_status != 0).count();
721            ShellInitFilterTarget {
722                target,
723                display_target,
724                pack,
725                handler,
726                runs: runs_vec,
727                failure_count,
728            }
729        })
730        .collect();
731
732    Ok(ProbeResult::ShellInitFilter(ShellInitFilterView {
733        profiling_enabled,
734        profiles_dir,
735        filter: filter.trim().to_string(),
736        filter_pack,
737        filter_filename,
738        runs_examined: profiles.len(),
739        targets,
740        stale: is_stale(latest_profile_ts, last_up_ts),
741        latest_profile_when: format_unix_ts(latest_profile_ts),
742        last_up_when,
743    }))
744}
745
746/// Render the cross-history errors view.
747///
748/// Scans the last `runs` profiles, keeps only entries with non-zero
749/// exit status, groups them by target, and orders by failure count
750/// (most-broken first). The `runs` parameter follows the same window
751/// convention as [`shell_init_filter`].
752pub fn shell_init_errors(ctx: &ExecutionContext, runs: usize) -> Result<ProbeResult> {
753    let root_config = ctx.config_manager.root_config()?;
754    let profiling_enabled = root_config.profiling.enabled;
755    let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
756    let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
757    let last_up_when = last_up_ts.map(format_unix_ts).unwrap_or_default();
758
759    let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
760    let latest_profile_ts = profiles
761        .first()
762        .map(|p| parse_unix_ts_from_filename(&p.filename))
763        .unwrap_or(0);
764
765    use std::collections::BTreeMap;
766    let mut buckets: BTreeMap<(String, String, String), Vec<ShellInitFilterRun>> = BTreeMap::new();
767
768    for profile in &profiles {
769        let when = format_unix_ts(parse_unix_ts_from_filename(&profile.filename));
770        for entry in &profile.entries {
771            // Errors-only: skip clean runs entirely.
772            if entry.exit_status == 0 {
773                continue;
774            }
775            let stderr_lines: Vec<String> = profile
776                .errors
777                .iter()
778                .find(|er| er.target == entry.target)
779                .map(|er| {
780                    er.message
781                        .trim_end()
782                        .lines()
783                        .map(|s| s.to_string())
784                        .collect()
785                })
786                .unwrap_or_default();
787            buckets
788                .entry((
789                    entry.pack.clone(),
790                    entry.handler.clone(),
791                    entry.target.clone(),
792                ))
793                .or_default()
794                .push(ShellInitFilterRun {
795                    when: when.clone(),
796                    duration_us: entry.duration_us,
797                    duration_label: humanize_us(entry.duration_us),
798                    exit_status: entry.exit_status,
799                    status_class: "error",
800                    stderr_lines,
801                    profile_filename: profile.filename.clone(),
802                });
803        }
804    }
805
806    let mut targets: Vec<ShellInitFilterTarget> = buckets
807        .into_iter()
808        .map(|((pack, handler, target), runs_vec)| {
809            let display_target = std::path::Path::new(&target)
810                .file_name()
811                .map(|s| s.to_string_lossy().into_owned())
812                .unwrap_or_else(|| target.clone());
813            let failure_count = runs_vec.len();
814            ShellInitFilterTarget {
815                target,
816                display_target,
817                pack,
818                handler,
819                runs: runs_vec,
820                failure_count,
821            }
822        })
823        .collect();
824
825    // Sort: most-broken first, with a stable (pack, handler, target)
826    // tiebreaker so two targets with the same failure count don't swap
827    // positions across runs.
828    targets.sort_by(|a, b| {
829        b.failure_count
830            .cmp(&a.failure_count)
831            .then_with(|| a.pack.cmp(&b.pack))
832            .then_with(|| a.handler.cmp(&b.handler))
833            .then_with(|| a.target.cmp(&b.target))
834    });
835
836    Ok(ProbeResult::ShellInitErrors(ShellInitErrorsView {
837        profiling_enabled,
838        profiles_dir,
839        runs_examined: profiles.len(),
840        targets,
841        stale: is_stale(latest_profile_ts, last_up_ts),
842        latest_profile_when: format_unix_ts(latest_profile_ts),
843        last_up_when,
844    }))
845}
846
847/// Render the per-run history view (one summary line per profile).
848pub fn shell_init_history(ctx: &ExecutionContext, limit: usize) -> Result<ProbeResult> {
849    let root_config = ctx.config_manager.root_config()?;
850    let profiling_enabled = root_config.profiling.enabled;
851    let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), limit)?;
852    // `read_recent_profiles` already returns newest-first, which is the
853    // order users expect for a history listing (most recent at the top
854    // of the table). Don't reverse.
855    let latest_profile_ts = profiles
856        .first()
857        .map(|p| parse_unix_ts_from_filename(&p.filename))
858        .unwrap_or(0);
859    let history = summarize_history(&profiles);
860    let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
861    let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
862
863    Ok(ProbeResult::ShellInitHistory(ShellInitHistoryView {
864        profiling_enabled,
865        profiles_dir,
866        rows: history.into_iter().map(into_history_row).collect(),
867        stale: is_stale(latest_profile_ts, last_up_ts),
868        latest_profile_when: format_unix_ts(latest_profile_ts),
869        last_up_when: last_up_ts.map(format_unix_ts).unwrap_or_default(),
870    }))
871}
872
873fn into_history_row(h: HistoryEntry) -> ShellInitHistoryRow {
874    ShellInitHistoryRow {
875        filename: h.filename,
876        unix_ts: h.unix_ts,
877        when: format_unix_ts(h.unix_ts),
878        shell: h.shell,
879        total_label: humanize_us(h.total_us),
880        user_total_label: humanize_us(h.user_total_us),
881        total_us: h.total_us,
882        user_total_us: h.user_total_us,
883        failed_entries: h.failed_entries,
884        entry_count: h.entry_count,
885    }
886}
887
888/// Format a unix timestamp as `YYYY-MM-DD HH:MM` in UTC. Returns an
889/// empty string for `0` (parse-failure sentinel) so the renderer can
890/// just print a blank cell.
891///
892/// Does the calendar math by hand to avoid pulling a dep — chrono is
893/// overkill for one display string. Algorithm: Howard Hinnant's
894/// civil_from_days.
895pub fn format_unix_ts(ts: u64) -> String {
896    // 0 is the parse-failure sentinel from `parse_unix_ts_from_filename`;
897    // anything past year 9999 is also nonsense in a shell-startup
898    // profile (the file format itself is the giveaway). Returning an
899    // empty string keeps the renderer predictable even in the face of
900    // a tampered-with filename, and bounds the i64 cast on `days`
901    // safely below i64::MAX regardless of input.
902    const MAX_REASONABLE_TS: u64 = 253_402_300_799; // 9999-12-31T23:59:59 UTC.
903    if ts == 0 || ts > MAX_REASONABLE_TS {
904        return String::new();
905    }
906    let secs_per_day: u64 = 86_400;
907    let days = (ts / secs_per_day) as i64; // safe: ts < 2.5e11 → days < 3e6
908    let secs_of_day = ts % secs_per_day;
909    let hour = secs_of_day / 3600;
910    let minute = (secs_of_day % 3600) / 60;
911    let (y, m, d) = civil_from_days(days);
912    format!("{y:04}-{m:02}-{d:02} {hour:02}:{minute:02}")
913}
914
915/// Howard Hinnant's `civil_from_days`: convert days since 1970-01-01
916/// (UTC) into `(year, month, day)`. Public-domain algorithm.
917fn civil_from_days(z: i64) -> (i32, u32, u32) {
918    let z = z + 719468;
919    let era = if z >= 0 { z } else { z - 146096 } / 146097;
920    let doe = (z - era * 146097) as u64; // [0, 146096]
921    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
922    let y = yoe as i64 + era * 400;
923    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
924    let mp = (5 * doy + 2) / 153; // [0, 11]
925    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
926    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
927    let y = if m <= 2 { y + 1 } else { y };
928    (y as i32, m as u32, d as u32)
929}
930
931/// `dodot probe app <pack>` — advisory introspection of macOS
932/// app-support paths for a pack.
933///
934/// Walks the pack's `_app/<X>/...` matches, configured `force_app`
935/// hits, and `[symlink.app_aliases]` entries; checks each candidate
936/// folder against the on-disk app-support root, and (on macOS)
937/// enriches with brew cask metadata and Spotlight bundle IDs.
938///
939/// `refresh = true` invalidates the brew cache for every cask token
940/// matched against this pack, forcing a fresh `brew info` fetch.
941///
942/// Resolver state is not consulted — this is purely advisory display.
943pub fn app(pack_name: &str, refresh: bool, ctx: &ExecutionContext) -> Result<ProbeResult> {
944    use std::collections::BTreeSet;
945
946    // Resolve pack: try display name, fall back to a *validated* raw
947    // on-disk dir name. Untrusted CLI input (e.g. `dodot probe app
948    // ..` or `dodot probe app foo/bar`) must never reach
949    // `paths.pack_path`, which would let `read_dir` below traverse
950    // outside the dotfiles root.
951    let pack_dir = crate::packs::orchestration::resolve_pack_dir_name(pack_name, ctx)
952        .unwrap_or_else(|_| {
953            if is_single_normal_path_component(pack_name) {
954                pack_name.to_string()
955            } else {
956                // Invalid path-like input — produce an empty-but-named
957                // view rather than an error. `pack_dir == ""` is the
958                // sentinel the rest of the function checks to skip
959                // any filesystem traversal.
960                String::new()
961            }
962        });
963    let display_name = if pack_dir.is_empty() {
964        pack_name.to_string()
965    } else {
966        crate::packs::display_name_for(&pack_dir).to_string()
967    };
968    let pack_config = if pack_dir.is_empty() {
969        ctx.config_manager.root_config()?
970    } else {
971        match ctx
972            .config_manager
973            .config_for_pack(&ctx.paths.pack_path(&pack_dir))
974        {
975            Ok(c) => c,
976            // Pack-level config is optional; fall back to root config so
977            // alias/force_app entries declared at root still surface for
978            // a pack that hasn't been created yet.
979            Err(_) => ctx.config_manager.root_config()?,
980        }
981    };
982
983    // Collect distinct folder names this pack would route to.
984    //
985    // Three sources:
986    //   - `app_aliases[<pack>]` value
987    //   - `force_app` entries that appear at the top of the pack's tree
988    //   - `_app/<X>/` subdirectory names found by walking the pack
989    let mut folders: Vec<(String, &'static str)> = Vec::new();
990    let mut seen: BTreeSet<String> = BTreeSet::new();
991
992    if let Some(alias) = pack_config.symlink.app_aliases.get(&display_name) {
993        if seen.insert(alias.clone()) {
994            folders.push((alias.clone(), "alias"));
995        }
996    }
997
998    let pack_path = ctx.paths.pack_path(&pack_dir);
999    if !pack_dir.is_empty() && ctx.fs.exists(&pack_path) {
1000        if let Ok(entries) = ctx.fs.read_dir(&pack_path) {
1001            for e in entries {
1002                if e.is_dir
1003                    && pack_config.symlink.force_app.iter().any(|f| f == &e.name)
1004                    && seen.insert(e.name.clone())
1005                {
1006                    folders.push((e.name.clone(), "force_app"));
1007                }
1008            }
1009            // _app/<X>/ subtree
1010            let app_dir = pack_path.join("_app");
1011            if ctx.fs.exists(&app_dir) {
1012                if let Ok(children) = ctx.fs.read_dir(&app_dir) {
1013                    for e in children {
1014                        if e.is_dir && seen.insert(e.name.clone()) {
1015                            folders.push((e.name.clone(), "_app/"));
1016                        }
1017                    }
1018                }
1019            }
1020        }
1021    }
1022
1023    // On non-macOS we still produce a useful (if minimal) view: just
1024    // the list of folders and their existence under the *collapsed*
1025    // app-support root (= xdg). Skip the brew/mdls work entirely.
1026    let macos = cfg!(target_os = "macos");
1027    let app_support = ctx.paths.app_support_dir();
1028    let cache_dir = ctx.paths.probes_brew_cache_dir();
1029
1030    // `--refresh` clears the entire brew probe cache before any
1031    // matching runs, so the next `brew info` call rehydrates fresh
1032    // data. Per-folder invalidation can't work here because the cache
1033    // is keyed by cask token (not folder name) — we don't know the
1034    // tokens until matching has already populated the cache.
1035    if refresh && macos {
1036        crate::probe::brew::invalidate_all_cache(&cache_dir, ctx.fs.as_ref());
1037    }
1038
1039    let now = crate::probe::brew::now_secs_unix();
1040    let folder_names: Vec<String> = folders.iter().map(|(f, _)| f.clone()).collect();
1041    // The on-demand `dodot probe app` subcommand is allowed to
1042    // populate the cache, so cache_only=false. The matcher returns
1043    // the installed-token set so we don't re-run `brew list` below.
1044    let matches = if macos {
1045        crate::probe::brew::match_folders_to_installed_casks(
1046            &folder_names,
1047            ctx.command_runner.as_ref(),
1048            &cache_dir,
1049            now,
1050            ctx.fs.as_ref(),
1051            /*cache_only=*/ false,
1052        )
1053    } else {
1054        crate::probe::brew::InstalledCaskMatches::default()
1055    };
1056
1057    let mut entries: Vec<AppProbeEntry> = Vec::new();
1058    let mut suggested: BTreeSet<String> = BTreeSet::new();
1059
1060    for (folder, source_rule) in &folders {
1061        let target = app_support.join(folder);
1062        let target_exists = ctx.fs.exists(&target);
1063        let cask = matches.folder_to_token.get(folder).cloned();
1064
1065        let mut app_bundle = None;
1066        let mut bundle_id = None;
1067        if macos {
1068            if let Some(token) = &cask {
1069                if let Ok(Some(info)) = crate::probe::brew::info_cask(
1070                    token,
1071                    &cache_dir,
1072                    now,
1073                    ctx.fs.as_ref(),
1074                    ctx.command_runner.as_ref(),
1075                ) {
1076                    app_bundle = info.app_bundle_name();
1077                    if let Some(bundle_name) = &app_bundle {
1078                        let app_path = std::path::PathBuf::from("/Applications").join(bundle_name);
1079                        bundle_id = crate::probe::macos_native::bundle_id(
1080                            &app_path,
1081                            ctx.command_runner.as_ref(),
1082                        );
1083                    }
1084                    for plist in info.preferences_plists() {
1085                        suggested.insert(plist);
1086                    }
1087                }
1088            }
1089        }
1090
1091        entries.push(AppProbeEntry {
1092            folder: folder.clone(),
1093            target_path: display_path(&target, ctx.paths.home_dir()),
1094            target_exists,
1095            source_rule: (*source_rule).into(),
1096            cask,
1097            app_bundle,
1098            bundle_id,
1099        });
1100    }
1101
1102    Ok(ProbeResult::App(AppProbeView {
1103        pack: display_name,
1104        macos,
1105        entries,
1106        suggested_adoptions: suggested.into_iter().collect(),
1107    }))
1108}
1109
1110/// True iff `value` is a single, normal path component — no path
1111/// separators, no `.`/`..` components, not empty. Used as a
1112/// security guard before passing untrusted CLI input to
1113/// `Pather::pack_path` (which would otherwise let traversal escape
1114/// the dotfiles root via the resulting `read_dir` calls).
1115fn is_single_normal_path_component(value: &str) -> bool {
1116    if value.is_empty() {
1117        return false;
1118    }
1119    let mut comps = std::path::Path::new(value).components();
1120    matches!(
1121        (comps.next(), comps.next()),
1122        (Some(std::path::Component::Normal(_)), None)
1123    )
1124}
1125
1126/// Render the data-dir tree.
1127pub fn show_data_dir(ctx: &ExecutionContext, max_depth: usize) -> Result<ProbeResult> {
1128    let tree = collect_data_dir_tree(ctx.fs.as_ref(), ctx.paths.as_ref(), max_depth)?;
1129    let total_nodes = tree.count_nodes();
1130    let total_size = tree.total_size();
1131    let mut lines = Vec::new();
1132    flatten_tree(&tree, "", true, &mut lines, true);
1133    Ok(ProbeResult::ShowDataDir {
1134        data_dir: ctx.paths.data_dir().display().to_string(),
1135        lines,
1136        total_nodes,
1137        total_size,
1138    })
1139}
1140
1141// ── Display helpers ─────────────────────────────────────────────────
1142
1143fn into_display_entry(e: DeploymentMapEntry, home: &std::path::Path) -> DeploymentDisplayEntry {
1144    DeploymentDisplayEntry {
1145        pack: e.pack,
1146        handler: e.handler,
1147        kind: e.kind.as_str().into(),
1148        source: if e.source.as_os_str().is_empty() {
1149            // Sentinel / rendered file — no source file backs this entry.
1150            // Show an em dash so the column stays populated.
1151            "—".into()
1152        } else {
1153            display_path(&e.source, home)
1154        },
1155        datastore: display_path(&e.datastore, home),
1156    }
1157}
1158
1159fn display_path(p: &std::path::Path, home: &std::path::Path) -> String {
1160    if let Ok(rel) = p.strip_prefix(home) {
1161        format!("~/{}", rel.display())
1162    } else {
1163        p.display().to_string()
1164    }
1165}
1166
1167/// Flatten a `TreeNode` into [`TreeLine`]s with box-drawing prefixes.
1168///
1169/// `prefix` is the continuation prefix applied to this node's
1170/// descendants (e.g. `"│  "` if this node has more siblings below,
1171/// `"   "` if it's the last). `is_last` controls the branch glyph for
1172/// this node itself (`"└─ "` vs `"├─ "`). `is_root` skips the branch
1173/// glyph on the topmost call so the root displays flush-left.
1174fn flatten_tree(
1175    node: &TreeNode,
1176    prefix: &str,
1177    is_last: bool,
1178    out: &mut Vec<TreeLine>,
1179    is_root: bool,
1180) {
1181    let branch = if is_root {
1182        String::new()
1183    } else if is_last {
1184        "└─ ".to_string()
1185    } else {
1186        "├─ ".to_string()
1187    };
1188    let line_prefix = format!("{prefix}{branch}");
1189    out.push(TreeLine {
1190        prefix: line_prefix,
1191        name: node.name.clone(),
1192        annotation: annotate(node),
1193    });
1194
1195    if node.children.is_empty() {
1196        return;
1197    }
1198
1199    // Extend the prefix for children. The root contributes no prefix
1200    // characters; a last-child contributes "   "; an inner child
1201    // contributes "│  ".
1202    let child_prefix = if is_root {
1203        String::new()
1204    } else if is_last {
1205        format!("{prefix}   ")
1206    } else {
1207        format!("{prefix}│  ")
1208    };
1209
1210    let last_idx = node.children.len() - 1;
1211    for (i, child) in node.children.iter().enumerate() {
1212        flatten_tree(child, &child_prefix, i == last_idx, out, false);
1213    }
1214}
1215
1216fn annotate(node: &TreeNode) -> String {
1217    match node.kind {
1218        "dir" => match node.truncated_count {
1219            Some(n) if n > 0 => format!("(… {n} more)"),
1220            _ => String::new(),
1221        },
1222        "file" => match node.size {
1223            Some(n) => humanize_bytes(n),
1224            None => String::new(),
1225        },
1226        "symlink" => match &node.link_target {
1227            Some(t) => format!("→ {t}"),
1228            None => "→ (broken)".into(),
1229        },
1230        _ => String::new(),
1231    }
1232}
1233
1234/// Compact byte sizing: "512 B", "1.2 KB", "3.4 MB".
1235///
1236/// KB = 1024 bytes. No fractional KB below 1024.
1237pub fn humanize_bytes(n: u64) -> String {
1238    const KB: u64 = 1024;
1239    const MB: u64 = KB * 1024;
1240    const GB: u64 = MB * 1024;
1241    if n < KB {
1242        format!("{n} B")
1243    } else if n < MB {
1244        format!("{:.1} KB", n as f64 / KB as f64)
1245    } else if n < GB {
1246        format!("{:.1} MB", n as f64 / MB as f64)
1247    } else {
1248        format!("{:.1} GB", n as f64 / GB as f64)
1249    }
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254    use super::*;
1255    use crate::probe::{DeploymentKind, DeploymentMapEntry};
1256    use std::path::PathBuf;
1257
1258    fn home() -> PathBuf {
1259        PathBuf::from("/home/alice")
1260    }
1261
1262    #[test]
1263    fn display_path_shortens_home() {
1264        assert_eq!(
1265            display_path(&PathBuf::from("/home/alice/dotfiles/vim/rc"), &home()),
1266            "~/dotfiles/vim/rc"
1267        );
1268    }
1269
1270    #[test]
1271    fn display_path_keeps_paths_outside_home() {
1272        assert_eq!(
1273            display_path(&PathBuf::from("/opt/data"), &home()),
1274            "/opt/data"
1275        );
1276    }
1277
1278    #[test]
1279    fn humanize_bytes_boundaries() {
1280        assert_eq!(humanize_bytes(0), "0 B");
1281        assert_eq!(humanize_bytes(1023), "1023 B");
1282        assert_eq!(humanize_bytes(1024), "1.0 KB");
1283        assert_eq!(humanize_bytes(1024 * 1024), "1.0 MB");
1284        assert_eq!(humanize_bytes(1024 * 1024 * 1024), "1.0 GB");
1285    }
1286
1287    #[test]
1288    fn format_unix_ts_handles_zero_and_out_of_range() {
1289        // Sentinel for parse-failure → empty, not a date.
1290        assert_eq!(format_unix_ts(0), "");
1291        // Real timestamp → formatted.
1292        assert_eq!(format_unix_ts(1_714_000_000), "2024-04-24 23:06");
1293        // Past year 9999 → empty (defensive ceiling so a tampered
1294        // filename doesn't produce a nonsense date or risk overflow
1295        // during the i64 cast on `days`).
1296        assert_eq!(format_unix_ts(u64::MAX), "");
1297        assert_eq!(format_unix_ts(253_402_300_800), ""); // 1s past year 9999.
1298                                                         // Right below the ceiling still renders.
1299        assert_eq!(format_unix_ts(253_402_300_799), "9999-12-31 23:59");
1300    }
1301
1302    #[test]
1303    fn into_display_entry_handles_sentinel_source() {
1304        let entry = DeploymentMapEntry {
1305            pack: "nvim".into(),
1306            handler: "install".into(),
1307            kind: DeploymentKind::File,
1308            source: PathBuf::new(),
1309            datastore: PathBuf::from("/home/alice/.local/share/dodot/packs/nvim/install/sent"),
1310        };
1311        let display = into_display_entry(entry, &home());
1312        assert_eq!(display.source, "—");
1313        assert!(display.datastore.starts_with("~/"));
1314    }
1315
1316    #[test]
1317    fn tree_flattening_produces_branch_glyphs() {
1318        // Fabricate a small tree:
1319        //   root
1320        //   ├─ a
1321        //   │  └─ aa
1322        //   └─ b
1323        let tree = TreeNode {
1324            name: "root".into(),
1325            path: PathBuf::from("/root"),
1326            kind: "dir",
1327            size: None,
1328            link_target: None,
1329            truncated_count: None,
1330            children: vec![
1331                TreeNode {
1332                    name: "a".into(),
1333                    path: PathBuf::from("/root/a"),
1334                    kind: "dir",
1335                    size: None,
1336                    link_target: None,
1337                    truncated_count: None,
1338                    children: vec![TreeNode {
1339                        name: "aa".into(),
1340                        path: PathBuf::from("/root/a/aa"),
1341                        kind: "file",
1342                        size: Some(10),
1343                        link_target: None,
1344                        truncated_count: None,
1345                        children: Vec::new(),
1346                    }],
1347                },
1348                TreeNode {
1349                    name: "b".into(),
1350                    path: PathBuf::from("/root/b"),
1351                    kind: "file",
1352                    size: Some(42),
1353                    link_target: None,
1354                    truncated_count: None,
1355                    children: Vec::new(),
1356                },
1357            ],
1358        };
1359        let mut lines = Vec::new();
1360        flatten_tree(&tree, "", true, &mut lines, true);
1361        assert_eq!(lines.len(), 4);
1362        assert_eq!(lines[0].name, "root");
1363        assert_eq!(lines[0].prefix, ""); // root is flush-left
1364        assert_eq!(lines[1].name, "a");
1365        assert!(lines[1].prefix.ends_with("├─ "));
1366        assert_eq!(lines[2].name, "aa");
1367        assert!(lines[2].prefix.ends_with("└─ "));
1368        assert!(lines[2].prefix.starts_with("│")); // parent is not last
1369        assert_eq!(lines[3].name, "b");
1370        assert!(lines[3].prefix.ends_with("└─ "));
1371        assert_eq!(lines[3].annotation, "42 B");
1372    }
1373
1374    #[test]
1375    fn annotate_symlink_with_target() {
1376        let node = TreeNode {
1377            name: "link".into(),
1378            path: PathBuf::from("/x"),
1379            kind: "symlink",
1380            size: Some(20),
1381            link_target: Some("/target".into()),
1382            truncated_count: None,
1383            children: Vec::new(),
1384        };
1385        assert_eq!(annotate(&node), "→ /target");
1386    }
1387
1388    #[test]
1389    fn annotate_broken_symlink() {
1390        let node = TreeNode {
1391            name: "link".into(),
1392            path: PathBuf::from("/x"),
1393            kind: "symlink",
1394            size: Some(20),
1395            link_target: None,
1396            truncated_count: None,
1397            children: Vec::new(),
1398        };
1399        assert_eq!(annotate(&node), "→ (broken)");
1400    }
1401
1402    #[test]
1403    fn annotate_truncated_dir() {
1404        let node = TreeNode {
1405            name: "deep".into(),
1406            path: PathBuf::from("/x"),
1407            kind: "dir",
1408            size: None,
1409            link_target: None,
1410            truncated_count: Some(7),
1411            children: Vec::new(),
1412        };
1413        assert_eq!(annotate(&node), "(… 7 more)");
1414    }
1415
1416    #[test]
1417    fn probe_result_deployment_map_serialises_with_kind_tag() {
1418        let result = ProbeResult::DeploymentMap {
1419            data_dir: "/d".into(),
1420            map_path: "/d/deployment-map.tsv".into(),
1421            entries: Vec::new(),
1422        };
1423        let json = serde_json::to_value(&result).unwrap();
1424        assert_eq!(json["kind"], "deployment-map");
1425        assert!(json["entries"].is_array());
1426    }
1427
1428    #[test]
1429    fn probe_result_show_data_dir_serialises_with_kind_tag() {
1430        let result = ProbeResult::ShowDataDir {
1431            data_dir: "/d".into(),
1432            lines: Vec::new(),
1433            total_nodes: 1,
1434            total_size: 0,
1435        };
1436        let json = serde_json::to_value(&result).unwrap();
1437        assert_eq!(json["kind"], "show-data-dir");
1438        assert_eq!(json["total_nodes"], 1);
1439    }
1440
1441    #[test]
1442    fn probe_subcommands_list_matches_variants() {
1443        // Failsafe: if we add a probe subcommand to the enum we should
1444        // add it to the summary list too. This assertion catches the
1445        // former getting ahead of the latter.
1446        let names: Vec<&str> = PROBE_SUBCOMMANDS.iter().map(|s| s.name).collect();
1447        assert!(names.contains(&"deployment-map"));
1448        assert!(names.contains(&"show-data-dir"));
1449    }
1450}