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