1use crate::analysis::curl::entry_to_curl;
2use crate::filter::Filter;
3use crate::model::{Capture, Entry};
4use ahash::AHashSet;
5use serde::Serialize;
6
7#[derive(Debug, Serialize)]
8pub struct HandoffResult {
9 pub items: Vec<HandoffItem>,
10}
11
12#[derive(Debug, Serialize)]
13pub struct HandoffItem {
14 pub id: String,
15 pub method: String,
16 pub host: String,
17 pub norm_path: String,
18 pub status: i64,
19 pub timestamp: Option<String>,
20 pub offset_ms: f64,
21 pub correlation_ids: Vec<String>,
22 pub server_ip: Option<String>,
23 pub curl: String,
24}
25
26fn abs_iso(start_ms: Option<i64>, offset_ms: f64) -> Option<String> {
27 let ms = start_ms? + offset_ms as i64;
28 chrono::DateTime::from_timestamp_millis(ms).map(|dt| dt.to_rfc3339())
29}
30
31pub fn compute_handoff(
33 cap: &Capture,
34 filter: &Filter,
35 top: usize,
36 unsafe_include: bool,
37) -> HandoffResult {
38 let entries: Vec<&Entry> = cap.entries.iter().filter(|e| filter.matches(e)).collect();
39
40 let mut by_dur: Vec<&Entry> = entries.clone();
42 by_dur.sort_by(|a, b| {
43 b.duration_ms
44 .partial_cmp(&a.duration_ms)
45 .unwrap_or(std::cmp::Ordering::Equal)
46 });
47 let slow_ids: AHashSet<String> = by_dur.iter().take(top).map(|e| e.id.clone()).collect();
48
49 let mut selected: Vec<&Entry> = entries
50 .iter()
51 .filter(|e| e.is_error() || slow_ids.contains(&e.id))
52 .copied()
53 .collect();
54 selected.sort_by(|a, b| {
55 a.started_offset_ms
56 .partial_cmp(&b.started_offset_ms)
57 .unwrap_or(std::cmp::Ordering::Equal)
58 .then(a.index.cmp(&b.index))
59 });
60
61 let items = selected
62 .iter()
63 .map(|e| HandoffItem {
64 id: e.id.clone(),
65 method: e.method.to_ascii_uppercase(),
66 host: e.host.clone(),
67 norm_path: e.norm_path.clone(),
68 status: e.status,
69 timestamp: abs_iso(cap.meta.start_ms, e.started_offset_ms),
70 offset_ms: e.started_offset_ms,
71 correlation_ids: e.correlation.iter().map(|(_, v)| v.clone()).collect(),
72 server_ip: e.server_ip.clone(),
73 curl: entry_to_curl(e, unsafe_include).command,
74 })
75 .collect();
76
77 HandoffResult { items }
78}
79
80pub fn render_handoff_text(r: &HandoffResult) -> String {
82 let mut out = String::new();
83 out.push_str("== wiretrail handoff ==\n");
84 for i in &r.items {
85 out.push_str(&format!(
86 "\n# {} [{}] {} {}{}\n",
87 i.id, i.status, i.method, i.host, i.norm_path
88 ));
89 if let Some(ts) = &i.timestamp {
90 out.push_str(&format!(" time: {ts} (+{}ms)\n", i.offset_ms as i64));
91 }
92 if !i.correlation_ids.is_empty() {
93 out.push_str(&format!(
94 " correlation: {}\n",
95 i.correlation_ids.join(", ")
96 ));
97 }
98 if let Some(ip) = &i.server_ip {
99 out.push_str(&format!(" server ip: {ip}\n"));
100 }
101 out.push_str(&format!(" {}\n", i.curl.replace('\n', "\n ")));
102 }
103 out
104}
105
106#[cfg(test)]
107mod tests {
108 use super::compute_handoff;
109 use crate::filter::Filter;
110 use crate::model::{sample_capture, sample_entry};
111
112 #[test]
113 fn emits_block_for_failed_request() {
114 let mut e = sample_entry(0, "api.x", "POST", "/bulk", 500);
115 e.correlation = vec![("x-request-id".to_string(), "abc-123".to_string())];
116 e.server_ip = Some("10.0.0.1".to_string());
117 let cap = sample_capture(vec![e, sample_entry(1, "api.x", "GET", "/ok", 200)]);
118 let r = compute_handoff(&cap, &Filter::parse(&[]).unwrap(), 10, false);
119 let item = r.items.iter().find(|i| i.id == "e000000").unwrap();
120 assert_eq!(item.status, 500);
121 assert!(item.curl.contains("curl -X POST"));
122 assert_eq!(item.correlation_ids, vec!["abc-123".to_string()]);
123 assert_eq!(item.server_ip.as_deref(), Some("10.0.0.1"));
124 }
125
126 #[test]
127 fn redacts_curl_by_default() {
128 let mut e = sample_entry(0, "api.x", "GET", "/x", 500);
129 e.req_headers = vec![("Authorization".to_string(), "Bearer secret".to_string())];
130 let r = compute_handoff(
131 &sample_capture(vec![e]),
132 &Filter::parse(&[]).unwrap(),
133 10,
134 false,
135 );
136 assert!(!r.items[0].curl.contains("Bearer secret"));
137 }
138}