Skip to main content

code_analyze_mcp/
metrics.rs

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}
18
19#[derive(Clone)]
20pub struct MetricsSender(pub tokio::sync::mpsc::UnboundedSender<MetricEvent>);
21
22impl MetricsSender {
23    pub fn send(&self, event: MetricEvent) {
24        let _ = self.0.send(event);
25    }
26}
27
28pub struct MetricsWriter {
29    rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
30    base_dir: PathBuf,
31}
32
33impl MetricsWriter {
34    pub fn new(
35        rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
36        base_dir: Option<PathBuf>,
37    ) -> Self {
38        let dir = base_dir.unwrap_or_else(xdg_metrics_dir);
39        cleanup_old_files(&dir);
40        Self { rx, base_dir: dir }
41    }
42
43    pub async fn run(mut self) {
44        let mut current_date = current_date_str();
45        let mut current_file: Option<PathBuf> = None;
46
47        loop {
48            let mut batch = Vec::new();
49            if let Some(event) = self.rx.recv().await {
50                batch.push(event);
51                for _ in 0..99 {
52                    match self.rx.try_recv() {
53                        Ok(e) => batch.push(e),
54                        Err(mpsc::error::TryRecvError::Empty) => break,
55                        Err(mpsc::error::TryRecvError::Disconnected) => break,
56                    }
57                }
58            } else {
59                break;
60            }
61
62            let new_date = current_date_str();
63            if new_date != current_date {
64                current_date = new_date;
65                current_file = None;
66            }
67
68            if current_file.is_none() {
69                current_file = Some(rotate_path(&self.base_dir, &current_date));
70            }
71
72            let path = current_file.as_ref().unwrap();
73
74            // Create directory once per batch
75            if let Some(parent) = path.parent()
76                && !parent.as_os_str().is_empty()
77            {
78                tokio::fs::create_dir_all(parent).await.ok();
79            }
80
81            // Open file once per batch
82            let file = tokio::fs::OpenOptions::new()
83                .create(true)
84                .append(true)
85                .open(path)
86                .await;
87
88            if let Ok(mut file) = file {
89                for event in batch {
90                    if let Ok(json) = serde_json::to_string(&event) {
91                        let _ = file.write_all(json.as_bytes()).await;
92                        let _ = file.write_all(b"\n").await;
93                    }
94                }
95            }
96        }
97    }
98}
99
100pub fn unix_ms() -> u64 {
101    SystemTime::now()
102        .duration_since(UNIX_EPOCH)
103        .unwrap_or_default()
104        .as_millis() as u64
105}
106
107pub fn path_component_count(path: &str) -> usize {
108    Path::new(path).components().count()
109}
110
111pub fn error_code_to_type(code: rmcp::model::ErrorCode) -> &'static str {
112    match code {
113        rmcp::model::ErrorCode::PARSE_ERROR => "parse",
114        rmcp::model::ErrorCode::INVALID_PARAMS => "invalid_params",
115        rmcp::model::ErrorCode::METHOD_NOT_FOUND => "unknown",
116        rmcp::model::ErrorCode::INTERNAL_ERROR => "unknown",
117        _ => "unknown",
118    }
119}
120
121fn xdg_metrics_dir() -> PathBuf {
122    if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME")
123        && !xdg_data_home.is_empty()
124    {
125        return PathBuf::from(xdg_data_home).join("code-analyze-mcp");
126    }
127
128    if let Ok(home) = std::env::var("HOME") {
129        PathBuf::from(home)
130            .join(".local")
131            .join("share")
132            .join("code-analyze-mcp")
133    } else {
134        PathBuf::from(".")
135    }
136}
137
138fn rotate_path(base_dir: &Path, date_str: &str) -> PathBuf {
139    base_dir.join(format!("metrics-{}.jsonl", date_str))
140}
141
142fn cleanup_old_files(base_dir: &Path) {
143    let now_days = (unix_ms() / 86_400_000) as u32;
144
145    let Ok(entries) = std::fs::read_dir(base_dir) else {
146        return;
147    };
148
149    for entry in entries.flatten() {
150        let path = entry.path();
151        let file_name = match path.file_name() {
152            Some(n) => n.to_string_lossy().into_owned(),
153            None => continue,
154        };
155
156        // Expected format: metrics-YYYY-MM-DD.jsonl
157        if !file_name.starts_with("metrics-") || !file_name.ends_with(".jsonl") {
158            continue;
159        }
160        let date_part = &file_name[8..file_name.len() - 6];
161        if date_part.len() != 10
162            || date_part.as_bytes().get(4) != Some(&b'-')
163            || date_part.as_bytes().get(7) != Some(&b'-')
164        {
165            continue;
166        }
167        let Ok(year) = date_part[0..4].parse::<u32>() else {
168            continue;
169        };
170        let Ok(month) = date_part[5..7].parse::<u32>() else {
171            continue;
172        };
173        let Ok(day) = date_part[8..10].parse::<u32>() else {
174            continue;
175        };
176        if month == 0 || month > 12 || day == 0 || day > 31 {
177            continue;
178        }
179
180        let file_days = date_to_days_since_epoch(year, month, day);
181        if now_days > file_days && (now_days - file_days) > 30 {
182            let _ = std::fs::remove_file(&path);
183        }
184    }
185}
186
187fn date_to_days_since_epoch(y: u32, m: u32, d: u32) -> u32 {
188    // Shift year so March is month 0
189    let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
190    let era = y / 400;
191    let yoe = y - era * 400;
192    let doy = (153 * m + 2) / 5 + d - 1;
193    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
194    era * 146_097
195        + doe // this is the Proleptic Gregorian day number
196            .saturating_sub(719_468) // subtract the epoch offset to get days since 1970-01-01
197}
198
199pub fn current_date_str() -> String {
200    let days = (unix_ms() / 86_400_000) as u32;
201    let z = days + 719_468;
202    let era = z / 146_097;
203    let doe = z - era * 146_097;
204    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
205    let y = yoe + era * 400;
206    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
207    let mp = (5 * doy + 2) / 153;
208    let d = doy - (153 * mp + 2) / 5 + 1;
209    let m = if mp < 10 { mp + 3 } else { mp - 9 };
210    let y = if m <= 2 { y + 1 } else { y };
211    format!("{:04}-{:02}-{:02}", y, m, d)
212}
213
214#[cfg(test)]
215mod tests {
216    use super::*;
217
218    #[test]
219    fn test_metric_event_serialization() {
220        let event = MetricEvent {
221            ts: 1_700_000_000_000,
222            tool: "analyze_directory",
223            duration_ms: 42,
224            output_chars: 100,
225            param_path_depth: 3,
226            max_depth: Some(2),
227            result: "ok",
228            error_type: None,
229        };
230        let json = serde_json::to_string(&event).unwrap();
231        assert!(json.contains("analyze_directory"));
232        assert!(json.contains(r#""result":"ok""#));
233        assert!(json.contains(r#""output_chars":100"#));
234    }
235
236    #[test]
237    fn test_metric_event_serialization_error() {
238        let event = MetricEvent {
239            ts: 1_700_000_000_000,
240            tool: "analyze_directory",
241            duration_ms: 5,
242            output_chars: 0,
243            param_path_depth: 3,
244            max_depth: Some(3),
245            result: "error",
246            error_type: Some("invalid_params".to_string()),
247        };
248        let json = serde_json::to_string(&event).unwrap();
249        assert!(json.contains(r#""result":"error""#));
250        assert!(json.contains(r#""error_type":"invalid_params""#));
251        assert!(json.contains(r#""output_chars":0"#));
252        assert!(json.contains(r#""max_depth":3"#));
253    }
254}