1use 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
22pub const DEFAULT_SHOW_DATA_DIR_DEPTH: usize = 4;
26
27#[derive(Debug, Clone, Serialize)]
31pub struct DeploymentDisplayEntry {
32 pub pack: String,
33 pub handler: String,
34 pub kind: String,
35 pub source: String,
38 pub datastore: String,
40}
41
42#[derive(Debug, Clone, Serialize)]
48pub struct TreeLine {
49 pub prefix: String,
51 pub name: String,
53 pub annotation: String,
56}
57
58#[derive(Debug, Clone, Serialize)]
61#[serde(tag = "kind", rename_all = "kebab-case")]
62pub enum ProbeResult {
63 Summary {
66 data_dir: String,
67 available: Vec<ProbeSubcommandInfo>,
68 },
69 DeploymentMap {
71 data_dir: String,
72 map_path: String,
73 entries: Vec<DeploymentDisplayEntry>,
74 },
75 ShowDataDir {
78 data_dir: String,
79 lines: Vec<TreeLine>,
81 total_nodes: usize,
82 total_size: u64,
85 },
86 ShellInit(ShellInitView),
89 ShellInitAggregate(ShellInitAggregateView),
92 ShellInitHistory(ShellInitHistoryView),
96}
97
98#[derive(Debug, Clone, Serialize)]
100pub struct ShellInitAggregateView {
101 pub runs: usize,
104 pub requested_runs: usize,
107 pub profiling_enabled: bool,
108 pub profiles_dir: String,
109 pub rows: Vec<ShellInitAggregateRow>,
110}
111
112#[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 pub seen_label: String,
128 pub runs_seen: usize,
129 pub runs_total: usize,
130}
131
132#[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#[derive(Debug, Clone, Serialize)]
142pub struct ShellInitHistoryRow {
143 pub filename: String,
146 pub unix_ts: u64,
151 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#[derive(Debug, Clone, Serialize)]
167pub struct ShellInitView {
168 pub filename: String,
171 pub shell: String,
173 pub profiling_enabled: bool,
175 pub has_profile: bool,
177 pub groups: Vec<ShellInitGroup>,
180 pub user_total_us: u64,
181 pub framing_us: u64,
182 pub total_us: u64,
183 pub profiles_dir: String,
185}
186
187#[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 pub status_class: &'static str,
199}
200
201#[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#[derive(Debug, Clone, Serialize)]
213pub struct ProbeSubcommandInfo {
214 pub name: &'static str,
215 pub description: &'static str,
216}
217
218pub 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
236pub 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
246pub 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
265pub 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
337fn 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
347pub 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
358pub 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
398pub const DEFAULT_HISTORY_LIMIT: usize = 50;
402
403pub 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 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
437pub fn format_unix_ts(ts: u64) -> String {
445 const MAX_REASONABLE_TS: u64 = 253_402_300_799; 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; 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
464fn 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; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i64 + era * 400;
472 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = doy - (153 * mp + 2) / 5 + 1; let m = if mp < 10 { mp + 3 } else { mp - 9 }; let y = if m <= 2 { y + 1 } else { y };
477 (y as i32, m as u32, d as u32)
478}
479
480pub 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
495fn 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 "—".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
521fn 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 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
588pub 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 assert_eq!(format_unix_ts(0), "");
645 assert_eq!(format_unix_ts(1_714_000_000), "2024-04-24 23:06");
647 assert_eq!(format_unix_ts(u64::MAX), "");
651 assert_eq!(format_unix_ts(253_402_300_800), ""); 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 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, ""); 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("│")); 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 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}