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