1use std::time::Duration;
22
23#[derive(Debug, Clone, PartialEq)]
25pub enum TraceEntryKind {
26 Command {
28 command: String,
30 duration: Duration,
32 result: TraceResult,
34 },
35 Instant {
37 name: String,
39 },
40}
41
42#[derive(Debug, Clone, PartialEq)]
44pub struct TraceEntry {
45 pub context: Option<String>,
47 pub kind: TraceEntryKind,
49 pub start_time_us: Option<u64>,
51 pub thread_id: Option<u64>,
53}
54
55#[derive(Debug, Clone, PartialEq)]
57pub enum TraceResult {
58 Completed { success: bool },
60 Error { message: String },
62}
63
64impl TraceEntry {
65 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
77fn parse_line(line: &str) -> Option<TraceEntry> {
86 let marker = "[wt-trace] ";
88 let marker_pos = line.find(marker)?;
89 let rest = &line[marker_pos + marker.len()..];
90
91 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 let eq_pos = remaining.find('=')?;
110 let key = &remaining[..eq_pos];
111 remaining = &remaining[eq_pos + 1..];
112
113 let value = if remaining.starts_with('"') {
115 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 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 _ => {} }
154 }
155
156 let kind = if let Some(event_name) = event {
158 TraceEntryKind::Instant { name: event_name }
160 } else {
161 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
177pub 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 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 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 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 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 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 #[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()); }
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 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}