From cdeaf5b755babb5b064aa87e16a561dcbfd84ce9 Mon Sep 17 00:00:00 2001 From: Bruno Melo Date: Thu, 16 Apr 2026 22:23:16 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20add=20aggregate=20views=20=E2=80=94=20-?= =?UTF-8?q?-domains,=20--size-by-type,=20--redirects=20=E2=80=94=20and=20-?= =?UTF-8?q?-body-grep=20filter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR 2 of the LLM-friendly series. Stacks on feat/llm-improvements-pr1. Each new aggregate view answers a question that previously forced an agent to chain several hargrep calls and post-process the output. --body-grep replaces falling back to rg/grep on the raw HAR (noisy and unaware of JSON escaping). Flags: - --domains: [{domain, count}] sorted by count desc. Respects filters, so e.g. --status-range 4xx --domains shows which hosts are erroring. - --size-by-type: [{mime_type, total_bytes, count}] sorted by total_bytes desc. Makes "where's my bandwidth going?" a one-liner. - --redirects: [{id, url, status, location}] for every 3xx entry. Raw pairs rather than stitched chains — stitching is one step in the agent and keeps the format simple. - --body-grep SUBSTRING: new filter that matches against request postData.text or response content.text. Composes with the existing filter pipeline. All four are mutually exclusive with each other and with --overview, --count, --fields, --entry, --no-body, --include-all-bodies, --output where combining would be nonsensical. 116 tests pass (55 unit + 61 integration). Clippy clean, fmt clean. --- README.md | 12 +++ src/aggregates.rs | 235 +++++++++++++++++++++++++++++++++++++++++++ src/filter.rs | 24 +++++ src/main.rs | 87 ++++++++++++++-- src/overview.rs | 4 +- tests/integration.rs | 201 ++++++++++++++++++++++++++++++++++++ 6 files changed, 552 insertions(+), 11 deletions(-) create mode 100644 src/aggregates.rs diff --git a/README.md b/README.md index 10ef1cb..5f96cd5 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Reads from stdin if no file is given. | `--header ` | `--header 'Authorization:Bearer'` | | `--mime ` | `--mime application/json` (matches `application/json; charset=utf-8` too) | | `--min-time ` | `--min-time 500` | +| `--body-grep ` | Match against request or response body text (case-sensitive). | Filters combine with AND logic. @@ -66,6 +67,9 @@ Filters combine with AND logic. | `--fields ` | Comma-separated. Valid names: `id`, `url`, `method`, `status`, `status-text`, `time`, `mime-type`, `started-date-time`. CLI names are kebab-case; emitted JSON keys preserve HAR camelCase (`statusText`, `mimeType`). Unknown names error at parse time. | | `--count` | Print only the count of matching entries. Conflicts with `--fields`, `--no-body`, `--output`. | | `--overview` | Print a single JSON dashboard of the filtered HAR: entry count, status/method/MIME histograms, top 10 domains, total body size, total time. Replaces a cascade of exploratory queries with one call. | +| `--domains` | Emit `[{domain, count}]` sorted by count desc. Respects filters. | +| `--size-by-type` | Emit `[{mime_type, total_bytes, count}]` sorted by total_bytes desc. Respects filters. | +| `--redirects` | Emit `[{id, url, status, location}]` for every 3xx entry. Respects filters. | | `--entry ` | Fetch a single entry by id (its original 0-indexed position in the HAR). Returns a JSON object, not an array. | | `--no-body` | Exclude all request/response body text. | | `--include-all-bodies` | Include bodies for static-asset MIME types (CSS/JS/images/fonts/WASM). By default those are stripped to save tokens. | @@ -109,6 +113,14 @@ hargrep --overview recording.har hargrep --status-range 5xx --fields id,url,status --output jsonl recording.har hargrep --entry 42 recording.har +# Aggregate views — one call each +hargrep --domains recording.har # which hosts? +hargrep --size-by-type recording.har # where's the bandwidth going? +hargrep --redirects recording.har # all 3xx + Location headers + +# Body search that actually knows about HAR schema +hargrep --body-grep 'session expired' --fields id,url,status recording.har + # Validate before processing hargrep --validate untrusted.har diff --git a/src/aggregates.rs b/src/aggregates.rs new file mode 100644 index 0000000..676e761 --- /dev/null +++ b/src/aggregates.rs @@ -0,0 +1,235 @@ +//! Standalone aggregate views — focused answers that would otherwise require +//! an agent to synthesize across multiple `hargrep` calls. +//! +//! Each view emits a single JSON document: an array of aggregate rows or, for +//! `--redirects`, a flat list of 3xx entries with their Location headers. +//! Respects the filter pipeline so you can scope a view with any of the +//! existing filter flags. + +use crate::har::Entry; +use crate::overview::extract_host; +use serde_json::{Value, json}; +use std::collections::BTreeMap; + +/// `--domains`: [{domain, count}] sorted by count desc, then domain asc. +pub fn domains(entries: &[(usize, Entry)]) -> Value { + let mut counts: BTreeMap = BTreeMap::new(); + for (_, entry) in entries { + if let Some(host) = extract_host(&entry.request.url) { + *counts.entry(host).or_insert(0) += 1; + } + } + let mut rows: Vec<(String, u64)> = counts.into_iter().collect(); + rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + Value::Array( + rows.into_iter() + .map(|(domain, count)| json!({ "domain": domain, "count": count })) + .collect(), + ) +} + +/// `--size-by-type`: [{mime_type, total_bytes, count}] sorted by total_bytes desc. +/// Uses the full MIME string (including charset) so `application/json` and +/// `application/json; charset=utf-8` are separate rows — matches how the HAR +/// actually labelled them. Agents that want to collapse variants can do so. +pub fn size_by_type(entries: &[(usize, Entry)]) -> Value { + let mut by_mime: BTreeMap = BTreeMap::new(); + for (_, entry) in entries { + let mime = entry + .response + .content + .mime_type + .as_deref() + .unwrap_or("unknown"); + let size = entry.response.content.size.max(0); + let cell = by_mime.entry(mime.to_string()).or_insert((0, 0)); + cell.0 += size; + cell.1 += 1; + } + let mut rows: Vec<(String, i64, u64)> = by_mime + .into_iter() + .map(|(mime, (bytes, count))| (mime, bytes, count)) + .collect(); + rows.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + Value::Array( + rows.into_iter() + .map(|(mime_type, total_bytes, count)| { + json!({ "mime_type": mime_type, "total_bytes": total_bytes, "count": count }) + }) + .collect(), + ) +} + +/// `--redirects`: flat list of 3xx entries with their Location header. +/// Each row: {id, url, status, location}. Chain reconstruction is left to the +/// caller — the raw pairs are enough information and the format stays simple. +pub fn redirects(entries: &[(usize, Entry)]) -> Value { + let mut rows = Vec::new(); + for (id, entry) in entries { + let status = entry.response.status; + if (300..400).contains(&status) { + let location = find_location_header(entry).unwrap_or_default(); + rows.push(json!({ + "id": id, + "url": entry.request.url, + "status": status, + "location": location, + })); + } + } + Value::Array(rows) +} + +fn find_location_header(entry: &Entry) -> Option { + entry + .response + .headers + .iter() + .find(|h| h.name.eq_ignore_ascii_case("location")) + .map(|h| h.value.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::har::{Content, Entry, Header, Request, Response, Timings}; + + fn make_entry(method: &str, url: &str, status: u16, mime: &str, body_size: i64) -> Entry { + Entry { + started_date_time: "2026-01-15T10:00:00.000Z".to_string(), + time: 10.0, + request: Request { + method: method.to_string(), + url: url.to_string(), + http_version: "HTTP/1.1".to_string(), + headers: vec![], + query_string: vec![], + headers_size: -1, + body_size: -1, + post_data: None, + }, + response: Response { + status, + status_text: String::new(), + http_version: "HTTP/1.1".to_string(), + headers: vec![], + content: Content { + size: body_size, + mime_type: Some(mime.to_string()), + text: None, + }, + redirect_url: String::new(), + headers_size: -1, + body_size: 0, + }, + timings: Timings { + send: 0.0, + wait: 10.0, + receive: 0.0, + }, + cache: None, + } + } + + fn with_location(mut entry: Entry, location: &str) -> Entry { + entry.response.headers.push(Header { + name: "Location".to_string(), + value: location.to_string(), + }); + entry + } + + fn indexed(entries: Vec) -> Vec<(usize, Entry)> { + entries.into_iter().enumerate().collect() + } + + #[test] + fn domains_counts_and_sorts() { + let rows = domains(&indexed(vec![ + make_entry("GET", "https://a.example/x", 200, "application/json", 10), + make_entry("GET", "https://a.example/y", 200, "application/json", 10), + make_entry("GET", "https://b.example/z", 200, "application/json", 10), + ])); + let arr = rows.as_array().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0]["domain"], "a.example"); + assert_eq!(arr[0]["count"], 2); + assert_eq!(arr[1]["domain"], "b.example"); + assert_eq!(arr[1]["count"], 1); + } + + #[test] + fn size_by_type_sums_and_sorts() { + let rows = size_by_type(&indexed(vec![ + make_entry("GET", "u", 200, "image/png", 1000), + make_entry("GET", "u", 200, "application/json", 50), + make_entry("GET", "u", 200, "application/json", 150), + ])); + let arr = rows.as_array().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0]["mime_type"], "image/png"); + assert_eq!(arr[0]["total_bytes"], 1000); + assert_eq!(arr[0]["count"], 1); + assert_eq!(arr[1]["mime_type"], "application/json"); + assert_eq!(arr[1]["total_bytes"], 200); + assert_eq!(arr[1]["count"], 2); + } + + #[test] + fn size_by_type_treats_unknown_mime_as_unknown_bucket() { + let mut e = make_entry("GET", "u", 200, "application/json", 10); + e.response.content.mime_type = None; + let rows = size_by_type(&indexed(vec![e])); + let arr = rows.as_array().unwrap(); + assert_eq!(arr[0]["mime_type"], "unknown"); + } + + #[test] + fn size_by_type_treats_negative_sizes_as_zero() { + let rows = size_by_type(&indexed(vec![make_entry( + "GET", + "u", + 200, + "application/json", + -1, + )])); + assert_eq!(rows.as_array().unwrap()[0]["total_bytes"], 0); + } + + #[test] + fn redirects_only_includes_3xx() { + let entries = vec![ + make_entry("GET", "https://x/home", 200, "text/html", 0), + with_location( + make_entry("GET", "https://x/", 301, "text/html", 0), + "https://x/home", + ), + with_location( + make_entry("GET", "https://x/old", 302, "text/html", 0), + "https://x/new", + ), + make_entry("GET", "https://x/y", 404, "text/html", 0), + ]; + let rows = redirects(&indexed(entries)); + let arr = rows.as_array().unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0]["status"], 301); + assert_eq!(arr[0]["id"], 1); + assert_eq!(arr[0]["location"], "https://x/home"); + assert_eq!(arr[1]["status"], 302); + } + + #[test] + fn redirects_handles_missing_location_header() { + let rows = redirects(&indexed(vec![make_entry( + "GET", + "https://x/", + 301, + "text/html", + 0, + )])); + let arr = rows.as_array().unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["location"], ""); + } +} diff --git a/src/filter.rs b/src/filter.rs index 3c57c25..cc770ab 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -106,6 +106,10 @@ pub struct FilterOptions { pub header: Option, pub mime: Option, pub min_time: Option, + /// Substring match against request postData.text OR response content.text. + /// Matches if either contains the pattern. Agents fall through to + /// `grep`/`rg` on raw HAR otherwise, which is noisy and unreliable. + pub body_grep: Option, } /// Filter entries against the provided options, preserving each entry's @@ -165,9 +169,29 @@ fn matches_all(entry: &Entry, opts: &FilterOptions) -> bool { { return false; } + if let Some(ref pat) = opts.body_grep + && !body_contains(entry, pat) + { + return false; + } true } +fn body_contains(entry: &Entry, pat: &str) -> bool { + if let Some(resp_text) = entry.response.content.text.as_deref() + && resp_text.contains(pat) + { + return true; + } + if let Some(post_data) = &entry.request.post_data + && let Some(req_text) = post_data.text.as_deref() + && req_text.contains(pat) + { + return true; + } + false +} + fn has_header(entry: &Entry, hf: &HeaderFilter) -> bool { entry .request diff --git a/src/main.rs b/src/main.rs index c8dcb30..829b432 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod aggregates; mod filter; mod har; mod input; @@ -50,6 +51,11 @@ struct Cli { #[arg(long)] min_time: Option, + /// Filter by substring match against request or response body text. + /// Matches when either contains the pattern. Case-sensitive. + #[arg(long)] + body_grep: Option, + /// Output format #[arg(long, value_enum, default_value_t = OutputFormat::Json, conflicts_with = "count")] output: OutputFormat, @@ -68,10 +74,34 @@ struct Cli { /// Replaces a cascade of exploratory queries with one call. #[arg( long, - conflicts_with_all = ["count", "fields", "entry", "no_body", "include_all_bodies", "output"] + conflicts_with_all = ["count", "fields", "entry", "no_body", "include_all_bodies", "output", "domains", "size_by_type", "redirects"] )] overview: bool, + /// List unique request domains with per-domain request counts, sorted desc. + /// Respects filters. + #[arg( + long, + conflicts_with_all = ["count", "fields", "entry", "no_body", "include_all_bodies", "output", "overview", "size_by_type", "redirects"] + )] + domains: bool, + + /// Breakdown of response body size by MIME type: [{mime_type, total_bytes, count}] + /// sorted by total_bytes desc. Respects filters. + #[arg( + long, + conflicts_with_all = ["count", "fields", "entry", "no_body", "include_all_bodies", "output", "overview", "domains", "redirects"] + )] + size_by_type: bool, + + /// List 3xx entries with their Location header: [{id, url, status, location}]. + /// Respects filters. + #[arg( + long, + conflicts_with_all = ["count", "fields", "entry", "no_body", "include_all_bodies", "output", "overview", "domains", "size_by_type"] + )] + redirects: bool, + /// Fetch a single entry by id (the original 0-indexed position in the HAR). /// Returns a JSON object, not an array. Useful after listing entries with /// `--fields id,url,status` and then zeroing in on one. `--entry` is a @@ -158,21 +188,37 @@ fn run(cli: Cli) -> Result { header: cli.header, mime: cli.mime, min_time: cli.min_time, + body_grep: cli.body_grep, }; let filtered = filter::filter_entries(har.log.entries, &filter_opts); let exit_code = if filtered.is_empty() { 1 } else { 0 }; + // All aggregate views honor grep-like exit semantics: exit 1 when the + // emitted document is empty. The document is still printed either way so + // downstream tooling sees well-formed output. if cli.overview { let doc = overview::build_overview(&filtered); - let serialized = if std::io::IsTerminal::is_terminal(&std::io::stdout()) { - serde_json::to_string_pretty(&doc)? - } else { - serde_json::to_string(&doc)? - }; - println!("{serialized}"); - // Keep grep-like exit semantics: empty filtered set → exit 1. - return Ok(exit_code); + emit_json_doc(&doc)?; + return Ok(aggregate_exit_code(&doc)); + } + + if cli.domains { + let doc = aggregates::domains(&filtered); + emit_json_doc(&doc)?; + return Ok(aggregate_exit_code(&doc)); + } + + if cli.size_by_type { + let doc = aggregates::size_by_type(&filtered); + emit_json_doc(&doc)?; + return Ok(aggregate_exit_code(&doc)); + } + + if cli.redirects { + let doc = aggregates::redirects(&filtered); + emit_json_doc(&doc)?; + return Ok(aggregate_exit_code(&doc)); } let mode = if cli.count { @@ -191,6 +237,29 @@ fn run(cli: Cli) -> Result { Ok(exit_code) } +/// Exit 1 when the aggregate document has nothing to report, 0 otherwise. +/// Array documents (`--domains`, `--size-by-type`, `--redirects`) are empty +/// when the array has no rows. The overview object is empty when its +/// `entries` count is zero. +fn aggregate_exit_code(doc: &serde_json::Value) -> i32 { + let is_empty = match doc { + serde_json::Value::Array(rows) => rows.is_empty(), + serde_json::Value::Object(_) => doc.get("entries").and_then(|v| v.as_u64()) == Some(0), + _ => false, + }; + if is_empty { 1 } else { 0 } +} + +fn emit_json_doc(value: &serde_json::Value) -> Result<()> { + let serialized = if std::io::IsTerminal::is_terminal(&std::io::stdout()) { + serde_json::to_string_pretty(value)? + } else { + serde_json::to_string(value)? + }; + println!("{serialized}"); + Ok(()) +} + fn main() { let cli = Cli::parse(); match run(cli) { diff --git a/src/overview.rs b/src/overview.rs index c07bc6b..7a93735 100644 --- a/src/overview.rs +++ b/src/overview.rs @@ -92,8 +92,8 @@ fn methods_to_map(bt: BTreeMap) -> Map { /// Extract the host portion of a URL without pulling in a full URL parser. /// Tolerates missing schemes and malformed input — returns None rather than -/// erroring, so one weird URL doesn't break the whole overview. -fn extract_host(url: &str) -> Option { +/// erroring, so one weird URL doesn't break aggregate views. +pub fn extract_host(url: &str) -> Option { let after_scheme = url.split_once("://").map(|(_, rest)| rest).unwrap_or(url); let host = after_scheme .split(['/', '?', '#']) diff --git a/tests/integration.rs b/tests/integration.rs index 1318af6..378794d 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -237,6 +237,207 @@ fn test_stdin_input() { assert_eq!(stdout.trim(), "4"); } +// --- --domains aggregate --- + +#[test] +fn test_domains_emits_json_array_sorted_desc() { + let (stdout, _, code) = hargrep(&["--domains", "tests/fixtures/valid.har"]); + assert_eq!(code, 0); + let parsed: Vec = serde_json::from_str(&stdout).unwrap(); + assert_eq!(parsed.len(), 2); + // api.example.com: 3, cdn.example.com: 1. Sorted desc. + assert_eq!(parsed[0]["domain"], "api.example.com"); + assert_eq!(parsed[0]["count"], 3); + assert_eq!(parsed[1]["domain"], "cdn.example.com"); + assert_eq!(parsed[1]["count"], 1); +} + +#[test] +fn test_domains_respects_filter() { + let (stdout, _, _) = hargrep(&["--domains", "--method", "GET", "tests/fixtures/valid.har"]); + let parsed: Vec = serde_json::from_str(&stdout).unwrap(); + // Only GETs: 2 to api.example.com (users, users/999), 1 to cdn (image.png). + let api = parsed + .iter() + .find(|d| d["domain"] == "api.example.com") + .unwrap(); + assert_eq!(api["count"], 2); +} + +#[test] +fn test_domains_conflicts_with_overview() { + let (_, _, code) = hargrep(&["--domains", "--overview", "tests/fixtures/valid.har"]); + assert_eq!(code, 2); +} + +// --- --size-by-type aggregate --- + +#[test] +fn test_size_by_type_emits_json_array_sorted_by_bytes_desc() { + let (stdout, _, code) = hargrep(&["--size-by-type", "tests/fixtures/valid.har"]); + assert_eq!(code, 0); + let parsed: Vec = serde_json::from_str(&stdout).unwrap(); + assert!(!parsed.is_empty()); + // Each entry: mime_type, total_bytes, count. + let first = &parsed[0]; + assert!(first.get("mime_type").is_some()); + assert!(first.get("total_bytes").is_some()); + assert!(first.get("count").is_some()); + // Sorted by total_bytes desc. + let bytes: Vec = parsed + .iter() + .map(|e| e["total_bytes"].as_i64().unwrap()) + .collect(); + let mut sorted = bytes.clone(); + sorted.sort_by(|a, b| b.cmp(a)); + assert_eq!(bytes, sorted); +} + +#[test] +fn test_size_by_type_respects_filter() { + let (stdout, _, _) = hargrep(&[ + "--size-by-type", + "--method", + "POST", + "tests/fixtures/valid.har", + ]); + let parsed: Vec = serde_json::from_str(&stdout).unwrap(); + // Only 1 POST with application/json. + assert_eq!(parsed.len(), 1); + assert!(parsed[0]["mime_type"].as_str().unwrap().contains("json")); + assert_eq!(parsed[0]["count"], 1); +} + +// --- --redirects view --- + +#[test] +fn test_redirects_lists_3xx_with_location() { + // valid.har doesn't have 3xx entries; use stdin to synthesize. + use std::io::Write; + use std::process::{Command, Stdio}; + let synth = r#"{"log":{"version":"1.2","creator":{"name":"t","version":"1"}, + "entries":[ + {"startedDateTime":"2026-01-15T10:00:00.000Z","time":10, + "request":{"method":"GET","url":"https://a.example/","httpVersion":"HTTP/1.1","headers":[],"queryString":[],"headersSize":-1,"bodySize":-1}, + "response":{"status":301,"statusText":"Moved","httpVersion":"HTTP/1.1", + "headers":[{"name":"Location","value":"https://a.example/home"}], + "content":{"size":0,"mimeType":"text/html"},"redirectURL":"https://a.example/home","headersSize":-1,"bodySize":0}, + "cache":{},"timings":{"send":0,"wait":10,"receive":0}}, + {"startedDateTime":"2026-01-15T10:00:01.000Z","time":12, + "request":{"method":"GET","url":"https://a.example/old","httpVersion":"HTTP/1.1","headers":[],"queryString":[],"headersSize":-1,"bodySize":-1}, + "response":{"status":302,"statusText":"Found","httpVersion":"HTTP/1.1", + "headers":[{"name":"Location","value":"https://a.example/new"}], + "content":{"size":0,"mimeType":"text/html"},"redirectURL":"https://a.example/new","headersSize":-1,"bodySize":0}, + "cache":{},"timings":{"send":0,"wait":12,"receive":0}} + ]}}"#; + let mut child = Command::new(env!("CARGO_BIN_EXE_hargrep")) + .arg("--redirects") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .as_mut() + .unwrap() + .write_all(synth.as_bytes()) + .unwrap(); + let out = child.wait_with_output().unwrap(); + let stdout = String::from_utf8_lossy(&out.stdout).to_string(); + let parsed: Vec = serde_json::from_str(&stdout).unwrap(); + assert_eq!(parsed.len(), 2); + assert_eq!(parsed[0]["status"], 301); + assert_eq!(parsed[0]["location"], "https://a.example/home"); + assert_eq!(parsed[0]["id"], 0); + assert_eq!(parsed[1]["status"], 302); + assert_eq!(parsed[1]["location"], "https://a.example/new"); +} + +#[test] +fn test_redirects_with_no_3xx_returns_empty_array() { + let (stdout, _, _) = hargrep(&["--redirects", "tests/fixtures/valid.har"]); + let parsed: Vec = serde_json::from_str(&stdout).unwrap(); + assert!(parsed.is_empty()); +} + +#[test] +fn test_redirects_exits_1_when_empty() { + // valid.har has no 3xx entries — grep-like contract: empty result → exit 1. + let (_, _, code) = hargrep(&["--redirects", "tests/fixtures/valid.har"]); + assert_eq!(code, 1); +} + +#[test] +fn test_domains_exits_1_when_filter_produces_no_entries() { + let (_, _, code) = hargrep(&["--domains", "--status", "999", "tests/fixtures/valid.har"]); + assert_eq!(code, 1); +} + +#[test] +fn test_size_by_type_exits_1_when_filter_produces_no_entries() { + let (_, _, code) = hargrep(&[ + "--size-by-type", + "--status", + "999", + "tests/fixtures/valid.har", + ]); + assert_eq!(code, 1); +} + +#[test] +fn test_domains_exits_0_on_matches() { + let (_, _, code) = hargrep(&["--domains", "tests/fixtures/valid.har"]); + assert_eq!(code, 0); +} + +// --- --body-grep filter --- + +#[test] +fn test_body_grep_matches_response_body_substring() { + // Entry 1 response body is {"id": 2, "name": "Alice"} — grep for "Alice". + let (stdout, _, _) = hargrep(&["--body-grep", "Alice", "tests/fixtures/valid.har"]); + let parsed: Vec = serde_json::from_str(&stdout).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0]["id"], 1); +} + +#[test] +fn test_body_grep_matches_request_post_body_substring() { + // Entry 1 is a POST with postData text containing "Alice". + let (stdout, _, _) = hargrep(&[ + "--body-grep", + "\"name\": \"Alice\"", + "tests/fixtures/valid.har", + ]); + let parsed: Vec = serde_json::from_str(&stdout).unwrap(); + assert!(parsed.iter().any(|e| e["id"] == 1)); +} + +#[test] +fn test_body_grep_no_match_exits_1() { + let (_, _, code) = hargrep(&[ + "--body-grep", + "zzz_nothing_here_zzz", + "tests/fixtures/valid.har", + ]); + assert_eq!(code, 1); +} + +#[test] +fn test_body_grep_composes_with_other_filters() { + let (stdout, _, _) = hargrep(&[ + "--body-grep", + "Alice", + "--method", + "POST", + "tests/fixtures/valid.har", + ]); + let parsed: Vec = serde_json::from_str(&stdout).unwrap(); + assert_eq!(parsed.len(), 1); + assert_eq!(parsed[0]["request"]["method"], "POST"); +} + // --- --overview dashboard --- #[test]