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 App(AppProbeView),
112}
113
114#[derive(Debug, Clone, Serialize)]
116pub struct AppProbeView {
117 pub pack: String,
118 pub macos: bool,
123 pub entries: Vec<AppProbeEntry>,
127 pub suggested_adoptions: Vec<String>,
130}
131
132#[derive(Debug, Clone, Serialize)]
134pub struct AppProbeEntry {
135 pub folder: String,
137 pub target_path: String,
141 pub target_exists: bool,
143 pub source_rule: String,
146 pub cask: Option<String>,
152 pub app_bundle: Option<String>,
155 pub bundle_id: Option<String>,
158}
159
160#[derive(Debug, Clone, Serialize)]
162pub struct ShellInitAggregateView {
163 pub runs: usize,
166 pub requested_runs: usize,
169 pub profiling_enabled: bool,
170 pub profiles_dir: String,
171 pub rows: Vec<ShellInitAggregateRow>,
172 pub stale: bool,
176 pub latest_profile_when: String,
179 pub last_up_when: String,
182}
183
184#[derive(Debug, Clone, Serialize)]
187pub struct ShellInitAggregateRow {
188 pub pack: String,
189 pub handler: String,
190 pub target: String,
191 pub p50_label: String,
192 pub p95_label: String,
193 pub max_label: String,
194 pub p50_us: u64,
195 pub p95_us: u64,
196 pub max_us: u64,
197 pub seen_label: String,
200 pub runs_seen: usize,
201 pub runs_total: usize,
202}
203
204#[derive(Debug, Clone, Serialize)]
206pub struct ShellInitHistoryView {
207 pub profiling_enabled: bool,
208 pub profiles_dir: String,
209 pub rows: Vec<ShellInitHistoryRow>,
210 pub stale: bool,
214 pub latest_profile_when: String,
217 pub last_up_when: String,
220}
221
222#[derive(Debug, Clone, Serialize)]
224pub struct ShellInitHistoryRow {
225 pub filename: String,
228 pub unix_ts: u64,
233 pub when: String,
236 pub shell: String,
237 pub total_label: String,
238 pub user_total_label: String,
239 pub total_us: u64,
240 pub user_total_us: u64,
241 pub failed_entries: usize,
242 pub entry_count: usize,
243}
244
245#[derive(Debug, Clone, Serialize)]
249pub struct ShellInitView {
250 pub filename: String,
253 pub shell: String,
255 pub profiling_enabled: bool,
257 pub has_profile: bool,
259 pub groups: Vec<ShellInitGroup>,
262 pub user_total_us: u64,
263 pub framing_us: u64,
264 pub total_us: u64,
265 pub profiles_dir: String,
267 pub stale: bool,
271 pub profile_when: String,
274 pub last_up_when: String,
277}
278
279#[derive(Debug, Clone, Serialize)]
281pub struct ShellInitRow {
282 pub target: String,
283 pub duration_us: u64,
284 pub duration_label: String,
285 pub exit_status: i32,
286 pub status_class: &'static str,
291}
292
293#[derive(Debug, Clone, Serialize)]
295pub struct ShellInitGroup {
296 pub pack: String,
297 pub handler: String,
298 pub rows: Vec<ShellInitRow>,
299 pub group_total_us: u64,
300 pub group_total_label: String,
301}
302
303#[derive(Debug, Clone, Serialize)]
310pub struct ShellInitFilterView {
311 pub profiling_enabled: bool,
312 pub profiles_dir: String,
313 pub filter: String,
315 pub filter_pack: String,
317 pub filter_filename: Option<String>,
319 pub runs_examined: usize,
321 pub targets: Vec<ShellInitFilterTarget>,
326 pub stale: bool,
327 pub latest_profile_when: String,
328 pub last_up_when: String,
329}
330
331#[derive(Debug, Clone, Serialize)]
333pub struct ShellInitFilterTarget {
334 pub target: String,
336 pub display_target: String,
338 pub pack: String,
340 pub handler: String,
342 pub runs: Vec<ShellInitFilterRun>,
344 pub failure_count: usize,
346}
347
348#[derive(Debug, Clone, Serialize)]
352pub struct ShellInitErrorsView {
353 pub profiling_enabled: bool,
354 pub profiles_dir: String,
355 pub runs_examined: usize,
356 pub targets: Vec<ShellInitFilterTarget>,
360 pub stale: bool,
361 pub latest_profile_when: String,
362 pub last_up_when: String,
363}
364
365#[derive(Debug, Clone, Serialize)]
367pub struct ShellInitFilterRun {
368 pub when: String,
370 pub duration_label: String,
372 pub duration_us: u64,
373 pub exit_status: i32,
374 pub status_class: &'static str,
377 pub stderr_lines: Vec<String>,
383 pub profile_filename: String,
385}
386
387#[derive(Debug, Clone, Serialize)]
389pub struct ProbeSubcommandInfo {
390 pub name: &'static str,
391 pub description: &'static str,
392}
393
394pub const PROBE_SUBCOMMANDS: &[ProbeSubcommandInfo] = &[
398 ProbeSubcommandInfo {
399 name: "deployment-map",
400 description: "Source↔deployed map — what dodot linked where.",
401 },
402 ProbeSubcommandInfo {
403 name: "shell-init",
404 description: "Per-source timings for the most recent shell startup.",
405 },
406 ProbeSubcommandInfo {
407 name: "show-data-dir",
408 description: "Tree of dodot's data directory, with sizes.",
409 },
410];
411
412pub fn summary(ctx: &ExecutionContext) -> Result<ProbeResult> {
416 Ok(ProbeResult::Summary {
417 data_dir: ctx.paths.data_dir().display().to_string(),
418 available: PROBE_SUBCOMMANDS.to_vec(),
419 })
420}
421
422pub fn deployment_map(ctx: &ExecutionContext) -> Result<ProbeResult> {
427 let raw = collect_deployment_map(ctx.fs.as_ref(), ctx.paths.as_ref())?;
428 let home = ctx.paths.home_dir();
429 let entries = raw
430 .into_iter()
431 .map(|e| into_display_entry(e, home))
432 .collect();
433
434 Ok(ProbeResult::DeploymentMap {
435 data_dir: ctx.paths.data_dir().display().to_string(),
436 map_path: ctx.paths.deployment_map_path().display().to_string(),
437 entries,
438 })
439}
440
441pub fn shell_init(ctx: &ExecutionContext) -> Result<ProbeResult> {
448 let root_config = ctx.config_manager.root_config()?;
449 let profiling_enabled = root_config.profiling.enabled;
450
451 let profile_opt = read_latest_profile(ctx.fs.as_ref(), ctx.paths.as_ref())?;
452 let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
453 let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
454 let last_up_when = last_up_ts.map(format_unix_ts).unwrap_or_default();
455
456 let view = match profile_opt {
457 Some(profile) => {
458 let grouped = group_profile(&profile);
459 let profile_ts = parse_unix_ts_from_filename(&profile.filename);
460 let stale = is_stale(profile_ts, last_up_ts);
461 ShellInitView {
462 filename: profile.filename.clone(),
463 shell: profile.shell.clone(),
464 profiling_enabled,
465 has_profile: true,
466 groups: shell_init_groups(&grouped),
467 user_total_us: grouped.user_total_us,
468 framing_us: grouped.framing_us,
469 total_us: grouped.total_us,
470 profiles_dir,
471 stale,
472 profile_when: format_unix_ts(profile_ts),
473 last_up_when,
474 }
475 }
476 None => ShellInitView {
477 filename: String::new(),
478 shell: String::new(),
479 profiling_enabled,
480 has_profile: false,
481 groups: Vec::new(),
482 user_total_us: 0,
483 framing_us: 0,
484 total_us: 0,
485 profiles_dir,
486 stale: false,
487 profile_when: String::new(),
488 last_up_when,
489 },
490 };
491
492 Ok(ProbeResult::ShellInit(view))
493}
494
495fn is_stale(profile_ts: u64, last_up_ts: Option<u64>) -> bool {
499 matches!(last_up_ts, Some(last) if profile_ts > 0 && profile_ts < last)
500}
501
502fn shell_init_groups(grouped: &GroupedProfile) -> Vec<ShellInitGroup> {
503 grouped
504 .groups
505 .iter()
506 .map(|g| ShellInitGroup {
507 pack: g.pack.clone(),
508 handler: g.handler.clone(),
509 rows: g
510 .rows
511 .iter()
512 .map(|r| ShellInitRow {
513 target: short_target(&r.target),
514 duration_us: r.duration_us,
515 duration_label: humanize_us(r.duration_us),
516 exit_status: r.exit_status,
517 status_class: if r.exit_status == 0 {
518 "deployed"
519 } else {
520 "error"
521 },
522 })
523 .collect(),
524 group_total_us: g.group_total_us,
525 group_total_label: humanize_us(g.group_total_us),
526 })
527 .collect()
528}
529
530fn short_target(target: &str) -> String {
534 std::path::Path::new(target)
535 .file_name()
536 .map(|n| n.to_string_lossy().into_owned())
537 .unwrap_or_else(|| target.to_string())
538}
539
540pub fn humanize_us(us: u64) -> String {
542 if us < 1_000 {
543 format!("{us} µs")
544 } else if us < 1_000_000 {
545 format!("{:.1} ms", us as f64 / 1_000.0)
546 } else {
547 format!("{:.2} s", us as f64 / 1_000_000.0)
548 }
549}
550
551pub fn shell_init_aggregate(ctx: &ExecutionContext, runs: usize) -> Result<ProbeResult> {
559 let root_config = ctx.config_manager.root_config()?;
560 let profiling_enabled = root_config.profiling.enabled;
561 let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
562 let latest_profile_ts = profiles
565 .first()
566 .map(|p| parse_unix_ts_from_filename(&p.filename))
567 .unwrap_or(0);
568 let view = aggregate_profiles(&profiles);
569 let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
570 let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
571
572 Ok(ProbeResult::ShellInitAggregate(ShellInitAggregateView {
573 runs: view.runs,
574 requested_runs: runs,
575 profiling_enabled,
576 profiles_dir,
577 rows: view.targets.into_iter().map(into_aggregate_row).collect(),
578 stale: is_stale(latest_profile_ts, last_up_ts),
579 latest_profile_when: format_unix_ts(latest_profile_ts),
580 last_up_when: last_up_ts.map(format_unix_ts).unwrap_or_default(),
581 }))
582}
583
584fn into_aggregate_row(t: AggregatedTarget) -> ShellInitAggregateRow {
585 ShellInitAggregateRow {
586 pack: t.pack,
587 handler: t.handler,
588 target: short_target(&t.target),
589 p50_label: humanize_us(t.p50_us),
590 p95_label: humanize_us(t.p95_us),
591 max_label: humanize_us(t.max_us),
592 p50_us: t.p50_us,
593 p95_us: t.p95_us,
594 max_us: t.max_us,
595 seen_label: format!("{}/{}", t.runs_seen, t.runs_total),
596 runs_seen: t.runs_seen,
597 runs_total: t.runs_total,
598 }
599}
600
601pub const DEFAULT_HISTORY_LIMIT: usize = 50;
605
606pub const DEFAULT_FILTER_RUNS: usize = 20;
612
613fn target_matches_filter(target: &str, filter: &str) -> bool {
625 if !filter.contains('/') {
626 return std::path::Path::new(target)
627 .file_name()
628 .is_some_and(|s| s == std::ffi::OsStr::new(filter));
629 }
630 target.ends_with(&format!("/{filter}")) || target == filter
633}
634
635pub fn shell_init_filter(ctx: &ExecutionContext, filter: &str, runs: usize) -> Result<ProbeResult> {
641 let root_config = ctx.config_manager.root_config()?;
642 let profiling_enabled = root_config.profiling.enabled;
643 let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
644 let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
645 let last_up_when = last_up_ts.map(format_unix_ts).unwrap_or_default();
646
647 let trimmed = filter.trim().trim_start_matches("./").trim_end_matches('/');
650 let (filter_pack, filter_filename) = match trimmed.split_once('/') {
651 Some((p, f)) if !p.is_empty() && !f.is_empty() => (p.to_string(), Some(f.to_string())),
652 _ => (trimmed.to_string(), None),
653 };
654
655 let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
656 let latest_profile_ts = profiles
657 .first()
658 .map(|p| parse_unix_ts_from_filename(&p.filename))
659 .unwrap_or(0);
660
661 use std::collections::BTreeMap;
665 let mut buckets: BTreeMap<(String, String, String), Vec<ShellInitFilterRun>> = BTreeMap::new();
666
667 for profile in &profiles {
668 let when = format_unix_ts(parse_unix_ts_from_filename(&profile.filename));
669 for entry in &profile.entries {
670 if entry.pack != filter_pack {
671 continue;
672 }
673 if let Some(name) = &filter_filename {
674 if !target_matches_filter(&entry.target, name) {
675 continue;
676 }
677 }
678 let stderr_lines: Vec<String> = profile
679 .errors
680 .iter()
681 .find(|er| er.target == entry.target)
682 .map(|er| {
683 er.message
684 .trim_end()
685 .lines()
686 .map(|s| s.to_string())
687 .collect()
688 })
689 .unwrap_or_default();
690 buckets
691 .entry((
692 entry.pack.clone(),
693 entry.handler.clone(),
694 entry.target.clone(),
695 ))
696 .or_default()
697 .push(ShellInitFilterRun {
698 when: when.clone(),
699 duration_us: entry.duration_us,
700 duration_label: humanize_us(entry.duration_us),
701 exit_status: entry.exit_status,
702 status_class: if entry.exit_status == 0 {
703 "deployed"
704 } else {
705 "error"
706 },
707 stderr_lines,
708 profile_filename: profile.filename.clone(),
709 });
710 }
711 }
712
713 let targets: Vec<ShellInitFilterTarget> = buckets
714 .into_iter()
715 .map(|((pack, handler, target), runs_vec)| {
716 let display_target = std::path::Path::new(&target)
717 .file_name()
718 .map(|s| s.to_string_lossy().into_owned())
719 .unwrap_or_else(|| target.clone());
720 let failure_count = runs_vec.iter().filter(|r| r.exit_status != 0).count();
721 ShellInitFilterTarget {
722 target,
723 display_target,
724 pack,
725 handler,
726 runs: runs_vec,
727 failure_count,
728 }
729 })
730 .collect();
731
732 Ok(ProbeResult::ShellInitFilter(ShellInitFilterView {
733 profiling_enabled,
734 profiles_dir,
735 filter: filter.trim().to_string(),
736 filter_pack,
737 filter_filename,
738 runs_examined: profiles.len(),
739 targets,
740 stale: is_stale(latest_profile_ts, last_up_ts),
741 latest_profile_when: format_unix_ts(latest_profile_ts),
742 last_up_when,
743 }))
744}
745
746pub fn shell_init_errors(ctx: &ExecutionContext, runs: usize) -> Result<ProbeResult> {
753 let root_config = ctx.config_manager.root_config()?;
754 let profiling_enabled = root_config.profiling.enabled;
755 let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
756 let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
757 let last_up_when = last_up_ts.map(format_unix_ts).unwrap_or_default();
758
759 let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
760 let latest_profile_ts = profiles
761 .first()
762 .map(|p| parse_unix_ts_from_filename(&p.filename))
763 .unwrap_or(0);
764
765 use std::collections::BTreeMap;
766 let mut buckets: BTreeMap<(String, String, String), Vec<ShellInitFilterRun>> = BTreeMap::new();
767
768 for profile in &profiles {
769 let when = format_unix_ts(parse_unix_ts_from_filename(&profile.filename));
770 for entry in &profile.entries {
771 if entry.exit_status == 0 {
773 continue;
774 }
775 let stderr_lines: Vec<String> = profile
776 .errors
777 .iter()
778 .find(|er| er.target == entry.target)
779 .map(|er| {
780 er.message
781 .trim_end()
782 .lines()
783 .map(|s| s.to_string())
784 .collect()
785 })
786 .unwrap_or_default();
787 buckets
788 .entry((
789 entry.pack.clone(),
790 entry.handler.clone(),
791 entry.target.clone(),
792 ))
793 .or_default()
794 .push(ShellInitFilterRun {
795 when: when.clone(),
796 duration_us: entry.duration_us,
797 duration_label: humanize_us(entry.duration_us),
798 exit_status: entry.exit_status,
799 status_class: "error",
800 stderr_lines,
801 profile_filename: profile.filename.clone(),
802 });
803 }
804 }
805
806 let mut targets: Vec<ShellInitFilterTarget> = buckets
807 .into_iter()
808 .map(|((pack, handler, target), runs_vec)| {
809 let display_target = std::path::Path::new(&target)
810 .file_name()
811 .map(|s| s.to_string_lossy().into_owned())
812 .unwrap_or_else(|| target.clone());
813 let failure_count = runs_vec.len();
814 ShellInitFilterTarget {
815 target,
816 display_target,
817 pack,
818 handler,
819 runs: runs_vec,
820 failure_count,
821 }
822 })
823 .collect();
824
825 targets.sort_by(|a, b| {
829 b.failure_count
830 .cmp(&a.failure_count)
831 .then_with(|| a.pack.cmp(&b.pack))
832 .then_with(|| a.handler.cmp(&b.handler))
833 .then_with(|| a.target.cmp(&b.target))
834 });
835
836 Ok(ProbeResult::ShellInitErrors(ShellInitErrorsView {
837 profiling_enabled,
838 profiles_dir,
839 runs_examined: profiles.len(),
840 targets,
841 stale: is_stale(latest_profile_ts, last_up_ts),
842 latest_profile_when: format_unix_ts(latest_profile_ts),
843 last_up_when,
844 }))
845}
846
847pub fn shell_init_history(ctx: &ExecutionContext, limit: usize) -> Result<ProbeResult> {
849 let root_config = ctx.config_manager.root_config()?;
850 let profiling_enabled = root_config.profiling.enabled;
851 let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), limit)?;
852 let latest_profile_ts = profiles
856 .first()
857 .map(|p| parse_unix_ts_from_filename(&p.filename))
858 .unwrap_or(0);
859 let history = summarize_history(&profiles);
860 let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
861 let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
862
863 Ok(ProbeResult::ShellInitHistory(ShellInitHistoryView {
864 profiling_enabled,
865 profiles_dir,
866 rows: history.into_iter().map(into_history_row).collect(),
867 stale: is_stale(latest_profile_ts, last_up_ts),
868 latest_profile_when: format_unix_ts(latest_profile_ts),
869 last_up_when: last_up_ts.map(format_unix_ts).unwrap_or_default(),
870 }))
871}
872
873fn into_history_row(h: HistoryEntry) -> ShellInitHistoryRow {
874 ShellInitHistoryRow {
875 filename: h.filename,
876 unix_ts: h.unix_ts,
877 when: format_unix_ts(h.unix_ts),
878 shell: h.shell,
879 total_label: humanize_us(h.total_us),
880 user_total_label: humanize_us(h.user_total_us),
881 total_us: h.total_us,
882 user_total_us: h.user_total_us,
883 failed_entries: h.failed_entries,
884 entry_count: h.entry_count,
885 }
886}
887
888pub fn format_unix_ts(ts: u64) -> String {
896 const MAX_REASONABLE_TS: u64 = 253_402_300_799; if ts == 0 || ts > MAX_REASONABLE_TS {
904 return String::new();
905 }
906 let secs_per_day: u64 = 86_400;
907 let days = (ts / secs_per_day) as i64; let secs_of_day = ts % secs_per_day;
909 let hour = secs_of_day / 3600;
910 let minute = (secs_of_day % 3600) / 60;
911 let (y, m, d) = civil_from_days(days);
912 format!("{y:04}-{m:02}-{d:02} {hour:02}:{minute:02}")
913}
914
915fn civil_from_days(z: i64) -> (i32, u32, u32) {
918 let z = z + 719468;
919 let era = if z >= 0 { z } else { z - 146096 } / 146097;
920 let doe = (z - era * 146097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i64 + era * 400;
923 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 };
928 (y as i32, m as u32, d as u32)
929}
930
931pub fn app(pack_name: &str, refresh: bool, ctx: &ExecutionContext) -> Result<ProbeResult> {
944 use std::collections::BTreeSet;
945
946 let pack_dir = crate::packs::orchestration::resolve_pack_dir_name(pack_name, ctx)
952 .unwrap_or_else(|_| {
953 if is_single_normal_path_component(pack_name) {
954 pack_name.to_string()
955 } else {
956 String::new()
961 }
962 });
963 let display_name = if pack_dir.is_empty() {
964 pack_name.to_string()
965 } else {
966 crate::packs::display_name_for(&pack_dir).to_string()
967 };
968 let pack_config = if pack_dir.is_empty() {
969 ctx.config_manager.root_config()?
970 } else {
971 match ctx
972 .config_manager
973 .config_for_pack(&ctx.paths.pack_path(&pack_dir))
974 {
975 Ok(c) => c,
976 Err(_) => ctx.config_manager.root_config()?,
980 }
981 };
982
983 let mut folders: Vec<(String, &'static str)> = Vec::new();
990 let mut seen: BTreeSet<String> = BTreeSet::new();
991
992 if let Some(alias) = pack_config.symlink.app_aliases.get(&display_name) {
993 if seen.insert(alias.clone()) {
994 folders.push((alias.clone(), "alias"));
995 }
996 }
997
998 let pack_path = ctx.paths.pack_path(&pack_dir);
999 if !pack_dir.is_empty() && ctx.fs.exists(&pack_path) {
1000 if let Ok(entries) = ctx.fs.read_dir(&pack_path) {
1001 for e in entries {
1002 if e.is_dir
1003 && pack_config.symlink.force_app.iter().any(|f| f == &e.name)
1004 && seen.insert(e.name.clone())
1005 {
1006 folders.push((e.name.clone(), "force_app"));
1007 }
1008 }
1009 let app_dir = pack_path.join("_app");
1011 if ctx.fs.exists(&app_dir) {
1012 if let Ok(children) = ctx.fs.read_dir(&app_dir) {
1013 for e in children {
1014 if e.is_dir && seen.insert(e.name.clone()) {
1015 folders.push((e.name.clone(), "_app/"));
1016 }
1017 }
1018 }
1019 }
1020 }
1021 }
1022
1023 let macos = cfg!(target_os = "macos");
1027 let app_support = ctx.paths.app_support_dir();
1028 let cache_dir = ctx.paths.probes_brew_cache_dir();
1029
1030 if refresh && macos {
1036 crate::probe::brew::invalidate_all_cache(&cache_dir, ctx.fs.as_ref());
1037 }
1038
1039 let now = crate::probe::brew::now_secs_unix();
1040 let folder_names: Vec<String> = folders.iter().map(|(f, _)| f.clone()).collect();
1041 let matches = if macos {
1045 crate::probe::brew::match_folders_to_installed_casks(
1046 &folder_names,
1047 ctx.command_runner.as_ref(),
1048 &cache_dir,
1049 now,
1050 ctx.fs.as_ref(),
1051 false,
1052 )
1053 } else {
1054 crate::probe::brew::InstalledCaskMatches::default()
1055 };
1056
1057 let mut entries: Vec<AppProbeEntry> = Vec::new();
1058 let mut suggested: BTreeSet<String> = BTreeSet::new();
1059
1060 for (folder, source_rule) in &folders {
1061 let target = app_support.join(folder);
1062 let target_exists = ctx.fs.exists(&target);
1063 let cask = matches.folder_to_token.get(folder).cloned();
1064
1065 let mut app_bundle = None;
1066 let mut bundle_id = None;
1067 if macos {
1068 if let Some(token) = &cask {
1069 if let Ok(Some(info)) = crate::probe::brew::info_cask(
1070 token,
1071 &cache_dir,
1072 now,
1073 ctx.fs.as_ref(),
1074 ctx.command_runner.as_ref(),
1075 ) {
1076 app_bundle = info.app_bundle_name();
1077 if let Some(bundle_name) = &app_bundle {
1078 let app_path = std::path::PathBuf::from("/Applications").join(bundle_name);
1079 bundle_id = crate::probe::macos_native::bundle_id(
1080 &app_path,
1081 ctx.command_runner.as_ref(),
1082 );
1083 }
1084 for plist in info.preferences_plists() {
1085 suggested.insert(plist);
1086 }
1087 }
1088 }
1089 }
1090
1091 entries.push(AppProbeEntry {
1092 folder: folder.clone(),
1093 target_path: display_path(&target, ctx.paths.home_dir()),
1094 target_exists,
1095 source_rule: (*source_rule).into(),
1096 cask,
1097 app_bundle,
1098 bundle_id,
1099 });
1100 }
1101
1102 Ok(ProbeResult::App(AppProbeView {
1103 pack: display_name,
1104 macos,
1105 entries,
1106 suggested_adoptions: suggested.into_iter().collect(),
1107 }))
1108}
1109
1110fn is_single_normal_path_component(value: &str) -> bool {
1116 if value.is_empty() {
1117 return false;
1118 }
1119 let mut comps = std::path::Path::new(value).components();
1120 matches!(
1121 (comps.next(), comps.next()),
1122 (Some(std::path::Component::Normal(_)), None)
1123 )
1124}
1125
1126pub fn show_data_dir(ctx: &ExecutionContext, max_depth: usize) -> Result<ProbeResult> {
1128 let tree = collect_data_dir_tree(ctx.fs.as_ref(), ctx.paths.as_ref(), max_depth)?;
1129 let total_nodes = tree.count_nodes();
1130 let total_size = tree.total_size();
1131 let mut lines = Vec::new();
1132 flatten_tree(&tree, "", true, &mut lines, true);
1133 Ok(ProbeResult::ShowDataDir {
1134 data_dir: ctx.paths.data_dir().display().to_string(),
1135 lines,
1136 total_nodes,
1137 total_size,
1138 })
1139}
1140
1141fn into_display_entry(e: DeploymentMapEntry, home: &std::path::Path) -> DeploymentDisplayEntry {
1144 DeploymentDisplayEntry {
1145 pack: e.pack,
1146 handler: e.handler,
1147 kind: e.kind.as_str().into(),
1148 source: if e.source.as_os_str().is_empty() {
1149 "—".into()
1152 } else {
1153 display_path(&e.source, home)
1154 },
1155 datastore: display_path(&e.datastore, home),
1156 }
1157}
1158
1159fn display_path(p: &std::path::Path, home: &std::path::Path) -> String {
1160 if let Ok(rel) = p.strip_prefix(home) {
1161 format!("~/{}", rel.display())
1162 } else {
1163 p.display().to_string()
1164 }
1165}
1166
1167fn flatten_tree(
1175 node: &TreeNode,
1176 prefix: &str,
1177 is_last: bool,
1178 out: &mut Vec<TreeLine>,
1179 is_root: bool,
1180) {
1181 let branch = if is_root {
1182 String::new()
1183 } else if is_last {
1184 "└─ ".to_string()
1185 } else {
1186 "├─ ".to_string()
1187 };
1188 let line_prefix = format!("{prefix}{branch}");
1189 out.push(TreeLine {
1190 prefix: line_prefix,
1191 name: node.name.clone(),
1192 annotation: annotate(node),
1193 });
1194
1195 if node.children.is_empty() {
1196 return;
1197 }
1198
1199 let child_prefix = if is_root {
1203 String::new()
1204 } else if is_last {
1205 format!("{prefix} ")
1206 } else {
1207 format!("{prefix}│ ")
1208 };
1209
1210 let last_idx = node.children.len() - 1;
1211 for (i, child) in node.children.iter().enumerate() {
1212 flatten_tree(child, &child_prefix, i == last_idx, out, false);
1213 }
1214}
1215
1216fn annotate(node: &TreeNode) -> String {
1217 match node.kind {
1218 "dir" => match node.truncated_count {
1219 Some(n) if n > 0 => format!("(… {n} more)"),
1220 _ => String::new(),
1221 },
1222 "file" => match node.size {
1223 Some(n) => humanize_bytes(n),
1224 None => String::new(),
1225 },
1226 "symlink" => match &node.link_target {
1227 Some(t) => format!("→ {t}"),
1228 None => "→ (broken)".into(),
1229 },
1230 _ => String::new(),
1231 }
1232}
1233
1234pub fn humanize_bytes(n: u64) -> String {
1238 const KB: u64 = 1024;
1239 const MB: u64 = KB * 1024;
1240 const GB: u64 = MB * 1024;
1241 if n < KB {
1242 format!("{n} B")
1243 } else if n < MB {
1244 format!("{:.1} KB", n as f64 / KB as f64)
1245 } else if n < GB {
1246 format!("{:.1} MB", n as f64 / MB as f64)
1247 } else {
1248 format!("{:.1} GB", n as f64 / GB as f64)
1249 }
1250}
1251
1252#[cfg(test)]
1253mod tests {
1254 use super::*;
1255 use crate::probe::{DeploymentKind, DeploymentMapEntry};
1256 use std::path::PathBuf;
1257
1258 fn home() -> PathBuf {
1259 PathBuf::from("/home/alice")
1260 }
1261
1262 #[test]
1263 fn display_path_shortens_home() {
1264 assert_eq!(
1265 display_path(&PathBuf::from("/home/alice/dotfiles/vim/rc"), &home()),
1266 "~/dotfiles/vim/rc"
1267 );
1268 }
1269
1270 #[test]
1271 fn display_path_keeps_paths_outside_home() {
1272 assert_eq!(
1273 display_path(&PathBuf::from("/opt/data"), &home()),
1274 "/opt/data"
1275 );
1276 }
1277
1278 #[test]
1279 fn humanize_bytes_boundaries() {
1280 assert_eq!(humanize_bytes(0), "0 B");
1281 assert_eq!(humanize_bytes(1023), "1023 B");
1282 assert_eq!(humanize_bytes(1024), "1.0 KB");
1283 assert_eq!(humanize_bytes(1024 * 1024), "1.0 MB");
1284 assert_eq!(humanize_bytes(1024 * 1024 * 1024), "1.0 GB");
1285 }
1286
1287 #[test]
1288 fn format_unix_ts_handles_zero_and_out_of_range() {
1289 assert_eq!(format_unix_ts(0), "");
1291 assert_eq!(format_unix_ts(1_714_000_000), "2024-04-24 23:06");
1293 assert_eq!(format_unix_ts(u64::MAX), "");
1297 assert_eq!(format_unix_ts(253_402_300_800), ""); assert_eq!(format_unix_ts(253_402_300_799), "9999-12-31 23:59");
1300 }
1301
1302 #[test]
1303 fn into_display_entry_handles_sentinel_source() {
1304 let entry = DeploymentMapEntry {
1305 pack: "nvim".into(),
1306 handler: "install".into(),
1307 kind: DeploymentKind::File,
1308 source: PathBuf::new(),
1309 datastore: PathBuf::from("/home/alice/.local/share/dodot/packs/nvim/install/sent"),
1310 };
1311 let display = into_display_entry(entry, &home());
1312 assert_eq!(display.source, "—");
1313 assert!(display.datastore.starts_with("~/"));
1314 }
1315
1316 #[test]
1317 fn tree_flattening_produces_branch_glyphs() {
1318 let tree = TreeNode {
1324 name: "root".into(),
1325 path: PathBuf::from("/root"),
1326 kind: "dir",
1327 size: None,
1328 link_target: None,
1329 truncated_count: None,
1330 children: vec![
1331 TreeNode {
1332 name: "a".into(),
1333 path: PathBuf::from("/root/a"),
1334 kind: "dir",
1335 size: None,
1336 link_target: None,
1337 truncated_count: None,
1338 children: vec![TreeNode {
1339 name: "aa".into(),
1340 path: PathBuf::from("/root/a/aa"),
1341 kind: "file",
1342 size: Some(10),
1343 link_target: None,
1344 truncated_count: None,
1345 children: Vec::new(),
1346 }],
1347 },
1348 TreeNode {
1349 name: "b".into(),
1350 path: PathBuf::from("/root/b"),
1351 kind: "file",
1352 size: Some(42),
1353 link_target: None,
1354 truncated_count: None,
1355 children: Vec::new(),
1356 },
1357 ],
1358 };
1359 let mut lines = Vec::new();
1360 flatten_tree(&tree, "", true, &mut lines, true);
1361 assert_eq!(lines.len(), 4);
1362 assert_eq!(lines[0].name, "root");
1363 assert_eq!(lines[0].prefix, ""); assert_eq!(lines[1].name, "a");
1365 assert!(lines[1].prefix.ends_with("├─ "));
1366 assert_eq!(lines[2].name, "aa");
1367 assert!(lines[2].prefix.ends_with("└─ "));
1368 assert!(lines[2].prefix.starts_with("│")); assert_eq!(lines[3].name, "b");
1370 assert!(lines[3].prefix.ends_with("└─ "));
1371 assert_eq!(lines[3].annotation, "42 B");
1372 }
1373
1374 #[test]
1375 fn annotate_symlink_with_target() {
1376 let node = TreeNode {
1377 name: "link".into(),
1378 path: PathBuf::from("/x"),
1379 kind: "symlink",
1380 size: Some(20),
1381 link_target: Some("/target".into()),
1382 truncated_count: None,
1383 children: Vec::new(),
1384 };
1385 assert_eq!(annotate(&node), "→ /target");
1386 }
1387
1388 #[test]
1389 fn annotate_broken_symlink() {
1390 let node = TreeNode {
1391 name: "link".into(),
1392 path: PathBuf::from("/x"),
1393 kind: "symlink",
1394 size: Some(20),
1395 link_target: None,
1396 truncated_count: None,
1397 children: Vec::new(),
1398 };
1399 assert_eq!(annotate(&node), "→ (broken)");
1400 }
1401
1402 #[test]
1403 fn annotate_truncated_dir() {
1404 let node = TreeNode {
1405 name: "deep".into(),
1406 path: PathBuf::from("/x"),
1407 kind: "dir",
1408 size: None,
1409 link_target: None,
1410 truncated_count: Some(7),
1411 children: Vec::new(),
1412 };
1413 assert_eq!(annotate(&node), "(… 7 more)");
1414 }
1415
1416 #[test]
1417 fn probe_result_deployment_map_serialises_with_kind_tag() {
1418 let result = ProbeResult::DeploymentMap {
1419 data_dir: "/d".into(),
1420 map_path: "/d/deployment-map.tsv".into(),
1421 entries: Vec::new(),
1422 };
1423 let json = serde_json::to_value(&result).unwrap();
1424 assert_eq!(json["kind"], "deployment-map");
1425 assert!(json["entries"].is_array());
1426 }
1427
1428 #[test]
1429 fn probe_result_show_data_dir_serialises_with_kind_tag() {
1430 let result = ProbeResult::ShowDataDir {
1431 data_dir: "/d".into(),
1432 lines: Vec::new(),
1433 total_nodes: 1,
1434 total_size: 0,
1435 };
1436 let json = serde_json::to_value(&result).unwrap();
1437 assert_eq!(json["kind"], "show-data-dir");
1438 assert_eq!(json["total_nodes"], 1);
1439 }
1440
1441 #[test]
1442 fn probe_subcommands_list_matches_variants() {
1443 let names: Vec<&str> = PROBE_SUBCOMMANDS.iter().map(|s| s.name).collect();
1447 assert!(names.contains(&"deployment-map"));
1448 assert!(names.contains(&"show-data-dir"));
1449 }
1450}