use serde::Serialize;
use crate::packs::orchestration::ExecutionContext;
use crate::probe::{
collect_data_dir_tree, collect_deployment_map, group_profile, read_latest_profile,
DeploymentMapEntry, GroupedProfile, 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),
}
#[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,
}
#[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 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 view = match profile_opt {
Some(profile) => {
let grouped = group_profile(&profile);
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,
}
}
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,
},
};
Ok(ProbeResult::ShellInit(view))
}
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 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 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"));
}
}