Skip to main content

worktrunk/trace/
parse.rs

1//! Parse wt-trace log lines into structured entries.
2//!
3//! Trace lines are emitted by `shell_exec::Cmd` with this format:
4//! ```text
5//! [wt-trace] ts=1234567 tid=3 context=worktree cmd="git status" dur_us=12300 ok=true
6//! [wt-trace] ts=1234567 tid=3 cmd="gh pr list" dur_us=45200 ok=false
7//! [wt-trace] ts=1234567 tid=3 context=main cmd="git merge-base" dur_us=100000 err="fatal: ..."
8//! ```
9//!
10//! Instant events (milestones without duration) use this format:
11//! ```text
12//! [wt-trace] ts=1234567 tid=3 event="Showed skeleton"
13//! ```
14//!
15//! The `ts` (timestamp in microseconds since trace epoch) and `tid` (thread ID) fields
16//! enable concurrency analysis and Chrome Trace Format export for visualizing
17//! thread utilization in tools like chrome://tracing or Perfetto.
18//!
19//! Duration is specified in microseconds via `dur_us`.
20
21use std::time::Duration;
22
23/// The kind of trace entry: command execution or instant event.
24#[derive(Debug, Clone, PartialEq)]
25pub enum TraceEntryKind {
26    /// A command execution with duration and result
27    Command {
28        /// Full command string (e.g., "git status --porcelain")
29        command: String,
30        /// Command duration
31        duration: Duration,
32        /// Command result
33        result: TraceResult,
34    },
35    /// An instant event (milestone marker with no duration)
36    Instant {
37        /// Event name (e.g., "Showed skeleton")
38        name: String,
39    },
40}
41
42/// A parsed trace entry from a wt-trace log line.
43#[derive(Debug, Clone, PartialEq)]
44pub struct TraceEntry {
45    /// Optional context (typically worktree name for git commands)
46    pub context: Option<String>,
47    /// The kind of trace entry
48    pub kind: TraceEntryKind,
49    /// Start timestamp in microseconds since Unix epoch (for Chrome Trace Format)
50    pub start_time_us: Option<u64>,
51    /// Thread ID that executed this command (for concurrency analysis)
52    pub thread_id: Option<u64>,
53}
54
55/// Result of a traced command.
56#[derive(Debug, Clone, PartialEq)]
57pub enum TraceResult {
58    /// Command completed (ok=true or ok=false)
59    Completed { success: bool },
60    /// Command failed with error (err="...")
61    Error { message: String },
62}
63
64impl TraceEntry {
65    /// Returns true if the command succeeded.
66    /// Instant events always return true.
67    pub fn is_success(&self) -> bool {
68        match &self.kind {
69            TraceEntryKind::Command { result, .. } => {
70                matches!(result, TraceResult::Completed { success: true })
71            }
72            TraceEntryKind::Instant { .. } => true,
73        }
74    }
75}
76
77/// Parse a single trace line.
78///
79/// Returns `None` if the line doesn't match the expected format.
80/// The `[wt-trace]` marker can appear anywhere in the line (to handle log prefixes).
81///
82/// Supports two entry types:
83/// - Command events: `cmd="..." dur_us=... ok=true/false` or `err="..."`
84/// - Instant events: `event="..."`
85fn parse_line(line: &str) -> Option<TraceEntry> {
86    // Find the [wt-trace] marker anywhere in the line
87    let marker = "[wt-trace] ";
88    let marker_pos = line.find(marker)?;
89    let rest = &line[marker_pos + marker.len()..];
90
91    // Parse key=value pairs
92    let mut context = None;
93    let mut command = None;
94    let mut event = None;
95    let mut duration = None;
96    let mut result = None;
97    let mut start_time_us = None;
98    let mut thread_id = None;
99
100    let mut remaining = rest;
101
102    while !remaining.is_empty() {
103        remaining = remaining.trim_start();
104        if remaining.is_empty() {
105            break;
106        }
107
108        // Find key=
109        let eq_pos = remaining.find('=')?;
110        let key = &remaining[..eq_pos];
111        remaining = &remaining[eq_pos + 1..];
112
113        // Parse value (quoted or unquoted)
114        let value = if remaining.starts_with('"') {
115            // Quoted value - find closing quote
116            remaining = &remaining[1..];
117            let end_quote = remaining.find('"')?;
118            let val = &remaining[..end_quote];
119            remaining = &remaining[end_quote + 1..];
120            val
121        } else {
122            // Unquoted value - ends at space or end
123            let end = remaining.find(' ').unwrap_or(remaining.len());
124            let val = &remaining[..end];
125            remaining = &remaining[end..];
126            val
127        };
128
129        match key {
130            "context" => context = Some(value.to_string()),
131            "cmd" => command = Some(value.to_string()),
132            "event" => event = Some(value.to_string()),
133            "dur_us" => {
134                let us: u64 = value.parse().ok()?;
135                duration = Some(Duration::from_micros(us));
136            }
137            "ok" => {
138                let success = value == "true";
139                result = Some(TraceResult::Completed { success });
140            }
141            "err" => {
142                result = Some(TraceResult::Error {
143                    message: value.to_string(),
144                });
145            }
146            "ts" => {
147                start_time_us = value.parse().ok();
148            }
149            "tid" => {
150                thread_id = value.parse().ok();
151            }
152            _ => {} // Ignore unknown keys for forward compatibility
153        }
154    }
155
156    // Determine the entry kind based on what was parsed
157    let kind = if let Some(event_name) = event {
158        // Instant event
159        TraceEntryKind::Instant { name: event_name }
160    } else {
161        // Command event - requires cmd, dur, and result
162        TraceEntryKind::Command {
163            command: command?,
164            duration: duration?,
165            result: result?,
166        }
167    };
168
169    Some(TraceEntry {
170        context,
171        kind,
172        start_time_us,
173        thread_id,
174    })
175}
176
177/// Parse multiple lines, filtering to only valid trace entries.
178pub fn parse_lines(input: &str) -> Vec<TraceEntry> {
179    input.lines().filter_map(parse_line).collect()
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn test_parse_basic() {
188        let line = r#"[wt-trace] cmd="git status" dur_us=12300 ok=true"#;
189        let entry = parse_line(line).unwrap();
190
191        assert_eq!(entry.context, None);
192        let TraceEntryKind::Command {
193            command, duration, ..
194        } = &entry.kind
195        else {
196            panic!("expected command");
197        };
198        assert_eq!(command, "git status");
199        assert_eq!(*duration, Duration::from_micros(12300));
200        assert!(entry.is_success());
201    }
202
203    #[test]
204    fn test_parse_with_context() {
205        let line =
206            r#"[wt-trace] context=main cmd="git merge-base HEAD origin/main" dur_us=45200 ok=true"#;
207        let entry = parse_line(line).unwrap();
208
209        assert_eq!(entry.context, Some("main".to_string()));
210        let TraceEntryKind::Command { command, .. } = &entry.kind else {
211            panic!("expected command");
212        };
213        assert_eq!(command, "git merge-base HEAD origin/main");
214    }
215
216    #[test]
217    fn test_parse_error() {
218        let line = r#"[wt-trace] cmd="git rev-list" dur_us=100000 err="fatal: bad revision""#;
219        let entry = parse_line(line).unwrap();
220
221        assert!(!entry.is_success());
222        assert!(matches!(
223            &entry.kind,
224            TraceEntryKind::Command { result: TraceResult::Error { message }, .. } if message == "fatal: bad revision"
225        ));
226    }
227
228    #[test]
229    fn test_parse_ok_false() {
230        let line = r#"[wt-trace] cmd="git diff" dur_us=5000 ok=false"#;
231        let entry = parse_line(line).unwrap();
232
233        assert!(!entry.is_success());
234        assert!(matches!(
235            &entry.kind,
236            TraceEntryKind::Command {
237                result: TraceResult::Completed { success: false },
238                ..
239            }
240        ));
241    }
242
243    #[test]
244    fn test_parse_non_trace_line() {
245        assert!(parse_line("some random log line").is_none());
246        assert!(parse_line("[other-tag] something").is_none());
247    }
248
249    #[test]
250    fn test_parse_with_log_prefix() {
251        // Real output has thread ID prefix like "[a] "
252        let line = r#"[a] [wt-trace] cmd="git status" dur_us=5000 ok=true"#;
253        let entry = parse_line(line).unwrap();
254        assert!(matches!(
255            &entry.kind,
256            TraceEntryKind::Command { command, .. } if command == "git status"
257        ));
258    }
259
260    #[test]
261    fn test_parse_unknown_keys_ignored() {
262        // Unknown keys should be ignored for forward compatibility
263        let line =
264            r#"[wt-trace] future_field=xyz cmd="git status" dur_us=5000 ok=true extra=ignored"#;
265        let entry = parse_line(line).unwrap();
266        assert!(matches!(
267            &entry.kind,
268            TraceEntryKind::Command { command, .. } if command == "git status"
269        ));
270        assert!(entry.is_success());
271    }
272
273    #[test]
274    fn test_parse_trailing_whitespace() {
275        // Trailing whitespace should be handled (exercises trim_start + break)
276        let line = "[wt-trace] cmd=\"git status\" dur_us=5000 ok=true   ";
277        let entry = parse_line(line).unwrap();
278        assert!(matches!(
279            &entry.kind,
280            TraceEntryKind::Command { command, .. } if command == "git status"
281        ));
282    }
283
284    #[test]
285    fn test_parse_lines() {
286        let input = r#"
287DEBUG some other log
288[wt-trace] cmd="git status" dur_us=10000 ok=true
289more noise
290[wt-trace] cmd="git diff" dur_us=20000 ok=true
291"#;
292        let entries = parse_lines(input);
293        assert_eq!(entries.len(), 2);
294        assert!(matches!(
295            &entries[0].kind,
296            TraceEntryKind::Command { command, .. } if command == "git status"
297        ));
298        assert!(matches!(
299            &entries[1].kind,
300            TraceEntryKind::Command { command, .. } if command == "git diff"
301        ));
302    }
303
304    #[test]
305    fn test_parse_with_timestamp_and_thread_id() {
306        let line = r#"[wt-trace] ts=1736600000000000 tid=5 context=feature cmd="git status" dur_us=12300 ok=true"#;
307        let entry = parse_line(line).unwrap();
308
309        assert_eq!(entry.start_time_us, Some(1736600000000000));
310        assert_eq!(entry.thread_id, Some(5));
311        assert_eq!(entry.context, Some("feature".to_string()));
312        assert!(matches!(
313            &entry.kind,
314            TraceEntryKind::Command { command, .. } if command == "git status"
315        ));
316        assert!(entry.is_success());
317    }
318
319    #[test]
320    fn test_parse_without_timestamp_and_thread_id() {
321        // Traces without ts/tid should parse with None values
322        let line = r#"[wt-trace] cmd="git status" dur_us=12300 ok=true"#;
323        let entry = parse_line(line).unwrap();
324
325        assert_eq!(entry.start_time_us, None);
326        assert_eq!(entry.thread_id, None);
327        assert!(matches!(
328            &entry.kind,
329            TraceEntryKind::Command { command, .. } if command == "git status"
330        ));
331    }
332
333    #[test]
334    fn test_parse_partial_new_fields() {
335        // Only ts provided, no tid
336        let line = r#"[wt-trace] ts=1736600000000000 cmd="git status" dur_us=12300 ok=true"#;
337        let entry = parse_line(line).unwrap();
338
339        assert_eq!(entry.start_time_us, Some(1736600000000000));
340        assert_eq!(entry.thread_id, None);
341    }
342
343    // ========================================================================
344    // Instant event tests
345    // ========================================================================
346
347    #[test]
348    fn test_parse_instant_event() {
349        let line = r#"[wt-trace] ts=1736600000000000 tid=3 event="Showed skeleton""#;
350        let entry = parse_line(line).unwrap();
351
352        assert_eq!(entry.start_time_us, Some(1736600000000000));
353        assert_eq!(entry.thread_id, Some(3));
354        let TraceEntryKind::Instant { name } = &entry.kind else {
355            panic!("expected instant event");
356        };
357        assert_eq!(name, "Showed skeleton");
358        assert!(entry.is_success()); // Instant events are always "successful"
359    }
360
361    #[test]
362    fn test_parse_instant_event_with_context() {
363        let line = r#"[wt-trace] ts=1736600000000000 tid=3 context=main event="Skeleton rendered""#;
364        let entry = parse_line(line).unwrap();
365
366        assert_eq!(entry.context, Some("main".to_string()));
367        assert!(matches!(
368            &entry.kind,
369            TraceEntryKind::Instant { name } if name == "Skeleton rendered"
370        ));
371    }
372
373    #[test]
374    fn test_parse_instant_event_minimal() {
375        // Instant event with only the required field
376        let line = r#"[wt-trace] event="Started""#;
377        let entry = parse_line(line).unwrap();
378
379        assert!(matches!(
380            &entry.kind,
381            TraceEntryKind::Instant { name } if name == "Started"
382        ));
383        assert_eq!(entry.start_time_us, None);
384        assert_eq!(entry.thread_id, None);
385    }
386
387    #[test]
388    fn test_parse_lines_mixed() {
389        let input = r#"
390[wt-trace] event="Started"
391[wt-trace] cmd="git status" dur_us=10000 ok=true
392[wt-trace] event="Showed skeleton"
393[wt-trace] cmd="git diff" dur_us=20000 ok=true
394[wt-trace] event="Done"
395"#;
396        let entries = parse_lines(input);
397        assert_eq!(entries.len(), 5);
398        assert!(matches!(&entries[0].kind, TraceEntryKind::Instant { name } if name == "Started"));
399        assert!(
400            matches!(&entries[1].kind, TraceEntryKind::Command { command, .. } if command == "git status")
401        );
402        assert!(
403            matches!(&entries[2].kind, TraceEntryKind::Instant { name } if name == "Showed skeleton")
404        );
405        assert!(
406            matches!(&entries[3].kind, TraceEntryKind::Command { command, .. } if command == "git diff")
407        );
408        assert!(matches!(&entries[4].kind, TraceEntryKind::Instant { name } if name == "Done"));
409    }
410}