1use serde::{Deserialize, Serialize};
9use std::path::{Path, PathBuf};
10use std::time::{SystemTime, UNIX_EPOCH};
11use tokio::io::AsyncWriteExt;
12use tokio::sync::mpsc;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct MetricEvent {
17 pub ts: u64,
18 pub tool: &'static str,
19 pub duration_ms: u64,
20 pub output_chars: usize,
21 pub param_path_depth: usize,
22 pub max_depth: Option<u32>,
23 pub result: &'static str,
24 pub error_type: Option<String>,
25 #[serde(default)]
26 pub session_id: Option<String>,
27 #[serde(default)]
28 pub seq: Option<u32>,
29}
30
31#[derive(Clone)]
33pub struct MetricsSender(pub tokio::sync::mpsc::UnboundedSender<MetricEvent>);
34
35impl MetricsSender {
36 pub fn send(&self, event: MetricEvent) {
37 let _ = self.0.send(event);
38 }
39}
40
41pub struct MetricsWriter {
43 rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
44 base_dir: PathBuf,
45}
46
47impl MetricsWriter {
48 pub fn new(
49 rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
50 base_dir: Option<PathBuf>,
51 ) -> Self {
52 let dir = base_dir.unwrap_or_else(xdg_metrics_dir);
53 cleanup_old_files(&dir);
54 Self { rx, base_dir: dir }
55 }
56
57 pub async fn run(mut self) {
58 let mut current_date = current_date_str();
59 let mut current_file: Option<PathBuf> = None;
60
61 loop {
62 let mut batch = Vec::new();
63 if let Some(event) = self.rx.recv().await {
64 batch.push(event);
65 for _ in 0..99 {
66 match self.rx.try_recv() {
67 Ok(e) => batch.push(e),
68 Err(mpsc::error::TryRecvError::Empty) => break,
69 Err(mpsc::error::TryRecvError::Disconnected) => break,
70 }
71 }
72 } else {
73 break;
74 }
75
76 let new_date = current_date_str();
77 if new_date != current_date {
78 current_date = new_date;
79 current_file = None;
80 }
81
82 if current_file.is_none() {
83 current_file = Some(rotate_path(&self.base_dir, ¤t_date));
84 }
85
86 let path = current_file.as_ref().unwrap();
87
88 if let Some(parent) = path.parent()
90 && !parent.as_os_str().is_empty()
91 {
92 tokio::fs::create_dir_all(parent).await.ok();
93 }
94
95 let file = tokio::fs::OpenOptions::new()
97 .create(true)
98 .append(true)
99 .open(path)
100 .await;
101
102 if let Ok(mut file) = file {
103 for event in batch {
104 if let Ok(json) = serde_json::to_string(&event) {
105 let _ = file.write_all(json.as_bytes()).await;
106 let _ = file.write_all(b"\n").await;
107 }
108 }
109 }
110 }
111 }
112}
113
114pub fn unix_ms() -> u64 {
116 SystemTime::now()
117 .duration_since(UNIX_EPOCH)
118 .unwrap_or_default()
119 .as_millis() as u64
120}
121
122pub fn path_component_count(path: &str) -> usize {
124 Path::new(path).components().count()
125}
126
127pub fn error_code_to_type(code: rmcp::model::ErrorCode) -> &'static str {
129 match code {
130 rmcp::model::ErrorCode::PARSE_ERROR => "parse",
131 rmcp::model::ErrorCode::INVALID_PARAMS => "invalid_params",
132 rmcp::model::ErrorCode::METHOD_NOT_FOUND => "unknown",
133 rmcp::model::ErrorCode::INTERNAL_ERROR => "unknown",
134 _ => "unknown",
135 }
136}
137
138fn xdg_metrics_dir() -> PathBuf {
139 if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME")
140 && !xdg_data_home.is_empty()
141 {
142 return PathBuf::from(xdg_data_home).join("code-analyze-mcp");
143 }
144
145 if let Ok(home) = std::env::var("HOME") {
146 PathBuf::from(home)
147 .join(".local")
148 .join("share")
149 .join("code-analyze-mcp")
150 } else {
151 PathBuf::from(".")
152 }
153}
154
155fn rotate_path(base_dir: &Path, date_str: &str) -> PathBuf {
156 base_dir.join(format!("metrics-{}.jsonl", date_str))
157}
158
159fn cleanup_old_files(base_dir: &Path) {
160 let now_days = (unix_ms() / 86_400_000) as u32;
161
162 let Ok(entries) = std::fs::read_dir(base_dir) else {
163 return;
164 };
165
166 for entry in entries.flatten() {
167 let path = entry.path();
168 let file_name = match path.file_name() {
169 Some(n) => n.to_string_lossy().into_owned(),
170 None => continue,
171 };
172
173 if !file_name.starts_with("metrics-") || !file_name.ends_with(".jsonl") {
175 continue;
176 }
177 let date_part = &file_name[8..file_name.len() - 6];
178 if date_part.len() != 10
179 || date_part.as_bytes().get(4) != Some(&b'-')
180 || date_part.as_bytes().get(7) != Some(&b'-')
181 {
182 continue;
183 }
184 let Ok(year) = date_part[0..4].parse::<u32>() else {
185 continue;
186 };
187 let Ok(month) = date_part[5..7].parse::<u32>() else {
188 continue;
189 };
190 let Ok(day) = date_part[8..10].parse::<u32>() else {
191 continue;
192 };
193 if month == 0 || month > 12 || day == 0 || day > 31 {
194 continue;
195 }
196
197 let file_days = date_to_days_since_epoch(year, month, day);
198 if now_days > file_days && (now_days - file_days) > 30 {
199 let _ = std::fs::remove_file(&path);
200 }
201 }
202}
203
204fn date_to_days_since_epoch(y: u32, m: u32, d: u32) -> u32 {
205 let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
207 let era = y / 400;
208 let yoe = y - era * 400;
209 let doy = (153 * m + 2) / 5 + d - 1;
210 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
211 era * 146_097
212 + doe .saturating_sub(719_468) }
215
216pub fn current_date_str() -> String {
218 let days = (unix_ms() / 86_400_000) as u32;
219 let z = days + 719_468;
220 let era = z / 146_097;
221 let doe = z - era * 146_097;
222 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
223 let y = yoe + era * 400;
224 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
225 let mp = (5 * doy + 2) / 153;
226 let d = doy - (153 * mp + 2) / 5 + 1;
227 let m = if mp < 10 { mp + 3 } else { mp - 9 };
228 let y = if m <= 2 { y + 1 } else { y };
229 format!("{:04}-{:02}-{:02}", y, m, d)
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 #[test]
237 fn test_metric_event_serialization() {
238 let event = MetricEvent {
239 ts: 1_700_000_000_000,
240 tool: "analyze_directory",
241 duration_ms: 42,
242 output_chars: 100,
243 param_path_depth: 3,
244 max_depth: Some(2),
245 result: "ok",
246 error_type: None,
247 session_id: None,
248 seq: None,
249 };
250 let json = serde_json::to_string(&event).unwrap();
251 assert!(json.contains("analyze_directory"));
252 assert!(json.contains(r#""result":"ok""#));
253 assert!(json.contains(r#""output_chars":100"#));
254 }
255
256 #[test]
257 fn test_metric_event_serialization_error() {
258 let event = MetricEvent {
259 ts: 1_700_000_000_000,
260 tool: "analyze_directory",
261 duration_ms: 5,
262 output_chars: 0,
263 param_path_depth: 3,
264 max_depth: Some(3),
265 result: "error",
266 error_type: Some("invalid_params".to_string()),
267 session_id: None,
268 seq: None,
269 };
270 let json = serde_json::to_string(&event).unwrap();
271 assert!(json.contains(r#""result":"error""#));
272 assert!(json.contains(r#""error_type":"invalid_params""#));
273 assert!(json.contains(r#""output_chars":0"#));
274 assert!(json.contains(r#""max_depth":3"#));
275 }
276
277 #[test]
278 fn test_metric_event_new_fields_round_trip() {
279 let event = MetricEvent {
280 ts: 1_700_000_000_000,
281 tool: "analyze_file",
282 duration_ms: 100,
283 output_chars: 500,
284 param_path_depth: 2,
285 max_depth: Some(3),
286 result: "ok",
287 error_type: None,
288 session_id: Some("1742468880123-42".to_string()),
289 seq: Some(5),
290 };
291 let serialized = serde_json::to_string(&event).unwrap();
292 let json_str = r#"{"ts":1700000000000,"tool":"analyze_file","duration_ms":100,"output_chars":500,"param_path_depth":2,"max_depth":3,"result":"ok","error_type":null,"session_id":"1742468880123-42","seq":5}"#;
293 assert_eq!(serialized, json_str);
294 let parsed: MetricEvent = serde_json::from_str(json_str).unwrap();
295 assert_eq!(parsed.session_id, Some("1742468880123-42".to_string()));
296 assert_eq!(parsed.seq, Some(5));
297 }
298
299 #[test]
300 fn test_metric_event_backward_compat_parse() {
301 let old_jsonl = r#"{"ts":1700000000000,"tool":"analyze_directory","duration_ms":42,"output_chars":100,"param_path_depth":3,"max_depth":2,"result":"ok","error_type":null}"#;
302 let parsed: MetricEvent = serde_json::from_str(old_jsonl).unwrap();
303 assert_eq!(parsed.tool, "analyze_directory");
304 assert_eq!(parsed.session_id, None);
305 assert_eq!(parsed.seq, None);
306 }
307
308 #[test]
309 fn test_session_id_format() {
310 let event = MetricEvent {
311 ts: 1_700_000_000_000,
312 tool: "analyze_symbol",
313 duration_ms: 20,
314 output_chars: 50,
315 param_path_depth: 1,
316 max_depth: None,
317 result: "ok",
318 error_type: None,
319 session_id: Some("1742468880123-0".to_string()),
320 seq: Some(0),
321 };
322 let sid = event.session_id.unwrap();
323 assert!(sid.contains('-'), "session_id should contain a dash");
324 let parts: Vec<&str> = sid.split('-').collect();
325 assert_eq!(parts.len(), 2, "session_id should have exactly 2 parts");
326 assert!(parts[0].len() == 13, "millis part should be 13 digits");
327 }
328}