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
18pub 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}