1use serde::Serialize;
25
26use crate::fs::Fs;
27use crate::paths::Pather;
28use crate::Result;
29
30#[derive(Debug, Clone, PartialEq, Serialize)]
32pub struct ProfileEntry {
33 pub phase: String,
35 pub pack: String,
36 pub handler: String,
37 pub target: String,
38 pub duration_us: u64,
41 pub exit_status: i32,
44}
45
46#[derive(Debug, Clone, Serialize)]
48pub struct Profile {
49 pub filename: String,
52 pub shell: String,
54 pub total_duration_us: u64,
57 pub entries: Vec<ProfileEntry>,
58}
59
60impl Profile {
61 pub fn entries_duration_us(&self) -> u64 {
65 self.entries.iter().map(|e| e.duration_us).sum()
66 }
67
68 pub fn framing_duration_us(&self) -> u64 {
72 self.total_duration_us
73 .saturating_sub(self.entries_duration_us())
74 }
75}
76
77pub fn read_latest_profile(fs: &dyn Fs, paths: &dyn Pather) -> Result<Option<Profile>> {
80 let dir = paths.probes_shell_init_dir();
81 if !fs.is_dir(&dir) {
82 return Ok(None);
83 }
84 let mut entries = fs.read_dir(&dir)?;
85 entries.sort_by(|a, b| a.name.cmp(&b.name));
89 let Some(latest) = entries
90 .into_iter()
91 .rfind(|e| e.is_file && e.name.starts_with("profile-") && e.name.ends_with(".tsv"))
92 else {
93 return Ok(None);
94 };
95 let content = fs.read_to_string(&latest.path)?;
96 Ok(Some(parse_profile(&latest.name, &content)))
97}
98
99pub fn parse_profile(filename: &str, content: &str) -> Profile {
102 let mut shell = String::new();
103 let mut start_t: Option<f64> = None;
104 let mut end_t: Option<f64> = None;
105 let mut entries: Vec<ProfileEntry> = Vec::new();
106
107 for raw_line in content.lines() {
108 let line = raw_line.trim_end_matches('\r');
109 if line.is_empty() {
110 continue;
111 }
112 if let Some(rest) = line.strip_prefix('#') {
113 let trimmed = rest.trim_start();
116 if let Some((key, val)) = trimmed.split_once('\t') {
117 match key {
118 "shell" => shell = val.to_string(),
119 "start_t" => start_t = val.parse::<f64>().ok(),
120 "end_t" => end_t = val.parse::<f64>().ok(),
121 _ => {} }
123 }
124 continue;
125 }
126 if let Some(entry) = parse_row(line) {
127 entries.push(entry);
128 }
129 }
131
132 let total_duration_us = match (start_t, end_t) {
133 (Some(s), Some(e)) if e >= s => seconds_to_micros(e - s),
134 _ => 0,
135 };
136
137 Profile {
138 filename: filename.to_string(),
139 shell,
140 total_duration_us,
141 entries,
142 }
143}
144
145fn parse_row(line: &str) -> Option<ProfileEntry> {
146 let mut parts = line.splitn(7, '\t');
147 let phase = parts.next()?;
148 let pack = parts.next()?;
149 let handler = parts.next()?;
150 let target = parts.next()?;
151 let start = parts.next()?.parse::<f64>().ok()?;
152 let end = parts.next()?.parse::<f64>().ok()?;
153 let exit_status = parts.next()?.parse::<i32>().ok()?;
154 if !matches!(phase, "path" | "source") {
155 return None;
156 }
157 let duration_us = if end >= start {
158 seconds_to_micros(end - start)
159 } else {
160 0
161 };
162 Some(ProfileEntry {
163 phase: phase.to_string(),
164 pack: pack.to_string(),
165 handler: handler.to_string(),
166 target: target.to_string(),
167 duration_us,
168 exit_status,
169 })
170}
171
172fn seconds_to_micros(secs: f64) -> u64 {
173 if !secs.is_finite() || secs < 0.0 {
174 return 0;
175 }
176 (secs * 1_000_000.0).round() as u64
177}
178
179pub fn rotate_profiles(fs: &dyn Fs, paths: &dyn Pather, keep: usize) -> Result<usize> {
184 if keep == 0 {
185 return Ok(0);
186 }
187 let dir = paths.probes_shell_init_dir();
188 if !fs.is_dir(&dir) {
189 return Ok(0);
190 }
191 let mut entries: Vec<_> = fs
192 .read_dir(&dir)?
193 .into_iter()
194 .filter(|e| e.is_file && e.name.starts_with("profile-") && e.name.ends_with(".tsv"))
195 .collect();
196 if entries.len() <= keep {
197 return Ok(0);
198 }
199 entries.sort_by(|a, b| a.name.cmp(&b.name));
200 let to_remove = entries.len() - keep;
201 let mut removed = 0;
202 for entry in entries.into_iter().take(to_remove) {
203 if fs.remove_file(&entry.path).is_ok() {
204 removed += 1;
205 }
206 }
207 Ok(removed)
208}
209
210#[derive(Debug, Clone, Serialize)]
212pub struct GroupedProfile {
213 pub groups: Vec<ProfileGroup>,
214 pub user_total_us: u64,
215 pub framing_us: u64,
216 pub total_us: u64,
217}
218
219#[derive(Debug, Clone, Serialize)]
220pub struct ProfileGroup {
221 pub pack: String,
222 pub handler: String,
223 pub rows: Vec<ProfileEntry>,
224 pub group_total_us: u64,
225}
226
227pub fn group_profile(profile: &Profile) -> GroupedProfile {
237 let user_total_us = profile.entries_duration_us();
238 let total_us = profile.total_duration_us.max(user_total_us);
239 let framing_us = total_us.saturating_sub(user_total_us);
240
241 let mut groups: Vec<ProfileGroup> = Vec::new();
242 for entry in &profile.entries {
243 let key = (&entry.pack, &entry.handler);
244 let pos = groups
245 .iter()
246 .position(|g| (&g.pack, &g.handler) == (key.0, key.1));
247 match pos {
248 Some(i) => {
249 groups[i].rows.push(entry.clone());
250 groups[i].group_total_us += entry.duration_us;
251 }
252 None => groups.push(ProfileGroup {
253 pack: entry.pack.clone(),
254 handler: entry.handler.clone(),
255 rows: vec![entry.clone()],
256 group_total_us: entry.duration_us,
257 }),
258 }
259 }
260
261 groups.sort_by(|a, b| a.pack.cmp(&b.pack).then(a.handler.cmp(&b.handler)));
262
263 GroupedProfile {
264 groups,
265 user_total_us,
266 framing_us,
267 total_us,
268 }
269}
270
271#[cfg(test)]
272mod tests {
273 use super::*;
274 use crate::testing::TempEnvironment;
275
276 fn write_profile(env: &TempEnvironment, name: &str, content: &str) -> std::path::PathBuf {
277 let dir = env.paths.probes_shell_init_dir();
278 env.fs.mkdir_all(&dir).unwrap();
279 let path = dir.join(name);
280 env.fs.write_file(&path, content.as_bytes()).unwrap();
281 path
282 }
283
284 #[test]
285 fn parser_extracts_preamble_and_rows() {
286 let content = "# dodot shell-init profile v1\n\
287# shell\tbash 5.2\n\
288# start_t\t1714000000.000000\n\
289# init_script\t/x/dodot-init.sh\n\
290# columns\tphase\tpack\thandler\ttarget\tstart_t\tend_t\texit_status\n\
291path\tvim\tpath\t/x/bin\t1714000000.001000\t1714000000.001005\t0\n\
292source\tgit\tshell\t/x/aliases.sh\t1714000000.002000\t1714000000.005000\t0\n\
293# end_t\t1714000000.010000\n";
294 let p = parse_profile("profile-1714000000-1-1.tsv", content);
295 assert_eq!(p.shell, "bash 5.2");
296 assert_eq!(p.entries.len(), 2);
297 assert_eq!(p.entries[0].phase, "path");
298 assert_eq!(p.entries[0].duration_us, 5);
299 assert_eq!(p.entries[1].duration_us, 3000);
300 assert_eq!(p.total_duration_us, 10_000);
301 }
302
303 #[test]
304 fn parser_skips_malformed_rows() {
305 let content = "# columns\tphase\tpack\thandler\ttarget\tstart_t\tend_t\texit_status\n\
306junk\trow\twith\ttoo\tfew\tcols\n\
307path\tvim\tpath\t/x\t1.0\t1.001\t0\n\
308weird\tphase\twrong\t/x\t1.0\t1.001\t0\n";
309 let p = parse_profile("p.tsv", content);
310 assert_eq!(p.entries.len(), 1);
311 assert_eq!(p.entries[0].phase, "path");
312 }
313
314 #[test]
315 fn parser_handles_missing_end_marker() {
316 let content = "# start_t\t1714000000.000000\n\
319source\tvim\tshell\t/x\t1714000000.001000\t1714000000.002000\t0\n";
320 let p = parse_profile("p.tsv", content);
321 assert_eq!(p.total_duration_us, 0); assert_eq!(p.entries.len(), 1);
323 assert_eq!(p.entries[0].duration_us, 1000);
324 }
325
326 #[test]
327 fn read_latest_returns_none_when_dir_missing() {
328 let env = TempEnvironment::builder().build();
329 let r = read_latest_profile(env.fs.as_ref(), env.paths.as_ref()).unwrap();
330 assert!(r.is_none());
331 }
332
333 #[test]
334 fn read_latest_picks_highest_filename_lexicographically() {
335 let env = TempEnvironment::builder().build();
336 write_profile(&env, "profile-1000-1-1.tsv", "# shell\told\n");
337 write_profile(&env, "profile-2000-1-1.tsv", "# shell\tnew\n");
338 write_profile(&env, "profile-1500-1-1.tsv", "# shell\tmid\n");
339 let p = read_latest_profile(env.fs.as_ref(), env.paths.as_ref())
340 .unwrap()
341 .unwrap();
342 assert_eq!(p.shell, "new");
343 assert_eq!(p.filename, "profile-2000-1-1.tsv");
344 }
345
346 #[test]
347 fn rotate_keeps_newest_n() {
348 let env = TempEnvironment::builder().build();
349 for i in 0..10 {
350 write_profile(&env, &format!("profile-{i:04}-1-1.tsv"), "x");
351 }
352 let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 3).unwrap();
353 assert_eq!(removed, 7);
354 let remaining: Vec<String> = env
355 .fs
356 .read_dir(&env.paths.probes_shell_init_dir())
357 .unwrap()
358 .into_iter()
359 .map(|e| e.name)
360 .collect();
361 assert_eq!(
363 remaining,
364 vec![
365 "profile-0007-1-1.tsv".to_string(),
366 "profile-0008-1-1.tsv".to_string(),
367 "profile-0009-1-1.tsv".to_string(),
368 ]
369 );
370 }
371
372 #[test]
373 fn rotate_with_keep_zero_is_a_noop() {
374 let env = TempEnvironment::builder().build();
377 for i in 0..3 {
378 write_profile(&env, &format!("profile-{i}-1-1.tsv"), "x");
379 }
380 let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 0).unwrap();
381 assert_eq!(removed, 0);
382 let count = env
383 .fs
384 .read_dir(&env.paths.probes_shell_init_dir())
385 .unwrap()
386 .len();
387 assert_eq!(count, 3);
388 }
389
390 #[test]
391 fn rotate_below_threshold_is_a_noop() {
392 let env = TempEnvironment::builder().build();
393 write_profile(&env, "profile-1-1-1.tsv", "x");
394 let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 100).unwrap();
395 assert_eq!(removed, 0);
396 }
397
398 #[test]
399 fn rotate_ignores_non_profile_files() {
400 let env = TempEnvironment::builder().build();
401 let dir = env.paths.probes_shell_init_dir();
402 env.fs.mkdir_all(&dir).unwrap();
403 for i in 1..=5 {
406 env.fs
407 .write_file(&dir.join(format!("profile-{i}-1-1.tsv")), b"")
408 .unwrap();
409 }
410 env.fs
411 .write_file(&dir.join("README"), b"do not delete")
412 .unwrap();
413 env.fs
414 .write_file(&dir.join("notes.txt"), b"keep me")
415 .unwrap();
416
417 let removed = rotate_profiles(env.fs.as_ref(), env.paths.as_ref(), 2).unwrap();
419 assert_eq!(removed, 3);
420
421 assert!(env.fs.exists(&dir.join("profile-4-1-1.tsv")));
423 assert!(env.fs.exists(&dir.join("profile-5-1-1.tsv")));
424 assert!(!env.fs.exists(&dir.join("profile-1-1-1.tsv")));
426 assert!(!env.fs.exists(&dir.join("profile-2-1-1.tsv")));
427 assert!(!env.fs.exists(&dir.join("profile-3-1-1.tsv")));
428
429 assert!(env.fs.exists(&dir.join("README")));
431 assert!(env.fs.exists(&dir.join("notes.txt")));
432 }
433
434 #[test]
435 fn group_profile_aggregates_by_pack_handler() {
436 let p = Profile {
437 filename: "x".into(),
438 shell: "bash".into(),
439 total_duration_us: 10_000,
440 entries: vec![
441 ProfileEntry {
442 phase: "source".into(),
443 pack: "vim".into(),
444 handler: "shell".into(),
445 target: "/a".into(),
446 duration_us: 100,
447 exit_status: 0,
448 },
449 ProfileEntry {
450 phase: "source".into(),
451 pack: "vim".into(),
452 handler: "shell".into(),
453 target: "/b".into(),
454 duration_us: 200,
455 exit_status: 0,
456 },
457 ProfileEntry {
458 phase: "path".into(),
459 pack: "vim".into(),
460 handler: "path".into(),
461 target: "/bin".into(),
462 duration_us: 5,
463 exit_status: 0,
464 },
465 ],
466 };
467 let g = group_profile(&p);
468 assert_eq!(g.groups.len(), 2);
469 assert_eq!(g.groups[0].pack, "vim");
472 assert_eq!(g.groups[0].handler, "path");
473 assert_eq!(g.groups[0].group_total_us, 5);
474 assert_eq!(g.groups[1].handler, "shell");
475 assert_eq!(g.groups[1].group_total_us, 300);
476 assert_eq!(g.user_total_us, 305);
477 assert_eq!(g.total_us, 10_000);
478 assert_eq!(g.framing_us, 9_695);
479 }
480
481 #[test]
482 fn group_profile_sorts_across_packs() {
483 let p = Profile {
486 filename: "x".into(),
487 shell: "bash".into(),
488 total_duration_us: 0,
489 entries: vec![
490 entry("vim", "shell", "/a", 1),
491 entry("git", "symlink", "/b", 1),
492 entry("vim", "path", "/c", 1),
493 entry("git", "shell", "/d", 1),
494 ],
495 };
496 let g = group_profile(&p);
497 let keys: Vec<(String, String)> = g
498 .groups
499 .iter()
500 .map(|gp| (gp.pack.clone(), gp.handler.clone()))
501 .collect();
502 assert_eq!(
503 keys,
504 vec![
505 ("git".into(), "shell".into()),
506 ("git".into(), "symlink".into()),
507 ("vim".into(), "path".into()),
508 ("vim".into(), "shell".into()),
509 ]
510 );
511 }
512
513 fn entry(pack: &str, handler: &str, target: &str, dur_us: u64) -> ProfileEntry {
514 ProfileEntry {
515 phase: "source".into(),
516 pack: pack.into(),
517 handler: handler.into(),
518 target: target.into(),
519 duration_us: dur_us,
520 exit_status: 0,
521 }
522 }
523
524 #[test]
525 fn group_profile_clamps_framing_when_total_below_entries() {
526 let p = Profile {
529 filename: "x".into(),
530 shell: "".into(),
531 total_duration_us: 0,
532 entries: vec![ProfileEntry {
533 phase: "source".into(),
534 pack: "vim".into(),
535 handler: "shell".into(),
536 target: "/a".into(),
537 duration_us: 500,
538 exit_status: 0,
539 }],
540 };
541 let g = group_profile(&p);
542 assert_eq!(g.user_total_us, 500);
543 assert_eq!(g.total_us, 500);
544 assert_eq!(g.framing_us, 0);
545 }
546}