use serde::Serialize;
use crate::packs::orchestration::ExecutionContext;
use crate::probe::{
aggregate_profiles, collect_data_dir_tree, collect_deployment_map, group_profile,
parse_unix_ts_from_filename, read_last_up_marker, read_latest_profile, read_recent_profiles,
summarize_history, AggregatedTarget, DeploymentMapEntry, GroupedProfile, HistoryEntry,
TreeNode,
};
use crate::Result;
pub const DEFAULT_SHOW_DATA_DIR_DEPTH: usize = 4;
#[derive(Debug, Clone, Serialize)]
pub struct DeploymentDisplayEntry {
pub pack: String,
pub handler: String,
pub kind: String,
pub source: String,
pub datastore: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct TreeLine {
pub prefix: String,
pub name: String,
pub annotation: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum ProbeResult {
Summary {
data_dir: String,
available: Vec<ProbeSubcommandInfo>,
},
DeploymentMap {
data_dir: String,
map_path: String,
entries: Vec<DeploymentDisplayEntry>,
},
ShowDataDir {
data_dir: String,
lines: Vec<TreeLine>,
total_nodes: usize,
total_size: u64,
},
ShellInit(ShellInitView),
ShellInitAggregate(ShellInitAggregateView),
ShellInitHistory(ShellInitHistoryView),
ShellInitFilter(ShellInitFilterView),
ShellInitErrors(ShellInitErrorsView),
App(AppProbeView),
}
#[derive(Debug, Clone, Serialize)]
pub struct AppProbeView {
pub pack: String,
pub macos: bool,
pub entries: Vec<AppProbeEntry>,
pub suggested_adoptions: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct AppProbeEntry {
pub folder: String,
pub target_path: String,
pub target_exists: bool,
pub source_rule: String,
pub cask: Option<String>,
pub app_bundle: Option<String>,
pub bundle_id: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShellInitAggregateView {
pub runs: usize,
pub requested_runs: usize,
pub profiling_enabled: bool,
pub profiles_dir: String,
pub rows: Vec<ShellInitAggregateRow>,
pub stale: bool,
pub latest_profile_when: String,
pub last_up_when: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShellInitAggregateRow {
pub pack: String,
pub handler: String,
pub target: String,
pub p50_label: String,
pub p95_label: String,
pub max_label: String,
pub p50_us: u64,
pub p95_us: u64,
pub max_us: u64,
pub seen_label: String,
pub runs_seen: usize,
pub runs_total: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShellInitHistoryView {
pub profiling_enabled: bool,
pub profiles_dir: String,
pub rows: Vec<ShellInitHistoryRow>,
pub stale: bool,
pub latest_profile_when: String,
pub last_up_when: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShellInitHistoryRow {
pub filename: String,
pub unix_ts: u64,
pub when: String,
pub shell: String,
pub total_label: String,
pub user_total_label: String,
pub total_us: u64,
pub user_total_us: u64,
pub failed_entries: usize,
pub entry_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShellInitView {
pub filename: String,
pub shell: String,
pub profiling_enabled: bool,
pub has_profile: bool,
pub groups: Vec<ShellInitGroup>,
pub user_total_us: u64,
pub framing_us: u64,
pub total_us: u64,
pub profiles_dir: String,
pub stale: bool,
pub profile_when: String,
pub last_up_when: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShellInitRow {
pub target: String,
pub duration_us: u64,
pub duration_label: String,
pub exit_status: i32,
pub status_class: &'static str,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShellInitGroup {
pub pack: String,
pub handler: String,
pub rows: Vec<ShellInitRow>,
pub group_total_us: u64,
pub group_total_label: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShellInitFilterView {
pub profiling_enabled: bool,
pub profiles_dir: String,
pub filter: String,
pub filter_pack: String,
pub filter_filename: Option<String>,
pub runs_examined: usize,
pub targets: Vec<ShellInitFilterTarget>,
pub stale: bool,
pub latest_profile_when: String,
pub last_up_when: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShellInitFilterTarget {
pub target: String,
pub display_target: String,
pub pack: String,
pub handler: String,
pub runs: Vec<ShellInitFilterRun>,
pub failure_count: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShellInitErrorsView {
pub profiling_enabled: bool,
pub profiles_dir: String,
pub runs_examined: usize,
pub targets: Vec<ShellInitFilterTarget>,
pub stale: bool,
pub latest_profile_when: String,
pub last_up_when: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ShellInitFilterRun {
pub when: String,
pub duration_label: String,
pub duration_us: u64,
pub exit_status: i32,
pub status_class: &'static str,
pub stderr_lines: Vec<String>,
pub profile_filename: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProbeSubcommandInfo {
pub name: &'static str,
pub description: &'static str,
}
pub const PROBE_SUBCOMMANDS: &[ProbeSubcommandInfo] = &[
ProbeSubcommandInfo {
name: "deployment-map",
description: "Source↔deployed map — what dodot linked where.",
},
ProbeSubcommandInfo {
name: "shell-init",
description: "Per-source timings for the most recent shell startup.",
},
ProbeSubcommandInfo {
name: "show-data-dir",
description: "Tree of dodot's data directory, with sizes.",
},
];
pub fn summary(ctx: &ExecutionContext) -> Result<ProbeResult> {
Ok(ProbeResult::Summary {
data_dir: ctx.paths.data_dir().display().to_string(),
available: PROBE_SUBCOMMANDS.to_vec(),
})
}
pub fn deployment_map(ctx: &ExecutionContext) -> Result<ProbeResult> {
let raw = collect_deployment_map(ctx.fs.as_ref(), ctx.paths.as_ref())?;
let home = ctx.paths.home_dir();
let entries = raw
.into_iter()
.map(|e| into_display_entry(e, home))
.collect();
Ok(ProbeResult::DeploymentMap {
data_dir: ctx.paths.data_dir().display().to_string(),
map_path: ctx.paths.deployment_map_path().display().to_string(),
entries,
})
}
pub fn shell_init(ctx: &ExecutionContext) -> Result<ProbeResult> {
let root_config = ctx.config_manager.root_config()?;
let profiling_enabled = root_config.profiling.enabled;
let profile_opt = read_latest_profile(ctx.fs.as_ref(), ctx.paths.as_ref())?;
let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
let last_up_when = last_up_ts.map(format_unix_ts).unwrap_or_default();
let view = match profile_opt {
Some(profile) => {
let grouped = group_profile(&profile);
let profile_ts = parse_unix_ts_from_filename(&profile.filename);
let stale = is_stale(profile_ts, last_up_ts);
ShellInitView {
filename: profile.filename.clone(),
shell: profile.shell.clone(),
profiling_enabled,
has_profile: true,
groups: shell_init_groups(&grouped),
user_total_us: grouped.user_total_us,
framing_us: grouped.framing_us,
total_us: grouped.total_us,
profiles_dir,
stale,
profile_when: format_unix_ts(profile_ts),
last_up_when,
}
}
None => ShellInitView {
filename: String::new(),
shell: String::new(),
profiling_enabled,
has_profile: false,
groups: Vec::new(),
user_total_us: 0,
framing_us: 0,
total_us: 0,
profiles_dir,
stale: false,
profile_when: String::new(),
last_up_when,
},
};
Ok(ProbeResult::ShellInit(view))
}
fn is_stale(profile_ts: u64, last_up_ts: Option<u64>) -> bool {
matches!(last_up_ts, Some(last) if profile_ts > 0 && profile_ts < last)
}
fn shell_init_groups(grouped: &GroupedProfile) -> Vec<ShellInitGroup> {
grouped
.groups
.iter()
.map(|g| ShellInitGroup {
pack: g.pack.clone(),
handler: g.handler.clone(),
rows: g
.rows
.iter()
.map(|r| ShellInitRow {
target: short_target(&r.target),
duration_us: r.duration_us,
duration_label: humanize_us(r.duration_us),
exit_status: r.exit_status,
status_class: if r.exit_status == 0 {
"deployed"
} else {
"error"
},
})
.collect(),
group_total_us: g.group_total_us,
group_total_label: humanize_us(g.group_total_us),
})
.collect()
}
fn short_target(target: &str) -> String {
std::path::Path::new(target)
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| target.to_string())
}
pub fn humanize_us(us: u64) -> String {
if us < 1_000 {
format!("{us} µs")
} else if us < 1_000_000 {
format!("{:.1} ms", us as f64 / 1_000.0)
} else {
format!("{:.2} s", us as f64 / 1_000_000.0)
}
}
pub fn shell_init_aggregate(ctx: &ExecutionContext, runs: usize) -> Result<ProbeResult> {
let root_config = ctx.config_manager.root_config()?;
let profiling_enabled = root_config.profiling.enabled;
let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
let latest_profile_ts = profiles
.first()
.map(|p| parse_unix_ts_from_filename(&p.filename))
.unwrap_or(0);
let view = aggregate_profiles(&profiles);
let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
Ok(ProbeResult::ShellInitAggregate(ShellInitAggregateView {
runs: view.runs,
requested_runs: runs,
profiling_enabled,
profiles_dir,
rows: view.targets.into_iter().map(into_aggregate_row).collect(),
stale: is_stale(latest_profile_ts, last_up_ts),
latest_profile_when: format_unix_ts(latest_profile_ts),
last_up_when: last_up_ts.map(format_unix_ts).unwrap_or_default(),
}))
}
fn into_aggregate_row(t: AggregatedTarget) -> ShellInitAggregateRow {
ShellInitAggregateRow {
pack: t.pack,
handler: t.handler,
target: short_target(&t.target),
p50_label: humanize_us(t.p50_us),
p95_label: humanize_us(t.p95_us),
max_label: humanize_us(t.max_us),
p50_us: t.p50_us,
p95_us: t.p95_us,
max_us: t.max_us,
seen_label: format!("{}/{}", t.runs_seen, t.runs_total),
runs_seen: t.runs_seen,
runs_total: t.runs_total,
}
}
pub const DEFAULT_HISTORY_LIMIT: usize = 50;
pub const DEFAULT_FILTER_RUNS: usize = 20;
fn target_matches_filter(target: &str, filter: &str) -> bool {
if !filter.contains('/') {
return std::path::Path::new(target)
.file_name()
.is_some_and(|s| s == std::ffi::OsStr::new(filter));
}
target.ends_with(&format!("/{filter}")) || target == filter
}
pub fn shell_init_filter(ctx: &ExecutionContext, filter: &str, runs: usize) -> Result<ProbeResult> {
let root_config = ctx.config_manager.root_config()?;
let profiling_enabled = root_config.profiling.enabled;
let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
let last_up_when = last_up_ts.map(format_unix_ts).unwrap_or_default();
let trimmed = filter.trim().trim_start_matches("./").trim_end_matches('/');
let (filter_pack, filter_filename) = match trimmed.split_once('/') {
Some((p, f)) if !p.is_empty() && !f.is_empty() => (p.to_string(), Some(f.to_string())),
_ => (trimmed.to_string(), None),
};
let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
let latest_profile_ts = profiles
.first()
.map(|p| parse_unix_ts_from_filename(&p.filename))
.unwrap_or(0);
use std::collections::BTreeMap;
let mut buckets: BTreeMap<(String, String, String), Vec<ShellInitFilterRun>> = BTreeMap::new();
for profile in &profiles {
let when = format_unix_ts(parse_unix_ts_from_filename(&profile.filename));
for entry in &profile.entries {
if entry.pack != filter_pack {
continue;
}
if let Some(name) = &filter_filename {
if !target_matches_filter(&entry.target, name) {
continue;
}
}
let stderr_lines: Vec<String> = profile
.errors
.iter()
.find(|er| er.target == entry.target)
.map(|er| {
er.message
.trim_end()
.lines()
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
buckets
.entry((
entry.pack.clone(),
entry.handler.clone(),
entry.target.clone(),
))
.or_default()
.push(ShellInitFilterRun {
when: when.clone(),
duration_us: entry.duration_us,
duration_label: humanize_us(entry.duration_us),
exit_status: entry.exit_status,
status_class: if entry.exit_status == 0 {
"deployed"
} else {
"error"
},
stderr_lines,
profile_filename: profile.filename.clone(),
});
}
}
let targets: Vec<ShellInitFilterTarget> = buckets
.into_iter()
.map(|((pack, handler, target), runs_vec)| {
let display_target = std::path::Path::new(&target)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| target.clone());
let failure_count = runs_vec.iter().filter(|r| r.exit_status != 0).count();
ShellInitFilterTarget {
target,
display_target,
pack,
handler,
runs: runs_vec,
failure_count,
}
})
.collect();
Ok(ProbeResult::ShellInitFilter(ShellInitFilterView {
profiling_enabled,
profiles_dir,
filter: filter.trim().to_string(),
filter_pack,
filter_filename,
runs_examined: profiles.len(),
targets,
stale: is_stale(latest_profile_ts, last_up_ts),
latest_profile_when: format_unix_ts(latest_profile_ts),
last_up_when,
}))
}
pub fn shell_init_errors(ctx: &ExecutionContext, runs: usize) -> Result<ProbeResult> {
let root_config = ctx.config_manager.root_config()?;
let profiling_enabled = root_config.profiling.enabled;
let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
let last_up_when = last_up_ts.map(format_unix_ts).unwrap_or_default();
let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), runs)?;
let latest_profile_ts = profiles
.first()
.map(|p| parse_unix_ts_from_filename(&p.filename))
.unwrap_or(0);
use std::collections::BTreeMap;
let mut buckets: BTreeMap<(String, String, String), Vec<ShellInitFilterRun>> = BTreeMap::new();
for profile in &profiles {
let when = format_unix_ts(parse_unix_ts_from_filename(&profile.filename));
for entry in &profile.entries {
if entry.exit_status == 0 {
continue;
}
let stderr_lines: Vec<String> = profile
.errors
.iter()
.find(|er| er.target == entry.target)
.map(|er| {
er.message
.trim_end()
.lines()
.map(|s| s.to_string())
.collect()
})
.unwrap_or_default();
buckets
.entry((
entry.pack.clone(),
entry.handler.clone(),
entry.target.clone(),
))
.or_default()
.push(ShellInitFilterRun {
when: when.clone(),
duration_us: entry.duration_us,
duration_label: humanize_us(entry.duration_us),
exit_status: entry.exit_status,
status_class: "error",
stderr_lines,
profile_filename: profile.filename.clone(),
});
}
}
let mut targets: Vec<ShellInitFilterTarget> = buckets
.into_iter()
.map(|((pack, handler, target), runs_vec)| {
let display_target = std::path::Path::new(&target)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| target.clone());
let failure_count = runs_vec.len();
ShellInitFilterTarget {
target,
display_target,
pack,
handler,
runs: runs_vec,
failure_count,
}
})
.collect();
targets.sort_by(|a, b| {
b.failure_count
.cmp(&a.failure_count)
.then_with(|| a.pack.cmp(&b.pack))
.then_with(|| a.handler.cmp(&b.handler))
.then_with(|| a.target.cmp(&b.target))
});
Ok(ProbeResult::ShellInitErrors(ShellInitErrorsView {
profiling_enabled,
profiles_dir,
runs_examined: profiles.len(),
targets,
stale: is_stale(latest_profile_ts, last_up_ts),
latest_profile_when: format_unix_ts(latest_profile_ts),
last_up_when,
}))
}
pub fn shell_init_history(ctx: &ExecutionContext, limit: usize) -> Result<ProbeResult> {
let root_config = ctx.config_manager.root_config()?;
let profiling_enabled = root_config.profiling.enabled;
let profiles = read_recent_profiles(ctx.fs.as_ref(), ctx.paths.as_ref(), limit)?;
let latest_profile_ts = profiles
.first()
.map(|p| parse_unix_ts_from_filename(&p.filename))
.unwrap_or(0);
let history = summarize_history(&profiles);
let profiles_dir = ctx.paths.probes_shell_init_dir().display().to_string();
let last_up_ts = read_last_up_marker(ctx.fs.as_ref(), ctx.paths.as_ref());
Ok(ProbeResult::ShellInitHistory(ShellInitHistoryView {
profiling_enabled,
profiles_dir,
rows: history.into_iter().map(into_history_row).collect(),
stale: is_stale(latest_profile_ts, last_up_ts),
latest_profile_when: format_unix_ts(latest_profile_ts),
last_up_when: last_up_ts.map(format_unix_ts).unwrap_or_default(),
}))
}
fn into_history_row(h: HistoryEntry) -> ShellInitHistoryRow {
ShellInitHistoryRow {
filename: h.filename,
unix_ts: h.unix_ts,
when: format_unix_ts(h.unix_ts),
shell: h.shell,
total_label: humanize_us(h.total_us),
user_total_label: humanize_us(h.user_total_us),
total_us: h.total_us,
user_total_us: h.user_total_us,
failed_entries: h.failed_entries,
entry_count: h.entry_count,
}
}
pub fn format_unix_ts(ts: u64) -> String {
const MAX_REASONABLE_TS: u64 = 253_402_300_799; if ts == 0 || ts > MAX_REASONABLE_TS {
return String::new();
}
let secs_per_day: u64 = 86_400;
let days = (ts / secs_per_day) as i64; let secs_of_day = ts % secs_per_day;
let hour = secs_of_day / 3600;
let minute = (secs_of_day % 3600) / 60;
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02} {hour:02}:{minute:02}")
}
fn civil_from_days(z: i64) -> (i32, u32, u32) {
let z = z + 719468;
let era = if z >= 0 { z } else { z - 146096 } / 146097;
let doe = (z - era * 146097) as u64; let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; let y = yoe as i64 + era * 400;
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 };
(y as i32, m as u32, d as u32)
}
pub fn app(pack_name: &str, refresh: bool, ctx: &ExecutionContext) -> Result<ProbeResult> {
use std::collections::BTreeSet;
let pack_dir = crate::packs::orchestration::resolve_pack_dir_name(pack_name, ctx)
.unwrap_or_else(|_| {
if is_single_normal_path_component(pack_name) {
pack_name.to_string()
} else {
String::new()
}
});
let display_name = if pack_dir.is_empty() {
pack_name.to_string()
} else {
crate::packs::display_name_for(&pack_dir).to_string()
};
let pack_config = if pack_dir.is_empty() {
ctx.config_manager.root_config()?
} else {
match ctx
.config_manager
.config_for_pack(&ctx.paths.pack_path(&pack_dir))
{
Ok(c) => c,
Err(_) => ctx.config_manager.root_config()?,
}
};
let mut folders: Vec<(String, &'static str)> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
if let Some(alias) = pack_config.symlink.app_aliases.get(&display_name) {
if seen.insert(alias.clone()) {
folders.push((alias.clone(), "alias"));
}
}
let pack_path = ctx.paths.pack_path(&pack_dir);
if !pack_dir.is_empty() && ctx.fs.exists(&pack_path) {
if let Ok(entries) = ctx.fs.read_dir(&pack_path) {
for e in entries {
if e.is_dir
&& pack_config.symlink.force_app.iter().any(|f| f == &e.name)
&& seen.insert(e.name.clone())
{
folders.push((e.name.clone(), "force_app"));
}
}
let app_dir = pack_path.join("_app");
if ctx.fs.exists(&app_dir) {
if let Ok(children) = ctx.fs.read_dir(&app_dir) {
for e in children {
if e.is_dir && seen.insert(e.name.clone()) {
folders.push((e.name.clone(), "_app/"));
}
}
}
}
}
}
let macos = cfg!(target_os = "macos");
let app_support = ctx.paths.app_support_dir();
let cache_dir = ctx.paths.probes_brew_cache_dir();
if refresh && macos {
crate::probe::brew::invalidate_all_cache(&cache_dir, ctx.fs.as_ref());
}
let now = crate::probe::brew::now_secs_unix();
let folder_names: Vec<String> = folders.iter().map(|(f, _)| f.clone()).collect();
let matches = if macos {
crate::probe::brew::match_folders_to_installed_casks(
&folder_names,
ctx.command_runner.as_ref(),
&cache_dir,
now,
ctx.fs.as_ref(),
false,
)
} else {
crate::probe::brew::InstalledCaskMatches::default()
};
let mut entries: Vec<AppProbeEntry> = Vec::new();
let mut suggested: BTreeSet<String> = BTreeSet::new();
for (folder, source_rule) in &folders {
let target = app_support.join(folder);
let target_exists = ctx.fs.exists(&target);
let cask = matches.folder_to_token.get(folder).cloned();
let mut app_bundle = None;
let mut bundle_id = None;
if macos {
if let Some(token) = &cask {
if let Ok(Some(info)) = crate::probe::brew::info_cask(
token,
&cache_dir,
now,
ctx.fs.as_ref(),
ctx.command_runner.as_ref(),
) {
app_bundle = info.app_bundle_name();
if let Some(bundle_name) = &app_bundle {
let app_path = std::path::PathBuf::from("/Applications").join(bundle_name);
bundle_id = crate::probe::macos_native::bundle_id(
&app_path,
ctx.command_runner.as_ref(),
);
}
for plist in info.preferences_plists() {
suggested.insert(plist);
}
}
}
}
entries.push(AppProbeEntry {
folder: folder.clone(),
target_path: display_path(&target, ctx.paths.home_dir()),
target_exists,
source_rule: (*source_rule).into(),
cask,
app_bundle,
bundle_id,
});
}
Ok(ProbeResult::App(AppProbeView {
pack: display_name,
macos,
entries,
suggested_adoptions: suggested.into_iter().collect(),
}))
}
fn is_single_normal_path_component(value: &str) -> bool {
if value.is_empty() {
return false;
}
let mut comps = std::path::Path::new(value).components();
matches!(
(comps.next(), comps.next()),
(Some(std::path::Component::Normal(_)), None)
)
}
pub fn show_data_dir(ctx: &ExecutionContext, max_depth: usize) -> Result<ProbeResult> {
let tree = collect_data_dir_tree(ctx.fs.as_ref(), ctx.paths.as_ref(), max_depth)?;
let total_nodes = tree.count_nodes();
let total_size = tree.total_size();
let mut lines = Vec::new();
flatten_tree(&tree, "", true, &mut lines, true);
Ok(ProbeResult::ShowDataDir {
data_dir: ctx.paths.data_dir().display().to_string(),
lines,
total_nodes,
total_size,
})
}
fn into_display_entry(e: DeploymentMapEntry, home: &std::path::Path) -> DeploymentDisplayEntry {
DeploymentDisplayEntry {
pack: e.pack,
handler: e.handler,
kind: e.kind.as_str().into(),
source: if e.source.as_os_str().is_empty() {
"—".into()
} else {
display_path(&e.source, home)
},
datastore: display_path(&e.datastore, home),
}
}
fn display_path(p: &std::path::Path, home: &std::path::Path) -> String {
if let Ok(rel) = p.strip_prefix(home) {
format!("~/{}", rel.display())
} else {
p.display().to_string()
}
}
fn flatten_tree(
node: &TreeNode,
prefix: &str,
is_last: bool,
out: &mut Vec<TreeLine>,
is_root: bool,
) {
let branch = if is_root {
String::new()
} else if is_last {
"└─ ".to_string()
} else {
"├─ ".to_string()
};
let line_prefix = format!("{prefix}{branch}");
out.push(TreeLine {
prefix: line_prefix,
name: node.name.clone(),
annotation: annotate(node),
});
if node.children.is_empty() {
return;
}
let child_prefix = if is_root {
String::new()
} else if is_last {
format!("{prefix} ")
} else {
format!("{prefix}│ ")
};
let last_idx = node.children.len() - 1;
for (i, child) in node.children.iter().enumerate() {
flatten_tree(child, &child_prefix, i == last_idx, out, false);
}
}
fn annotate(node: &TreeNode) -> String {
match node.kind {
"dir" => match node.truncated_count {
Some(n) if n > 0 => format!("(… {n} more)"),
_ => String::new(),
},
"file" => match node.size {
Some(n) => humanize_bytes(n),
None => String::new(),
},
"symlink" => match &node.link_target {
Some(t) => format!("→ {t}"),
None => "→ (broken)".into(),
},
_ => String::new(),
}
}
pub fn humanize_bytes(n: u64) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if n < KB {
format!("{n} B")
} else if n < MB {
format!("{:.1} KB", n as f64 / KB as f64)
} else if n < GB {
format!("{:.1} MB", n as f64 / MB as f64)
} else {
format!("{:.1} GB", n as f64 / GB as f64)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::probe::{DeploymentKind, DeploymentMapEntry};
use std::path::PathBuf;
fn home() -> PathBuf {
PathBuf::from("/home/alice")
}
#[test]
fn display_path_shortens_home() {
assert_eq!(
display_path(&PathBuf::from("/home/alice/dotfiles/vim/rc"), &home()),
"~/dotfiles/vim/rc"
);
}
#[test]
fn display_path_keeps_paths_outside_home() {
assert_eq!(
display_path(&PathBuf::from("/opt/data"), &home()),
"/opt/data"
);
}
#[test]
fn humanize_bytes_boundaries() {
assert_eq!(humanize_bytes(0), "0 B");
assert_eq!(humanize_bytes(1023), "1023 B");
assert_eq!(humanize_bytes(1024), "1.0 KB");
assert_eq!(humanize_bytes(1024 * 1024), "1.0 MB");
assert_eq!(humanize_bytes(1024 * 1024 * 1024), "1.0 GB");
}
#[test]
fn format_unix_ts_handles_zero_and_out_of_range() {
assert_eq!(format_unix_ts(0), "");
assert_eq!(format_unix_ts(1_714_000_000), "2024-04-24 23:06");
assert_eq!(format_unix_ts(u64::MAX), "");
assert_eq!(format_unix_ts(253_402_300_800), ""); assert_eq!(format_unix_ts(253_402_300_799), "9999-12-31 23:59");
}
#[test]
fn into_display_entry_handles_sentinel_source() {
let entry = DeploymentMapEntry {
pack: "nvim".into(),
handler: "install".into(),
kind: DeploymentKind::File,
source: PathBuf::new(),
datastore: PathBuf::from("/home/alice/.local/share/dodot/packs/nvim/install/sent"),
};
let display = into_display_entry(entry, &home());
assert_eq!(display.source, "—");
assert!(display.datastore.starts_with("~/"));
}
#[test]
fn tree_flattening_produces_branch_glyphs() {
let tree = TreeNode {
name: "root".into(),
path: PathBuf::from("/root"),
kind: "dir",
size: None,
link_target: None,
truncated_count: None,
children: vec![
TreeNode {
name: "a".into(),
path: PathBuf::from("/root/a"),
kind: "dir",
size: None,
link_target: None,
truncated_count: None,
children: vec![TreeNode {
name: "aa".into(),
path: PathBuf::from("/root/a/aa"),
kind: "file",
size: Some(10),
link_target: None,
truncated_count: None,
children: Vec::new(),
}],
},
TreeNode {
name: "b".into(),
path: PathBuf::from("/root/b"),
kind: "file",
size: Some(42),
link_target: None,
truncated_count: None,
children: Vec::new(),
},
],
};
let mut lines = Vec::new();
flatten_tree(&tree, "", true, &mut lines, true);
assert_eq!(lines.len(), 4);
assert_eq!(lines[0].name, "root");
assert_eq!(lines[0].prefix, ""); assert_eq!(lines[1].name, "a");
assert!(lines[1].prefix.ends_with("├─ "));
assert_eq!(lines[2].name, "aa");
assert!(lines[2].prefix.ends_with("└─ "));
assert!(lines[2].prefix.starts_with("│")); assert_eq!(lines[3].name, "b");
assert!(lines[3].prefix.ends_with("└─ "));
assert_eq!(lines[3].annotation, "42 B");
}
#[test]
fn annotate_symlink_with_target() {
let node = TreeNode {
name: "link".into(),
path: PathBuf::from("/x"),
kind: "symlink",
size: Some(20),
link_target: Some("/target".into()),
truncated_count: None,
children: Vec::new(),
};
assert_eq!(annotate(&node), "→ /target");
}
#[test]
fn annotate_broken_symlink() {
let node = TreeNode {
name: "link".into(),
path: PathBuf::from("/x"),
kind: "symlink",
size: Some(20),
link_target: None,
truncated_count: None,
children: Vec::new(),
};
assert_eq!(annotate(&node), "→ (broken)");
}
#[test]
fn annotate_truncated_dir() {
let node = TreeNode {
name: "deep".into(),
path: PathBuf::from("/x"),
kind: "dir",
size: None,
link_target: None,
truncated_count: Some(7),
children: Vec::new(),
};
assert_eq!(annotate(&node), "(… 7 more)");
}
#[test]
fn probe_result_deployment_map_serialises_with_kind_tag() {
let result = ProbeResult::DeploymentMap {
data_dir: "/d".into(),
map_path: "/d/deployment-map.tsv".into(),
entries: Vec::new(),
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["kind"], "deployment-map");
assert!(json["entries"].is_array());
}
#[test]
fn probe_result_show_data_dir_serialises_with_kind_tag() {
let result = ProbeResult::ShowDataDir {
data_dir: "/d".into(),
lines: Vec::new(),
total_nodes: 1,
total_size: 0,
};
let json = serde_json::to_value(&result).unwrap();
assert_eq!(json["kind"], "show-data-dir");
assert_eq!(json["total_nodes"], 1);
}
#[test]
fn probe_subcommands_list_matches_variants() {
let names: Vec<&str> = PROBE_SUBCOMMANDS.iter().map(|s| s.name).collect();
assert!(names.contains(&"deployment-map"));
assert!(names.contains(&"show-data-dir"));
}
}