Skip to main content

har/analysis/
report.rs

1use crate::analysis::duplicates::compute_duplicates;
2use crate::analysis::errors::compute_errors;
3use crate::analysis::redirects::compute_redirects;
4use crate::analysis::slowest::compute_slowest;
5use crate::analysis::subsystems::compute_subsystems;
6use crate::analysis::summary::compute_summary;
7use crate::config::Config;
8use crate::filter::Filter;
9use crate::model::Capture;
10use crate::render::human_ms;
11use serde::Serialize;
12
13#[derive(Debug, Serialize)]
14pub struct ReportResult {
15    pub markdown: String,
16}
17
18/// Compose a dossier-style markdown report from the existing analyses.
19pub fn compose_report(
20    cap: &Capture,
21    filter: &Filter,
22    config: &Config,
23    top: usize,
24    unsafe_include: bool,
25) -> String {
26    let mut md = String::new();
27
28    md.push_str("# wiretrail report\n\n");
29    md.push_str(&format!(
30        "- Creator: {} {}\n",
31        cap.meta.creator, cap.meta.creator_version
32    ));
33    md.push_str(&format!("- HAR version: {}\n", cap.meta.har_version));
34    md.push_str(&format!("- Entries: {}\n", cap.meta.entry_count));
35    md.push_str(&format!("- Window: {}\n\n", human_ms(cap.meta.duration_ms)));
36
37    let summary = compute_summary(cap, filter, top);
38    md.push_str("## Executive Summary\n\n");
39    md.push_str(&format!(
40        "{} requests after filter, {} error responses, {} duplicate groups in the top list.\n\n",
41        summary.filtered_entries,
42        summary.error_count,
43        summary.top_duplicates.len()
44    ));
45
46    let subs = compute_subsystems(cap, filter, config, top);
47    md.push_str("## Subsystems\n\n");
48    md.push_str("| Subsystem | Requests | Window | Errors | Dups |\n");
49    md.push_str("|---|---:|---|---:|---:|\n");
50    for s in &subs.subsystems {
51        md.push_str(&format!(
52            "| {} | {} | {} - {} | {} | {} |\n",
53            s.name,
54            s.count,
55            human_ms(s.first_offset_ms),
56            human_ms(s.last_offset_ms),
57            s.error_count,
58            s.duplicate_count
59        ));
60    }
61    md.push('\n');
62
63    let dups = compute_duplicates(cap, filter, top);
64    if !dups.groups.is_empty() {
65        md.push_str("## Duplicate Index\n\n");
66        for g in &dups.groups {
67            let tag = if g.is_retry_pattern {
68                " (retry pattern)"
69            } else {
70                ""
71            };
72            md.push_str(&format!(
73                "- {}x `{} {}{}`{}\n",
74                g.count, g.method, g.host, g.norm_path, tag
75            ));
76        }
77        md.push('\n');
78    }
79
80    let errs = compute_errors(cap, filter, top, unsafe_include);
81    if !errs.groups.is_empty() {
82        md.push_str("## Errors\n\n");
83        for g in &errs.groups {
84            md.push_str(&format!(
85                "- {}x [{}] `{} {}{}`",
86                g.count, g.status, g.method, g.host, g.norm_path
87            ));
88            if let Some(m) = &g.error_message {
89                md.push_str(&format!(" — {m}"));
90            }
91            md.push('\n');
92        }
93        md.push('\n');
94    }
95
96    let reds = compute_redirects(cap, filter, top);
97    let storms: Vec<_> = reds.groups.iter().filter(|g| g.is_storm).collect();
98    if !storms.is_empty() {
99        md.push_str("## Redirect Storms\n\n");
100        for g in storms {
101            md.push_str(&format!(
102                "- {}x [{}] `{} {}{}`\n",
103                g.count, g.status, g.method, g.host, g.norm_path
104            ));
105        }
106        md.push('\n');
107    }
108
109    let slow = compute_slowest(cap, filter, top);
110    if !slow.entries.is_empty() {
111        md.push_str("## Slowest Requests\n\n");
112        for e in &slow.entries {
113            md.push_str(&format!(
114                "- {} `{} {}{}` [{}] — {}\n",
115                human_ms(e.duration_ms),
116                e.method,
117                e.host,
118                e.norm_path,
119                e.status,
120                e.bottleneck
121            ));
122        }
123        md.push('\n');
124    }
125
126    md
127}
128
129#[cfg(test)]
130mod tests {
131    use super::compose_report;
132    use crate::config::Config;
133    use crate::filter::Filter;
134    use crate::model::{sample_capture, sample_entry};
135
136    fn cap() -> crate::model::Capture {
137        let d0 = sample_entry(0, "api.x", "POST", "/resolve", 200);
138        let d1 = sample_entry(1, "api.x", "POST", "/resolve", 200);
139        let mut err = sample_entry(2, "api.x", "GET", "/missing", 404);
140        err.resp_body = Some(r#"{"message":"nope"}"#.to_string());
141        sample_capture(vec![d0, d1, err])
142    }
143
144    #[test]
145    fn report_has_expected_sections() {
146        let md = compose_report(
147            &cap(),
148            &Filter::parse(&[]).unwrap(),
149            &Config::default(),
150            10,
151            false,
152        );
153        assert!(md.contains("# wiretrail report"));
154        assert!(md.contains("## Executive Summary"));
155        assert!(md.contains("## Subsystems"));
156        assert!(md.contains("## Duplicate Index"));
157        assert!(md.contains("## Errors"));
158    }
159
160    #[test]
161    fn duplicate_index_lists_the_repeated_call() {
162        let md = compose_report(
163            &cap(),
164            &Filter::parse(&[]).unwrap(),
165            &Config::default(),
166            10,
167            false,
168        );
169        assert!(md.contains("POST api.x/resolve"));
170    }
171
172    #[test]
173    fn error_message_is_included() {
174        let md = compose_report(
175            &cap(),
176            &Filter::parse(&[]).unwrap(),
177            &Config::default(),
178            10,
179            false,
180        );
181        assert!(md.contains("nope"));
182    }
183}