Skip to main content

fastmcp_console/
status.rs

1//! Status/progress output.
2//!
3//! Provides lightweight request status logging for stderr, with rich output
4//! when available and a plain-text fallback in agent contexts.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use fastmcp_console::console::FastMcpConsole;
10//! use fastmcp_console::status::RequestLog;
11//!
12//! let console = FastMcpConsole::new();
13//! let log = RequestLog::new("tools/call", Some("1")).success();
14//! log.render(&console);
15//! ```
16
17use std::time::{Duration, Instant};
18
19use crate::console::FastMcpConsole;
20
21/// Format for displaying request/response activity.
22pub struct RequestLog {
23    method: String,
24    id: Option<String>,
25    start: Instant,
26    status: RequestStatus,
27}
28
29/// Current request status for display.
30pub enum RequestStatus {
31    /// Request is still pending.
32    Pending,
33    /// Request completed successfully.
34    Success(Duration),
35    /// Request failed with a message and duration.
36    Error(String, Duration),
37    /// Request was cancelled with duration.
38    Cancelled(Duration),
39}
40
41impl RequestLog {
42    /// Create a new request log for a method and optional request ID.
43    pub fn new(method: &str, id: Option<&str>) -> Self {
44        Self {
45            method: method.to_string(),
46            id: id.map(String::from),
47            start: Instant::now(),
48            status: RequestStatus::Pending,
49        }
50    }
51
52    /// Mark the request as successful.
53    pub fn success(mut self) -> Self {
54        self.status = RequestStatus::Success(self.start.elapsed());
55        self
56    }
57
58    /// Mark the request as failed with an error message.
59    pub fn error(mut self, msg: &str) -> Self {
60        self.status = RequestStatus::Error(msg.to_string(), self.start.elapsed());
61        self
62    }
63
64    /// Mark the request as cancelled.
65    pub fn cancelled(mut self) -> Self {
66        self.status = RequestStatus::Cancelled(self.start.elapsed());
67        self
68    }
69
70    /// Render the log entry to the console.
71    pub fn render(&self, console: &FastMcpConsole) {
72        if !console.is_rich() {
73            self.render_plain();
74            return;
75        }
76
77        let theme = console.theme();
78        let (icon, style, duration) = match &self.status {
79            RequestStatus::Pending => ("◐", &theme.info_style, None),
80            RequestStatus::Success(d) => ("✓", &theme.success_style, Some(d)),
81            RequestStatus::Error(_, d) => ("✗", &theme.error_style, Some(d)),
82            RequestStatus::Cancelled(d) => ("⊘", &theme.warning_style, Some(d)),
83        };
84
85        let id_str = self
86            .id
87            .as_ref()
88            .map(|id| {
89                format!(
90                    " [{}]#{}[/]",
91                    theme.text_dim.triplet.unwrap_or_default().hex(),
92                    id
93                )
94            })
95            .unwrap_or_default();
96
97        let duration_str = duration
98            .map(|d| {
99                format!(
100                    " [{}]{}[/]",
101                    theme.text_muted.triplet.unwrap_or_default().hex(),
102                    format_duration(*d)
103                )
104            })
105            .unwrap_or_default();
106
107        console.print(&format!(
108            "[{}]{}[/] [{}]{}[/]{}{}",
109            style
110                .color
111                .as_ref()
112                .map(|c| c.triplet.unwrap_or_default().hex())
113                .unwrap_or_default(),
114            icon,
115            theme
116                .key_style
117                .color
118                .as_ref()
119                .map(|c| c.triplet.unwrap_or_default().hex())
120                .unwrap_or_default(),
121            self.method,
122            id_str,
123            duration_str
124        ));
125
126        if let RequestStatus::Error(msg, _) = &self.status {
127            console.print(&format!(
128                "  [{}]└─ {}[/]",
129                theme.error.triplet.unwrap_or_default().hex(),
130                msg
131            ));
132        }
133    }
134
135    fn render_plain(&self) {
136        let (icon, duration) = match &self.status {
137            RequestStatus::Pending => ("...", None),
138            RequestStatus::Success(d) => ("OK", Some(d)),
139            RequestStatus::Error(_, d) => ("ERR", Some(d)),
140            RequestStatus::Cancelled(d) => ("CANCEL", Some(d)),
141        };
142
143        let duration_str = duration
144            .map(|d| format!(" ({})", format_duration(*d)))
145            .unwrap_or_default();
146
147        let id_str = self
148            .id
149            .as_ref()
150            .map(|id| format!(" #{}", id))
151            .unwrap_or_default();
152
153        eprintln!("[{}] {}{}{}", icon, self.method, id_str, duration_str);
154
155        if let RequestStatus::Error(msg, _) = &self.status {
156            eprintln!("  Error: {}", msg);
157        }
158    }
159}
160
161fn format_duration(d: Duration) -> String {
162    if d.as_millis() < 1000 {
163        format!("{}ms", d.as_millis())
164    } else if d.as_secs() < 60 {
165        format!("{:.2}s", d.as_secs_f64())
166    } else {
167        format!("{}m {}s", d.as_secs() / 60, d.as_secs() % 60)
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174    use std::io::Write;
175    use std::sync::{Arc, Mutex};
176
177    use crate::testing::TestConsole;
178
179    #[derive(Clone, Debug)]
180    struct SharedWriter {
181        buf: Arc<Mutex<Vec<u8>>>,
182    }
183
184    impl SharedWriter {
185        fn new() -> (Self, Arc<Mutex<Vec<u8>>>) {
186            let buf = Arc::new(Mutex::new(Vec::new()));
187            (
188                Self {
189                    buf: Arc::clone(&buf),
190                },
191                buf,
192            )
193        }
194    }
195
196    impl Write for SharedWriter {
197        fn write(&mut self, input: &[u8]) -> std::io::Result<usize> {
198            if let Ok(mut guard) = self.buf.lock() {
199                guard.extend_from_slice(input);
200            }
201            Ok(input.len())
202        }
203
204        fn flush(&mut self) -> std::io::Result<()> {
205            Ok(())
206        }
207    }
208
209    #[test]
210    fn format_duration_formats_ms_s_and_minutes() {
211        assert_eq!(format_duration(Duration::from_millis(500)), "500ms");
212        assert_eq!(format_duration(Duration::from_millis(1234)), "1.23s");
213        assert_eq!(format_duration(Duration::from_secs(61)), "1m 1s");
214    }
215
216    #[test]
217    fn request_log_renders_method_and_id() {
218        let tc = TestConsole::new();
219        let log = RequestLog::new("tools/call", Some("1")).success();
220        log.render(tc.console());
221        assert!(tc.contains("tools/call"));
222        assert!(tc.contains("#1"));
223    }
224
225    #[test]
226    fn request_log_error_renders_message() {
227        let tc = TestConsole::new();
228        let log = RequestLog::new("tools/call", Some("1")).error("bad request");
229        log.render(tc.console());
230        assert!(tc.contains("bad request"));
231    }
232
233    #[test]
234    fn request_log_builders_cover_all_status_variants() {
235        let pending = RequestLog::new("tools/call", None);
236        assert!(matches!(pending.status, RequestStatus::Pending));
237
238        let success = RequestLog::new("tools/call", Some("1")).success();
239        assert!(matches!(success.status, RequestStatus::Success(_)));
240
241        let error = RequestLog::new("tools/call", Some("2")).error("oops");
242        assert!(matches!(error.status, RequestStatus::Error(_, _)));
243
244        let cancelled = RequestLog::new("tools/call", Some("3")).cancelled();
245        assert!(matches!(cancelled.status, RequestStatus::Cancelled(_)));
246    }
247
248    #[test]
249    fn request_log_pending_and_cancelled_render_without_id_in_rich_mode() {
250        let (writer, captured) = SharedWriter::new();
251        let console = FastMcpConsole::with_writer(writer, true);
252
253        RequestLog::new("tools/list", None).render(&console);
254        RequestLog::new("tools/list", None)
255            .cancelled()
256            .render(&console);
257
258        let output = String::from_utf8(captured.lock().expect("writer lock poisoned").clone())
259            .unwrap_or_default();
260        assert!(output.contains("tools/list"));
261        assert!(output.contains("◐"));
262        assert!(output.contains("⊘"));
263        assert!(!output.contains('#'));
264    }
265
266    #[test]
267    fn request_log_render_plain_covers_all_statuses() {
268        RequestLog::new("tools/list", None).render_plain();
269        RequestLog::new("tools/list", Some("1"))
270            .success()
271            .render_plain();
272        RequestLog::new("tools/list", Some("2"))
273            .error("bad request")
274            .render_plain();
275        RequestLog::new("tools/list", Some("3"))
276            .cancelled()
277            .render_plain();
278    }
279
280    // =========================================================================
281    // Additional coverage tests (bd-teh7)
282    // =========================================================================
283
284    #[test]
285    fn format_duration_edge_cases() {
286        assert_eq!(format_duration(Duration::from_millis(0)), "0ms");
287        assert_eq!(format_duration(Duration::from_millis(999)), "999ms");
288        // Exactly 1 second
289        assert_eq!(format_duration(Duration::from_secs(1)), "1.00s");
290        // Exactly 60 seconds
291        assert_eq!(format_duration(Duration::from_secs(60)), "1m 0s");
292        // Large duration
293        assert_eq!(format_duration(Duration::from_secs(3661)), "61m 1s");
294    }
295
296    #[test]
297    fn request_log_new_defaults() {
298        let log = RequestLog::new("resources/read", Some("42"));
299        assert_eq!(log.method, "resources/read");
300        assert_eq!(log.id, Some("42".to_string()));
301        assert!(matches!(log.status, RequestStatus::Pending));
302
303        let no_id = RequestLog::new("ping", None);
304        assert!(no_id.id.is_none());
305    }
306
307    #[test]
308    fn render_success_includes_duration_text() {
309        let tc = TestConsole::new();
310        let log = RequestLog::new("tools/call", Some("5")).success();
311        log.render(tc.console());
312        // Duration is always rendered for success
313        let output = tc.output_string();
314        assert!(output.contains("tools/call"));
315        // Duration should be present (some number followed by ms or s)
316        assert!(output.contains("ms") || output.contains('s'));
317    }
318
319    #[test]
320    fn render_cancelled_shows_icon() {
321        let tc = TestConsole::new();
322        let log = RequestLog::new("tools/call", None).cancelled();
323        log.render(tc.console());
324        assert!(tc.output_string().contains("tools/call"));
325    }
326
327    #[test]
328    fn render_routes_to_plain_for_non_rich_console() {
329        let (writer, _buf) = SharedWriter::new();
330        let console = FastMcpConsole::with_writer(writer, false);
331        // Should route to render_plain without panicking
332        RequestLog::new("tools/call", Some("1"))
333            .success()
334            .render(&console);
335        RequestLog::new("tools/call", None)
336            .error("fail")
337            .render(&console);
338    }
339}