Skip to main content

mockforge_runtime_daemon/
detector.rs

1//! 404 detection middleware for the runtime daemon
2//!
3//! This module provides middleware that detects 404 responses and triggers
4//! automatic mock generation.
5
6use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response};
7use std::sync::Arc;
8use tokio::sync::RwLock;
9use tracing::{debug, info, warn};
10
11use crate::auto_generator::AutoGenerator;
12use crate::config::RuntimeDaemonConfig;
13
14/// State for the 404 detector middleware
15#[derive(Clone)]
16pub struct NotFoundDetector {
17    /// Daemon configuration
18    config: Arc<RuntimeDaemonConfig>,
19    /// Auto-generator for creating mocks (wrapped in Arc for sharing)
20    generator: Arc<RwLock<Option<Arc<AutoGenerator>>>>,
21}
22
23impl NotFoundDetector {
24    /// Create a new 404 detector
25    pub fn new(config: RuntimeDaemonConfig) -> Self {
26        Self {
27            config: Arc::new(config),
28            generator: Arc::new(RwLock::new(None)),
29        }
30    }
31
32    /// Set the auto-generator
33    pub async fn set_generator(&self, generator: Arc<AutoGenerator>) {
34        let mut gen = self.generator.write().await;
35        *gen = Some(generator);
36    }
37
38    /// Check if a path should be excluded from auto-generation
39    fn should_exclude(&self, path: &str) -> bool {
40        self.config.exclude_patterns.iter().any(|pattern| {
41            if pattern.starts_with('/') {
42                path.starts_with(pattern)
43            } else {
44                path.contains(pattern)
45            }
46        })
47    }
48
49    /// Middleware function that detects 404s and triggers auto-generation
50    pub async fn detect_and_auto_create(self, request: Request, next: Next) -> Response {
51        // Extract request details before consuming the request
52        let method = request.method().clone();
53        let uri = request.uri().clone();
54        let path = uri.path().to_string();
55
56        // Execute the request and get the response
57        let response = next.run(request).await;
58
59        // Only process if daemon is enabled and auto-create is enabled
60        if !self.config.enabled || !self.config.auto_create_on_404 {
61            return response;
62        }
63
64        // Check if response is a 404
65        if response.status() != StatusCode::NOT_FOUND {
66            return response;
67        }
68
69        // Check if path should be excluded
70        if self.should_exclude(&path) {
71            debug!("Excluding path from auto-generation: {}", path);
72            return response;
73        }
74
75        info!("Detected 404 for {} {}, triggering auto-generation", method, path);
76
77        // Try to auto-generate a mock
78        let generator = self.generator.read().await;
79        if let Some(ref gen) = *generator {
80            // Clone what we need for async operation
81            let method_str = method.to_string();
82            let path_str = path.clone();
83            let gen_clone = Arc::clone(gen);
84
85            // Spawn async task to generate mock (don't block the response)
86            tokio::spawn(async move {
87                if let Err(e) = gen_clone.generate_mock_from_404(&method_str, &path_str).await {
88                    warn!("Failed to auto-generate mock for {} {}: {}", method_str, path_str, e);
89                } else {
90                    info!("Successfully auto-generated mock for {} {}", method_str, path_str);
91                }
92            });
93        } else {
94            debug!("Auto-generator not available, skipping mock creation");
95        }
96
97        response
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn test_new_detector() {
107        let config = RuntimeDaemonConfig::default();
108        let detector = NotFoundDetector::new(config);
109        assert!(detector.config.exclude_patterns.contains(&"/health".to_string()));
110    }
111
112    #[test]
113    fn test_detector_clone() {
114        let config = RuntimeDaemonConfig {
115            enabled: true,
116            exclude_patterns: vec!["/test".to_string()],
117            ..Default::default()
118        };
119        let detector = NotFoundDetector::new(config);
120        let cloned = detector.clone();
121        assert!(cloned.config.enabled);
122        assert!(cloned.config.exclude_patterns.contains(&"/test".to_string()));
123    }
124
125    #[tokio::test]
126    async fn test_should_exclude() {
127        let config = RuntimeDaemonConfig {
128            exclude_patterns: vec!["/health".to_string(), "/metrics".to_string()],
129            ..Default::default()
130        };
131        let detector = NotFoundDetector::new(config);
132
133        assert!(detector.should_exclude("/health"));
134        assert!(detector.should_exclude("/health/check"));
135        assert!(detector.should_exclude("/metrics"));
136        assert!(!detector.should_exclude("/api/users"));
137    }
138
139    #[test]
140    fn test_should_exclude_prefix_patterns() {
141        let config = RuntimeDaemonConfig {
142            exclude_patterns: vec!["/internal".to_string(), "/admin".to_string()],
143            ..Default::default()
144        };
145        let detector = NotFoundDetector::new(config);
146
147        // Prefix patterns (start with /) should match path starts
148        assert!(detector.should_exclude("/internal"));
149        assert!(detector.should_exclude("/internal/api"));
150        assert!(detector.should_exclude("/internal/users/123"));
151        assert!(detector.should_exclude("/admin"));
152        assert!(detector.should_exclude("/admin/dashboard"));
153        assert!(!detector.should_exclude("/api/internal"));
154    }
155
156    #[test]
157    fn test_should_exclude_contains_patterns() {
158        let config = RuntimeDaemonConfig {
159            exclude_patterns: vec!["secret".to_string(), "private".to_string()],
160            ..Default::default()
161        };
162        let detector = NotFoundDetector::new(config);
163
164        // Contains patterns (not starting with /) should match anywhere
165        assert!(detector.should_exclude("/api/secret/data"));
166        assert!(detector.should_exclude("/secret"));
167        assert!(detector.should_exclude("/users/secret/key"));
168        assert!(detector.should_exclude("/private/info"));
169        assert!(detector.should_exclude("/api/private"));
170        assert!(!detector.should_exclude("/api/public/data"));
171    }
172
173    #[test]
174    fn test_should_exclude_mixed_patterns() {
175        let config = RuntimeDaemonConfig {
176            exclude_patterns: vec![
177                "/health".to_string(),  // Prefix pattern
178                "internal".to_string(), // Contains pattern
179            ],
180            ..Default::default()
181        };
182        let detector = NotFoundDetector::new(config);
183
184        // Prefix pattern
185        assert!(detector.should_exclude("/health"));
186        assert!(detector.should_exclude("/health/check"));
187        assert!(!detector.should_exclude("/api/health")); // /health only matches start
188
189        // Contains pattern
190        assert!(detector.should_exclude("/internal"));
191        assert!(detector.should_exclude("/api/internal"));
192        assert!(detector.should_exclude("/internal/api"));
193    }
194
195    #[test]
196    fn test_should_exclude_empty_patterns() {
197        let config = RuntimeDaemonConfig {
198            exclude_patterns: vec![],
199            ..Default::default()
200        };
201        let detector = NotFoundDetector::new(config);
202
203        // Nothing should be excluded
204        assert!(!detector.should_exclude("/health"));
205        assert!(!detector.should_exclude("/metrics"));
206        assert!(!detector.should_exclude("/api/users"));
207    }
208
209    #[test]
210    fn test_should_exclude_default_patterns() {
211        let config = RuntimeDaemonConfig::default();
212        let detector = NotFoundDetector::new(config);
213
214        // Default patterns include /health, /metrics, /__mockforge
215        assert!(detector.should_exclude("/health"));
216        assert!(detector.should_exclude("/metrics"));
217        assert!(detector.should_exclude("/__mockforge"));
218        assert!(detector.should_exclude("/__mockforge/api/mocks"));
219        assert!(!detector.should_exclude("/api/users"));
220    }
221
222    #[tokio::test]
223    async fn test_set_generator() {
224        let config = RuntimeDaemonConfig::default();
225        let detector = NotFoundDetector::new(config.clone());
226
227        // Initially generator should be None
228        {
229            let gen = detector.generator.read().await;
230            assert!(gen.is_none());
231        }
232
233        // Set a generator
234        let auto_gen = Arc::new(AutoGenerator::new(config, "http://localhost:3000".to_string()));
235        detector.set_generator(auto_gen).await;
236
237        // Now generator should be Some
238        {
239            let gen = detector.generator.read().await;
240            assert!(gen.is_some());
241        }
242    }
243
244    #[test]
245    fn test_detector_config_arc_sharing() {
246        let config = RuntimeDaemonConfig {
247            enabled: true,
248            auto_create_on_404: true,
249            ..Default::default()
250        };
251        let detector = NotFoundDetector::new(config);
252        let cloned = detector.clone();
253
254        // Both should point to the same Arc
255        assert!(Arc::ptr_eq(&detector.config, &cloned.config));
256    }
257}