1use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12use std::time::{SystemTime, UNIX_EPOCH};
13use tokio::io::AsyncWriteExt;
14use tokio::sync::mpsc;
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct MetricEvent {
19 pub ts: u64,
20 pub tool: &'static str,
21 pub duration_ms: u64,
22 pub output_chars: usize,
23 pub param_path_depth: usize,
24 pub max_depth: Option<u32>,
25 pub result: &'static str,
26 pub error_type: Option<String>,
27 #[serde(default)]
28 pub session_id: Option<String>,
29 #[serde(default)]
30 pub seq: Option<u32>,
31 #[serde(default)]
32 #[serde(skip_serializing_if = "Option::is_none")]
33 pub cache_hit: Option<bool>,
34}
35
36#[derive(Clone)]
38pub struct MetricsSender(pub tokio::sync::mpsc::UnboundedSender<MetricEvent>);
39
40impl MetricsSender {
41 pub fn send(&self, event: MetricEvent) {
42 let _ = self.0.send(event);
43 }
44}
45
46pub struct MetricsWriter {
48 rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
49 base_dir: PathBuf,
50 dir_created: bool,
51}
52
53impl MetricsWriter {
54 pub fn new(
55 rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
56 base_dir: Option<PathBuf>,
57 ) -> Self {
58 let dir = base_dir.unwrap_or_else(xdg_metrics_dir);
59 Self {
60 rx,
61 base_dir: dir,
62 dir_created: false,
63 }
64 }
65
66 pub async fn run(mut self) {
67 cleanup_old_files(&self.base_dir).await;
68 let mut current_date = current_date_str();
69 let mut current_file: Option<PathBuf> = None;
70
71 loop {
72 let mut batch = Vec::new();
73 if let Some(event) = self.rx.recv().await {
74 batch.push(event);
75 for _ in 0..99 {
76 match self.rx.try_recv() {
77 Ok(e) => batch.push(e),
78 Err(
79 mpsc::error::TryRecvError::Empty
80 | mpsc::error::TryRecvError::Disconnected,
81 ) => break,
82 }
83 }
84 } else {
85 break;
86 }
87
88 let new_date = current_date_str();
89 if new_date != current_date {
90 current_date = new_date;
91 current_file = None;
92 self.dir_created = false;
93 }
94
95 if current_file.is_none() {
96 current_file = Some(
97 self.base_dir
98 .join(format!("metrics-{}.jsonl", current_date)),
99 );
100 }
101
102 let path = current_file.as_ref().unwrap();
103
104 if !self.dir_created
106 && let Some(parent) = path.parent()
107 && !parent.as_os_str().is_empty()
108 {
109 match tokio::fs::create_dir_all(parent).await {
110 Ok(()) => {
111 self.dir_created = true;
112 }
113 Err(e) => {
114 tracing::warn!(
115 error = %e,
116 path = %parent.display(),
117 "metrics: failed to create directory; will retry next batch"
118 );
119 }
120 }
121 }
122
123 let file = tokio::fs::OpenOptions::new()
125 .create(true)
126 .append(true)
127 .open(path)
128 .await;
129
130 if let Ok(mut file) = file {
131 for event in batch {
132 if let Ok(mut json) = serde_json::to_string(&event) {
133 json.push('\n');
134 let _ = file.write_all(json.as_bytes()).await;
135 }
136 }
137 let _ = file.flush().await;
138 }
139 }
140 }
141}
142
143#[must_use]
145pub fn unix_ms() -> u64 {
146 SystemTime::now()
147 .duration_since(UNIX_EPOCH)
148 .unwrap_or_default()
149 .as_millis()
150 .try_into()
151 .unwrap_or(u64::MAX)
152}
153
154#[must_use]
156pub fn path_component_count(path: &str) -> usize {
157 Path::new(path).components().count()
158}
159
160fn xdg_metrics_dir() -> PathBuf {
161 if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME")
162 && !xdg_data_home.is_empty()
163 {
164 return PathBuf::from(xdg_data_home).join("aptu-coder");
165 }
166
167 if let Ok(home) = std::env::var("HOME") {
168 PathBuf::from(home)
169 .join(".local")
170 .join("share")
171 .join("aptu-coder")
172 } else {
173 PathBuf::from(".")
174 }
175}
176
177async fn cleanup_old_files(base_dir: &Path) {
178 let now_days = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
179
180 let Ok(mut entries) = tokio::fs::read_dir(base_dir).await else {
181 return;
182 };
183
184 loop {
185 match entries.next_entry().await {
186 Ok(Some(entry)) => {
187 let path = entry.path();
188 let file_name = match path.file_name() {
189 Some(n) => n.to_string_lossy().into_owned(),
190 None => continue,
191 };
192
193 if !file_name.starts_with("metrics-")
195 || std::path::Path::new(&*file_name)
196 .extension()
197 .is_none_or(|e| !e.eq_ignore_ascii_case("jsonl"))
198 {
199 continue;
200 }
201 let date_part = &file_name[8..file_name.len() - 6];
202 if date_part.len() != 10
203 || date_part.as_bytes().get(4) != Some(&b'-')
204 || date_part.as_bytes().get(7) != Some(&b'-')
205 {
206 continue;
207 }
208 let Ok(year) = date_part[0..4].parse::<u32>() else {
209 continue;
210 };
211 let Ok(month) = date_part[5..7].parse::<u32>() else {
212 continue;
213 };
214 let Ok(day) = date_part[8..10].parse::<u32>() else {
215 continue;
216 };
217 if month == 0 || month > 12 || day == 0 || day > 31 {
218 continue;
219 }
220
221 let file_days = date_to_days_since_epoch(year, month, day);
222 if now_days > file_days && (now_days - file_days) > 30 {
223 let _ = tokio::fs::remove_file(&path).await;
224 }
225 }
226 Ok(None) => break,
227 Err(e) => {
228 tracing::warn!("error reading metrics directory entry: {e}");
229 }
230 }
231 }
232}
233
234fn date_to_days_since_epoch(y: u32, m: u32, d: u32) -> u32 {
235 let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
237 let era = y / 400;
238 let yoe = y - era * 400;
239 let doy = (153 * m + 2) / 5 + d - 1;
240 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
241 (era * 146_097 + doe).saturating_sub(719_468)
245}
246
247#[must_use]
249pub fn current_date_str() -> String {
250 let days = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
251 let z = days + 719_468;
252 let era = z / 146_097;
253 let doe = z - era * 146_097;
254 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
255 let y = yoe + era * 400;
256 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
257 let mp = (5 * doy + 2) / 153;
258 let d = doy - (153 * mp + 2) / 5 + 1;
259 let m = if mp < 10 { mp + 3 } else { mp - 9 };
260 let y = if m <= 2 { y + 1 } else { y };
261 format!("{y:04}-{m:02}-{d:02}")
262}
263
264pub fn migrate_legacy_metrics_dir() -> std::io::Result<()> {
272 let home =
273 std::env::var("HOME").map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?;
274 migrate_legacy_metrics_dir_impl(&home)
275}
276
277#[allow(dead_code)]
278fn migrate_legacy_metrics_dir_impl(home: &str) -> std::io::Result<()> {
279 let old_dir = PathBuf::from(home).join(".local/share/code-analyze-mcp");
280 let new_dir = PathBuf::from(home).join(".local/share/aptu-coder");
281
282 let old_exists = old_dir.is_dir();
283 let new_exists = new_dir.is_dir();
284
285 if old_exists && !new_exists {
286 std::fs::rename(&old_dir, &new_dir)?;
287 tracing::info!(
288 "Migrated legacy metrics directory from {:?} to {:?}",
289 old_dir,
290 new_dir
291 );
292 } else if old_exists && new_exists {
293 tracing::warn!("Both legacy and new metrics directories exist; not migrating");
294 }
295 Ok(())
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use std::fs;
303 use tempfile::TempDir;
304
305 #[test]
306 fn test_migrate_legacy_only_old_exists() {
307 let tmp_home = TempDir::new().unwrap();
309 let home_str = tmp_home.path().to_str().unwrap();
310 let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
311 let new_path = tmp_home.path().join(".local/share/aptu-coder");
312 fs::create_dir_all(&old_path).unwrap();
313 assert!(!new_path.exists());
314
315 let result = migrate_legacy_metrics_dir_impl(home_str);
317
318 assert!(result.is_ok());
320 assert!(!old_path.exists(), "old dir should be moved");
321 assert!(new_path.is_dir(), "new dir should exist");
322 }
323
324 #[test]
325 fn test_migrate_legacy_both_exist() {
326 let tmp_home = TempDir::new().unwrap();
328 let home_str = tmp_home.path().to_str().unwrap();
329 let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
330 let new_path = tmp_home.path().join(".local/share/aptu-coder");
331 fs::create_dir_all(&old_path).unwrap();
332 fs::create_dir_all(&new_path).unwrap();
333
334 let result = migrate_legacy_metrics_dir_impl(home_str);
336
337 assert!(result.is_ok());
339 assert!(old_path.is_dir(), "old dir should remain");
340 assert!(new_path.is_dir(), "new dir should remain");
341 }
342
343 #[test]
344 fn test_migrate_legacy_neither_exists() {
345 let tmp_home = TempDir::new().unwrap();
347 let home_str = tmp_home.path().to_str().unwrap();
348 let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
349 let new_path = tmp_home.path().join(".local/share/aptu-coder");
350
351 let result = migrate_legacy_metrics_dir_impl(home_str);
353
354 assert!(result.is_ok());
356 assert!(!old_path.exists(), "old dir should not exist");
357 assert!(!new_path.exists(), "new dir should not exist");
358 }
359
360 #[test]
361 fn test_date_to_days_since_epoch_known_dates() {
362 assert_eq!(date_to_days_since_epoch(1970, 1, 1), 0);
363 assert_eq!(date_to_days_since_epoch(2020, 1, 1), 18_262);
364 assert_eq!(date_to_days_since_epoch(2000, 2, 29), 11_016);
365 }
366
367 #[test]
368 fn test_current_date_str_format() {
369 let s = current_date_str();
370 assert_eq!(s.len(), 10);
371 assert_eq!(s.as_bytes()[4], b'-');
372 assert_eq!(s.as_bytes()[7], b'-');
373 let year: u32 = s[0..4].parse().expect("year must be numeric");
374 assert!(year >= 2020 && year <= 2100);
375 }
376
377 #[tokio::test]
378 async fn test_metrics_writer_batching() {
379 let dir = TempDir::new().unwrap();
380 let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
381 let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
382 let make_event = || MetricEvent {
383 ts: unix_ms(),
384 tool: "analyze_directory",
385 duration_ms: 1,
386 output_chars: 10,
387 param_path_depth: 1,
388 max_depth: None,
389 result: "ok",
390 error_type: None,
391 session_id: None,
392 seq: None,
393 cache_hit: None,
394 };
395 tx.send(make_event()).unwrap();
396 tx.send(make_event()).unwrap();
397 tx.send(make_event()).unwrap();
398 drop(tx);
399 writer.run().await;
400 let entries: Vec<_> = std::fs::read_dir(dir.path())
401 .unwrap()
402 .filter_map(|e| e.ok())
403 .filter(|e| {
404 e.path()
405 .extension()
406 .and_then(|x| x.to_str())
407 .map(|x| x.eq_ignore_ascii_case("jsonl"))
408 .unwrap_or(false)
409 })
410 .collect();
411 assert_eq!(entries.len(), 1);
412 let content = std::fs::read_to_string(entries[0].path()).unwrap();
413 let lines: Vec<&str> = content.lines().collect();
414 assert_eq!(lines.len(), 3);
415 }
416
417 #[tokio::test]
418 async fn test_cleanup_old_files_deletes_old_keeps_recent() {
419 let dir = TempDir::new().unwrap();
420 let old_file = dir.path().join("metrics-1970-01-01.jsonl");
421 let today = current_date_str();
422 let recent_file = dir.path().join(format!("metrics-{}.jsonl", today));
423 std::fs::write(&old_file, "old\n").unwrap();
424 std::fs::write(&recent_file, "recent\n").unwrap();
425 cleanup_old_files(dir.path()).await;
426 assert!(!old_file.exists());
427 assert!(recent_file.exists());
428 }
429
430 #[test]
431 fn test_metric_event_serialization() {
432 let event = MetricEvent {
433 ts: 1_700_000_000_000,
434 tool: "analyze_directory",
435 duration_ms: 42,
436 output_chars: 100,
437 param_path_depth: 3,
438 max_depth: Some(2),
439 result: "ok",
440 error_type: None,
441 session_id: None,
442 seq: None,
443 cache_hit: None,
444 };
445 let json = serde_json::to_string(&event).unwrap();
446 assert!(json.contains("analyze_directory"));
447 assert!(json.contains(r#""result":"ok""#));
448 assert!(json.contains(r#""output_chars":100"#));
449 }
450
451 #[test]
452 fn test_metric_event_serialization_error() {
453 let event = MetricEvent {
454 ts: 1_700_000_000_000,
455 tool: "analyze_directory",
456 duration_ms: 5,
457 output_chars: 0,
458 param_path_depth: 3,
459 max_depth: Some(3),
460 result: "error",
461 error_type: Some("invalid_params".to_string()),
462 session_id: None,
463 seq: None,
464 cache_hit: None,
465 };
466 let json = serde_json::to_string(&event).unwrap();
467 assert!(json.contains(r#""result":"error""#));
468 assert!(json.contains(r#""error_type":"invalid_params""#));
469 assert!(json.contains(r#""output_chars":0"#));
470 assert!(json.contains(r#""max_depth":3"#));
471 }
472
473 #[test]
474 fn test_metric_event_new_fields_round_trip() {
475 let event = MetricEvent {
476 ts: 1_700_000_000_000,
477 tool: "analyze_file",
478 duration_ms: 100,
479 output_chars: 500,
480 param_path_depth: 2,
481 max_depth: Some(3),
482 result: "ok",
483 error_type: None,
484 session_id: Some("1742468880123-42".to_string()),
485 seq: Some(5),
486 cache_hit: None,
487 };
488 let serialized = serde_json::to_string(&event).unwrap();
489 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}"#;
490 assert_eq!(serialized, json_str);
491 }
492}