Skip to main content

mockforge_foundation/
conformance_violations.rs

1//! Server-side conformance violation tracking.
2//!
3//! Issue #79 round 12 — Srikanth's ask: "It would be good if mockforge tui
4//! have a separate section for conformance failures on the incoming
5//! requests to the mockforge server which has spec violation from the
6//! Server Side point of view, that way I can cross check Server Side
7//! Info with our proxy and understand the diff."
8//!
9//! The OpenAPI router already rejects requests that violate the loaded
10//! spec (status 400/422). This module captures every such rejection into
11//! a bounded ring buffer so the TUI / admin API can surface them
12//! without scraping logs.
13//!
14//! Storage is best-effort, in-memory, and bounded — under sustained
15//! WAF / load-test traffic we keep only the most recent N violations.
16
17use chrono::{DateTime, Utc};
18use once_cell::sync::Lazy;
19use parking_lot::Mutex;
20use serde::{Deserialize, Serialize};
21use std::collections::{HashMap, VecDeque};
22use std::sync::atomic::{AtomicU64, Ordering};
23
24/// A single server-side conformance violation captured at the OpenAPI
25/// router. Mirrors `ConformanceViolation` semantics from the bench-side
26/// client validator so consumers can use the same dashboards.
27#[derive(Debug, Clone, Serialize, Deserialize)]
28pub struct ServerConformanceViolation {
29    /// When the request was rejected.
30    pub timestamp: DateTime<Utc>,
31    /// HTTP method (uppercase).
32    pub method: String,
33    /// Spec-template path the request matched (e.g. `/users/{id}`).
34    pub path: String,
35    /// Client IP if available, else `"unknown"`.
36    pub client_ip: String,
37    /// HTTP status the server replied with (typically 400 or 422).
38    pub status: u16,
39    /// Short, human-readable reason — derived from the validator error.
40    pub reason: String,
41    /// Spec category the violation falls into (`"parameters"`,
42    /// `"request-body"`, `"headers"`, etc.). Empty if the validator
43    /// couldn't classify.
44    pub category: String,
45    /// Round 30 — number of times this signature has been observed.
46    /// Always `1` in FIFO mode (the default). In unique-buffer mode
47    /// (`MOCKFORGE_CONFORMANCE_BUFFER_UNIQUE=true`) every duplicate
48    /// hit bumps this counter on the existing entry instead of
49    /// consuming a new buffer slot. Defaults to `1` when deserialising
50    /// older payloads that don't carry the field.
51    #[serde(default = "one")]
52    pub occurrences: u32,
53    /// Round 36 (#876) — mockforge version the *client* (the bench
54    /// driver) was running when it sent the request, as read from the
55    /// `X-Mockforge-Client-Version` header. `None` when the inbound
56    /// request didn't carry the header (older client, real proxy
57    /// traffic, etc.). Lets users cross-correlate a client-side
58    /// `CaseCapture` JSONL line with the matching server-side
59    /// violation when both sides log against the same code base.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub client_mockforge_version: Option<String>,
62    /// Round 36 (#876) — wall-clock timestamp the *client* stamped on
63    /// its `CaseCapture`, as read from the `X-Mockforge-Client-Sent-At`
64    /// header (RFC3339). Server-side `timestamp` is when the
65    /// violation was *received*; this is when the probe was *sent*.
66    /// Grep both for the same value to line up client + server
67    /// records of the same probe.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub client_sent_at: Option<DateTime<Utc>>,
70    /// Round 44 (#79) — short human-readable summary of `reason`, for
71    /// dashboards / report tables that don't want to display the full
72    /// JSON-shaped validator error. Built once at insertion time from
73    /// `reason` via `summarize_reason`; empty when the caller didn't
74    /// supply one. Older payloads without this field deserialise as
75    /// empty so consumers can fall back to `reason`.
76    ///
77    /// Srikanth on 0.3.188: "What is the difference between
78    /// Validation error: and errors both the content seems similar
79    /// with few differences here and there... The reason I am asking
80    /// is both the errors are overwhelming to view."
81    #[serde(default)]
82    pub summary: String,
83    /// Round 47 (#79) — Srikanth on 0.3.191: "I will not know other
84    /// violations until previous ones are fixed. Is it possible to
85    /// give a option to show all the violation irrespective of the
86    /// order both on client request logs and mockforge tui logs".
87    /// `category` already records the priority-winning location for
88    /// dashboards that filter by single category; `categories` lists
89    /// EVERY distinct location the validator's `details[]` payload
90    /// surfaced (e.g. `["query","request-body"]` for a POST that
91    /// failed both a query enum AND a required body field). Empty
92    /// when the validator didn't embed a structured `details` map.
93    /// Older clients deserialise this as empty and fall back to
94    /// `category` alone.
95    #[serde(default)]
96    pub categories: Vec<String>,
97}
98
99/// Header set by the bench client (round 36, #876) carrying the
100/// mockforge version that sent the request.
101pub const CLIENT_VERSION_HEADER: &str = "x-mockforge-client-version";
102
103/// Header set by the bench client (round 36, #876) carrying the
104/// RFC3339 timestamp the request was sent at.
105pub const CLIENT_SENT_AT_HEADER: &str = "x-mockforge-client-sent-at";
106
107/// Parse the client-stamp headers off a raw `(name, value)` lookup
108/// function. Accepts a closure so the same helper can read from
109/// `axum::http::HeaderMap`, `reqwest::header::HeaderMap`, or a plain
110/// `HashMap<String, String>` without forcing a particular type on
111/// the caller. Header names are looked up case-insensitively.
112pub fn read_client_stamps<F>(get: F) -> (Option<String>, Option<DateTime<Utc>>)
113where
114    F: Fn(&str) -> Option<String>,
115{
116    let version = get(CLIENT_VERSION_HEADER).filter(|s| !s.is_empty());
117    let sent_at = get(CLIENT_SENT_AT_HEADER)
118        .and_then(|s| DateTime::parse_from_rfc3339(&s).ok())
119        .map(|d| d.with_timezone(&Utc));
120    (version, sent_at)
121}
122
123fn one() -> u32 {
124    1
125}
126
127const DEFAULT_BUFFER_SIZE: usize = 256;
128
129/// Round 29 — Srikanth on 0.3.172 had 10,145 violations seen but only
130/// 114 unique entries in his export, because the in-memory ring buffer
131/// caps at 256. For long-running runs against large specs (vCenter,
132/// Microsoft Graph) that fills quickly. Override via
133/// `MOCKFORGE_CONFORMANCE_BUFFER_SIZE` so users can raise it without
134/// recompiling. Capped at 64k to keep peak memory bounded.
135fn effective_buffer_size() -> usize {
136    let cap: usize = 64 * 1024;
137    std::env::var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE")
138        .ok()
139        .and_then(|s| s.parse::<usize>().ok())
140        .filter(|n| *n > 0)
141        .map(|n| n.min(cap))
142        .unwrap_or(DEFAULT_BUFFER_SIZE)
143}
144
145/// Round 30 — Srikanth on 0.3.173: "Can we have this buffer for unique
146/// violation as opposed to duplicate violation. If this buffer size
147/// doesn't discount duplicates then again we will run out of buffer
148/// easily when more and more requests come to the server."
149///
150/// `MOCKFORGE_CONFORMANCE_BUFFER_UNIQUE=true` switches storage from
151/// FIFO to dedup-by-signature: every duplicate of an already-buffered
152/// (method, path, status, category, reason) hits its existing entry
153/// and bumps `occurrences` instead of consuming a new slot. The buffer
154/// fills only as fast as unique signatures arrive — so at 256 entries
155/// a vCenter spec with ~150 unique violation kinds will hold every
156/// kind even under 10M+ requests, instead of being clobbered by the
157/// most common offender.
158fn unique_mode_enabled() -> bool {
159    std::env::var("MOCKFORGE_CONFORMANCE_BUFFER_UNIQUE")
160        .ok()
161        .map(|s| matches!(s.to_ascii_lowercase().as_str(), "1" | "true" | "yes" | "on"))
162        .unwrap_or(false)
163}
164
165fn signature(v: &ServerConformanceViolation) -> String {
166    format!("{}|{}|{}|{}|{}", v.method, v.path, v.status, v.category, v.reason)
167}
168
169/// FIFO buffer (default mode). Each violation consumes one slot,
170/// oldest evicted when full.
171static VIOLATIONS: Lazy<Mutex<VecDeque<ServerConformanceViolation>>> =
172    Lazy::new(|| Mutex::new(VecDeque::with_capacity(effective_buffer_size())));
173
174/// Unique-mode buffer: signature → entry (with bumped `occurrences`)
175/// plus a `VecDeque<signature>` for insertion-order eviction. Only
176/// touched when `MOCKFORGE_CONFORMANCE_BUFFER_UNIQUE` is enabled.
177struct UniqueBuffer {
178    by_sig: HashMap<String, ServerConformanceViolation>,
179    order: VecDeque<String>,
180}
181
182impl UniqueBuffer {
183    fn new() -> Self {
184        Self {
185            by_sig: HashMap::new(),
186            order: VecDeque::new(),
187        }
188    }
189
190    fn record(&mut self, mut v: ServerConformanceViolation, cap: usize) {
191        let sig = signature(&v);
192        if let Some(existing) = self.by_sig.get_mut(&sig) {
193            existing.occurrences = existing.occurrences.saturating_add(1);
194            existing.timestamp = v.timestamp;
195            return;
196        }
197        v.occurrences = 1;
198        while self.order.len() >= cap {
199            if let Some(old) = self.order.pop_front() {
200                self.by_sig.remove(&old);
201            } else {
202                break;
203            }
204        }
205        self.order.push_back(sig.clone());
206        self.by_sig.insert(sig, v);
207    }
208
209    fn snapshot(&self) -> Vec<ServerConformanceViolation> {
210        self.order.iter().rev().filter_map(|s| self.by_sig.get(s).cloned()).collect()
211    }
212
213    fn len(&self) -> usize {
214        self.order.len()
215    }
216
217    fn clear(&mut self) {
218        self.by_sig.clear();
219        self.order.clear();
220    }
221}
222
223static UNIQUE_VIOLATIONS: Lazy<Mutex<UniqueBuffer>> = Lazy::new(|| Mutex::new(UniqueBuffer::new()));
224
225/// Lifetime count of violations recorded since process start (Issue #79
226/// round 15). The ring buffer only keeps the most recent
227/// `DEFAULT_BUFFER_SIZE`; this counter answers Srikanth's "I sent 656k
228/// requests but only see 256" — the 256 is the buffer cap, this is the
229/// true total seen.
230static TOTAL_SEEN: AtomicU64 = AtomicU64::new(0);
231
232/// Lifetime count of requests that *passed* the spec validator (round
233/// 17.1). Bumped on the `Ok(())` branch of
234/// `run_validation_with_recording`. Lets the TUI display
235/// "X conformant / Y violations" instead of just one side.
236static TOTAL_OK: AtomicU64 = AtomicU64::new(0);
237
238/// Bump the conformant-request counter. Called from the validator's
239/// success path. Bench code can call it directly when wiring its own
240/// counters too.
241pub fn record_ok() {
242    TOTAL_OK.fetch_add(1, Ordering::Relaxed);
243}
244
245/// Lifetime total of requests that passed the spec validator.
246pub fn total_ok() -> u64 {
247    TOTAL_OK.load(Ordering::Relaxed)
248}
249
250/// Record a violation. Old entries are dropped when the buffer is full
251/// (FIFO by default; signature-deduped under
252/// `MOCKFORGE_CONFORMANCE_BUFFER_UNIQUE=true`). Cheap enough to call
253/// from the hot path — uses a parking_lot Mutex which is uncontended in
254/// steady state.
255pub fn record(mut violation: ServerConformanceViolation) {
256    TOTAL_SEEN.fetch_add(1, Ordering::Relaxed);
257    if violation.summary.is_empty() {
258        violation.summary = summarize_reason(&violation.reason);
259    }
260    // Round 47 (#79) — derive the full list of distinct categories
261    // from the validator's `details[]` payload at insertion time so
262    // consumers can show "what ALL is broken" at once. Empty array
263    // when the reason has no structured payload to walk.
264    if violation.categories.is_empty() {
265        violation.categories = all_categories_from_reason(&violation.reason);
266    }
267    let cap = effective_buffer_size();
268    if unique_mode_enabled() {
269        UNIQUE_VIOLATIONS.lock().record(violation, cap);
270        return;
271    }
272    if violation.occurrences == 0 {
273        violation.occurrences = 1;
274    }
275    let mut buf = VIOLATIONS.lock();
276    while buf.len() >= cap {
277        buf.pop_front();
278    }
279    buf.push_back(violation);
280}
281
282/// Build a short, human-readable summary of a validator-error `reason`
283/// string. Round 44 (#79) — Srikanth on 0.3.188: the full `reason`
284/// embeds both a `details` array AND a redundant `errors` string array
285/// of the same content with slightly different prose, which made the
286/// violations table hard to scan. The summary collapses the violator
287/// list to a single line shaped `<N> <category> violation(s): <name>
288/// (<rule>), <name> (<rule>)...` so the table can show that at a
289/// glance and keep the full `reason` JSON only for tooling that needs
290/// the structured form. Empty when the reason doesn't carry a parsable
291/// `"details": [...]` payload (the older heuristic fallback path).
292pub fn summarize_reason(reason: &str) -> String {
293    use serde_json::Value;
294
295    // Pull out the `{...}` JSON the validator embeds inside the reason
296    // prose (e.g. `Validation error: {"details":[...],"errors":[...]}`).
297    let json_start = reason.find('{');
298    let parsed: Option<Value> = json_start
299        .and_then(|i| serde_json::from_str(reason[i..].trim()).ok())
300        .or_else(|| serde_json::from_str(reason).ok());
301
302    let details = parsed
303        .as_ref()
304        .and_then(|v| v.get("details"))
305        .and_then(|v| v.as_array())
306        .cloned()
307        .unwrap_or_default();
308
309    if details.is_empty() {
310        return String::new();
311    }
312
313    // Bucket details by their `path` prefix (query / body / header /
314    // cookie / parameters) so we report a sensible category even when a
315    // request has violations across multiple locations.
316    let mut by_loc: std::collections::BTreeMap<String, Vec<(String, String)>> =
317        std::collections::BTreeMap::new();
318
319    for d in &details {
320        let path = d.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
321        let code = d.get("code").and_then(|v| v.as_str()).unwrap_or("").to_string();
322        let (loc, name) = match path.split_once('.') {
323            Some((l, n)) => (l.to_string(), n.to_string()),
324            None if !path.is_empty() => (path.clone(), String::new()),
325            _ => ("body".to_string(), String::new()),
326        };
327        let rule = match code.as_str() {
328            "schema_validation" => infer_rule(d),
329            "required" => "required".to_string(),
330            other => other.to_string(),
331        };
332        // Round 46 (#79) — Srikanth on 0.3.189: "Here Not able to
333        // understand what is actual value in the request and what is
334        // expected." Pull the got/expected pair out of the validator's
335        // message text so the summary becomes self-explaining instead
336        // of a one-word rule label.
337        let detail = infer_got_expected(d, &rule);
338        let labelled_rule = match detail {
339            Some(d) => format!("{}: {}", rule, d),
340            None => rule,
341        };
342        by_loc.entry(loc).or_default().push((name, labelled_rule));
343    }
344
345    let total = details.len();
346    // Round 45 (#79) — match `classify_validation_reason`'s priority
347    // order (query > header > cookie > path > body) so the summary's
348    // `<category>` label agrees with the violation's `category` field.
349    // BTreeMap's alphabetical order was producing "request-body
350    // violation(s)" when category was actually "query".
351    let primary_loc = ["query", "header", "cookie", "path", "body"]
352        .iter()
353        .find(|loc| by_loc.contains_key(**loc))
354        .map(|s| s.to_string())
355        .or_else(|| by_loc.keys().next().cloned())
356        .unwrap_or_else(|| "validation".to_string());
357    let primary_label = match primary_loc.as_str() {
358        "query" => "query",
359        "header" => "header",
360        "cookie" => "cookie",
361        "path" => "path parameter",
362        "body" => "request-body",
363        other => other,
364    };
365
366    let mut items: Vec<String> = Vec::new();
367    for (loc, names) in &by_loc {
368        for (name, rule) in names {
369            let head = if name.is_empty() {
370                loc.clone()
371            } else {
372                format!("{}.{}", loc, name)
373            };
374            if rule.is_empty() {
375                items.push(head);
376            } else {
377                items.push(format!("{} ({})", head, rule));
378            }
379        }
380    }
381
382    // Cap the visible items so the summary stays scannable; the full
383    // detail list still lives in `reason` for tooling.
384    const MAX_VISIBLE: usize = 5;
385    let visible: Vec<String> = items.iter().take(MAX_VISIBLE).cloned().collect();
386    let suffix = if items.len() > MAX_VISIBLE {
387        format!(", +{} more", items.len() - MAX_VISIBLE)
388    } else {
389        String::new()
390    };
391
392    format!("{} {} violation(s): {}{}", total, primary_label, visible.join(", "), suffix)
393}
394
395/// Round 46 (#79) — pull the (got, expected) pair out of the validator's
396/// freeform `message` text so the summary can show actual + expected
397/// values inline instead of just a bare rule name. Examples:
398/// - `"test-value" is not one of "1" or "2"` → `got "test-value", expected one of "1", "2"`
399/// - `"abc" is not of type "boolean"` → `got "abc", expected type "boolean"`
400/// - `"name" is a required property` → `expected property "name"`
401/// - Returns None when the message doesn't match any known shape; the
402///   summary then falls back to the bare rule label.
403fn infer_got_expected(detail: &serde_json::Value, rule: &str) -> Option<String> {
404    let msg = detail.get("message").and_then(|v| v.as_str()).unwrap_or("");
405    if msg.is_empty() {
406        return None;
407    }
408    let extract_quoted = |s: &str| -> Vec<String> {
409        let mut out = Vec::new();
410        let bytes = s.as_bytes();
411        let mut i = 0;
412        while i < bytes.len() {
413            if bytes[i] == b'"' {
414                let start = i + 1;
415                let mut j = start;
416                while j < bytes.len() && bytes[j] != b'"' {
417                    j += 1;
418                }
419                if j < bytes.len() {
420                    out.push(s[start..j].to_string());
421                    i = j + 1;
422                    continue;
423                }
424            }
425            i += 1;
426        }
427        out
428    };
429    let quoted = extract_quoted(msg);
430
431    match rule {
432        "enum" => {
433            if quoted.len() < 2 {
434                return None;
435            }
436            let got = &quoted[0];
437            let expected: Vec<String> = quoted[1..].iter().map(|s| format!("\"{}\"", s)).collect();
438            Some(format!("got \"{}\", expected one of {}", got, expected.join(", ")))
439        }
440        "type" => {
441            if quoted.len() < 2 {
442                return None;
443            }
444            Some(format!("got \"{}\", expected type \"{}\"", quoted[0], quoted[1]))
445        }
446        "required" => {
447            // `"<field>" is a required property` — the only quoted string is the field name.
448            quoted.first().map(|name| format!("missing required field \"{}\"", name))
449        }
450        "min" | "max" => {
451            // Bound shapes like `<N> is less than minimum <M>`. The
452            // numbers are usually unquoted, so try a regex-style
453            // extraction on the prose.
454            let lower = msg.to_lowercase();
455            let kw = if rule == "min" {
456                ["less than minimum", "less than"]
457            } else {
458                ["greater than maximum", "greater than"]
459            };
460            for k in kw {
461                if let Some(idx) = lower.find(k) {
462                    let head = msg[..idx].trim();
463                    let tail = msg[idx + k.len()..].trim();
464                    return Some(format!("got {}, {} {}", head, k, tail));
465                }
466            }
467            None
468        }
469        "pattern" => {
470            if quoted.len() >= 2 {
471                Some(format!("got \"{}\", expected pattern \"{}\"", quoted[0], quoted[1]))
472            } else {
473                None
474            }
475        }
476        _ => None,
477    }
478}
479
480/// Round 47 (#79) — walk the validator's `details[]` payload embedded
481/// in `reason` and emit ALL distinct location categories. Used to
482/// populate `ServerConformanceViolation::categories` at record time so
483/// consumers can show every category at once instead of just the
484/// priority winner (`category` field). Order is the same priority
485/// chain the classifier uses (query > header > cookie > path > body)
486/// for stable rendering, but every category is included.
487pub fn all_categories_from_reason(reason: &str) -> Vec<String> {
488    use serde_json::Value;
489    let json_start = reason.find('{');
490    let parsed: Option<Value> = json_start
491        .and_then(|i| serde_json::from_str(reason[i..].trim()).ok())
492        .or_else(|| serde_json::from_str(reason).ok());
493    let details = parsed
494        .as_ref()
495        .and_then(|v| v.get("details"))
496        .and_then(|v| v.as_array())
497        .cloned()
498        .unwrap_or_default();
499    let mut seen = std::collections::BTreeSet::new();
500    for d in &details {
501        let path = d.get("path").and_then(|v| v.as_str()).unwrap_or("");
502        let loc = match path.split_once('.') {
503            Some((l, _)) => l,
504            None if !path.is_empty() => path,
505            _ => continue,
506        };
507        let canonical = match loc {
508            "query" => "query",
509            "header" => "headers",
510            "cookie" => "cookies",
511            "path" => "parameters",
512            "body" => "request-body",
513            _ => continue,
514        };
515        seen.insert(canonical.to_string());
516    }
517    let order = ["query", "headers", "cookies", "parameters", "request-body"];
518    let mut out = Vec::new();
519    for cat in order {
520        if seen.remove(cat) {
521            out.push(cat.to_string());
522        }
523    }
524    out.extend(seen);
525    out
526}
527
528/// Read a single `details[]` entry and produce a short rule label like
529/// `enum` / `type` / `min` / `max` / `pattern` for the summary. Falls
530/// back to the validator's own `message` prose when no rule is
531/// recognisable (defensive against future validator wording).
532fn infer_rule(detail: &serde_json::Value) -> String {
533    let msg = detail.get("message").and_then(|v| v.as_str()).unwrap_or("").to_lowercase();
534    if msg.contains("is not one of") {
535        return "enum".to_string();
536    }
537    if msg.contains("not of type") || msg.contains("expected type") {
538        return "type".to_string();
539    }
540    if msg.contains("less than") || msg.contains("minimum") {
541        return "min".to_string();
542    }
543    if msg.contains("greater than") || msg.contains("maximum") {
544        return "max".to_string();
545    }
546    if msg.contains("pattern") {
547        return "pattern".to_string();
548    }
549    if msg.contains("required") {
550        return "required".to_string();
551    }
552    "schema".to_string()
553}
554
555/// Snapshot of the buffered violations, newest first.
556pub fn snapshot() -> Vec<ServerConformanceViolation> {
557    if unique_mode_enabled() {
558        UNIQUE_VIOLATIONS.lock().snapshot()
559    } else {
560        let buf = VIOLATIONS.lock();
561        buf.iter().rev().cloned().collect()
562    }
563}
564
565/// Number of violations currently buffered (≤ `effective_buffer_size`).
566pub fn len() -> usize {
567    if unique_mode_enabled() {
568        UNIQUE_VIOLATIONS.lock().len()
569    } else {
570        VIOLATIONS.lock().len()
571    }
572}
573
574/// Lifetime total of violations recorded since process start, including
575/// ones the ring buffer has since evicted.
576pub fn total_seen() -> u64 {
577    TOTAL_SEEN.load(Ordering::Relaxed)
578}
579
580/// Clear both buffers and reset lifetime counters. Primarily for
581/// tests and TUI "reset" actions.
582pub fn clear() {
583    VIOLATIONS.lock().clear();
584    UNIQUE_VIOLATIONS.lock().clear();
585    TOTAL_SEEN.store(0, Ordering::Relaxed);
586    TOTAL_OK.store(0, Ordering::Relaxed);
587}
588
589#[cfg(test)]
590mod tests {
591    use super::*;
592
593    fn v(method: &str, status: u16) -> ServerConformanceViolation {
594        ServerConformanceViolation {
595            timestamp: Utc::now(),
596            method: method.to_string(),
597            path: "/test".into(),
598            client_ip: "127.0.0.1".into(),
599            status,
600            reason: "test".into(),
601            category: "parameters".into(),
602            occurrences: 1,
603            client_mockforge_version: None,
604            client_sent_at: None,
605            summary: String::new(),
606            categories: Vec::new(),
607        }
608    }
609
610    /// Round 36 (#876) — `read_client_stamps` returns both fields
611    /// when the headers are present and RFC3339-parsable.
612    #[test]
613    fn read_client_stamps_roundtrips_when_headers_present() {
614        let stamped_at = "2026-06-17T12:34:56Z";
615        let (version, sent_at) = read_client_stamps(|name| match name {
616            CLIENT_VERSION_HEADER => Some("0.3.183".to_string()),
617            CLIENT_SENT_AT_HEADER => Some(stamped_at.to_string()),
618            _ => None,
619        });
620        assert_eq!(version.as_deref(), Some("0.3.183"));
621        let sent_at = sent_at.expect("should parse RFC3339 timestamp");
622        assert_eq!(sent_at.to_rfc3339(), "2026-06-17T12:34:56+00:00");
623    }
624
625    /// Missing or malformed headers should yield `None`, not panic
626    /// or fall back to "now" (we don't want to fabricate timestamps).
627    #[test]
628    fn read_client_stamps_returns_none_when_headers_absent_or_garbage() {
629        let (v, s) = read_client_stamps(|_| None);
630        assert!(v.is_none());
631        assert!(s.is_none());
632
633        let (v, s) = read_client_stamps(|name| {
634            if name == CLIENT_SENT_AT_HEADER {
635                Some("not-a-timestamp".to_string())
636            } else {
637                None
638            }
639        });
640        assert!(v.is_none());
641        assert!(s.is_none(), "garbage timestamp must not be invented");
642
643        // Empty version string treated as absent.
644        let (v, _) = read_client_stamps(|name| {
645            if name == CLIENT_VERSION_HEADER {
646                Some(String::new())
647            } else {
648                None
649            }
650        });
651        assert!(v.is_none());
652    }
653
654    #[test]
655    fn record_and_snapshot_in_lifo_order() {
656        clear();
657        record(v("GET", 400));
658        record(v("POST", 422));
659        let snap = snapshot();
660        assert_eq!(snap.len(), 2);
661        // newest first
662        assert_eq!(snap[0].method, "POST");
663        assert_eq!(snap[1].method, "GET");
664    }
665
666    #[test]
667    fn buffer_drops_oldest_at_capacity() {
668        clear();
669        for i in 0..(DEFAULT_BUFFER_SIZE + 50) {
670            let mut entry = v("GET", 400);
671            entry.reason = format!("{i}");
672            record(entry);
673        }
674        assert_eq!(len(), DEFAULT_BUFFER_SIZE);
675        let snap = snapshot();
676        // newest is the last one we pushed
677        assert_eq!(snap[0].reason, format!("{}", DEFAULT_BUFFER_SIZE + 50 - 1));
678        // oldest still present is index 50 (the first 50 got dropped)
679        assert_eq!(snap[DEFAULT_BUFFER_SIZE - 1].reason, format!("{}", 50));
680    }
681
682    /// Round 29 — `MOCKFORGE_CONFORMANCE_BUFFER_SIZE` env var
683    /// overrides the default 256 cap. Tagged `#[ignore]` because it
684    /// mutates a process-wide env var that races with the other
685    /// tests in this module (which call `record()` → which reads
686    /// the same env var). Run explicitly with
687    /// `cargo test -p mockforge-foundation -- --ignored
688    /// effective_buffer_size_respects_env_var --test-threads=1`.
689    #[test]
690    #[ignore]
691    fn effective_buffer_size_respects_env_var() {
692        let original = std::env::var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE").ok();
693
694        // SAFETY: process-wide env mutation is unsound under multi-
695        // threaded test runs; this test is gated with `#[ignore]` to
696        // force serial execution by the developer when needed.
697        unsafe {
698            std::env::set_var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE", "1000");
699        }
700        assert_eq!(effective_buffer_size(), 1000);
701
702        unsafe {
703            std::env::set_var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE", "0");
704        }
705        assert_eq!(effective_buffer_size(), DEFAULT_BUFFER_SIZE, "zero falls back to default");
706
707        unsafe {
708            std::env::set_var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE", "garbage");
709        }
710        assert_eq!(
711            effective_buffer_size(),
712            DEFAULT_BUFFER_SIZE,
713            "unparsable falls back to default"
714        );
715
716        unsafe {
717            std::env::set_var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE", "999999");
718        }
719        assert_eq!(effective_buffer_size(), 64 * 1024, "clamped to 64k");
720
721        // Restore
722        unsafe {
723            match original {
724                Some(v) => std::env::set_var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE", v),
725                None => std::env::remove_var("MOCKFORGE_CONFORMANCE_BUFFER_SIZE"),
726            }
727        }
728    }
729
730    /// Round 30 — unique-mode buffer dedups duplicate signatures and
731    /// bumps `occurrences` instead of consuming new slots. Direct
732    /// `UniqueBuffer::record` call avoids the global env-var read,
733    /// so this test stays threadsafe without `#[ignore]`.
734    #[test]
735    fn unique_buffer_dedups_by_signature_and_counts_occurrences() {
736        let mut buf = UniqueBuffer::new();
737        for _ in 0..10_000 {
738            buf.record(v("GET", 400), 256);
739        }
740        // 10k identical violations → 1 slot used, occurrences == 10000.
741        assert_eq!(buf.len(), 1);
742        let snap = buf.snapshot();
743        assert_eq!(snap.len(), 1);
744        assert_eq!(snap[0].occurrences, 10_000);
745        assert_eq!(snap[0].method, "GET");
746    }
747
748    /// Round 30 — different (method, path, status, category, reason)
749    /// tuples occupy distinct slots; identical tuples coalesce.
750    #[test]
751    fn unique_buffer_distinguishes_distinct_signatures() {
752        let mut buf = UniqueBuffer::new();
753        // 3 distinct signatures × 100 hits each
754        for _ in 0..100 {
755            buf.record(v("GET", 400), 256);
756            buf.record(v("POST", 422), 256);
757            let mut other = v("GET", 400);
758            other.reason = "different".into();
759            buf.record(other, 256);
760        }
761        assert_eq!(buf.len(), 3);
762        let snap = buf.snapshot();
763        assert_eq!(snap.len(), 3);
764        for entry in &snap {
765            assert_eq!(entry.occurrences, 100, "each signature seen 100×");
766        }
767    }
768
769    /// Round 30 — unique mode still evicts when distinct-signature
770    /// count exceeds the cap. Eviction is FIFO over insertion order
771    /// (NOT recency-of-hit), matching how the regular ring buffer
772    /// reads.
773    #[test]
774    fn unique_buffer_evicts_oldest_signature_at_capacity() {
775        let mut buf = UniqueBuffer::new();
776        let cap = 4;
777        for i in 0..(cap + 3) {
778            let mut entry = v("GET", 400);
779            entry.reason = format!("kind-{i}");
780            buf.record(entry, cap);
781        }
782        assert_eq!(buf.len(), cap);
783        let snap = buf.snapshot();
784        // newest first; signatures 0..2 evicted, 3..6 retained
785        let kinds: Vec<&str> = snap.iter().map(|e| e.reason.as_str()).collect();
786        assert_eq!(kinds, vec!["kind-6", "kind-5", "kind-4", "kind-3"]);
787    }
788}