Skip to main content

har/analysis/
export.rs

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
20/// Flatten the filtered capture into one normalized record per entry (redacted
21/// by construction — metadata only, no raw bodies/headers).
22pub 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
42/// One JSON object per line.
43pub 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
59/// RFC4180-ish CSV: header + one row per record.
60pub 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); // header + 2 rows
121    }
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}