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    collect_data_dir_tree, collect_deployment_map, group_profile, read_latest_profile,
17    DeploymentMapEntry, GroupedProfile, TreeNode,
18};
19use crate::Result;
20
21/// Default max depth for `probe show-data-dir`. Enough to show
22/// `packs / <pack> / <handler> / <entry>` without scrolling off
23/// screen for reasonable installs; deeper subtrees are summarised.
24pub const DEFAULT_SHOW_DATA_DIR_DEPTH: usize = 4;
25
26/// Display-shaped deployment-map row. Paths are pre-shortened to
27/// `~/…` where they live under HOME so the rendered table stays
28/// narrow; the machine-readable TSV on disk keeps absolute paths.
29#[derive(Debug, Clone, Serialize)]
30pub struct DeploymentDisplayEntry {
31    pub pack: String,
32    pub handler: String,
33    pub kind: String,
34    /// Pre-shortened (`~/…`) absolute source path; empty for
35    /// non-symlink entries (sentinels, rendered files).
36    pub source: String,
37    /// Pre-shortened absolute datastore path.
38    pub datastore: String,
39}
40
41/// One line of tree output, pre-flattened for the template.
42///
43/// The template for a tree is annoying to write directly in Jinja
44/// (indentation, prefix characters, etc.), so we flatten the tree to
45/// a list of `(indent, name, annotation)` triples here.
46#[derive(Debug, Clone, Serialize)]
47pub struct TreeLine {
48    /// Indent prefix (e.g. `"  │  ├─ "`).
49    pub prefix: String,
50    /// The node's display name (basename for non-root nodes).
51    pub name: String,
52    /// A dim-styled annotation shown after the name (size, link target,
53    /// truncation count). Empty when the node has nothing extra to say.
54    pub annotation: String,
55}
56
57/// Result of any `probe` invocation. Serialises with a `kind` tag so
58/// the Jinja template can dispatch on it.
59#[derive(Debug, Clone, Serialize)]
60#[serde(tag = "kind", rename_all = "kebab-case")]
61pub enum ProbeResult {
62    /// `dodot probe` with no subcommand — a summary pointing the user
63    /// at the real subcommands.
64    Summary {
65        data_dir: String,
66        available: Vec<ProbeSubcommandInfo>,
67    },
68    /// `dodot probe deployment-map` — the source↔deployed map.
69    DeploymentMap {
70        data_dir: String,
71        map_path: String,
72        entries: Vec<DeploymentDisplayEntry>,
73    },
74    /// `dodot probe show-data-dir` — a bounded tree view of
75    /// `<data_dir>`.
76    ShowDataDir {
77        data_dir: String,
78        /// Flattened, template-ready lines.
79        lines: Vec<TreeLine>,
80        total_nodes: usize,
81        /// Size in bytes of the whole tree (symlinks counted by their
82        /// link-entry size).
83        total_size: u64,
84    },
85    /// `dodot probe shell-init` — the most recent shell-startup profile,
86    /// grouped by pack and handler.
87    ShellInit(ShellInitView),
88}
89
90/// Display payload for `probe shell-init`. Pulled into its own struct
91/// so the JSON view stays clean and the variant constructor in
92/// `shell_init()` reads naturally.
93#[derive(Debug, Clone, Serialize)]
94pub struct ShellInitView {
95    /// Source filename of the report (for "which run is this?" UX).
96    /// Empty when no profile has been written yet.
97    pub filename: String,
98    /// Shell label as recorded in the preamble (e.g. `bash 5.3.9`).
99    pub shell: String,
100    /// True when the profiling wrapper is enabled in config.
101    pub profiling_enabled: bool,
102    /// True when the directory exists and contained a parseable file.
103    pub has_profile: bool,
104    /// Pre-grouped rows for the template; empty when `has_profile` is
105    /// false.
106    pub groups: Vec<ShellInitGroup>,
107    pub user_total_us: u64,
108    pub framing_us: u64,
109    pub total_us: u64,
110    /// Where the profiles live on disk (so the user can `ls` it).
111    pub profiles_dir: String,
112}
113
114/// Display row for one entry in a shell-init group.
115#[derive(Debug, Clone, Serialize)]
116pub struct ShellInitRow {
117    pub target: String,
118    pub duration_us: u64,
119    pub duration_label: String,
120    pub exit_status: i32,
121    /// `"deployed"` (success — rendered green) or `"error"` (non-zero
122    /// source exit). These map directly to existing styles in
123    /// `crate::render`'s theme; using fresh names here would require
124    /// theme additions for no UX gain.
125    pub status_class: &'static str,
126}
127
128/// Display group: one (pack, handler) bucket of shell-init rows.
129#[derive(Debug, Clone, Serialize)]
130pub struct ShellInitGroup {
131    pub pack: String,
132    pub handler: String,
133    pub rows: Vec<ShellInitRow>,
134    pub group_total_us: u64,
135    pub group_total_label: String,
136}
137
138/// One entry in the `probe` summary listing.
139#[derive(Debug, Clone, Serialize)]
140pub struct ProbeSubcommandInfo {
141    pub name: &'static str,
142    pub description: &'static str,
143}
144
145/// The full list of probe subcommands, used by the summary view.
146/// Keeping them in one array keeps the CLI registration, clap
147/// registration, and summary output trivially in sync.
148pub const PROBE_SUBCOMMANDS: &[ProbeSubcommandInfo] = &[
149    ProbeSubcommandInfo {
150        name: "deployment-map",
151        description: "Source↔deployed map — what dodot linked where.",
152    },
153    ProbeSubcommandInfo {
154        name: "shell-init",
155        description: "Per-source timings for the most recent shell startup.",
156    },
157    ProbeSubcommandInfo {
158        name: "show-data-dir",
159        description: "Tree of dodot's data directory, with sizes.",
160    },
161];
162
163// ── Entry points ────────────────────────────────────────────────────
164
165/// Render the bare `dodot probe` summary.
166pub fn summary(ctx: &ExecutionContext) -> Result<ProbeResult> {
167    Ok(ProbeResult::Summary {
168        data_dir: ctx.paths.data_dir().display().to_string(),
169        available: PROBE_SUBCOMMANDS.to_vec(),
170    })
171}
172
173/// Render the deployment map for display.
174///
175/// Reads the current datastore state (not the on-disk TSV) so the
176/// output is always fresh even if the user never ran `dodot up`.
177pub fn deployment_map(ctx: &ExecutionContext) -> Result<ProbeResult> {
178    let raw = collect_deployment_map(ctx.fs.as_ref(), ctx.paths.as_ref())?;
179    let home = ctx.paths.home_dir();
180    let entries = raw
181        .into_iter()
182        .map(|e| into_display_entry(e, home))
183        .collect();
184
185    Ok(ProbeResult::DeploymentMap {
186        data_dir: ctx.paths.data_dir().display().to_string(),
187        map_path: ctx.paths.deployment_map_path().display().to_string(),
188        entries,
189    })
190}
191
192/// Render the most recent shell-init profile.
193///
194/// When no profile has been written yet (fresh install, or profiling
195/// disabled, or the user hasn't started a shell since the last `up`),
196/// returns a "no data" view with `has_profile = false`. The template
197/// uses that flag to print a hint instead of an empty table.
198pub fn shell_init(ctx: &ExecutionContext) -> Result<ProbeResult> {
199    let root_config = ctx.config_manager.root_config()?;
200    let profiling_enabled = root_config.profiling.enabled;
201
202    let profile_opt = read_latest_profile(ctx.fs.as_ref(), ctx.paths.as_ref())?;
203    let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
204
205    let view = match profile_opt {
206        Some(profile) => {
207            let grouped = group_profile(&profile);
208            ShellInitView {
209                filename: profile.filename.clone(),
210                shell: profile.shell.clone(),
211                profiling_enabled,
212                has_profile: true,
213                groups: shell_init_groups(&grouped),
214                user_total_us: grouped.user_total_us,
215                framing_us: grouped.framing_us,
216                total_us: grouped.total_us,
217                profiles_dir,
218            }
219        }
220        None => ShellInitView {
221            filename: String::new(),
222            shell: String::new(),
223            profiling_enabled,
224            has_profile: false,
225            groups: Vec::new(),
226            user_total_us: 0,
227            framing_us: 0,
228            total_us: 0,
229            profiles_dir,
230        },
231    };
232
233    Ok(ProbeResult::ShellInit(view))
234}
235
236fn shell_init_groups(grouped: &GroupedProfile) -> Vec<ShellInitGroup> {
237    grouped
238        .groups
239        .iter()
240        .map(|g| ShellInitGroup {
241            pack: g.pack.clone(),
242            handler: g.handler.clone(),
243            rows: g
244                .rows
245                .iter()
246                .map(|r| ShellInitRow {
247                    target: short_target(&r.target),
248                    duration_us: r.duration_us,
249                    duration_label: humanize_us(r.duration_us),
250                    exit_status: r.exit_status,
251                    status_class: if r.exit_status == 0 {
252                        "deployed"
253                    } else {
254                        "error"
255                    },
256                })
257                .collect(),
258            group_total_us: g.group_total_us,
259            group_total_label: humanize_us(g.group_total_us),
260        })
261        .collect()
262}
263
264/// Display-friendly basename for a target path. The fully-qualified
265/// path is in the on-disk profile already; the rendered table is
266/// narrow.
267fn short_target(target: &str) -> String {
268    std::path::Path::new(target)
269        .file_name()
270        .map(|n| n.to_string_lossy().into_owned())
271        .unwrap_or_else(|| target.to_string())
272}
273
274/// Compact human duration: "0 µs" / "1.2 ms" / "350 ms" / "1.4 s".
275pub fn humanize_us(us: u64) -> String {
276    if us < 1_000 {
277        format!("{us} µs")
278    } else if us < 1_000_000 {
279        format!("{:.1} ms", us as f64 / 1_000.0)
280    } else {
281        format!("{:.2} s", us as f64 / 1_000_000.0)
282    }
283}
284
285/// Render the data-dir tree.
286pub fn show_data_dir(ctx: &ExecutionContext, max_depth: usize) -> Result<ProbeResult> {
287    let tree = collect_data_dir_tree(ctx.fs.as_ref(), ctx.paths.as_ref(), max_depth)?;
288    let total_nodes = tree.count_nodes();
289    let total_size = tree.total_size();
290    let mut lines = Vec::new();
291    flatten_tree(&tree, "", true, &mut lines, true);
292    Ok(ProbeResult::ShowDataDir {
293        data_dir: ctx.paths.data_dir().display().to_string(),
294        lines,
295        total_nodes,
296        total_size,
297    })
298}
299
300// ── Display helpers ─────────────────────────────────────────────────
301
302fn into_display_entry(e: DeploymentMapEntry, home: &std::path::Path) -> DeploymentDisplayEntry {
303    DeploymentDisplayEntry {
304        pack: e.pack,
305        handler: e.handler,
306        kind: e.kind.as_str().into(),
307        source: if e.source.as_os_str().is_empty() {
308            // Sentinel / rendered file — no source file backs this entry.
309            // Show an em dash so the column stays populated.
310            "—".into()
311        } else {
312            display_path(&e.source, home)
313        },
314        datastore: display_path(&e.datastore, home),
315    }
316}
317
318fn display_path(p: &std::path::Path, home: &std::path::Path) -> String {
319    if let Ok(rel) = p.strip_prefix(home) {
320        format!("~/{}", rel.display())
321    } else {
322        p.display().to_string()
323    }
324}
325
326/// Flatten a `TreeNode` into [`TreeLine`]s with box-drawing prefixes.
327///
328/// `prefix` is the continuation prefix applied to this node's
329/// descendants (e.g. `"│  "` if this node has more siblings below,
330/// `"   "` if it's the last). `is_last` controls the branch glyph for
331/// this node itself (`"└─ "` vs `"├─ "`). `is_root` skips the branch
332/// glyph on the topmost call so the root displays flush-left.
333fn flatten_tree(
334    node: &TreeNode,
335    prefix: &str,
336    is_last: bool,
337    out: &mut Vec<TreeLine>,
338    is_root: bool,
339) {
340    let branch = if is_root {
341        String::new()
342    } else if is_last {
343        "└─ ".to_string()
344    } else {
345        "├─ ".to_string()
346    };
347    let line_prefix = format!("{prefix}{branch}");
348    out.push(TreeLine {
349        prefix: line_prefix,
350        name: node.name.clone(),
351        annotation: annotate(node),
352    });
353
354    if node.children.is_empty() {
355        return;
356    }
357
358    // Extend the prefix for children. The root contributes no prefix
359    // characters; a last-child contributes "   "; an inner child
360    // contributes "│  ".
361    let child_prefix = if is_root {
362        String::new()
363    } else if is_last {
364        format!("{prefix}   ")
365    } else {
366        format!("{prefix}│  ")
367    };
368
369    let last_idx = node.children.len() - 1;
370    for (i, child) in node.children.iter().enumerate() {
371        flatten_tree(child, &child_prefix, i == last_idx, out, false);
372    }
373}
374
375fn annotate(node: &TreeNode) -> String {
376    match node.kind {
377        "dir" => match node.truncated_count {
378            Some(n) if n > 0 => format!("(… {n} more)"),
379            _ => String::new(),
380        },
381        "file" => match node.size {
382            Some(n) => humanize_bytes(n),
383            None => String::new(),
384        },
385        "symlink" => match &node.link_target {
386            Some(t) => format!("→ {t}"),
387            None => "→ (broken)".into(),
388        },
389        _ => String::new(),
390    }
391}
392
393/// Compact byte sizing: "512 B", "1.2 KB", "3.4 MB".
394///
395/// KB = 1024 bytes. No fractional KB below 1024.
396pub fn humanize_bytes(n: u64) -> String {
397    const KB: u64 = 1024;
398    const MB: u64 = KB * 1024;
399    const GB: u64 = MB * 1024;
400    if n < KB {
401        format!("{n} B")
402    } else if n < MB {
403        format!("{:.1} KB", n as f64 / KB as f64)
404    } else if n < GB {
405        format!("{:.1} MB", n as f64 / MB as f64)
406    } else {
407        format!("{:.1} GB", n as f64 / GB as f64)
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::probe::{DeploymentKind, DeploymentMapEntry};
415    use std::path::PathBuf;
416
417    fn home() -> PathBuf {
418        PathBuf::from("/home/alice")
419    }
420
421    #[test]
422    fn display_path_shortens_home() {
423        assert_eq!(
424            display_path(&PathBuf::from("/home/alice/dotfiles/vim/rc"), &home()),
425            "~/dotfiles/vim/rc"
426        );
427    }
428
429    #[test]
430    fn display_path_keeps_paths_outside_home() {
431        assert_eq!(
432            display_path(&PathBuf::from("/opt/data"), &home()),
433            "/opt/data"
434        );
435    }
436
437    #[test]
438    fn humanize_bytes_boundaries() {
439        assert_eq!(humanize_bytes(0), "0 B");
440        assert_eq!(humanize_bytes(1023), "1023 B");
441        assert_eq!(humanize_bytes(1024), "1.0 KB");
442        assert_eq!(humanize_bytes(1024 * 1024), "1.0 MB");
443        assert_eq!(humanize_bytes(1024 * 1024 * 1024), "1.0 GB");
444    }
445
446    #[test]
447    fn into_display_entry_handles_sentinel_source() {
448        let entry = DeploymentMapEntry {
449            pack: "nvim".into(),
450            handler: "install".into(),
451            kind: DeploymentKind::File,
452            source: PathBuf::new(),
453            datastore: PathBuf::from("/home/alice/.local/share/dodot/packs/nvim/install/sent"),
454        };
455        let display = into_display_entry(entry, &home());
456        assert_eq!(display.source, "—");
457        assert!(display.datastore.starts_with("~/"));
458    }
459
460    #[test]
461    fn tree_flattening_produces_branch_glyphs() {
462        // Fabricate a small tree:
463        //   root
464        //   ├─ a
465        //   │  └─ aa
466        //   └─ b
467        let tree = TreeNode {
468            name: "root".into(),
469            path: PathBuf::from("/root"),
470            kind: "dir",
471            size: None,
472            link_target: None,
473            truncated_count: None,
474            children: vec![
475                TreeNode {
476                    name: "a".into(),
477                    path: PathBuf::from("/root/a"),
478                    kind: "dir",
479                    size: None,
480                    link_target: None,
481                    truncated_count: None,
482                    children: vec![TreeNode {
483                        name: "aa".into(),
484                        path: PathBuf::from("/root/a/aa"),
485                        kind: "file",
486                        size: Some(10),
487                        link_target: None,
488                        truncated_count: None,
489                        children: Vec::new(),
490                    }],
491                },
492                TreeNode {
493                    name: "b".into(),
494                    path: PathBuf::from("/root/b"),
495                    kind: "file",
496                    size: Some(42),
497                    link_target: None,
498                    truncated_count: None,
499                    children: Vec::new(),
500                },
501            ],
502        };
503        let mut lines = Vec::new();
504        flatten_tree(&tree, "", true, &mut lines, true);
505        assert_eq!(lines.len(), 4);
506        assert_eq!(lines[0].name, "root");
507        assert_eq!(lines[0].prefix, ""); // root is flush-left
508        assert_eq!(lines[1].name, "a");
509        assert!(lines[1].prefix.ends_with("├─ "));
510        assert_eq!(lines[2].name, "aa");
511        assert!(lines[2].prefix.ends_with("└─ "));
512        assert!(lines[2].prefix.starts_with("│")); // parent is not last
513        assert_eq!(lines[3].name, "b");
514        assert!(lines[3].prefix.ends_with("└─ "));
515        assert_eq!(lines[3].annotation, "42 B");
516    }
517
518    #[test]
519    fn annotate_symlink_with_target() {
520        let node = TreeNode {
521            name: "link".into(),
522            path: PathBuf::from("/x"),
523            kind: "symlink",
524            size: Some(20),
525            link_target: Some("/target".into()),
526            truncated_count: None,
527            children: Vec::new(),
528        };
529        assert_eq!(annotate(&node), "→ /target");
530    }
531
532    #[test]
533    fn annotate_broken_symlink() {
534        let node = TreeNode {
535            name: "link".into(),
536            path: PathBuf::from("/x"),
537            kind: "symlink",
538            size: Some(20),
539            link_target: None,
540            truncated_count: None,
541            children: Vec::new(),
542        };
543        assert_eq!(annotate(&node), "→ (broken)");
544    }
545
546    #[test]
547    fn annotate_truncated_dir() {
548        let node = TreeNode {
549            name: "deep".into(),
550            path: PathBuf::from("/x"),
551            kind: "dir",
552            size: None,
553            link_target: None,
554            truncated_count: Some(7),
555            children: Vec::new(),
556        };
557        assert_eq!(annotate(&node), "(… 7 more)");
558    }
559
560    #[test]
561    fn probe_result_deployment_map_serialises_with_kind_tag() {
562        let result = ProbeResult::DeploymentMap {
563            data_dir: "/d".into(),
564            map_path: "/d/deployment-map.tsv".into(),
565            entries: Vec::new(),
566        };
567        let json = serde_json::to_value(&result).unwrap();
568        assert_eq!(json["kind"], "deployment-map");
569        assert!(json["entries"].is_array());
570    }
571
572    #[test]
573    fn probe_result_show_data_dir_serialises_with_kind_tag() {
574        let result = ProbeResult::ShowDataDir {
575            data_dir: "/d".into(),
576            lines: Vec::new(),
577            total_nodes: 1,
578            total_size: 0,
579        };
580        let json = serde_json::to_value(&result).unwrap();
581        assert_eq!(json["kind"], "show-data-dir");
582        assert_eq!(json["total_nodes"], 1);
583    }
584
585    #[test]
586    fn probe_subcommands_list_matches_variants() {
587        // Failsafe: if we add a probe subcommand to the enum we should
588        // add it to the summary list too. This assertion catches the
589        // former getting ahead of the latter.
590        let names: Vec<&str> = PROBE_SUBCOMMANDS.iter().map(|s| s.name).collect();
591        assert!(names.contains(&"deployment-map"));
592        assert!(names.contains(&"show-data-dir"));
593    }
594}