use serde::Serialize;
use crate::fs::Fs;
use crate::paths::Pather;
use crate::Result;
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct ProfileEntry {
pub phase: String,
pub pack: String,
pub handler: String,
pub target: String,
pub duration_us: u64,
pub exit_status: i32,
}
#[derive(Debug, Clone, Serialize)]
pub struct Profile {
pub filename: String,
pub shell: String,
pub total_duration_us: u64,
pub entries: Vec<ProfileEntry>,
}
impl Profile {
pub fn entries_duration_us(&self) -> u64 {
self.entries.iter().map(|e| e.duration_us).sum()
}
pub fn framing_duration_us(&self) -> u64 {
self.total_duration_us
.saturating_sub(self.entries_duration_us())
}
}
pub fn read_latest_profile(fs: &dyn Fs, paths: &dyn Pather) -> Result<Option<Profile>> {
let mut profiles = read_recent_profiles(fs, paths, 1)?;
Ok(profiles.pop())
}
pub fn read_recent_profiles(fs: &dyn Fs, paths: &dyn Pather, limit: usize) -> Result<Vec<Profile>> {
let dir = paths.probes_shell_init_dir();
if !fs.is_dir(&dir) || limit == 0 {
return Ok(Vec::new());
}
let entries: Vec<_> = fs
.read_dir(&dir)?
.into_iter()
.rev()
.filter(|e| e.is_file && e.name.starts_with("profile-") && e.name.ends_with(".tsv"))
.take(limit)
.collect();
let mut profiles = Vec::with_capacity(entries.len());
for entry in entries {
let content = fs.read_to_string(&entry.path)?;
profiles.push(parse_profile(&entry.name, &content));
}
Ok(profiles)
}
pub fn parse_profile(filename: &str, content: &str) -> Profile {
let mut shell = String::new();
let mut start_t: Option<f64> = None;
let mut end_t: Option<f64> = None;
let mut entries: Vec<ProfileEntry> = Vec::new();
for raw_line in content.lines() {
let line = raw_line.trim_end_matches('\r');
if line.is_empty() {
continue;
}
if let Some(rest) = line.strip_prefix('#') {
let trimmed = rest.trim_start();
if let Some((key, val)) = trimmed.split_once('\t') {
match key {
"shell" => shell = val.to_string(),
"start_t" => start_t = val.parse::<f64>().ok(),
"end_t" => end_t = val.parse::<f64>().ok(),
_ => {} }
}
continue;
}
if let Some(entry) = parse_row(line) {
entries.push(entry);
}
}
let total_duration_us = match (start_t, end_t) {
(Some(s), Some(e)) if e >= s => seconds_to_micros(e - s),
_ => 0,
};
Profile {
filename: filename.to_string(),
shell,
total_duration_us,
entries,
}
}
fn parse_row(line: &str) -> Option<ProfileEntry> {
let mut parts = line.splitn(7, '\t');
let phase = parts.next()?;
let pack = parts.next()?;
let handler = parts.next()?;
let target = parts.next()?;
let start = parts.next()?.parse::<f64>().ok()?;
let end = parts.next()?.parse::<f64>().ok()?;
let exit_status = parts.next()?.parse::<i32>().ok()?;
if !matches!(phase, "path" | "source") {
return None;
}
let duration_us = if end >= start {
seconds_to_micros(end - start)
} else {
0
};
Some(ProfileEntry {
phase: phase.to_string(),
pack: pack.to_string(),
handler: handler.to_string(),
target: target.to_string(),
duration_us,
exit_status,
})
}
fn seconds_to_micros(secs: f64) -> u64 {
if !secs.is_finite() || secs < 0.0 {
return 0;
}
(secs * 1_000_000.0).round() as u64
}
pub fn rotate_profiles(fs: &dyn Fs, paths: &dyn Pather, keep: usize) -> Result<usize> {
if keep == 0 {
return Ok(0);
}
let dir = paths.probes_shell_init_dir();
if !fs.is_dir(&dir) {
return Ok(0);
}
let entries: Vec<_> = fs
.read_dir(&dir)?
.into_iter()
.filter(|e| e.is_file && e.name.starts_with("profile-") && e.name.ends_with(".tsv"))
.collect();
if entries.len() <= keep {
return Ok(0);
}
let to_remove = entries.len() - keep;
let mut removed = 0;
for entry in entries.into_iter().take(to_remove) {
if fs.remove_file(&entry.path).is_ok() {
removed += 1;
}
}
Ok(removed)
}
#[derive(Debug, Clone, Serialize)]
pub struct GroupedProfile {
pub groups: Vec<ProfileGroup>,
pub user_total_us: u64,
pub framing_us: u64,
pub total_us: u64,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProfileGroup {
pub pack: String,
pub handler: String,
pub rows: Vec<ProfileEntry>,
pub group_total_us: u64,
}
pub fn group_profile(profile: &Profile) -> GroupedProfile {
let user_total_us = profile.entries_duration_us();
let total_us = profile.total_duration_us.max(user_total_us);
let framing_us = total_us.saturating_sub(user_total_us);
let mut groups: Vec<ProfileGroup> = Vec::new();
for entry in &profile.entries {
let key = (&entry.pack, &entry.handler);
let pos = groups
.iter()
.position(|g| (&g.pack, &g.handler) == (key.0, key.1));
match pos {
Some(i) => {
groups[i].rows.push(entry.clone());
groups[i].group_total_us += entry.duration_us;
}
None => groups.push(ProfileGroup {
pack: entry.pack.clone(),
handler: entry.handler.clone(),
rows: vec![entry.clone()],
group_total_us: entry.duration_us,
}),
}
}
groups.sort_by(|a, b| a.pack.cmp(&b.pack).then(a.handler.cmp(&b.handler)));
GroupedProfile {
groups,
user_total_us,
framing_us,
total_us,
}
}
#[derive(Debug, Clone, Serialize)]
pub struct AggregatedTarget {
pub pack: String,
pub handler: String,
pub target: String,
pub p50_us: u64,
pub p95_us: u64,
pub max_us: u64,
pub runs_seen: usize,
pub runs_total: usize,
}
#[derive(Debug, Clone, Serialize)]
pub struct AggregatedView {
pub runs: usize,
pub targets: Vec<AggregatedTarget>,
}
pub fn aggregate_profiles(profiles: &[Profile]) -> AggregatedView {
use std::collections::BTreeMap;
let mut buckets: BTreeMap<(String, String, String), Vec<u64>> = BTreeMap::new();
for p in profiles {
for e in &p.entries {
buckets
.entry((e.pack.clone(), e.handler.clone(), e.target.clone()))
.or_default()
.push(e.duration_us);
}
}
let runs_total = profiles.len();
let targets = buckets
.into_iter()
.map(|((pack, handler, target), mut durs)| {
durs.sort_unstable();
AggregatedTarget {
pack,
handler,
target,
p50_us: percentile(&durs, 50),
p95_us: percentile(&durs, 95),
max_us: *durs.last().unwrap_or(&0),
runs_seen: durs.len(),
runs_total,
}
})
.collect();
AggregatedView {
runs: runs_total,
targets,
}
}
fn percentile(sorted: &[u64], pct: u8) -> u64 {
if sorted.is_empty() {
return 0;
}
let n = sorted.len();
let rank = ((pct as f64 / 100.0) * n as f64).ceil() as usize;
let idx = rank.saturating_sub(1).min(n - 1);
sorted[idx]
}
#[derive(Debug, Clone, Serialize)]
pub struct HistoryEntry {
pub filename: String,
pub unix_ts: u64,
pub shell: String,
pub total_us: u64,
pub user_total_us: u64,
pub failed_entries: usize,
pub entry_count: usize,
}
pub fn summarize_history(profiles: &[Profile]) -> Vec<HistoryEntry> {
profiles.iter().map(history_entry_from).collect()
}
fn history_entry_from(profile: &Profile) -> HistoryEntry {
HistoryEntry {
filename: profile.filename.clone(),
unix_ts: parse_unix_ts_from_filename(&profile.filename),
shell: profile.shell.clone(),
total_us: profile.total_duration_us,
user_total_us: profile.entries_duration_us(),
failed_entries: profile
.entries
.iter()
.filter(|e| e.exit_status != 0)
.count(),
entry_count: profile.entries.len(),
}
}
pub(crate) fn parse_unix_ts_from_filename(filename: &str) -> u64 {
filename
.strip_prefix("profile-")
.and_then(|rest| rest.split('-').next())
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempEnvironment;
fn write_profile(env: &TempEnvironment, name: &str, content: &str) -> std::path::PathBuf {
let dir = env.paths.probes_shell_init_dir();
env.fs.mkdir_all(&dir).unwrap();
let path = dir.join(name);
env.fs.write_file(&path, content.as_bytes()).unwrap();
path
}
#[test]
fn parser_extracts_preamble_and_rows() {
let content = "# dodot shell-init profile v1\n\
# shell\tbash 5.2\n\
# start_t\t1714000000.000000\n\
# init_script\t/x/dodot-init.sh\n\
# columns\tphase\tpack\thandler\ttarget\tstart_t\tend_t\texit_status\n\
path\tvim\tpath\t/x/bin\t1714000000.001000\t1714000000.001005\t0\n\
source\tgit\tshell\t/x/aliases.sh\t1714000000.002000\t1714000000.005000\t0\n\
# end_t\t1714000000.010000\n";
let p = parse_profile("profile-1714000000-1-1.tsv", content);
assert_eq!(p.shell, "bash 5.2");
assert_eq!(p.entries.len(), 2);
assert_eq!(p.entries[0].phase, "path");
assert_eq!(p.entries[0].duration_us, 5);
assert_eq!(p.entries[1].duration_us, 3000);
assert_eq!(p.total_duration_us, 10_000);
}
#[test]
fn parser_skips_malformed_rows() {
let content = "# columns\tphase\tpack\thandler\ttarget\tstart_t\tend_t\texit_status\n\
junk\trow\twith\ttoo\tfew\tcols\n\
path\tvim\tpath\t/x\t1.0\t1.001\t0\n\
weird\tphase\twrong\t/x\t1.0\t1.001\t0\n";
let p = parse_profile("p.tsv", content);
assert_eq!(p.entries.len(), 1);
assert_eq!(p.entries[0].phase, "path");
}
#[test]
fn parser_handles_missing_end_marker() {
let content = "# start_t\t1714000000.000000\n\
source\tvim\tshell\t/x\t1714000000.001000\t1714000000.002000\t0\n";
let p = parse_profile("p.tsv", content);
assert_eq!(p.total_duration_us, 0); assert_eq!(p.entries.len(), 1);
assert_eq!(p.entries[0].duration_us, 1000);
}
#[test]
fn read_latest_returns_none_when_dir_missing() {
let env = TempEnvironment::builder().build();
let r = read_latest_profile(env.fs.as_ref(), env.paths.as_ref()).unwrap();
assert!(r.is_none());
}
#[test]
fn read_latest_picks_highest_filename_lexicographically() {
let env = TempEnvironment::builder().build();
write_profile(&env, "profile-1000-1-1.tsv", "# shell\told\n");
write_profile(&env, "profile-2000-1-1.tsv", "# shell\tnew\n");
write_profile(&env, "profile-1500-1-1.tsv", "# shell\tmid\n");
let p = read_latest_profile(env.fs.as_ref(), env.paths.as_ref())
.unwrap()
.unwrap();
assert_eq!(p.shell, "new");
assert_eq!(p.filename, "profile-2000-1-1.tsv");
}
#[test]
fn rotate_keeps_newest_n() {
let env = TempEnvironment::builder().build();
for i in 0..10 {
write_profile(&env, &format!("profile-{i:04}-1-1.tsv"), "x");
}
let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 3).unwrap();
assert_eq!(removed, 7);
let remaining: Vec<String> = env
.fs
.read_dir(&env.paths.probes_shell_init_dir())
.unwrap()
.into_iter()
.map(|e| e.name)
.collect();
assert_eq!(
remaining,
vec![
"profile-0007-1-1.tsv".to_string(),
"profile-0008-1-1.tsv".to_string(),
"profile-0009-1-1.tsv".to_string(),
]
);
}
#[test]
fn rotate_with_keep_zero_is_a_noop() {
let env = TempEnvironment::builder().build();
for i in 0..3 {
write_profile(&env, &format!("profile-{i}-1-1.tsv"), "x");
}
let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 0).unwrap();
assert_eq!(removed, 0);
let count = env
.fs
.read_dir(&env.paths.probes_shell_init_dir())
.unwrap()
.len();
assert_eq!(count, 3);
}
#[test]
fn rotate_below_threshold_is_a_noop() {
let env = TempEnvironment::builder().build();
write_profile(&env, "profile-1-1-1.tsv", "x");
let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 100).unwrap();
assert_eq!(removed, 0);
}
#[test]
fn rotate_ignores_non_profile_files() {
let env = TempEnvironment::builder().build();
let dir = env.paths.probes_shell_init_dir();
env.fs.mkdir_all(&dir).unwrap();
for i in 1..=5 {
env.fs
.write_file(&dir.join(format!("profile-{i}-1-1.tsv")), b"")
.unwrap();
}
env.fs
.write_file(&dir.join("README"), b"do not delete")
.unwrap();
env.fs
.write_file(&dir.join("notes.txt"), b"keep me")
.unwrap();
let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 2).unwrap();
assert_eq!(removed, 3);
assert!(env.fs.exists(&dir.join("profile-4-1-1.tsv")));
assert!(env.fs.exists(&dir.join("profile-5-1-1.tsv")));
assert!(!env.fs.exists(&dir.join("profile-1-1-1.tsv")));
assert!(!env.fs.exists(&dir.join("profile-2-1-1.tsv")));
assert!(!env.fs.exists(&dir.join("profile-3-1-1.tsv")));
assert!(env.fs.exists(&dir.join("README")));
assert!(env.fs.exists(&dir.join("notes.txt")));
}
#[test]
fn group_profile_aggregates_by_pack_handler() {
let p = Profile {
filename: "x".into(),
shell: "bash".into(),
total_duration_us: 10_000,
entries: vec![
ProfileEntry {
phase: "source".into(),
pack: "vim".into(),
handler: "shell".into(),
target: "/a".into(),
duration_us: 100,
exit_status: 0,
},
ProfileEntry {
phase: "source".into(),
pack: "vim".into(),
handler: "shell".into(),
target: "/b".into(),
duration_us: 200,
exit_status: 0,
},
ProfileEntry {
phase: "path".into(),
pack: "vim".into(),
handler: "path".into(),
target: "/bin".into(),
duration_us: 5,
exit_status: 0,
},
],
};
let g = group_profile(&p);
assert_eq!(g.groups.len(), 2);
assert_eq!(g.groups[0].pack, "vim");
assert_eq!(g.groups[0].handler, "path");
assert_eq!(g.groups[0].group_total_us, 5);
assert_eq!(g.groups[1].handler, "shell");
assert_eq!(g.groups[1].group_total_us, 300);
assert_eq!(g.user_total_us, 305);
assert_eq!(g.total_us, 10_000);
assert_eq!(g.framing_us, 9_695);
}
#[test]
fn group_profile_sorts_across_packs() {
let p = Profile {
filename: "x".into(),
shell: "bash".into(),
total_duration_us: 0,
entries: vec![
entry("vim", "shell", "/a", 1),
entry("git", "symlink", "/b", 1),
entry("vim", "path", "/c", 1),
entry("git", "shell", "/d", 1),
],
};
let g = group_profile(&p);
let keys: Vec<(String, String)> = g
.groups
.iter()
.map(|gp| (gp.pack.clone(), gp.handler.clone()))
.collect();
assert_eq!(
keys,
vec![
("git".into(), "shell".into()),
("git".into(), "symlink".into()),
("vim".into(), "path".into()),
("vim".into(), "shell".into()),
]
);
}
fn entry(pack: &str, handler: &str, target: &str, dur_us: u64) -> ProfileEntry {
ProfileEntry {
phase: "source".into(),
pack: pack.into(),
handler: handler.into(),
target: target.into(),
duration_us: dur_us,
exit_status: 0,
}
}
#[test]
fn group_profile_clamps_framing_when_total_below_entries() {
let p = Profile {
filename: "x".into(),
shell: "".into(),
total_duration_us: 0,
entries: vec![ProfileEntry {
phase: "source".into(),
pack: "vim".into(),
handler: "shell".into(),
target: "/a".into(),
duration_us: 500,
exit_status: 0,
}],
};
let g = group_profile(&p);
assert_eq!(g.user_total_us, 500);
assert_eq!(g.total_us, 500);
assert_eq!(g.framing_us, 0);
}
#[test]
fn read_recent_returns_newest_first_capped_at_limit() {
let env = TempEnvironment::builder().build();
for i in 1..=5 {
write_profile(
&env,
&format!("profile-{i}-1-1.tsv"),
"# columns\tphase\tpack\thandler\ttarget\tstart_t\tend_t\texit_status\n",
);
}
let recent = read_recent_profiles(env.fs.as_ref(), env.paths.as_ref(), 3).unwrap();
let names: Vec<&str> = recent.iter().map(|p| p.filename.as_str()).collect();
assert_eq!(
names,
vec![
"profile-5-1-1.tsv",
"profile-4-1-1.tsv",
"profile-3-1-1.tsv",
]
);
}
#[test]
fn read_recent_with_limit_zero_returns_empty() {
let env = TempEnvironment::builder().build();
write_profile(&env, "profile-1-1-1.tsv", "x");
let recent = read_recent_profiles(env.fs.as_ref(), env.paths.as_ref(), 0).unwrap();
assert!(recent.is_empty());
}
#[test]
fn read_recent_handles_fewer_files_than_limit() {
let env = TempEnvironment::builder().build();
write_profile(&env, "profile-1-1-1.tsv", "");
let recent = read_recent_profiles(env.fs.as_ref(), env.paths.as_ref(), 100).unwrap();
assert_eq!(recent.len(), 1);
}
#[test]
fn percentile_nearest_rank_basic_cases() {
let v: Vec<u64> = (1..=10).collect();
assert_eq!(percentile(&v, 50), 5);
assert_eq!(percentile(&v, 95), 10);
assert_eq!(percentile(&[42], 50), 42);
assert_eq!(percentile(&[42], 95), 42);
assert_eq!(percentile(&[], 50), 0);
}
#[test]
fn aggregate_profiles_buckets_by_pack_handler_target() {
let p1 = Profile {
filename: "profile-1-1-1.tsv".into(),
shell: "bash".into(),
total_duration_us: 0,
entries: vec![
entry("vim", "shell", "/a", 100),
entry("vim", "shell", "/b", 200),
],
};
let p2 = Profile {
filename: "profile-2-1-1.tsv".into(),
shell: "bash".into(),
total_duration_us: 0,
entries: vec![
entry("vim", "shell", "/a", 110),
entry("vim", "shell", "/b", 250),
],
};
let p3 = Profile {
filename: "profile-3-1-1.tsv".into(),
shell: "bash".into(),
total_duration_us: 0,
entries: vec![entry("vim", "shell", "/a", 120)],
};
let agg = aggregate_profiles(&[p1, p2, p3]);
assert_eq!(agg.runs, 3);
assert_eq!(agg.targets.len(), 2);
let a = agg.targets.iter().find(|t| t.target == "/a").unwrap();
assert_eq!(a.runs_seen, 3);
assert_eq!(a.runs_total, 3);
assert_eq!(a.p50_us, 110); assert_eq!(a.max_us, 120);
let b = agg.targets.iter().find(|t| t.target == "/b").unwrap();
assert_eq!(b.runs_seen, 2);
assert_eq!(b.runs_total, 3);
assert_eq!(b.max_us, 250);
}
#[test]
fn aggregate_empty_profiles_returns_empty_view() {
let agg = aggregate_profiles(&[]);
assert_eq!(agg.runs, 0);
assert!(agg.targets.is_empty());
}
#[test]
fn aggregate_targets_sort_by_pack_handler_target() {
let p = Profile {
filename: "p".into(),
shell: "".into(),
total_duration_us: 0,
entries: vec![
entry("vim", "shell", "/z", 1),
entry("git", "shell", "/a", 1),
entry("vim", "path", "/x", 1),
entry("git", "shell", "/y", 1),
],
};
let agg = aggregate_profiles(&[p]);
let keys: Vec<(&str, &str, &str)> = agg
.targets
.iter()
.map(|t| (t.pack.as_str(), t.handler.as_str(), t.target.as_str()))
.collect();
assert_eq!(
keys,
vec![
("git", "shell", "/a"),
("git", "shell", "/y"),
("vim", "path", "/x"),
("vim", "shell", "/z"),
]
);
}
#[test]
fn summarize_history_pulls_basic_metrics_per_run() {
let p1 = Profile {
filename: "profile-1714000000-12-34.tsv".into(),
shell: "bash 5.3".into(),
total_duration_us: 500,
entries: vec![
entry("vim", "shell", "/a", 100),
ProfileEntry {
phase: "source".into(),
pack: "gh".into(),
handler: "shell".into(),
target: "/x".into(),
duration_us: 50,
exit_status: 1, },
],
};
let h = summarize_history(&[p1]);
assert_eq!(h.len(), 1);
assert_eq!(h[0].unix_ts, 1714000000);
assert_eq!(h[0].shell, "bash 5.3");
assert_eq!(h[0].total_us, 500);
assert_eq!(h[0].user_total_us, 150);
assert_eq!(h[0].failed_entries, 1);
assert_eq!(h[0].entry_count, 2);
}
#[test]
fn parse_unix_ts_handles_unparseable_filenames() {
assert_eq!(
parse_unix_ts_from_filename("profile-1714000000-1-1.tsv"),
1714000000
);
assert_eq!(parse_unix_ts_from_filename("garbage.txt"), 0);
assert_eq!(parse_unix_ts_from_filename("profile-notanum-1-1.tsv"), 0);
}
}