1use std::time::{Duration, Instant};
18
19use crate::console::FastMcpConsole;
20
21pub struct RequestLog {
23 method: String,
24 id: Option<String>,
25 start: Instant,
26 status: RequestStatus,
27}
28
29pub enum RequestStatus {
31 Pending,
33 Success(Duration),
35 Error(String, Duration),
37 Cancelled(Duration),
39}
40
41impl RequestLog {
42 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 pub fn success(mut self) -> Self {
54 self.status = RequestStatus::Success(self.start.elapsed());
55 self
56 }
57
58 pub fn error(mut self, msg: &str) -> Self {
60 self.status = RequestStatus::Error(msg.to_string(), self.start.elapsed());
61 self
62 }
63
64 pub fn cancelled(mut self) -> Self {
66 self.status = RequestStatus::Cancelled(self.start.elapsed());
67 self
68 }
69
70 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 #[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 assert_eq!(format_duration(Duration::from_secs(1)), "1.00s");
290 assert_eq!(format_duration(Duration::from_secs(60)), "1m 0s");
292 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 let output = tc.output_string();
314 assert!(output.contains("tools/call"));
315 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 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}