mockforge_ui/
time_travel_handlers.rs

1//! Admin API handlers for time travel features
2
3use axum::{
4    extract::Path,
5    http::StatusCode,
6    response::{IntoResponse, Json},
7};
8use chrono::{DateTime, Duration, Utc};
9use mockforge_core::{RepeatConfig, ScheduledResponse, TimeTravelManager, VirtualClock};
10use serde::{Deserialize, Serialize};
11use std::collections::HashMap;
12use std::sync::{Arc, RwLock};
13use tracing::info;
14
15/// Global time travel manager (optional, can be set by the application)
16static TIME_TRAVEL_MANAGER: once_cell::sync::OnceCell<Arc<RwLock<Option<Arc<TimeTravelManager>>>>> =
17    once_cell::sync::OnceCell::new();
18
19/// Initialize the global time travel manager
20pub fn init_time_travel_manager(manager: Arc<TimeTravelManager>) {
21    let cell = TIME_TRAVEL_MANAGER.get_or_init(|| Arc::new(RwLock::new(None)));
22    let mut guard = cell.write().unwrap();
23    *guard = Some(manager);
24}
25
26/// Get the global time travel manager
27fn get_time_travel_manager() -> Option<Arc<TimeTravelManager>> {
28    TIME_TRAVEL_MANAGER.get().and_then(|cell| cell.read().unwrap().clone())
29}
30
31/// Request to enable time travel at a specific time
32#[derive(Debug, Serialize, Deserialize)]
33pub struct EnableTimeTravelRequest {
34    /// The time to set (ISO 8601 format)
35    pub time: Option<DateTime<Utc>>,
36    /// Time scale factor (default: 1.0)
37    pub scale: Option<f64>,
38}
39
40/// Request to advance time
41#[derive(Debug, Serialize, Deserialize)]
42pub struct AdvanceTimeRequest {
43    /// Duration to advance (e.g., "2h", "30m", "10s")
44    pub duration: String,
45}
46
47/// Request to set time scale
48#[derive(Debug, Serialize, Deserialize)]
49pub struct SetScaleRequest {
50    /// Time scale factor (1.0 = real time, 2.0 = 2x speed)
51    pub scale: f64,
52}
53
54/// Request to schedule a response
55#[derive(Debug, Serialize, Deserialize)]
56pub struct ScheduleResponseRequest {
57    /// When to trigger (ISO 8601 format or relative like "+1h")
58    pub trigger_time: String,
59    /// Response body (JSON)
60    pub body: serde_json::Value,
61    /// HTTP status code (default: 200)
62    #[serde(default = "default_status")]
63    pub status: u16,
64    /// Response headers
65    #[serde(default)]
66    pub headers: HashMap<String, String>,
67    /// Optional name/label
68    pub name: Option<String>,
69    /// Repeat configuration
70    pub repeat: Option<RepeatConfig>,
71}
72
73fn default_status() -> u16 {
74    200
75}
76
77/// Response with scheduled response ID
78#[derive(Debug, Serialize, Deserialize)]
79pub struct ScheduleResponseResponse {
80    pub id: String,
81    pub trigger_time: DateTime<Utc>,
82}
83
84/// Get time travel status
85pub async fn get_time_travel_status() -> impl IntoResponse {
86    match get_time_travel_manager() {
87        Some(manager) => {
88            let status = manager.clock().status();
89            Json(status).into_response()
90        }
91        None => (
92            StatusCode::NOT_FOUND,
93            Json(serde_json::json!({
94                "error": "Time travel not initialized"
95            })),
96        )
97            .into_response(),
98    }
99}
100
101/// Enable time travel
102pub async fn enable_time_travel(Json(req): Json<EnableTimeTravelRequest>) -> impl IntoResponse {
103    match get_time_travel_manager() {
104        Some(manager) => {
105            let time = req.time.unwrap_or_else(Utc::now);
106            manager.clock().enable_and_set(time);
107
108            if let Some(scale) = req.scale {
109                manager.clock().set_scale(scale);
110            }
111
112            info!("Time travel enabled at {}", time);
113
114            Json(serde_json::json!({
115                "success": true,
116                "status": manager.clock().status()
117            }))
118            .into_response()
119        }
120        None => (
121            StatusCode::NOT_FOUND,
122            Json(serde_json::json!({
123                "error": "Time travel not initialized"
124            })),
125        )
126            .into_response(),
127    }
128}
129
130/// Disable time travel
131pub async fn disable_time_travel() -> impl IntoResponse {
132    match get_time_travel_manager() {
133        Some(manager) => {
134            manager.clock().disable();
135            info!("Time travel disabled");
136
137            Json(serde_json::json!({
138                "success": true,
139                "status": manager.clock().status()
140            }))
141            .into_response()
142        }
143        None => (
144            StatusCode::NOT_FOUND,
145            Json(serde_json::json!({
146                "error": "Time travel not initialized"
147            })),
148        )
149            .into_response(),
150    }
151}
152
153/// Advance time by a duration
154pub async fn advance_time(Json(req): Json<AdvanceTimeRequest>) -> impl IntoResponse {
155    match get_time_travel_manager() {
156        Some(manager) => {
157            // Parse duration string (e.g., "2h", "30m", "10s")
158            let duration = parse_duration(&req.duration);
159
160            match duration {
161                Ok(dur) => {
162                    manager.clock().advance(dur);
163                    info!("Time advanced by {}", req.duration);
164
165                    Json(serde_json::json!({
166                        "success": true,
167                        "status": manager.clock().status()
168                    }))
169                    .into_response()
170                }
171                Err(e) => (
172                    StatusCode::BAD_REQUEST,
173                    Json(serde_json::json!({
174                        "error": format!("Invalid duration format: {}", e)
175                    })),
176                )
177                    .into_response(),
178            }
179        }
180        None => (
181            StatusCode::NOT_FOUND,
182            Json(serde_json::json!({
183                "error": "Time travel not initialized"
184            })),
185        )
186            .into_response(),
187    }
188}
189
190/// Set time scale
191pub async fn set_time_scale(Json(req): Json<SetScaleRequest>) -> impl IntoResponse {
192    match get_time_travel_manager() {
193        Some(manager) => {
194            manager.clock().set_scale(req.scale);
195            info!("Time scale set to {}x", req.scale);
196
197            Json(serde_json::json!({
198                "success": true,
199                "status": manager.clock().status()
200            }))
201            .into_response()
202        }
203        None => (
204            StatusCode::NOT_FOUND,
205            Json(serde_json::json!({
206                "error": "Time travel not initialized"
207            })),
208        )
209            .into_response(),
210    }
211}
212
213/// Reset time travel
214pub async fn reset_time_travel() -> impl IntoResponse {
215    match get_time_travel_manager() {
216        Some(manager) => {
217            manager.clock().reset();
218            info!("Time travel reset");
219
220            Json(serde_json::json!({
221                "success": true,
222                "status": manager.clock().status()
223            }))
224            .into_response()
225        }
226        None => (
227            StatusCode::NOT_FOUND,
228            Json(serde_json::json!({
229                "error": "Time travel not initialized"
230            })),
231        )
232            .into_response(),
233    }
234}
235
236/// Schedule a response
237pub async fn schedule_response(Json(req): Json<ScheduleResponseRequest>) -> impl IntoResponse {
238    match get_time_travel_manager() {
239        Some(manager) => {
240            // Parse trigger time (ISO 8601 or relative like "+1h")
241            let trigger_time = parse_trigger_time(&req.trigger_time, manager.clock());
242
243            match trigger_time {
244                Ok(time) => {
245                    let scheduled_response = ScheduledResponse {
246                        id: uuid::Uuid::new_v4().to_string(),
247                        trigger_time: time,
248                        body: req.body,
249                        status: req.status,
250                        headers: req.headers,
251                        name: req.name,
252                        repeat: req.repeat,
253                    };
254
255                    match manager.scheduler().schedule(scheduled_response.clone()) {
256                        Ok(id) => {
257                            info!("Scheduled response {} for {}", id, time);
258
259                            Json(ScheduleResponseResponse {
260                                id,
261                                trigger_time: time,
262                            })
263                            .into_response()
264                        }
265                        Err(e) => (
266                            StatusCode::INTERNAL_SERVER_ERROR,
267                            Json(serde_json::json!({
268                                "error": format!("Failed to schedule response: {}", e)
269                            })),
270                        )
271                            .into_response(),
272                    }
273                }
274                Err(e) => (
275                    StatusCode::BAD_REQUEST,
276                    Json(serde_json::json!({
277                        "error": format!("Invalid trigger time: {}", e)
278                    })),
279                )
280                    .into_response(),
281            }
282        }
283        None => (
284            StatusCode::NOT_FOUND,
285            Json(serde_json::json!({
286                "error": "Time travel not initialized"
287            })),
288        )
289            .into_response(),
290    }
291}
292
293/// List scheduled responses
294pub async fn list_scheduled_responses() -> impl IntoResponse {
295    match get_time_travel_manager() {
296        Some(manager) => {
297            let scheduled = manager.scheduler().list_scheduled();
298            Json(scheduled).into_response()
299        }
300        None => (
301            StatusCode::NOT_FOUND,
302            Json(serde_json::json!({
303                "error": "Time travel not initialized"
304            })),
305        )
306            .into_response(),
307    }
308}
309
310/// Cancel a scheduled response
311pub async fn cancel_scheduled_response(Path(id): Path<String>) -> impl IntoResponse {
312    match get_time_travel_manager() {
313        Some(manager) => {
314            let cancelled = manager.scheduler().cancel(&id);
315
316            if cancelled {
317                info!("Cancelled scheduled response {}", id);
318                Json(serde_json::json!({
319                    "success": true
320                }))
321                .into_response()
322            } else {
323                (
324                    StatusCode::NOT_FOUND,
325                    Json(serde_json::json!({
326                        "error": "Scheduled response not found"
327                    })),
328                )
329                    .into_response()
330            }
331        }
332        None => (
333            StatusCode::NOT_FOUND,
334            Json(serde_json::json!({
335                "error": "Time travel not initialized"
336            })),
337        )
338            .into_response(),
339    }
340}
341
342/// Clear all scheduled responses
343pub async fn clear_scheduled_responses() -> impl IntoResponse {
344    match get_time_travel_manager() {
345        Some(manager) => {
346            manager.scheduler().clear_all();
347            info!("Cleared all scheduled responses");
348
349            Json(serde_json::json!({
350                "success": true
351            }))
352            .into_response()
353        }
354        None => (
355            StatusCode::NOT_FOUND,
356            Json(serde_json::json!({
357                "error": "Time travel not initialized"
358            })),
359        )
360            .into_response(),
361    }
362}
363
364/// Parse a duration string like "2h", "30m", "10s", "1d"
365fn parse_duration(s: &str) -> Result<Duration, String> {
366    let s = s.trim();
367    if s.is_empty() {
368        return Err("Empty duration string".to_string());
369    }
370
371    // Extract number and unit
372    let (num_str, unit) = if let Some(pos) = s.chars().position(|c| !c.is_numeric()) {
373        (&s[..pos], &s[pos..])
374    } else {
375        return Err("No unit specified (use s, m, h, or d)".to_string());
376    };
377
378    let amount: i64 = num_str.parse().map_err(|e| format!("Invalid number: {}", e))?;
379
380    match unit {
381        "s" => Ok(Duration::seconds(amount)),
382        "m" => Ok(Duration::minutes(amount)),
383        "h" => Ok(Duration::hours(amount)),
384        "d" => Ok(Duration::days(amount)),
385        _ => Err(format!("Unknown unit: {}", unit)),
386    }
387}
388
389/// Parse a trigger time (ISO 8601 or relative like "+1h")
390fn parse_trigger_time(s: &str, clock: Arc<VirtualClock>) -> Result<DateTime<Utc>, String> {
391    let s = s.trim();
392
393    // Check if it's a relative time (starts with + or -)
394    if s.starts_with('+') || s.starts_with('-') {
395        let duration = parse_duration(&s[1..])?;
396        let current = clock.now();
397
398        if s.starts_with('+') {
399            Ok(current + duration)
400        } else {
401            Ok(current - duration)
402        }
403    } else {
404        // Parse as ISO 8601
405        DateTime::parse_from_rfc3339(s)
406            .map(|dt| dt.with_timezone(&Utc))
407            .map_err(|e| format!("Invalid ISO 8601 date: {}", e))
408    }
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414
415    #[test]
416    fn test_parse_duration() {
417        assert_eq!(parse_duration("10s").unwrap(), Duration::seconds(10));
418        assert_eq!(parse_duration("30m").unwrap(), Duration::minutes(30));
419        assert_eq!(parse_duration("2h").unwrap(), Duration::hours(2));
420        assert_eq!(parse_duration("1d").unwrap(), Duration::days(1));
421
422        assert!(parse_duration("").is_err());
423        assert!(parse_duration("10").is_err());
424        assert!(parse_duration("10x").is_err());
425    }
426
427    #[test]
428    fn test_parse_trigger_time_relative() {
429        let clock = Arc::new(VirtualClock::new());
430        let now = Utc::now();
431        clock.enable_and_set(now);
432
433        let future = parse_trigger_time("+1h", clock.clone()).unwrap();
434        assert!((future - now - Duration::hours(1)).num_seconds().abs() < 1);
435
436        let past = parse_trigger_time("-30m", clock.clone()).unwrap();
437        assert!((past - now + Duration::minutes(30)).num_seconds().abs() < 1);
438    }
439}