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    read_latest_profile, read_recent_profiles, summarize_history, AggregatedTarget,
18    DeploymentMapEntry, GroupedProfile, HistoryEntry, TreeNode,
19};
20use crate::Result;
21
22/// Default max depth for `probe show-data-dir`. Enough to show
23/// `packs / <pack> / <handler> / <entry>` without scrolling off
24/// screen for reasonable installs; deeper subtrees are summarised.
25pub const DEFAULT_SHOW_DATA_DIR_DEPTH: usize = 4;
26
27/// Display-shaped deployment-map row. Paths are pre-shortened to
28/// `~/…` where they live under HOME so the rendered table stays
29/// narrow; the machine-readable TSV on disk keeps absolute paths.
30#[derive(Debug, Clone, Serialize)]
31pub struct DeploymentDisplayEntry {
32    pub pack: String,
33    pub handler: String,
34    pub kind: String,
35    /// Pre-shortened (`~/…`) absolute source path; empty for
36    /// non-symlink entries (sentinels, rendered files).
37    pub source: String,
38    /// Pre-shortened absolute datastore path.
39    pub datastore: String,
40}
41
42/// One line of tree output, pre-flattened for the template.
43///
44/// The template for a tree is annoying to write directly in Jinja
45/// (indentation, prefix characters, etc.), so we flatten the tree to
46/// a list of `(indent, name, annotation)` triples here.
47#[derive(Debug, Clone, Serialize)]
48pub struct TreeLine {
49    /// Indent prefix (e.g. `"  │  ├─ "`).
50    pub prefix: String,
51    /// The node's display name (basename for non-root nodes).
52    pub name: String,
53    /// A dim-styled annotation shown after the name (size, link target,
54    /// truncation count). Empty when the node has nothing extra to say.
55    pub annotation: String,
56}
57
58/// Result of any `probe` invocation. Serialises with a `kind` tag so
59/// the Jinja template can dispatch on it.
60#[derive(Debug, Clone, Serialize)]
61#[serde(tag = "kind", rename_all = "kebab-case")]
62pub enum ProbeResult {
63    /// `dodot probe` with no subcommand — a summary pointing the user
64    /// at the real subcommands.
65    Summary {
66        data_dir: String,
67        available: Vec<ProbeSubcommandInfo>,
68    },
69    /// `dodot probe deployment-map` — the source↔deployed map.
70    DeploymentMap {
71        data_dir: String,
72        map_path: String,
73        entries: Vec<DeploymentDisplayEntry>,
74    },
75    /// `dodot probe show-data-dir` — a bounded tree view of
76    /// `<data_dir>`.
77    ShowDataDir {
78        data_dir: String,
79        /// Flattened, template-ready lines.
80        lines: Vec<TreeLine>,
81        total_nodes: usize,
82        /// Size in bytes of the whole tree (symlinks counted by their
83        /// link-entry size).
84        total_size: u64,
85    },
86    /// `dodot probe shell-init` — the most recent shell-startup profile,
87    /// grouped by pack and handler.
88    ShellInit(ShellInitView),
89    /// `dodot probe shell-init --runs N` — per-target percentile stats
90    /// across the last N runs.
91    ShellInitAggregate(ShellInitAggregateView),
92    /// `dodot probe shell-init --history` — one summary line per recent
93    /// run, oldest run first (so the latest is closest to the eye in a
94    /// terminal where output scrolls down).
95    ShellInitHistory(ShellInitHistoryView),
96}
97
98/// Display payload for `--runs N`.
99#[derive(Debug, Clone, Serialize)]
100pub struct ShellInitAggregateView {
101    /// How many profiles were actually loaded (may be smaller than the
102    /// requested N if there aren't enough on disk yet).
103    pub runs: usize,
104    /// User-requested N (echoed back so the renderer can say
105    /// "showing 4 of last 10 requested").
106    pub requested_runs: usize,
107    pub profiling_enabled: bool,
108    pub profiles_dir: String,
109    pub rows: Vec<ShellInitAggregateRow>,
110}
111
112/// One per-target aggregate row, durations pre-humanised for the
113/// template.
114#[derive(Debug, Clone, Serialize)]
115pub struct ShellInitAggregateRow {
116    pub pack: String,
117    pub handler: String,
118    pub target: String,
119    pub p50_label: String,
120    pub p95_label: String,
121    pub max_label: String,
122    pub p50_us: u64,
123    pub p95_us: u64,
124    pub max_us: u64,
125    /// e.g. `"7/10"` — formatted at the lib so JSON consumers and the
126    /// template both render identically.
127    pub seen_label: String,
128    pub runs_seen: usize,
129    pub runs_total: usize,
130}
131
132/// Display payload for `--history`.
133#[derive(Debug, Clone, Serialize)]
134pub struct ShellInitHistoryView {
135    pub profiling_enabled: bool,
136    pub profiles_dir: String,
137    pub rows: Vec<ShellInitHistoryRow>,
138}
139
140/// One per-run row in `--history`.
141#[derive(Debug, Clone, Serialize)]
142pub struct ShellInitHistoryRow {
143    /// Filename of the underlying TSV — useful for cross-reference and
144    /// keeps history rows traceable to the on-disk artefact.
145    pub filename: String,
146    /// Unix timestamp parsed from the filename (or `0` when the
147    /// filename doesn't follow the expected pattern). Surfaced in JSON
148    /// so machine consumers can do their own date math without
149    /// re-parsing `filename`.
150    pub unix_ts: u64,
151    /// Compact `YYYY-MM-DD HH:MM` formatted from the unix timestamp in
152    /// the filename. Empty when the timestamp couldn't be parsed.
153    pub when: String,
154    pub shell: String,
155    pub total_label: String,
156    pub user_total_label: String,
157    pub total_us: u64,
158    pub user_total_us: u64,
159    pub failed_entries: usize,
160    pub entry_count: usize,
161}
162
163/// Display payload for `probe shell-init`. Pulled into its own struct
164/// so the JSON view stays clean and the variant constructor in
165/// `shell_init()` reads naturally.
166#[derive(Debug, Clone, Serialize)]
167pub struct ShellInitView {
168    /// Source filename of the report (for "which run is this?" UX).
169    /// Empty when no profile has been written yet.
170    pub filename: String,
171    /// Shell label as recorded in the preamble (e.g. `bash 5.3.9`).
172    pub shell: String,
173    /// True when the profiling wrapper is enabled in config.
174    pub profiling_enabled: bool,
175    /// True when the directory exists and contained a parseable file.
176    pub has_profile: bool,
177    /// Pre-grouped rows for the template; empty when `has_profile` is
178    /// false.
179    pub groups: Vec<ShellInitGroup>,
180    pub user_total_us: u64,
181    pub framing_us: u64,
182    pub total_us: u64,
183    /// Where the profiles live on disk (so the user can `ls` it).
184    pub profiles_dir: String,
185}
186
187/// Display row for one entry in a shell-init group.
188#[derive(Debug, Clone, Serialize)]
189pub struct ShellInitRow {
190    pub target: String,
191    pub duration_us: u64,
192    pub duration_label: String,
193    pub exit_status: i32,
194    /// `"deployed"` (success — rendered green) or `"error"` (non-zero
195    /// source exit). These map directly to existing styles in
196    /// `crate::render`'s theme; using fresh names here would require
197    /// theme additions for no UX gain.
198    pub status_class: &'static str,
199}
200
201/// Display group: one (pack, handler) bucket of shell-init rows.
202#[derive(Debug, Clone, Serialize)]
203pub struct ShellInitGroup {
204    pub pack: String,
205    pub handler: String,
206    pub rows: Vec<ShellInitRow>,
207    pub group_total_us: u64,
208    pub group_total_label: String,
209}
210
211/// One entry in the `probe` summary listing.
212#[derive(Debug, Clone, Serialize)]
213pub struct ProbeSubcommandInfo {
214    pub name: &'static str,
215    pub description: &'static str,
216}
217
218/// The full list of probe subcommands, used by the summary view.
219/// Keeping them in one array keeps the CLI registration, clap
220/// registration, and summary output trivially in sync.
221pub const PROBE_SUBCOMMANDS: &[ProbeSubcommandInfo] = &[
222    ProbeSubcommandInfo {
223        name: "deployment-map",
224        description: "Source↔deployed map — what dodot linked where.",
225    },
226    ProbeSubcommandInfo {
227        name: "shell-init",
228        description: "Per-source timings for the most recent shell startup.",
229    },
230    ProbeSubcommandInfo {
231        name: "show-data-dir",
232        description: "Tree of dodot's data directory, with sizes.",
233    },
234];
235
236// ── Entry points ────────────────────────────────────────────────────
237
238/// Render the bare `dodot probe` summary.
239pub fn summary(ctx: &ExecutionContext) -> Result<ProbeResult> {
240    Ok(ProbeResult::Summary {
241        data_dir: ctx.paths.data_dir().display().to_string(),
242        available: PROBE_SUBCOMMANDS.to_vec(),
243    })
244}
245
246/// Render the deployment map for display.
247///
248/// Reads the current datastore state (not the on-disk TSV) so the
249/// output is always fresh even if the user never ran `dodot up`.
250pub fn deployment_map(ctx: &ExecutionContext) -> Result<ProbeResult> {
251    let raw = collect_deployment_map(ctx.fs.as_ref(), ctx.paths.as_ref())?;
252    let home = ctx.paths.home_dir();
253    let entries = raw
254        .into_iter()
255        .map(|e| into_display_entry(e, home))
256        .collect();
257
258    Ok(ProbeResult::DeploymentMap {
259        data_dir: ctx.paths.data_dir().display().to_string(),
260        map_path: ctx.paths.deployment_map_path().display().to_string(),
261        entries,
262    })
263}
264
265/// Render the most recent shell-init profile.
266///
267/// When no profile has been written yet (fresh install, or profiling
268/// disabled, or the user hasn't started a shell since the last `up`),
269/// returns a "no data" view with `has_profile = false`. The template
270/// uses that flag to print a hint instead of an empty table.
271pub fn shell_init(ctx: &ExecutionContext) -> Result<ProbeResult> {
272    let root_config = ctx.config_manager.root_config()?;
273    let profiling_enabled = root_config.profiling.enabled;
274
275    let profile_opt = read_latest_profile(ctx.fs.as_ref(), ctx.paths.as_ref())?;
276    let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
277
278    let view = match profile_opt {
279        Some(profile) => {
280            let grouped = group_profile(&profile);
281            ShellInitView {
282                filename: profile.filename.clone(),
283                shell: profile.shell.clone(),
284                profiling_enabled,
285                has_profile: true,
286                groups: shell_init_groups(&grouped),
287                user_total_us: grouped.user_total_us,
288                framing_us: grouped.framing_us,
289                total_us: grouped.total_us,
290                profiles_dir,
291            }
292        }
293        None => ShellInitView {
294            filename: String::new(),
295            shell: String::new(),
296            profiling_enabled,
297            has_profile: false,
298            groups: Vec::new(),
299            user_total_us: 0,
300            framing_us: 0,
301            total_us: 0,
302            profiles_dir,
303        },
304    };
305
306    Ok(ProbeResult::ShellInit(view))
307}
308
309fn shell_init_groups(grouped: &GroupedProfile) -> Vec<ShellInitGroup> {
310    grouped
311        .groups
312        .iter()
313        .map(|g| ShellInitGroup {
314            pack: g.pack.clone(),
315            handler: g.handler.clone(),
316            rows: g
317                .rows
318                .iter()
319                .map(|r| ShellInitRow {
320                    target: short_target(&r.target),
321                    duration_us: r.duration_us,
322                    duration_label: humanize_us(r.duration_us),
323                    exit_status: r.exit_status,
324                    status_class: if r.exit_status == 0 {
325                        "deployed"
326                    } else {
327                        "error"
328                    },
329                })
330                .collect(),
331            group_total_us: g.group_total_us,
332            group_total_label: humanize_us(g.group_total_us),
333        })
334        .collect()
335}
336
337/// Display-friendly basename for a target path. The fully-qualified
338/// path is in the on-disk profile already; the rendered table is
339/// narrow.
340fn short_target(target: &str) -> String {
341    std::path::Path::new(target)
342        .file_name()
343        .map(|n| n.to_string_lossy().into_owned())
344        .unwrap_or_else(|| target.to_string())
345}
346
347/// Compact human duration: "0 µs" / "1.2 ms" / "350 ms" / "1.4 s".
348pub fn humanize_us(us: u64) -> String {
349    if us < 1_000 {
350        format!("{us} µs")
351    } else if us < 1_000_000 {
352        format!("{:.1} ms", us as f64 / 1_000.0)
353    } else {
354        format!("{:.2} s", us as f64 / 1_000_000.0)
355    }
356}
357
358/// Aggregate the last `runs` profiles into per-target percentile stats.
359///
360/// The CLI applies a default of 10 when the user passes `--runs`
361/// without a value (see `clap`'s `default_missing_value` in
362/// `dodot-cli/src/main.rs`); this function takes the resolved count
363/// directly so it stays useful from external callers (tests, custom
364/// harnesses) that pick their own N.
365pub fn shell_init_aggregate(ctx: &ExecutionContext, runs: usize) -> Result<ProbeResult> {
366    let root_config = ctx.config_manager.root_config()?;
367    let profiling_enabled = root_config.profiling.enabled;
368    let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
369    let view = aggregate_profiles(&profiles);
370    let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
371
372    Ok(ProbeResult::ShellInitAggregate(ShellInitAggregateView {
373        runs: view.runs,
374        requested_runs: runs,
375        profiling_enabled,
376        profiles_dir,
377        rows: view.targets.into_iter().map(into_aggregate_row).collect(),
378    }))
379}
380
381fn into_aggregate_row(t: AggregatedTarget) -> ShellInitAggregateRow {
382    ShellInitAggregateRow {
383        pack: t.pack,
384        handler: t.handler,
385        target: short_target(&t.target),
386        p50_label: humanize_us(t.p50_us),
387        p95_label: humanize_us(t.p95_us),
388        max_label: humanize_us(t.max_us),
389        p50_us: t.p50_us,
390        p95_us: t.p95_us,
391        max_us: t.max_us,
392        seen_label: format!("{}/{}", t.runs_seen, t.runs_total),
393        runs_seen: t.runs_seen,
394        runs_total: t.runs_total,
395    }
396}
397
398/// Default cap on the number of history rows emitted, so a user with
399/// hundreds of profiles doesn't get a page-filling race down their
400/// terminal.
401pub const DEFAULT_HISTORY_LIMIT: usize = 50;
402
403/// Render the per-run history view (one summary line per profile).
404pub fn shell_init_history(ctx: &ExecutionContext, limit: usize) -> Result<ProbeResult> {
405    let root_config = ctx.config_manager.root_config()?;
406    let profiling_enabled = root_config.profiling.enabled;
407    let mut profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), limit)?;
408    // read_recent_profiles returns newest-first; reverse so output reads
409    // chronologically (oldest at top, latest at the bottom near the
410    // user's prompt).
411    profiles.reverse();
412    let history = summarize_history(&profiles);
413    let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
414
415    Ok(ProbeResult::ShellInitHistory(ShellInitHistoryView {
416        profiling_enabled,
417        profiles_dir,
418        rows: history.into_iter().map(into_history_row).collect(),
419    }))
420}
421
422fn into_history_row(h: HistoryEntry) -> ShellInitHistoryRow {
423    ShellInitHistoryRow {
424        filename: h.filename,
425        unix_ts: h.unix_ts,
426        when: format_unix_ts(h.unix_ts),
427        shell: h.shell,
428        total_label: humanize_us(h.total_us),
429        user_total_label: humanize_us(h.user_total_us),
430        total_us: h.total_us,
431        user_total_us: h.user_total_us,
432        failed_entries: h.failed_entries,
433        entry_count: h.entry_count,
434    }
435}
436
437/// Format a unix timestamp as `YYYY-MM-DD HH:MM` in UTC. Returns an
438/// empty string for `0` (parse-failure sentinel) so the renderer can
439/// just print a blank cell.
440///
441/// Does the calendar math by hand to avoid pulling a dep — chrono is
442/// overkill for one display string. Algorithm: Howard Hinnant's
443/// civil_from_days.
444pub fn format_unix_ts(ts: u64) -> String {
445    // 0 is the parse-failure sentinel from `parse_unix_ts_from_filename`;
446    // anything past year 9999 is also nonsense in a shell-startup
447    // profile (the file format itself is the giveaway). Returning an
448    // empty string keeps the renderer predictable even in the face of
449    // a tampered-with filename, and bounds the i64 cast on `days`
450    // safely below i64::MAX regardless of input.
451    const MAX_REASONABLE_TS: u64 = 253_402_300_799; // 9999-12-31T23:59:59 UTC.
452    if ts == 0 || ts > MAX_REASONABLE_TS {
453        return String::new();
454    }
455    let secs_per_day: u64 = 86_400;
456    let days = (ts / secs_per_day) as i64; // safe: ts < 2.5e11 → days < 3e6
457    let secs_of_day = ts % secs_per_day;
458    let hour = secs_of_day / 3600;
459    let minute = (secs_of_day % 3600) / 60;
460    let (y, m, d) = civil_from_days(days);
461    format!("{y:04}-{m:02}-{d:02} {hour:02}:{minute:02}")
462}
463
464/// Howard Hinnant's `civil_from_days`: convert days since 1970-01-01
465/// (UTC) into `(year, month, day)`. Public-domain algorithm.
466fn civil_from_days(z: i64) -> (i32, u32, u32) {
467    let z = z + 719468;
468    let era = if z >= 0 { z } else { z - 146096 } / 146097;
469    let doe = (z - era * 146097) as u64; // [0, 146096]
470    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
471    let y = yoe as i64 + era * 400;
472    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
473    let mp = (5 * doy + 2) / 153; // [0, 11]
474    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
475    let m = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
476    let y = if m <= 2 { y + 1 } else { y };
477    (y as i32, m as u32, d as u32)
478}
479
480/// Render the data-dir tree.
481pub fn show_data_dir(ctx: &ExecutionContext, max_depth: usize) -> Result<ProbeResult> {
482    let tree = collect_data_dir_tree(ctx.fs.as_ref(), ctx.paths.as_ref(), max_depth)?;
483    let total_nodes = tree.count_nodes();
484    let total_size = tree.total_size();
485    let mut lines = Vec::new();
486    flatten_tree(&tree, "", true, &mut lines, true);
487    Ok(ProbeResult::ShowDataDir {
488        data_dir: ctx.paths.data_dir().display().to_string(),
489        lines,
490        total_nodes,
491        total_size,
492    })
493}
494
495// ── Display helpers ─────────────────────────────────────────────────
496
497fn into_display_entry(e: DeploymentMapEntry, home: &std::path::Path) -> DeploymentDisplayEntry {
498    DeploymentDisplayEntry {
499        pack: e.pack,
500        handler: e.handler,
501        kind: e.kind.as_str().into(),
502        source: if e.source.as_os_str().is_empty() {
503            // Sentinel / rendered file — no source file backs this entry.
504            // Show an em dash so the column stays populated.
505            "—".into()
506        } else {
507            display_path(&e.source, home)
508        },
509        datastore: display_path(&e.datastore, home),
510    }
511}
512
513fn display_path(p: &std::path::Path, home: &std::path::Path) -> String {
514    if let Ok(rel) = p.strip_prefix(home) {
515        format!("~/{}", rel.display())
516    } else {
517        p.display().to_string()
518    }
519}
520
521/// Flatten a `TreeNode` into [`TreeLine`]s with box-drawing prefixes.
522///
523/// `prefix` is the continuation prefix applied to this node's
524/// descendants (e.g. `"│  "` if this node has more siblings below,
525/// `"   "` if it's the last). `is_last` controls the branch glyph for
526/// this node itself (`"└─ "` vs `"├─ "`). `is_root` skips the branch
527/// glyph on the topmost call so the root displays flush-left.
528fn flatten_tree(
529    node: &TreeNode,
530    prefix: &str,
531    is_last: bool,
532    out: &mut Vec<TreeLine>,
533    is_root: bool,
534) {
535    let branch = if is_root {
536        String::new()
537    } else if is_last {
538        "└─ ".to_string()
539    } else {
540        "├─ ".to_string()
541    };
542    let line_prefix = format!("{prefix}{branch}");
543    out.push(TreeLine {
544        prefix: line_prefix,
545        name: node.name.clone(),
546        annotation: annotate(node),
547    });
548
549    if node.children.is_empty() {
550        return;
551    }
552
553    // Extend the prefix for children. The root contributes no prefix
554    // characters; a last-child contributes "   "; an inner child
555    // contributes "│  ".
556    let child_prefix = if is_root {
557        String::new()
558    } else if is_last {
559        format!("{prefix}   ")
560    } else {
561        format!("{prefix}│  ")
562    };
563
564    let last_idx = node.children.len() - 1;
565    for (i, child) in node.children.iter().enumerate() {
566        flatten_tree(child, &child_prefix, i == last_idx, out, false);
567    }
568}
569
570fn annotate(node: &TreeNode) -> String {
571    match node.kind {
572        "dir" => match node.truncated_count {
573            Some(n) if n > 0 => format!("(… {n} more)"),
574            _ => String::new(),
575        },
576        "file" => match node.size {
577            Some(n) => humanize_bytes(n),
578            None => String::new(),
579        },
580        "symlink" => match &node.link_target {
581            Some(t) => format!("→ {t}"),
582            None => "→ (broken)".into(),
583        },
584        _ => String::new(),
585    }
586}
587
588/// Compact byte sizing: "512 B", "1.2 KB", "3.4 MB".
589///
590/// KB = 1024 bytes. No fractional KB below 1024.
591pub fn humanize_bytes(n: u64) -> String {
592    const KB: u64 = 1024;
593    const MB: u64 = KB * 1024;
594    const GB: u64 = MB * 1024;
595    if n < KB {
596        format!("{n} B")
597    } else if n < MB {
598        format!("{:.1} KB", n as f64 / KB as f64)
599    } else if n < GB {
600        format!("{:.1} MB", n as f64 / MB as f64)
601    } else {
602        format!("{:.1} GB", n as f64 / GB as f64)
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609    use crate::probe::{DeploymentKind, DeploymentMapEntry};
610    use std::path::PathBuf;
611
612    fn home() -> PathBuf {
613        PathBuf::from("/home/alice")
614    }
615
616    #[test]
617    fn display_path_shortens_home() {
618        assert_eq!(
619            display_path(&PathBuf::from("/home/alice/dotfiles/vim/rc"), &home()),
620            "~/dotfiles/vim/rc"
621        );
622    }
623
624    #[test]
625    fn display_path_keeps_paths_outside_home() {
626        assert_eq!(
627            display_path(&PathBuf::from("/opt/data"), &home()),
628            "/opt/data"
629        );
630    }
631
632    #[test]
633    fn humanize_bytes_boundaries() {
634        assert_eq!(humanize_bytes(0), "0 B");
635        assert_eq!(humanize_bytes(1023), "1023 B");
636        assert_eq!(humanize_bytes(1024), "1.0 KB");
637        assert_eq!(humanize_bytes(1024 * 1024), "1.0 MB");
638        assert_eq!(humanize_bytes(1024 * 1024 * 1024), "1.0 GB");
639    }
640
641    #[test]
642    fn format_unix_ts_handles_zero_and_out_of_range() {
643        // Sentinel for parse-failure → empty, not a date.
644        assert_eq!(format_unix_ts(0), "");
645        // Real timestamp → formatted.
646        assert_eq!(format_unix_ts(1_714_000_000), "2024-04-24 23:06");
647        // Past year 9999 → empty (defensive ceiling so a tampered
648        // filename doesn't produce a nonsense date or risk overflow
649        // during the i64 cast on `days`).
650        assert_eq!(format_unix_ts(u64::MAX), "");
651        assert_eq!(format_unix_ts(253_402_300_800), ""); // 1s past year 9999.
652                                                         // Right below the ceiling still renders.
653        assert_eq!(format_unix_ts(253_402_300_799), "9999-12-31 23:59");
654    }
655
656    #[test]
657    fn into_display_entry_handles_sentinel_source() {
658        let entry = DeploymentMapEntry {
659            pack: "nvim".into(),
660            handler: "install".into(),
661            kind: DeploymentKind::File,
662            source: PathBuf::new(),
663            datastore: PathBuf::from("/home/alice/.local/share/dodot/packs/nvim/install/sent"),
664        };
665        let display = into_display_entry(entry, &home());
666        assert_eq!(display.source, "—");
667        assert!(display.datastore.starts_with("~/"));
668    }
669
670    #[test]
671    fn tree_flattening_produces_branch_glyphs() {
672        // Fabricate a small tree:
673        //   root
674        //   ├─ a
675        //   │  └─ aa
676        //   └─ b
677        let tree = TreeNode {
678            name: "root".into(),
679            path: PathBuf::from("/root"),
680            kind: "dir",
681            size: None,
682            link_target: None,
683            truncated_count: None,
684            children: vec![
685                TreeNode {
686                    name: "a".into(),
687                    path: PathBuf::from("/root/a"),
688                    kind: "dir",
689                    size: None,
690                    link_target: None,
691                    truncated_count: None,
692                    children: vec![TreeNode {
693                        name: "aa".into(),
694                        path: PathBuf::from("/root/a/aa"),
695                        kind: "file",
696                        size: Some(10),
697                        link_target: None,
698                        truncated_count: None,
699                        children: Vec::new(),
700                    }],
701                },
702                TreeNode {
703                    name: "b".into(),
704                    path: PathBuf::from("/root/b"),
705                    kind: "file",
706                    size: Some(42),
707                    link_target: None,
708                    truncated_count: None,
709                    children: Vec::new(),
710                },
711            ],
712        };
713        let mut lines = Vec::new();
714        flatten_tree(&tree, "", true, &mut lines, true);
715        assert_eq!(lines.len(), 4);
716        assert_eq!(lines[0].name, "root");
717        assert_eq!(lines[0].prefix, ""); // root is flush-left
718        assert_eq!(lines[1].name, "a");
719        assert!(lines[1].prefix.ends_with("├─ "));
720        assert_eq!(lines[2].name, "aa");
721        assert!(lines[2].prefix.ends_with("└─ "));
722        assert!(lines[2].prefix.starts_with("│")); // parent is not last
723        assert_eq!(lines[3].name, "b");
724        assert!(lines[3].prefix.ends_with("└─ "));
725        assert_eq!(lines[3].annotation, "42 B");
726    }
727
728    #[test]
729    fn annotate_symlink_with_target() {
730        let node = TreeNode {
731            name: "link".into(),
732            path: PathBuf::from("/x"),
733            kind: "symlink",
734            size: Some(20),
735            link_target: Some("/target".into()),
736            truncated_count: None,
737            children: Vec::new(),
738        };
739        assert_eq!(annotate(&node), "→ /target");
740    }
741
742    #[test]
743    fn annotate_broken_symlink() {
744        let node = TreeNode {
745            name: "link".into(),
746            path: PathBuf::from("/x"),
747            kind: "symlink",
748            size: Some(20),
749            link_target: None,
750            truncated_count: None,
751            children: Vec::new(),
752        };
753        assert_eq!(annotate(&node), "→ (broken)");
754    }
755
756    #[test]
757    fn annotate_truncated_dir() {
758        let node = TreeNode {
759            name: "deep".into(),
760            path: PathBuf::from("/x"),
761            kind: "dir",
762            size: None,
763            link_target: None,
764            truncated_count: Some(7),
765            children: Vec::new(),
766        };
767        assert_eq!(annotate(&node), "(… 7 more)");
768    }
769
770    #[test]
771    fn probe_result_deployment_map_serialises_with_kind_tag() {
772        let result = ProbeResult::DeploymentMap {
773            data_dir: "/d".into(),
774            map_path: "/d/deployment-map.tsv".into(),
775            entries: Vec::new(),
776        };
777        let json = serde_json::to_value(&result).unwrap();
778        assert_eq!(json["kind"], "deployment-map");
779        assert!(json["entries"].is_array());
780    }
781
782    #[test]
783    fn probe_result_show_data_dir_serialises_with_kind_tag() {
784        let result = ProbeResult::ShowDataDir {
785            data_dir: "/d".into(),
786            lines: Vec::new(),
787            total_nodes: 1,
788            total_size: 0,
789        };
790        let json = serde_json::to_value(&result).unwrap();
791        assert_eq!(json["kind"], "show-data-dir");
792        assert_eq!(json["total_nodes"], 1);
793    }
794
795    #[test]
796    fn probe_subcommands_list_matches_variants() {
797        // Failsafe: if we add a probe subcommand to the enum we should
798        // add it to the summary list too. This assertion catches the
799        // former getting ahead of the latter.
800        let names: Vec<&str> = PROBE_SUBCOMMANDS.iter().map(|s| s.name).collect();
801        assert!(names.contains(&"deployment-map"));
802        assert!(names.contains(&"show-data-dir"));
803    }
804}