1use 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
21pub const DEFAULT_SHOW_DATA_DIR_DEPTH: usize = 4;
25
26#[derive(Debug, Clone, Serialize)]
30pub struct DeploymentDisplayEntry {
31 pub pack: String,
32 pub handler: String,
33 pub kind: String,
34 pub source: String,
37 pub datastore: String,
39}
40
41#[derive(Debug, Clone, Serialize)]
47pub struct TreeLine {
48 pub prefix: String,
50 pub name: String,
52 pub annotation: String,
55}
56
57#[derive(Debug, Clone, Serialize)]
60#[serde(tag = "kind", rename_all = "kebab-case")]
61pub enum ProbeResult {
62 Summary {
65 data_dir: String,
66 available: Vec<ProbeSubcommandInfo>,
67 },
68 DeploymentMap {
70 data_dir: String,
71 map_path: String,
72 entries: Vec<DeploymentDisplayEntry>,
73 },
74 ShowDataDir {
77 data_dir: String,
78 lines: Vec<TreeLine>,
80 total_nodes: usize,
81 total_size: u64,
84 },
85 ShellInit(ShellInitView),
88}
89
90#[derive(Debug, Clone, Serialize)]
94pub struct ShellInitView {
95 pub filename: String,
98 pub shell: String,
100 pub profiling_enabled: bool,
102 pub has_profile: bool,
104 pub groups: Vec<ShellInitGroup>,
107 pub user_total_us: u64,
108 pub framing_us: u64,
109 pub total_us: u64,
110 pub profiles_dir: String,
112}
113
114#[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 pub status_class: &'static str,
126}
127
128#[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#[derive(Debug, Clone, Serialize)]
140pub struct ProbeSubcommandInfo {
141 pub name: &'static str,
142 pub description: &'static str,
143}
144
145pub 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
163pub 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
173pub 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
192pub 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
264fn 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
274pub 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
285pub 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
300fn 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 "—".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
326fn 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 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
393pub 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 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, ""); 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("│")); 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 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}