mockforge_foundation/
unknown_paths.rs1use chrono::{DateTime, Utc};
15use once_cell::sync::Lazy;
16use parking_lot::Mutex;
17use serde::{Deserialize, Serialize};
18use std::collections::VecDeque;
19use std::sync::atomic::{AtomicU64, Ordering};
20
21pub fn shadow_mode_enabled() -> bool {
32 std::env::var("MOCKFORGE_SHADOW_MODE")
33 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
34 .unwrap_or(false)
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct UnknownPathRequest {
41 pub timestamp: DateTime<Utc>,
43 pub method: String,
45 pub path: String,
47 pub client_ip: String,
49 pub query: String,
51 #[serde(default = "default_unknown_status")]
56 pub status: u16,
57}
58
59fn default_unknown_status() -> u16 {
60 404
61}
62
63const DEFAULT_BUFFER_SIZE: usize = 256;
64
65static UNKNOWN_PATHS: Lazy<Mutex<VecDeque<UnknownPathRequest>>> =
66 Lazy::new(|| Mutex::new(VecDeque::with_capacity(DEFAULT_BUFFER_SIZE)));
67
68static TOTAL_SEEN: AtomicU64 = AtomicU64::new(0);
73
74pub fn record(req: UnknownPathRequest) {
76 TOTAL_SEEN.fetch_add(1, Ordering::Relaxed);
77 let mut buf = UNKNOWN_PATHS.lock();
78 if buf.len() == DEFAULT_BUFFER_SIZE {
79 buf.pop_front();
80 }
81 buf.push_back(req);
82}
83
84pub fn snapshot() -> Vec<UnknownPathRequest> {
86 let buf = UNKNOWN_PATHS.lock();
87 buf.iter().rev().cloned().collect()
88}
89
90pub fn len() -> usize {
92 UNKNOWN_PATHS.lock().len()
93}
94
95pub fn total_seen() -> u64 {
98 TOTAL_SEEN.load(Ordering::Relaxed)
99}
100
101pub fn clear() {
103 UNKNOWN_PATHS.lock().clear();
104 TOTAL_SEEN.store(0, Ordering::Relaxed);
105}
106
107#[cfg(test)]
108mod tests {
109 use super::*;
110
111 static TEST_LOCK: Mutex<()> = Mutex::new(());
115
116 fn req(path: &str) -> UnknownPathRequest {
117 UnknownPathRequest {
118 timestamp: Utc::now(),
119 method: "GET".into(),
120 path: path.into(),
121 client_ip: "127.0.0.1".into(),
122 query: String::new(),
123 status: 404,
124 }
125 }
126
127 #[test]
128 fn record_and_snapshot_lifo() {
129 let _guard = TEST_LOCK.lock();
130 clear();
131 record(req("/first"));
132 record(req("/second"));
133 let snap = snapshot();
134 assert_eq!(snap.len(), 2);
135 assert_eq!(snap[0].path, "/second");
136 assert_eq!(snap[1].path, "/first");
137 }
138
139 #[test]
140 fn drops_oldest_at_capacity() {
141 let _guard = TEST_LOCK.lock();
142 clear();
143 for i in 0..(DEFAULT_BUFFER_SIZE + 5) {
144 record(req(&format!("/p/{i}")));
145 }
146 assert_eq!(len(), DEFAULT_BUFFER_SIZE);
147 let snap = snapshot();
148 assert_eq!(snap[0].path, format!("/p/{}", DEFAULT_BUFFER_SIZE + 4));
149 assert_eq!(snap[DEFAULT_BUFFER_SIZE - 1].path, "/p/5");
150 }
151
152 #[test]
153 fn shadow_mode_reads_env() {
154 std::env::set_var("MOCKFORGE_SHADOW_MODE", "true");
155 assert!(shadow_mode_enabled());
156 std::env::set_var("MOCKFORGE_SHADOW_MODE", "1");
157 assert!(shadow_mode_enabled());
158 std::env::set_var("MOCKFORGE_SHADOW_MODE", "0");
159 assert!(!shadow_mode_enabled());
160 std::env::remove_var("MOCKFORGE_SHADOW_MODE");
161 assert!(!shadow_mode_enabled());
162 }
163
164 #[test]
165 fn status_defaults_to_404_on_legacy_payload() {
166 let json = r#"{"timestamp":"2026-05-26T00:00:00Z","method":"GET","path":"/x","client_ip":"unknown","query":""}"#;
169 let parsed: UnknownPathRequest = serde_json::from_str(json).unwrap();
170 assert_eq!(parsed.status, 404);
171 }
172}