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 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
23pub const DEFAULT_SHOW_DATA_DIR_DEPTH: usize = 4;
27
28#[derive(Debug, Clone, Serialize)]
32pub struct DeploymentDisplayEntry {
33 pub pack: String,
34 pub handler: String,
35 pub kind: String,
36 pub source: String,
39 pub datastore: String,
41}
42
43#[derive(Debug, Clone, Serialize)]
49pub struct TreeLine {
50 pub prefix: String,
52 pub name: String,
54 pub annotation: String,
57}
58
59#[derive(Debug, Clone, Serialize)]
62#[serde(tag = "kind", rename_all = "kebab-case")]
63pub enum ProbeResult {
64 Summary {
67 data_dir: String,
68 available: Vec<ProbeSubcommandInfo>,
69 },
70 DeploymentMap {
72 data_dir: String,
73 map_path: String,
74 entries: Vec<DeploymentDisplayEntry>,
75 },
76 ShowDataDir {
79 data_dir: String,
80 lines: Vec<TreeLine>,
82 total_nodes: usize,
83 total_size: u64,
86 },
87 ShellInit(ShellInitView),
90 ShellInitAggregate(ShellInitAggregateView),
93 ShellInitHistory(ShellInitHistoryView),
97 ShellInitFilter(ShellInitFilterView),
102 ShellInitErrors(ShellInitErrorsView),
106}
107
108#[derive(Debug, Clone, Serialize)]
110pub struct ShellInitAggregateView {
111 pub runs: usize,
114 pub requested_runs: usize,
117 pub profiling_enabled: bool,
118 pub profiles_dir: String,
119 pub rows: Vec<ShellInitAggregateRow>,
120 pub stale: bool,
124 pub latest_profile_when: String,
127 pub last_up_when: String,
130}
131
132#[derive(Debug, Clone, Serialize)]
135pub struct ShellInitAggregateRow {
136 pub pack: String,
137 pub handler: String,
138 pub target: String,
139 pub p50_label: String,
140 pub p95_label: String,
141 pub max_label: String,
142 pub p50_us: u64,
143 pub p95_us: u64,
144 pub max_us: u64,
145 pub seen_label: String,
148 pub runs_seen: usize,
149 pub runs_total: usize,
150}
151
152#[derive(Debug, Clone, Serialize)]
154pub struct ShellInitHistoryView {
155 pub profiling_enabled: bool,
156 pub profiles_dir: String,
157 pub rows: Vec<ShellInitHistoryRow>,
158 pub stale: bool,
162 pub latest_profile_when: String,
165 pub last_up_when: String,
168}
169
170#[derive(Debug, Clone, Serialize)]
172pub struct ShellInitHistoryRow {
173 pub filename: String,
176 pub unix_ts: u64,
181 pub when: String,
184 pub shell: String,
185 pub total_label: String,
186 pub user_total_label: String,
187 pub total_us: u64,
188 pub user_total_us: u64,
189 pub failed_entries: usize,
190 pub entry_count: usize,
191}
192
193#[derive(Debug, Clone, Serialize)]
197pub struct ShellInitView {
198 pub filename: String,
201 pub shell: String,
203 pub profiling_enabled: bool,
205 pub has_profile: bool,
207 pub groups: Vec<ShellInitGroup>,
210 pub user_total_us: u64,
211 pub framing_us: u64,
212 pub total_us: u64,
213 pub profiles_dir: String,
215 pub stale: bool,
219 pub profile_when: String,
222 pub last_up_when: String,
225}
226
227#[derive(Debug, Clone, Serialize)]
229pub struct ShellInitRow {
230 pub target: String,
231 pub duration_us: u64,
232 pub duration_label: String,
233 pub exit_status: i32,
234 pub status_class: &'static str,
239}
240
241#[derive(Debug, Clone, Serialize)]
243pub struct ShellInitGroup {
244 pub pack: String,
245 pub handler: String,
246 pub rows: Vec<ShellInitRow>,
247 pub group_total_us: u64,
248 pub group_total_label: String,
249}
250
251#[derive(Debug, Clone, Serialize)]
258pub struct ShellInitFilterView {
259 pub profiling_enabled: bool,
260 pub profiles_dir: String,
261 pub filter: String,
263 pub filter_pack: String,
265 pub filter_filename: Option<String>,
267 pub runs_examined: usize,
269 pub targets: Vec<ShellInitFilterTarget>,
274 pub stale: bool,
275 pub latest_profile_when: String,
276 pub last_up_when: String,
277}
278
279#[derive(Debug, Clone, Serialize)]
281pub struct ShellInitFilterTarget {
282 pub target: String,
284 pub display_target: String,
286 pub pack: String,
288 pub handler: String,
290 pub runs: Vec<ShellInitFilterRun>,
292 pub failure_count: usize,
294}
295
296#[derive(Debug, Clone, Serialize)]
300pub struct ShellInitErrorsView {
301 pub profiling_enabled: bool,
302 pub profiles_dir: String,
303 pub runs_examined: usize,
304 pub targets: Vec<ShellInitFilterTarget>,
308 pub stale: bool,
309 pub latest_profile_when: String,
310 pub last_up_when: String,
311}
312
313#[derive(Debug, Clone, Serialize)]
315pub struct ShellInitFilterRun {
316 pub when: String,
318 pub duration_label: String,
320 pub duration_us: u64,
321 pub exit_status: i32,
322 pub status_class: &'static str,
325 pub stderr_lines: Vec<String>,
331 pub profile_filename: String,
333}
334
335#[derive(Debug, Clone, Serialize)]
337pub struct ProbeSubcommandInfo {
338 pub name: &'static str,
339 pub description: &'static str,
340}
341
342pub const PROBE_SUBCOMMANDS: &[ProbeSubcommandInfo] = &[
346 ProbeSubcommandInfo {
347 name: "deployment-map",
348 description: "Source↔deployed map — what dodot linked where.",
349 },
350 ProbeSubcommandInfo {
351 name: "shell-init",
352 description: "Per-source timings for the most recent shell startup.",
353 },
354 ProbeSubcommandInfo {
355 name: "show-data-dir",
356 description: "Tree of dodot's data directory, with sizes.",
357 },
358];
359
360pub fn summary(ctx: &ExecutionContext) -> Result<ProbeResult> {
364 Ok(ProbeResult::Summary {
365 data_dir: ctx.paths.data_dir().display().to_string(),
366 available: PROBE_SUBCOMMANDS.to_vec(),
367 })
368}
369
370pub fn deployment_map(ctx: &ExecutionContext) -> Result<ProbeResult> {
375 let raw = collect_deployment_map(ctx.fs.as_ref(), ctx.paths.as_ref())?;
376 let home = ctx.paths.home_dir();
377 let entries = raw
378 .into_iter()
379 .map(|e| into_display_entry(e, home))
380 .collect();
381
382 Ok(ProbeResult::DeploymentMap {
383 data_dir: ctx.paths.data_dir().display().to_string(),
384 map_path: ctx.paths.deployment_map_path().display().to_string(),
385 entries,
386 })
387}
388
389pub fn shell_init(ctx: &ExecutionContext) -> Result<ProbeResult> {
396 let root_config = ctx.config_manager.root_config()?;
397 let profiling_enabled = root_config.profiling.enabled;
398
399 let profile_opt = read_latest_profile(ctx.fs.as_ref(), ctx.paths.as_ref())?;
400 let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
401 let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
402 let last_up_when = last_up_ts.map(format_unix_ts).unwrap_or_default();
403
404 let view = match profile_opt {
405 Some(profile) => {
406 let grouped = group_profile(&profile);
407 let profile_ts = parse_unix_ts_from_filename(&profile.filename);
408 let stale = is_stale(profile_ts, last_up_ts);
409 ShellInitView {
410 filename: profile.filename.clone(),
411 shell: profile.shell.clone(),
412 profiling_enabled,
413 has_profile: true,
414 groups: shell_init_groups(&grouped),
415 user_total_us: grouped.user_total_us,
416 framing_us: grouped.framing_us,
417 total_us: grouped.total_us,
418 profiles_dir,
419 stale,
420 profile_when: format_unix_ts(profile_ts),
421 last_up_when,
422 }
423 }
424 None => ShellInitView {
425 filename: String::new(),
426 shell: String::new(),
427 profiling_enabled,
428 has_profile: false,
429 groups: Vec::new(),
430 user_total_us: 0,
431 framing_us: 0,
432 total_us: 0,
433 profiles_dir,
434 stale: false,
435 profile_when: String::new(),
436 last_up_when,
437 },
438 };
439
440 Ok(ProbeResult::ShellInit(view))
441}
442
443fn is_stale(profile_ts: u64, last_up_ts: Option<u64>) -> bool {
447 matches!(last_up_ts, Some(last) if profile_ts > 0 && profile_ts < last)
448}
449
450fn shell_init_groups(grouped: &GroupedProfile) -> Vec<ShellInitGroup> {
451 grouped
452 .groups
453 .iter()
454 .map(|g| ShellInitGroup {
455 pack: g.pack.clone(),
456 handler: g.handler.clone(),
457 rows: g
458 .rows
459 .iter()
460 .map(|r| ShellInitRow {
461 target: short_target(&r.target),
462 duration_us: r.duration_us,
463 duration_label: humanize_us(r.duration_us),
464 exit_status: r.exit_status,
465 status_class: if r.exit_status == 0 {
466 "deployed"
467 } else {
468 "error"
469 },
470 })
471 .collect(),
472 group_total_us: g.group_total_us,
473 group_total_label: humanize_us(g.group_total_us),
474 })
475 .collect()
476}
477
478fn short_target(target: &str) -> String {
482 std::path::Path::new(target)
483 .file_name()
484 .map(|n| n.to_string_lossy().into_owned())
485 .unwrap_or_else(|| target.to_string())
486}
487
488pub fn humanize_us(us: u64) -> String {
490 if us < 1_000 {
491 format!("{us} µs")
492 } else if us < 1_000_000 {
493 format!("{:.1} ms", us as f64 / 1_000.0)
494 } else {
495 format!("{:.2} s", us as f64 / 1_000_000.0)
496 }
497}
498
499pub fn shell_init_aggregate(ctx: &ExecutionContext, runs: usize) -> Result<ProbeResult> {
507 let root_config = ctx.config_manager.root_config()?;
508 let profiling_enabled = root_config.profiling.enabled;
509 let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
510 let latest_profile_ts = profiles
513 .first()
514 .map(|p| parse_unix_ts_from_filename(&p.filename))
515 .unwrap_or(0);
516 let view = aggregate_profiles(&profiles);
517 let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
518 let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
519
520 Ok(ProbeResult::ShellInitAggregate(ShellInitAggregateView {
521 runs: view.runs,
522 requested_runs: runs,
523 profiling_enabled,
524 profiles_dir,
525 rows: view.targets.into_iter().map(into_aggregate_row).collect(),
526 stale: is_stale(latest_profile_ts, last_up_ts),
527 latest_profile_when: format_unix_ts(latest_profile_ts),
528 last_up_when: last_up_ts.map(format_unix_ts).unwrap_or_default(),
529 }))
530}
531
532fn into_aggregate_row(t: AggregatedTarget) -> ShellInitAggregateRow {
533 ShellInitAggregateRow {
534 pack: t.pack,
535 handler: t.handler,
536 target: short_target(&t.target),
537 p50_label: humanize_us(t.p50_us),
538 p95_label: humanize_us(t.p95_us),
539 max_label: humanize_us(t.max_us),
540 p50_us: t.p50_us,
541 p95_us: t.p95_us,
542 max_us: t.max_us,
543 seen_label: format!("{}/{}", t.runs_seen, t.runs_total),
544 runs_seen: t.runs_seen,
545 runs_total: t.runs_total,
546 }
547}
548
549pub const DEFAULT_HISTORY_LIMIT: usize = 50;
553
554pub const DEFAULT_FILTER_RUNS: usize = 20;
560
561fn target_matches_filter(target: &str, filter: &str) -> bool {
573 if !filter.contains('/') {
574 return std::path::Path::new(target)
575 .file_name()
576 .is_some_and(|s| s == std::ffi::OsStr::new(filter));
577 }
578 target.ends_with(&format!("/{filter}")) || target == filter
581}
582
583pub fn shell_init_filter(ctx: &ExecutionContext, filter: &str, runs: usize) -> Result<ProbeResult> {
589 let root_config = ctx.config_manager.root_config()?;
590 let profiling_enabled = root_config.profiling.enabled;
591 let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
592 let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
593 let last_up_when = last_up_ts.map(format_unix_ts).unwrap_or_default();
594
595 let trimmed = filter.trim().trim_start_matches("./").trim_end_matches('/');
598 let (filter_pack, filter_filename) = match trimmed.split_once('/') {
599 Some((p, f)) if !p.is_empty() && !f.is_empty() => (p.to_string(), Some(f.to_string())),
600 _ => (trimmed.to_string(), None),
601 };
602
603 let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
604 let latest_profile_ts = profiles
605 .first()
606 .map(|p| parse_unix_ts_from_filename(&p.filename))
607 .unwrap_or(0);
608
609 use std::collections::BTreeMap;
613 let mut buckets: BTreeMap<(String, String, String), Vec<ShellInitFilterRun>> = BTreeMap::new();
614
615 for profile in &profiles {
616 let when = format_unix_ts(parse_unix_ts_from_filename(&profile.filename));
617 for entry in &profile.entries {
618 if entry.pack != filter_pack {
619 continue;
620 }
621 if let Some(name) = &filter_filename {
622 if !target_matches_filter(&entry.target, name) {
623 continue;
624 }
625 }
626 let stderr_lines: Vec<String> = profile
627 .errors
628 .iter()
629 .find(|er| er.target == entry.target)
630 .map(|er| {
631 er.message
632 .trim_end()
633 .lines()
634 .map(|s| s.to_string())
635 .collect()
636 })
637 .unwrap_or_default();
638 buckets
639 .entry((
640 entry.pack.clone(),
641 entry.handler.clone(),
642 entry.target.clone(),
643 ))
644 .or_default()
645 .push(ShellInitFilterRun {
646 when: when.clone(),
647 duration_us: entry.duration_us,
648 duration_label: humanize_us(entry.duration_us),
649 exit_status: entry.exit_status,
650 status_class: if entry.exit_status == 0 {
651 "deployed"
652 } else {
653 "error"
654 },
655 stderr_lines,
656 profile_filename: profile.filename.clone(),
657 });
658 }
659 }
660
661 let targets: Vec<ShellInitFilterTarget> = buckets
662 .into_iter()
663 .map(|((pack, handler, target), runs_vec)| {
664 let display_target = std::path::Path::new(&target)
665 .file_name()
666 .map(|s| s.to_string_lossy().into_owned())
667 .unwrap_or_else(|| target.clone());
668 let failure_count = runs_vec.iter().filter(|r| r.exit_status != 0).count();
669 ShellInitFilterTarget {
670 target,
671 display_target,
672 pack,
673 handler,
674 runs: runs_vec,
675 failure_count,
676 }
677 })
678 .collect();
679
680 Ok(ProbeResult::ShellInitFilter(ShellInitFilterView {
681 profiling_enabled,
682 profiles_dir,
683 filter: filter.trim().to_string(),
684 filter_pack,
685 filter_filename,
686 runs_examined: profiles.len(),
687 targets,
688 stale: is_stale(latest_profile_ts, last_up_ts),
689 latest_profile_when: format_unix_ts(latest_profile_ts),
690 last_up_when,
691 }))
692}
693
694pub fn shell_init_errors(ctx: &ExecutionContext, runs: usize) -> Result<ProbeResult> {
701 let root_config = ctx.config_manager.root_config()?;
702 let profiling_enabled = root_config.profiling.enabled;
703 let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
704 let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
705 let last_up_when = last_up_ts.map(format_unix_ts).unwrap_or_default();
706
707 let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
708 let latest_profile_ts = profiles
709 .first()
710 .map(|p| parse_unix_ts_from_filename(&p.filename))
711 .unwrap_or(0);
712
713 use std::collections::BTreeMap;
714 let mut buckets: BTreeMap<(String, String, String), Vec<ShellInitFilterRun>> = BTreeMap::new();
715
716 for profile in &profiles {
717 let when = format_unix_ts(parse_unix_ts_from_filename(&profile.filename));
718 for entry in &profile.entries {
719 if entry.exit_status == 0 {
721 continue;
722 }
723 let stderr_lines: Vec<String> = profile
724 .errors
725 .iter()
726 .find(|er| er.target == entry.target)
727 .map(|er| {
728 er.message
729 .trim_end()
730 .lines()
731 .map(|s| s.to_string())
732 .collect()
733 })
734 .unwrap_or_default();
735 buckets
736 .entry((
737 entry.pack.clone(),
738 entry.handler.clone(),
739 entry.target.clone(),
740 ))
741 .or_default()
742 .push(ShellInitFilterRun {
743 when: when.clone(),
744 duration_us: entry.duration_us,
745 duration_label: humanize_us(entry.duration_us),
746 exit_status: entry.exit_status,
747 status_class: "error",
748 stderr_lines,
749 profile_filename: profile.filename.clone(),
750 });
751 }
752 }
753
754 let mut targets: Vec<ShellInitFilterTarget> = buckets
755 .into_iter()
756 .map(|((pack, handler, target), runs_vec)| {
757 let display_target = std::path::Path::new(&target)
758 .file_name()
759 .map(|s| s.to_string_lossy().into_owned())
760 .unwrap_or_else(|| target.clone());
761 let failure_count = runs_vec.len();
762 ShellInitFilterTarget {
763 target,
764 display_target,
765 pack,
766 handler,
767 runs: runs_vec,
768 failure_count,
769 }
770 })
771 .collect();
772
773 targets.sort_by(|a, b| {
777 b.failure_count
778 .cmp(&a.failure_count)
779 .then_with(|| a.pack.cmp(&b.pack))
780 .then_with(|| a.handler.cmp(&b.handler))
781 .then_with(|| a.target.cmp(&b.target))
782 });
783
784 Ok(ProbeResult::ShellInitErrors(ShellInitErrorsView {
785 profiling_enabled,
786 profiles_dir,
787 runs_examined: profiles.len(),
788 targets,
789 stale: is_stale(latest_profile_ts, last_up_ts),
790 latest_profile_when: format_unix_ts(latest_profile_ts),
791 last_up_when,
792 }))
793}
794
795pub fn shell_init_history(ctx: &ExecutionContext, limit: usize) -> Result<ProbeResult> {
797 let root_config = ctx.config_manager.root_config()?;
798 let profiling_enabled = root_config.profiling.enabled;
799 let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), limit)?;
800 let latest_profile_ts = profiles
804 .first()
805 .map(|p| parse_unix_ts_from_filename(&p.filename))
806 .unwrap_or(0);
807 let history = summarize_history(&profiles);
808 let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
809 let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
810
811 Ok(ProbeResult::ShellInitHistory(ShellInitHistoryView {
812 profiling_enabled,
813 profiles_dir,
814 rows: history.into_iter().map(into_history_row).collect(),
815 stale: is_stale(latest_profile_ts, last_up_ts),
816 latest_profile_when: format_unix_ts(latest_profile_ts),
817 last_up_when: last_up_ts.map(format_unix_ts).unwrap_or_default(),
818 }))
819}
820
821fn into_history_row(h: HistoryEntry) -> ShellInitHistoryRow {
822 ShellInitHistoryRow {
823 filename: h.filename,
824 unix_ts: h.unix_ts,
825 when: format_unix_ts(h.unix_ts),
826 shell: h.shell,
827 total_label: humanize_us(h.total_us),
828 user_total_label: humanize_us(h.user_total_us),
829 total_us: h.total_us,
830 user_total_us: h.user_total_us,
831 failed_entries: h.failed_entries,
832 entry_count: h.entry_count,
833 }
834}
835
836pub fn format_unix_ts(ts: u64) -> String {
844 const MAX_REASONABLE_TS: u64 = 253_402_300_799; if ts == 0 || ts > MAX_REASONABLE_TS {
852 return String::new();
853 }
854 let secs_per_day: u64 = 86_400;
855 let days = (ts / secs_per_day) as i64; let secs_of_day = ts % secs_per_day;
857 let hour = secs_of_day / 3600;
858 let minute = (secs_of_day % 3600) / 60;
859 let (y, m, d) = civil_from_days(days);
860 format!("{y:04}-{m:02}-{d:02} {hour:02}:{minute:02}")
861}
862
863fn civil_from_days(z: i64) -> (i32, u32, u32) {
866 let z = z + 719468;
867 let era = if z >= 0 { z } else { z - 146096 } / 146097;
868 let doe = (z - era * 146097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i64 + era * 400;
871 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 };
876 (y as i32, m as u32, d as u32)
877}
878
879pub fn show_data_dir(ctx: &ExecutionContext, max_depth: usize) -> Result<ProbeResult> {
881 let tree = collect_data_dir_tree(ctx.fs.as_ref(), ctx.paths.as_ref(), max_depth)?;
882 let total_nodes = tree.count_nodes();
883 let total_size = tree.total_size();
884 let mut lines = Vec::new();
885 flatten_tree(&tree, "", true, &mut lines, true);
886 Ok(ProbeResult::ShowDataDir {
887 data_dir: ctx.paths.data_dir().display().to_string(),
888 lines,
889 total_nodes,
890 total_size,
891 })
892}
893
894fn into_display_entry(e: DeploymentMapEntry, home: &std::path::Path) -> DeploymentDisplayEntry {
897 DeploymentDisplayEntry {
898 pack: e.pack,
899 handler: e.handler,
900 kind: e.kind.as_str().into(),
901 source: if e.source.as_os_str().is_empty() {
902 "—".into()
905 } else {
906 display_path(&e.source, home)
907 },
908 datastore: display_path(&e.datastore, home),
909 }
910}
911
912fn display_path(p: &std::path::Path, home: &std::path::Path) -> String {
913 if let Ok(rel) = p.strip_prefix(home) {
914 format!("~/{}", rel.display())
915 } else {
916 p.display().to_string()
917 }
918}
919
920fn flatten_tree(
928 node: &TreeNode,
929 prefix: &str,
930 is_last: bool,
931 out: &mut Vec<TreeLine>,
932 is_root: bool,
933) {
934 let branch = if is_root {
935 String::new()
936 } else if is_last {
937 "└─ ".to_string()
938 } else {
939 "├─ ".to_string()
940 };
941 let line_prefix = format!("{prefix}{branch}");
942 out.push(TreeLine {
943 prefix: line_prefix,
944 name: node.name.clone(),
945 annotation: annotate(node),
946 });
947
948 if node.children.is_empty() {
949 return;
950 }
951
952 let child_prefix = if is_root {
956 String::new()
957 } else if is_last {
958 format!("{prefix} ")
959 } else {
960 format!("{prefix}│ ")
961 };
962
963 let last_idx = node.children.len() - 1;
964 for (i, child) in node.children.iter().enumerate() {
965 flatten_tree(child, &child_prefix, i == last_idx, out, false);
966 }
967}
968
969fn annotate(node: &TreeNode) -> String {
970 match node.kind {
971 "dir" => match node.truncated_count {
972 Some(n) if n > 0 => format!("(… {n} more)"),
973 _ => String::new(),
974 },
975 "file" => match node.size {
976 Some(n) => humanize_bytes(n),
977 None => String::new(),
978 },
979 "symlink" => match &node.link_target {
980 Some(t) => format!("→ {t}"),
981 None => "→ (broken)".into(),
982 },
983 _ => String::new(),
984 }
985}
986
987pub fn humanize_bytes(n: u64) -> String {
991 const KB: u64 = 1024;
992 const MB: u64 = KB * 1024;
993 const GB: u64 = MB * 1024;
994 if n < KB {
995 format!("{n} B")
996 } else if n < MB {
997 format!("{:.1} KB", n as f64 / KB as f64)
998 } else if n < GB {
999 format!("{:.1} MB", n as f64 / MB as f64)
1000 } else {
1001 format!("{:.1} GB", n as f64 / GB as f64)
1002 }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007 use super::*;
1008 use crate::probe::{DeploymentKind, DeploymentMapEntry};
1009 use std::path::PathBuf;
1010
1011 fn home() -> PathBuf {
1012 PathBuf::from("/home/alice")
1013 }
1014
1015 #[test]
1016 fn display_path_shortens_home() {
1017 assert_eq!(
1018 display_path(&PathBuf::from("/home/alice/dotfiles/vim/rc"), &home()),
1019 "~/dotfiles/vim/rc"
1020 );
1021 }
1022
1023 #[test]
1024 fn display_path_keeps_paths_outside_home() {
1025 assert_eq!(
1026 display_path(&PathBuf::from("/opt/data"), &home()),
1027 "/opt/data"
1028 );
1029 }
1030
1031 #[test]
1032 fn humanize_bytes_boundaries() {
1033 assert_eq!(humanize_bytes(0), "0 B");
1034 assert_eq!(humanize_bytes(1023), "1023 B");
1035 assert_eq!(humanize_bytes(1024), "1.0 KB");
1036 assert_eq!(humanize_bytes(1024 * 1024), "1.0 MB");
1037 assert_eq!(humanize_bytes(1024 * 1024 * 1024), "1.0 GB");
1038 }
1039
1040 #[test]
1041 fn format_unix_ts_handles_zero_and_out_of_range() {
1042 assert_eq!(format_unix_ts(0), "");
1044 assert_eq!(format_unix_ts(1_714_000_000), "2024-04-24 23:06");
1046 assert_eq!(format_unix_ts(u64::MAX), "");
1050 assert_eq!(format_unix_ts(253_402_300_800), ""); assert_eq!(format_unix_ts(253_402_300_799), "9999-12-31 23:59");
1053 }
1054
1055 #[test]
1056 fn into_display_entry_handles_sentinel_source() {
1057 let entry = DeploymentMapEntry {
1058 pack: "nvim".into(),
1059 handler: "install".into(),
1060 kind: DeploymentKind::File,
1061 source: PathBuf::new(),
1062 datastore: PathBuf::from("/home/alice/.local/share/dodot/packs/nvim/install/sent"),
1063 };
1064 let display = into_display_entry(entry, &home());
1065 assert_eq!(display.source, "—");
1066 assert!(display.datastore.starts_with("~/"));
1067 }
1068
1069 #[test]
1070 fn tree_flattening_produces_branch_glyphs() {
1071 let tree = TreeNode {
1077 name: "root".into(),
1078 path: PathBuf::from("/root"),
1079 kind: "dir",
1080 size: None,
1081 link_target: None,
1082 truncated_count: None,
1083 children: vec![
1084 TreeNode {
1085 name: "a".into(),
1086 path: PathBuf::from("/root/a"),
1087 kind: "dir",
1088 size: None,
1089 link_target: None,
1090 truncated_count: None,
1091 children: vec![TreeNode {
1092 name: "aa".into(),
1093 path: PathBuf::from("/root/a/aa"),
1094 kind: "file",
1095 size: Some(10),
1096 link_target: None,
1097 truncated_count: None,
1098 children: Vec::new(),
1099 }],
1100 },
1101 TreeNode {
1102 name: "b".into(),
1103 path: PathBuf::from("/root/b"),
1104 kind: "file",
1105 size: Some(42),
1106 link_target: None,
1107 truncated_count: None,
1108 children: Vec::new(),
1109 },
1110 ],
1111 };
1112 let mut lines = Vec::new();
1113 flatten_tree(&tree, "", true, &mut lines, true);
1114 assert_eq!(lines.len(), 4);
1115 assert_eq!(lines[0].name, "root");
1116 assert_eq!(lines[0].prefix, ""); assert_eq!(lines[1].name, "a");
1118 assert!(lines[1].prefix.ends_with("├─ "));
1119 assert_eq!(lines[2].name, "aa");
1120 assert!(lines[2].prefix.ends_with("└─ "));
1121 assert!(lines[2].prefix.starts_with("│")); assert_eq!(lines[3].name, "b");
1123 assert!(lines[3].prefix.ends_with("└─ "));
1124 assert_eq!(lines[3].annotation, "42 B");
1125 }
1126
1127 #[test]
1128 fn annotate_symlink_with_target() {
1129 let node = TreeNode {
1130 name: "link".into(),
1131 path: PathBuf::from("/x"),
1132 kind: "symlink",
1133 size: Some(20),
1134 link_target: Some("/target".into()),
1135 truncated_count: None,
1136 children: Vec::new(),
1137 };
1138 assert_eq!(annotate(&node), "→ /target");
1139 }
1140
1141 #[test]
1142 fn annotate_broken_symlink() {
1143 let node = TreeNode {
1144 name: "link".into(),
1145 path: PathBuf::from("/x"),
1146 kind: "symlink",
1147 size: Some(20),
1148 link_target: None,
1149 truncated_count: None,
1150 children: Vec::new(),
1151 };
1152 assert_eq!(annotate(&node), "→ (broken)");
1153 }
1154
1155 #[test]
1156 fn annotate_truncated_dir() {
1157 let node = TreeNode {
1158 name: "deep".into(),
1159 path: PathBuf::from("/x"),
1160 kind: "dir",
1161 size: None,
1162 link_target: None,
1163 truncated_count: Some(7),
1164 children: Vec::new(),
1165 };
1166 assert_eq!(annotate(&node), "(… 7 more)");
1167 }
1168
1169 #[test]
1170 fn probe_result_deployment_map_serialises_with_kind_tag() {
1171 let result = ProbeResult::DeploymentMap {
1172 data_dir: "/d".into(),
1173 map_path: "/d/deployment-map.tsv".into(),
1174 entries: Vec::new(),
1175 };
1176 let json = serde_json::to_value(&result).unwrap();
1177 assert_eq!(json["kind"], "deployment-map");
1178 assert!(json["entries"].is_array());
1179 }
1180
1181 #[test]
1182 fn probe_result_show_data_dir_serialises_with_kind_tag() {
1183 let result = ProbeResult::ShowDataDir {
1184 data_dir: "/d".into(),
1185 lines: Vec::new(),
1186 total_nodes: 1,
1187 total_size: 0,
1188 };
1189 let json = serde_json::to_value(&result).unwrap();
1190 assert_eq!(json["kind"], "show-data-dir");
1191 assert_eq!(json["total_nodes"], 1);
1192 }
1193
1194 #[test]
1195 fn probe_subcommands_list_matches_variants() {
1196 let names: Vec<&str> = PROBE_SUBCOMMANDS.iter().map(|s| s.name).collect();
1200 assert!(names.contains(&"deployment-map"));
1201 assert!(names.contains(&"show-data-dir"));
1202 }
1203}