1use crate::filter::Filter;
2use crate::model::Capture;
3use serde::Serialize;
4
5#[derive(Debug, Serialize)]
6pub struct ExportRecord {
7 pub id: String,
8 pub offset_ms: f64,
9 pub duration_ms: f64,
10 pub method: String,
11 pub host: String,
12 pub norm_path: String,
13 pub status: i64,
14 pub bytes: i64,
15 pub content_type: Option<String>,
16 pub resource_type: String,
17 pub correlation: Option<String>,
18}
19
20pub fn export_records(cap: &Capture, filter: &Filter) -> Vec<ExportRecord> {
23 cap.entries
24 .iter()
25 .filter(|e| filter.matches(e))
26 .map(|e| ExportRecord {
27 id: e.id.clone(),
28 offset_ms: e.started_offset_ms,
29 duration_ms: e.duration_ms,
30 method: e.method.to_ascii_uppercase(),
31 host: e.host.clone(),
32 norm_path: e.norm_path.clone(),
33 status: e.status,
34 bytes: e.sizes.resp_content.max(e.sizes.resp_body).max(0),
35 content_type: e.content_type.clone(),
36 resource_type: format!("{:?}", e.resource_type).to_ascii_lowercase(),
37 correlation: e.correlation.first().map(|(_, v)| v.clone()),
38 })
39 .collect()
40}
41
42pub fn render_ndjson(records: &[ExportRecord]) -> String {
44 records
45 .iter()
46 .map(|r| serde_json::to_string(r).unwrap_or_default())
47 .collect::<Vec<_>>()
48 .join("\n")
49}
50
51fn csv_field(s: &str) -> String {
52 if s.contains([',', '"', '\n', '\r']) {
53 format!("\"{}\"", s.replace('"', "\"\""))
54 } else {
55 s.to_string()
56 }
57}
58
59pub fn render_csv(records: &[ExportRecord]) -> String {
61 let mut out = String::new();
62 out.push_str(
63 "id,offset_ms,duration_ms,method,host,norm_path,status,bytes,content_type,resource_type,correlation\n",
64 );
65 for r in records {
66 let row = [
67 r.id.clone(),
68 (r.offset_ms as i64).to_string(),
69 (r.duration_ms as i64).to_string(),
70 r.method.clone(),
71 r.host.clone(),
72 r.norm_path.clone(),
73 r.status.to_string(),
74 r.bytes.to_string(),
75 r.content_type.clone().unwrap_or_default(),
76 r.resource_type.clone(),
77 r.correlation.clone().unwrap_or_default(),
78 ];
79 out.push_str(
80 &row.iter()
81 .map(|f| csv_field(f))
82 .collect::<Vec<_>>()
83 .join(","),
84 );
85 out.push('\n');
86 }
87 out
88}
89
90#[cfg(test)]
91mod tests {
92 use super::{export_records, render_csv, render_ndjson};
93 use crate::filter::Filter;
94 use crate::model::{sample_capture, sample_entry};
95
96 fn cap() -> crate::model::Capture {
97 sample_capture(vec![
98 sample_entry(0, "api.x", "GET", "/a", 200),
99 sample_entry(1, "api.x", "POST", "/b", 500),
100 ])
101 }
102
103 #[test]
104 fn ndjson_one_line_per_entry() {
105 let recs = export_records(&cap(), &Filter::parse(&[]).unwrap());
106 let s = render_ndjson(&recs);
107 assert_eq!(s.lines().count(), 2);
108 assert!(
109 s.lines()
110 .all(|l| l.starts_with('{') && l.contains("\"id\""))
111 );
112 }
113
114 #[test]
115 fn csv_has_header_and_rows() {
116 let recs = export_records(&cap(), &Filter::parse(&[]).unwrap());
117 let s = render_csv(&recs);
118 let lines: Vec<&str> = s.lines().collect();
119 assert!(lines[0].starts_with("id,offset_ms,"));
120 assert_eq!(lines.len(), 3); }
122
123 #[test]
124 fn csv_quotes_fields_with_commas() {
125 let mut e = sample_entry(0, "api.x", "GET", "/a,b", 200);
126 e.content_type = Some("text/html; charset=utf-8".into());
127 let recs = export_records(&sample_capture(vec![e]), &Filter::parse(&[]).unwrap());
128 let s = render_csv(&recs);
129 assert!(s.contains("\"/a,b\""));
130 }
131}