Skip to main content

aptu_coder/
metrics_export.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Metrics file I/O: JSONL writing, rotation, cleanup, migration.
4//!
5//! Contains [`MetricsWriter`], the receiver half of the metrics channel that
6//! drains events and appends them to daily-rotated JSONL files under the XDG
7//! data directory. Also provides helper functions for file-level concerns:
8//! path analysis, date arithmetic, legacy migration, and old-file cleanup.
9
10use crate::metrics::{MetricEvent, MetricsLockGuard, ToolMetrics, record_otel_metrics};
11use aptu_coder_core::lang::language_for_extension;
12use fs2::FileExt;
13use std::path::{Path, PathBuf};
14use std::time::{SystemTime, UNIX_EPOCH};
15use tokio::io::AsyncWriteExt;
16use tokio::sync::mpsc;
17
18/// Receiver half of the metrics channel; drains events and writes them to daily-rotated JSONL files.
19pub struct MetricsWriter {
20    rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
21    base_dir: PathBuf,
22    dir_created: bool,
23}
24
25impl MetricsWriter {
26    pub fn new(
27        rx: tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
28        base_dir: Option<PathBuf>,
29    ) -> Self {
30        let dir = base_dir.unwrap_or_else(xdg_metrics_dir);
31        Self {
32            rx,
33            base_dir: dir,
34            dir_created: false,
35        }
36    }
37
38    /// Accumulate per-tool event counts for session summary export on shutdown.
39    fn accumulate_event(
40        tool_counts: &mut std::collections::HashMap<&'static str, ToolMetrics>,
41        export_session_id: &mut Option<String>,
42        event: &MetricEvent,
43    ) {
44        let entry = tool_counts.entry(event.tool).or_default();
45        entry.count += 1;
46        entry.duration_ms += event.duration_ms;
47        // output_chars is capped at 50 KB per stream (stdout + stderr each), so usize -> u64 is lossless.
48        entry.output_chars += event.output_chars as u64;
49        if export_session_id.is_none() {
50            *export_session_id = event.session_id.clone();
51        }
52    }
53
54    /// Write accumulated batch to file. Fire-and-forget semantics: errors are logged but not propagated.
55    /// Acquires an exclusive advisory lock on a sibling .lock file before writing
56    /// to prevent interleaving from concurrent processes writing to the same JSONL file.
57    /// Lock acquisition failures degrade gracefully (warn and continue) per the
58    /// non-blocking observability contract.
59    async fn flush_batch(file: &mut tokio::fs::File, path: &Path, batch: Vec<MetricEvent>) {
60        // Best-effort exclusive lock on sibling .lock file
61        let _lock_guard = Self::acquire_metrics_lock(path).await;
62
63        for event in batch {
64            // Record to OTel metrics if available
65            record_otel_metrics(&event);
66
67            // Always write to JSONL as fallback
68            if let Ok(mut json) = serde_json::to_string(&event) {
69                json.push('\n');
70                let _ = file.write_all(json.as_bytes()).await;
71            }
72        }
73        let _ = file.flush().await;
74    }
75
76    /// Acquire an exclusive lock on a sibling .lock file for the metrics JSONL file.
77    /// Returns a guard that releases the lock when dropped.
78    /// On failure, logs a warning and returns None (degrade gracefully).
79    async fn acquire_metrics_lock(path: &Path) -> Option<MetricsLockGuard> {
80        let lock_path = format!("{}.lock", path.display());
81        let file = match std::fs::OpenOptions::new()
82            .create(true)
83            .write(true)
84            .truncate(false)
85            .open(&lock_path)
86        {
87            Ok(f) => f,
88            Err(e) => {
89                tracing::warn!(
90                    error = %e,
91                    lock_path = %lock_path,
92                    "metrics: failed to open lock file; proceeding without lock"
93                );
94                return None;
95            }
96        };
97        let result = tokio::task::spawn_blocking(move || file.lock_exclusive().map(|_| file)).await;
98        match result {
99            Ok(Ok(locked)) => Some(MetricsLockGuard(locked)),
100            Ok(Err(e)) => {
101                tracing::warn!(
102                    error = %e,
103                    "metrics: failed to acquire exclusive lock; proceeding without lock"
104                );
105                None
106            }
107            Err(e) => {
108                tracing::warn!(
109                    error = %e,
110                    "metrics: spawn_blocking panicked acquiring lock; proceeding without lock"
111                );
112                None
113            }
114        }
115    }
116
117    /// Check for date transition and rotate metrics file if needed.
118    /// Returns the current file path and updates state if rotation occurred.
119    fn rotate_metrics_file(
120        base_dir: &std::path::Path,
121        current_date: &mut String,
122        current_file: &mut Option<PathBuf>,
123        dir_created: &mut bool,
124    ) -> PathBuf {
125        let new_date = current_date_str();
126        if new_date != *current_date {
127            *current_date = new_date;
128            *current_file = None;
129            *dir_created = false;
130        }
131
132        current_file
133            .get_or_insert_with(|| base_dir.join(format!("metrics-{}.jsonl", current_date)))
134            .clone()
135    }
136
137    /// Receive and accumulate a batch of events from the channel.
138    async fn receive_batch(
139        rx: &mut tokio::sync::mpsc::UnboundedReceiver<MetricEvent>,
140        tool_counts: &mut std::collections::HashMap<&'static str, ToolMetrics>,
141        export_session_id: &mut Option<String>,
142    ) -> Option<Vec<MetricEvent>> {
143        let mut batch = Vec::new();
144        if let Some(event) = rx.recv().await {
145            Self::accumulate_event(tool_counts, export_session_id, &event);
146            batch.push(event);
147            for _ in 0..99 {
148                match rx.try_recv() {
149                    Ok(e) => {
150                        Self::accumulate_event(tool_counts, export_session_id, &e);
151                        batch.push(e);
152                    }
153                    Err(
154                        mpsc::error::TryRecvError::Empty | mpsc::error::TryRecvError::Disconnected,
155                    ) => break,
156                }
157            }
158            Some(batch)
159        } else {
160            None
161        }
162    }
163
164    /// Ensure metrics directory exists for the given path.
165    async fn ensure_metrics_dir(path: &std::path::Path, dir_created: &mut bool) {
166        if !*dir_created
167            && let Some(parent) = path.parent()
168            && !parent.as_os_str().is_empty()
169        {
170            match tokio::fs::create_dir_all(parent).await {
171                Ok(()) => {
172                    *dir_created = true;
173                }
174                Err(e) => {
175                    tracing::warn!(
176                        error = %e,
177                        path = %parent.display(),
178                        "metrics: failed to create directory; will retry next batch"
179                    );
180                }
181            }
182        }
183    }
184
185    pub async fn run(mut self) {
186        cleanup_old_files(&self.base_dir).await;
187        let mut current_date = current_date_str();
188        let mut current_file: Option<PathBuf> = None;
189
190        // Accumulate per-tool metrics for export on shutdown (issue #773)
191        let mut tool_counts: std::collections::HashMap<&'static str, ToolMetrics> =
192            std::collections::HashMap::new();
193        let mut export_session_id: Option<String> = None;
194
195        loop {
196            let Some(batch) =
197                Self::receive_batch(&mut self.rx, &mut tool_counts, &mut export_session_id).await
198            else {
199                break;
200            };
201
202            let path = Self::rotate_metrics_file(
203                &self.base_dir,
204                &mut current_date,
205                &mut current_file,
206                &mut self.dir_created,
207            );
208
209            Self::ensure_metrics_dir(&path, &mut self.dir_created).await;
210
211            // Open file once per batch
212            let file = tokio::fs::OpenOptions::new()
213                .create(true)
214                .append(true)
215                .open(&path)
216                .await;
217
218            if let Ok(mut file) = file {
219                Self::flush_batch(&mut file, &path, batch).await;
220            }
221        }
222
223        // Export metrics summary on shutdown (issue #773)
224        if let Ok(export_path) = std::env::var("APTU_CODER_METRICS_EXPORT_FILE") {
225            if !std::path::Path::new(&export_path).is_absolute() {
226                tracing::warn!(
227                    path = %export_path,
228                    "metrics: APTU_CODER_METRICS_EXPORT_FILE must be an absolute path; skipping export"
229                );
230            } else {
231                let mut tool_calls = Vec::new();
232                let mut total_duration_ms = 0u64;
233                let mut total_output_chars_sum = 0u64;
234                // Sort by tool name for deterministic JSON output
235                let mut sorted_tools: Vec<_> = tool_counts.iter().collect();
236                sorted_tools.sort_by_key(|&(name, _)| name);
237                for (tool_name, metrics) in sorted_tools {
238                    tool_calls.push(serde_json::json!({
239                        "tool": tool_name,
240                        "call_count": metrics.count,
241                        "total_duration_ms": metrics.duration_ms,
242                        "total_output_chars": metrics.output_chars
243                    }));
244                    total_duration_ms += metrics.duration_ms;
245                    total_output_chars_sum += metrics.output_chars;
246                }
247                let summary = serde_json::json!({
248                    "session_id": export_session_id.unwrap_or_default(),
249                    "tool_calls": tool_calls,
250                    "total_duration_ms": total_duration_ms,
251                    "total_output_chars": total_output_chars_sum
252                });
253                if let Ok(json_str) = serde_json::to_string(&summary)
254                    && let Err(e) = tokio::fs::write(&export_path, json_str).await
255                {
256                    tracing::warn!(
257                        error = %e,
258                        path = %export_path,
259                        "metrics: failed to write export file"
260                    );
261                }
262            }
263        }
264    }
265}
266
267/// Returns the current UNIX timestamp in milliseconds.
268#[must_use]
269pub(crate) fn unix_ms() -> u64 {
270    SystemTime::now()
271        .duration_since(UNIX_EPOCH)
272        .unwrap_or_default()
273        .as_millis()
274        .try_into()
275        .unwrap_or(u64::MAX)
276}
277
278/// Counts the number of path segments in a file path.
279#[must_use]
280pub(crate) fn path_component_count(path: &str) -> usize {
281    Path::new(path).components().count()
282}
283
284/// Return the file extension for a path, normalized to lowercase.
285///
286/// - Returns `Some("rs")` for `src/main.rs`.
287/// - Returns `Some("other")` for unrecognized extensions (not in the supported list).
288/// - Returns `None` for paths with no extension or an empty extension.
289#[must_use]
290pub(crate) fn path_file_ext(file_path: &str) -> Option<&'static str> {
291    let ext_os = Path::new(file_path).extension()?;
292    let ext_str = ext_os.to_str()?;
293    if ext_str.is_empty() {
294        return None;
295    }
296    // language_for_extension does case-insensitive lookup; if found, return the
297    // canonical (lowercased) extension key from EXTENSION_MAP via supported_extensions().
298    if language_for_extension(ext_str).is_some() {
299        aptu_coder_core::lang::supported_extensions()
300            .into_iter()
301            .find(|e| e.eq_ignore_ascii_case(ext_str))
302    } else {
303        Some("other")
304    }
305}
306
307/// Derive a human-readable language name from a file path.
308///
309/// - Returns `Some("Rust")` for paths with a recognized extension.
310/// - Returns `None` for paths with no extension or an unrecognized extension.
311#[must_use]
312pub(crate) fn path_language(path: &str) -> Option<String> {
313    let ext_os = Path::new(path).extension()?;
314    let ext_str = ext_os.to_str()?;
315    if ext_str.is_empty() {
316        return None;
317    }
318    language_for_extension(ext_str).map(std::borrow::ToOwned::to_owned)
319}
320
321fn xdg_metrics_dir() -> PathBuf {
322    if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME")
323        && !xdg_data_home.is_empty()
324    {
325        return PathBuf::from(xdg_data_home).join("aptu-coder");
326    }
327
328    if let Ok(home) = std::env::var("HOME") {
329        PathBuf::from(home)
330            .join(".local")
331            .join("share")
332            .join("aptu-coder")
333    } else {
334        PathBuf::from(".")
335    }
336}
337
338async fn cleanup_old_files(base_dir: &Path) {
339    let now_days = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
340
341    let Ok(mut entries) = tokio::fs::read_dir(base_dir).await else {
342        return;
343    };
344
345    loop {
346        match entries.next_entry().await {
347            Ok(Some(entry)) => {
348                let path = entry.path();
349                let file_name = match path.file_name() {
350                    Some(n) => n.to_string_lossy().into_owned(),
351                    None => continue,
352                };
353
354                // Expected format: metrics-YYYY-MM-DD.jsonl
355                if !file_name.starts_with("metrics-")
356                    || std::path::Path::new(&*file_name)
357                        .extension()
358                        .is_none_or(|e| !e.eq_ignore_ascii_case("jsonl"))
359                {
360                    continue;
361                }
362                let date_part = &file_name[8..file_name.len() - 6];
363                if date_part.len() != 10
364                    || date_part.as_bytes().get(4) != Some(&b'-')
365                    || date_part.as_bytes().get(7) != Some(&b'-')
366                {
367                    continue;
368                }
369                let Ok(year) = date_part[0..4].parse::<u32>() else {
370                    continue;
371                };
372                let Ok(month) = date_part[5..7].parse::<u32>() else {
373                    continue;
374                };
375                let Ok(day) = date_part[8..10].parse::<u32>() else {
376                    continue;
377                };
378                if month == 0 || month > 12 || day == 0 || day > 31 {
379                    continue;
380                }
381
382                let file_days = date_to_days_since_epoch(year, month, day);
383                if now_days > file_days && (now_days - file_days) > 30 {
384                    let _ = tokio::fs::remove_file(&path).await;
385                    // Remove the sibling lock file created by acquire_metrics_lock.
386                    let lock_path = format!("{}.lock", path.display());
387                    let _ = tokio::fs::remove_file(&lock_path).await;
388                }
389            }
390            Ok(None) => break,
391            Err(e) => {
392                tracing::warn!("error reading metrics directory entry: {e}");
393            }
394        }
395    }
396}
397
398fn date_to_days_since_epoch(y: u32, m: u32, d: u32) -> u32 {
399    // Shift year so March is month 0
400    let (y, m) = if m <= 2 { (y - 1, m + 9) } else { (y, m - 3) };
401    let era = y / 400;
402    let yoe = y - era * 400;
403    let doy = (153 * m + 2) / 5 + d - 1;
404    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
405    // Compute the proleptic Gregorian day number, then subtract the Unix epoch offset.
406    // The subtraction must wrap the full expression; applying .saturating_sub to `doe`
407    // alone would underflow for recent dates where doe < 719_468.
408    (era * 146_097 + doe).saturating_sub(719_468)
409}
410
411/// Returns the current UTC date as a string in YYYY-MM-DD format.
412#[must_use]
413pub(crate) fn current_date_str() -> String {
414    let days = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
415    let z = days + 719_468;
416    let era = z / 146_097;
417    let doe = z - era * 146_097;
418    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
419    let y = yoe + era * 400;
420    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
421    let mp = (5 * doy + 2) / 153;
422    let d = doy - (153 * mp + 2) / 5 + 1;
423    let m = if mp < 10 { mp + 3 } else { mp - 9 };
424    let y = if m <= 2 { y + 1 } else { y };
425    format!("{y:04}-{m:02}-{d:02}")
426}
427
428/// Migrate legacy metrics directory from `code-analyze-mcp` to `aptu-coder`.
429///
430/// - If the old directory exists and the new one does not, rename it and log info.
431/// - If both exist, log a warning and do nothing.
432/// - If neither exists, do nothing.
433///
434/// Returns `Ok(())` on success, propagating any I/O errors.
435pub fn migrate_legacy_metrics_dir() -> std::io::Result<()> {
436    let home =
437        std::env::var("HOME").map_err(|e| std::io::Error::new(std::io::ErrorKind::NotFound, e))?;
438    migrate_legacy_metrics_dir_impl(&home)
439}
440
441#[allow(dead_code)]
442fn migrate_legacy_metrics_dir_impl(home: &str) -> std::io::Result<()> {
443    let old_dir = PathBuf::from(home).join(".local/share/code-analyze-mcp");
444    let new_dir = PathBuf::from(home).join(".local/share/aptu-coder");
445
446    let old_exists = old_dir.is_dir();
447    let new_exists = new_dir.is_dir();
448
449    if old_exists && !new_exists {
450        std::fs::rename(&old_dir, &new_dir)?;
451        tracing::info!(
452            "Migrated legacy metrics directory from {:?} to {:?}",
453            old_dir,
454            new_dir
455        );
456    } else if old_exists && new_exists {
457        tracing::warn!("Both legacy and new metrics directories exist; not migrating");
458    }
459    // If old does not exist, nothing to do.
460    Ok(())
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466    use std::fs;
467    use tempfile::TempDir;
468
469    /// Serializes tests that mutate `APTU_CODER_METRICS_EXPORT_FILE` to prevent parallel
470    /// pollution.
471    async fn metrics_export_lock() -> tokio::sync::MutexGuard<'static, ()> {
472        static LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
473        LOCK.lock().await
474    }
475
476    #[test]
477    fn test_migrate_legacy_only_old_exists() {
478        // Arrange
479        let tmp_home = TempDir::new().unwrap();
480        let home_str = tmp_home.path().to_str().unwrap();
481        let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
482        let new_path = tmp_home.path().join(".local/share/aptu-coder");
483        fs::create_dir_all(&old_path).unwrap();
484        assert!(!new_path.exists());
485
486        // Act
487        let result = migrate_legacy_metrics_dir_impl(home_str);
488
489        // Assert
490        assert!(result.is_ok());
491        assert!(!old_path.exists(), "old dir should be moved");
492        assert!(new_path.is_dir(), "new dir should exist");
493    }
494
495    #[test]
496    fn test_migrate_legacy_both_exist() {
497        // Arrange
498        let tmp_home = TempDir::new().unwrap();
499        let home_str = tmp_home.path().to_str().unwrap();
500        let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
501        let new_path = tmp_home.path().join(".local/share/aptu-coder");
502        fs::create_dir_all(&old_path).unwrap();
503        fs::create_dir_all(&new_path).unwrap();
504
505        // Act
506        let result = migrate_legacy_metrics_dir_impl(home_str);
507
508        // Assert
509        assert!(result.is_ok());
510        assert!(old_path.is_dir(), "old dir should remain");
511        assert!(new_path.is_dir(), "new dir should remain");
512    }
513
514    #[test]
515    fn test_migrate_legacy_neither_exists() {
516        // Arrange
517        let tmp_home = TempDir::new().unwrap();
518        let home_str = tmp_home.path().to_str().unwrap();
519        let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
520        let new_path = tmp_home.path().join(".local/share/aptu-coder");
521
522        // Act
523        let result = migrate_legacy_metrics_dir_impl(home_str);
524
525        // Assert
526        assert!(result.is_ok());
527        assert!(!old_path.exists(), "old dir should not exist");
528        assert!(!new_path.exists(), "new dir should not exist");
529    }
530
531    #[test]
532    fn test_date_to_days_since_epoch_known_dates() {
533        assert_eq!(date_to_days_since_epoch(1970, 1, 1), 0);
534        assert_eq!(date_to_days_since_epoch(2020, 1, 1), 18_262);
535        assert_eq!(date_to_days_since_epoch(2000, 2, 29), 11_016);
536    }
537
538    #[test]
539    fn test_date_to_days_since_epoch_edge_cases() {
540        // Year boundary: last day of year -> first day of next year
541        assert_eq!(date_to_days_since_epoch(1970, 12, 31), 364);
542        assert_eq!(date_to_days_since_epoch(1971, 1, 1), 365);
543        assert_eq!(date_to_days_since_epoch(2023, 12, 31), 19_722);
544        assert_eq!(date_to_days_since_epoch(2024, 1, 1), 19_723);
545
546        // Month transition: Jan->Feb and Feb->Mar
547        assert_eq!(date_to_days_since_epoch(2023, 1, 31), 19_388);
548        assert_eq!(date_to_days_since_epoch(2023, 2, 1), 19_389);
549
550        // Leap year 2024 (divisible by 4, not by 100): Feb 28 -> Feb 29 -> Mar 1
551        assert_eq!(date_to_days_since_epoch(2024, 2, 28), 19_781);
552        assert_eq!(date_to_days_since_epoch(2024, 2, 29), 19_782);
553        assert_eq!(date_to_days_since_epoch(2024, 3, 1), 19_783);
554
555        // Leap year 2000 (divisible by 400): Feb 28 -> Feb 29 -> Mar 1
556        assert_eq!(date_to_days_since_epoch(2000, 2, 28), 11_015);
557        assert_eq!(date_to_days_since_epoch(2000, 3, 1), 11_017);
558
559        // Non-leap century 2100 (divisible by 100 but not 400): no Feb 29
560        assert_eq!(date_to_days_since_epoch(2100, 2, 28), 47_540);
561        assert_eq!(date_to_days_since_epoch(2100, 3, 1), 47_541);
562    }
563
564    #[test]
565    fn test_current_date_str_roundtrip() {
566        // current_date_str() and date_to_days_since_epoch() must agree on today.
567        let s = current_date_str();
568        let year: u32 = s[0..4].parse().expect("year numeric");
569        let month: u32 = s[5..7].parse().expect("month numeric");
570        let day: u32 = s[8..10].parse().expect("day numeric");
571        let from_str = date_to_days_since_epoch(year, month, day);
572        let from_unix = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
573        assert_eq!(from_str, from_unix);
574    }
575
576    #[test]
577    fn test_current_date_str_format() {
578        let s = current_date_str();
579        assert_eq!(s.len(), 10);
580        assert_eq!(s.as_bytes()[4], b'-');
581        assert_eq!(s.as_bytes()[7], b'-');
582        let year: u32 = s[0..4].parse().expect("year must be numeric");
583        assert!((2020..=2100).contains(&year));
584    }
585
586    #[tokio::test]
587    async fn test_metrics_writer_batching() {
588        let _guard = metrics_export_lock().await;
589        let dir = TempDir::new().unwrap();
590        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
591        let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
592        let make_event = || MetricEvent {
593            ts: unix_ms(),
594            tool: "analyze_directory",
595            duration_ms: 1,
596            output_chars: 10,
597            param_path_depth: 1,
598            max_depth: None,
599            result: "ok",
600            error_type: None,
601            error_subtype: None,
602            session_id: None,
603            seq: None,
604            cache_hit: None,
605            cache_write_failure: None,
606            exit_code: None,
607            timed_out: false,
608            cache_tier: None,
609            output_truncated: None,
610            chars_threshold_breach: false,
611            file_ext: None,
612            ..Default::default()
613        };
614        tx.send(make_event()).unwrap();
615        tx.send(make_event()).unwrap();
616        tx.send(make_event()).unwrap();
617        drop(tx);
618        writer.run().await;
619        let entries: Vec<_> = std::fs::read_dir(dir.path())
620            .unwrap()
621            .filter_map(|e| e.ok())
622            .filter(|e| {
623                e.path()
624                    .extension()
625                    .and_then(|x| x.to_str())
626                    .map(|x| x.eq_ignore_ascii_case("jsonl"))
627                    .unwrap_or(false)
628            })
629            .collect();
630        assert_eq!(entries.len(), 1);
631        let content = std::fs::read_to_string(entries[0].path()).unwrap();
632        let lines: Vec<&str> = content.lines().collect();
633        assert_eq!(lines.len(), 3);
634    }
635
636    #[tokio::test]
637    async fn test_cleanup_old_files_deletes_old_keeps_recent() {
638        let _guard = metrics_export_lock().await;
639        let dir = TempDir::new().unwrap();
640        let old_file = dir.path().join("metrics-1970-01-01.jsonl");
641        let today = current_date_str();
642        let recent_file = dir.path().join(format!("metrics-{}.jsonl", today));
643        std::fs::write(&old_file, "old\n").unwrap();
644        std::fs::write(&recent_file, "recent\n").unwrap();
645        cleanup_old_files(dir.path()).await;
646        assert!(!old_file.exists());
647        assert!(recent_file.exists());
648    }
649
650    #[test]
651    fn test_path_file_ext_known() {
652        // Arrange / Act / Assert: known extension returns the lowercased extension key
653        assert_eq!(path_file_ext("src/main.rs"), Some("rs"));
654    }
655
656    #[test]
657    fn test_path_file_ext_unknown() {
658        // Arrange / Act / Assert: unrecognized extension returns Some("other")
659        assert_eq!(path_file_ext("file.xyz"), Some("other"));
660    }
661
662    #[test]
663    fn test_path_file_ext_no_ext() {
664        // Arrange / Act / Assert: path with no extension returns None
665        assert_eq!(path_file_ext("Makefile"), None);
666    }
667
668    #[test]
669    fn test_path_file_ext_case_insensitive() {
670        // Arrange / Act / Assert: uppercase extension is normalized to lowercase key
671        assert_eq!(path_file_ext("src/main.RS"), Some("rs"));
672    }
673
674    #[test]
675    fn test_path_file_ext_multi_dot() {
676        // Arrange / Act / Assert: multi-dot filename uses the last extension
677        assert_eq!(path_file_ext("file.test.rs"), Some("rs"));
678    }
679
680    #[test]
681    fn test_path_language_known_ext() {
682        // Arrange / Act / Assert: known extension returns Some(language name)
683        assert_eq!(path_language("src/main.rs"), Some("rust".to_string()));
684    }
685
686    #[test]
687    fn test_path_language_unknown_ext() {
688        // Arrange / Act / Assert: unknown extension returns None
689        assert_eq!(path_language("file.xyz"), None);
690    }
691
692    #[test]
693    fn test_path_language_no_ext() {
694        // Arrange / Act / Assert: path without extension returns None
695        assert_eq!(path_language("Makefile"), None);
696    }
697
698    #[tokio::test]
699    async fn test_metrics_export_file_created() {
700        let _guard = metrics_export_lock().await;
701        // Arrange: create temp dir and set export env var
702        let dir = TempDir::new().unwrap();
703        let export_file = dir.path().join("metrics_export.json");
704        let export_path = export_file.to_str().unwrap().to_string();
705        unsafe {
706            std::env::set_var("APTU_CODER_METRICS_EXPORT_FILE", &export_path);
707        }
708
709        // Act: run writer with a couple of events and drop the sender to trigger shutdown
710        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
711        let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
712
713        let make_event = || MetricEvent {
714            ts: unix_ms(),
715            tool: "analyze_directory",
716            duration_ms: 1,
717            output_chars: 10,
718            param_path_depth: 1,
719            max_depth: None,
720            result: "ok",
721            error_type: None,
722            error_subtype: None,
723            session_id: Some("test-session-1".to_string()),
724            seq: None,
725            cache_hit: None,
726            cache_write_failure: None,
727            exit_code: None,
728            timed_out: false,
729            cache_tier: None,
730            output_truncated: None,
731            chars_threshold_breach: false,
732            file_ext: None,
733            ..Default::default()
734        };
735
736        tx.send(make_event()).unwrap();
737        tx.send(make_event()).unwrap();
738        drop(tx);
739        writer.run().await;
740
741        // Assert: export file was created with JSON content
742        assert!(
743            export_file.exists(),
744            "export file should exist at {}",
745            export_path
746        );
747        let content = std::fs::read_to_string(&export_file).unwrap();
748        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
749        assert_eq!(parsed["session_id"], "test-session-1");
750        assert!(parsed["total_duration_ms"].as_u64().unwrap() >= 2);
751        assert_eq!(parsed["tool_calls"][0]["tool"], "analyze_directory");
752        assert_eq!(parsed["tool_calls"][0]["call_count"], 2);
753
754        // Cleanup
755        unsafe {
756            std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
757        }
758    }
759
760    #[tokio::test]
761    async fn test_metrics_export_env_var_unset() {
762        let _guard = metrics_export_lock().await;
763        // Edge case: no APTU_CODER_METRICS_EXPORT_FILE -> no export file written
764        let dir = TempDir::new().unwrap();
765        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
766        let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
767
768        let make_event = || MetricEvent {
769            ts: unix_ms(),
770            tool: "analyze_directory",
771            duration_ms: 1,
772            output_chars: 10,
773            param_path_depth: 1,
774            max_depth: None,
775            result: "ok",
776            error_type: None,
777            error_subtype: None,
778            session_id: None,
779            seq: None,
780            cache_hit: None,
781            cache_write_failure: None,
782            exit_code: None,
783            timed_out: false,
784            cache_tier: None,
785            output_truncated: None,
786            chars_threshold_breach: false,
787            file_ext: None,
788            ..Default::default()
789        };
790
791        tx.send(make_event()).unwrap();
792        drop(tx);
793        writer.run().await;
794
795        // No export file should exist in the dir
796        let entries: Vec<_> = std::fs::read_dir(dir.path())
797            .unwrap()
798            .filter_map(|e| e.ok())
799            .filter(|e| {
800                e.path()
801                    .file_name()
802                    .and_then(|n| n.to_str())
803                    .map(|n| n.contains("metrics.json"))
804                    .unwrap_or(false)
805            })
806            .collect();
807        assert_eq!(entries.len(), 0, "no export file should be created");
808    }
809
810    #[tokio::test]
811    async fn test_metrics_export_relative_path_rejected() {
812        let _guard = metrics_export_lock().await;
813        // Edge case: relative path in APTU_CODER_METRICS_EXPORT_FILE -> warning, no file
814        let dir = TempDir::new().unwrap();
815        unsafe {
816            std::env::set_var("APTU_CODER_METRICS_EXPORT_FILE", "relative/export.json");
817        }
818
819        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
820        let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
821
822        let make_event = || MetricEvent {
823            ts: unix_ms(),
824            tool: "analyze_file",
825            duration_ms: 1,
826            output_chars: 10,
827            param_path_depth: 1,
828            max_depth: None,
829            result: "ok",
830            error_type: None,
831            error_subtype: None,
832            session_id: None,
833            seq: None,
834            cache_hit: None,
835            cache_write_failure: None,
836            exit_code: None,
837            timed_out: false,
838            cache_tier: None,
839            output_truncated: None,
840            chars_threshold_breach: false,
841            file_ext: None,
842            ..Default::default()
843        };
844
845        tx.send(make_event()).unwrap();
846        drop(tx);
847        writer.run().await;
848
849        // No export file should be created for relative path
850        let entries: Vec<_> = std::fs::read_dir(dir.path())
851            .unwrap()
852            .filter_map(|e| e.ok())
853            .filter(|e| {
854                e.path()
855                    .file_name()
856                    .and_then(|n| n.to_str())
857                    .map(|n| n.contains("metrics.json"))
858                    .unwrap_or(false)
859            })
860            .collect();
861        assert_eq!(
862            entries.len(),
863            0,
864            "no export file should be created for relative path"
865        );
866
867        // Cleanup
868        unsafe {
869            std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
870        }
871    }
872
873    #[tokio::test]
874    async fn test_lock_file_created() {
875        let _guard = metrics_export_lock().await;
876        // Assert: lock file is created next to JSONL file with deterministic name
877        let dir = TempDir::new().unwrap();
878        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
879        let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
880        let make_event = || MetricEvent {
881            ts: unix_ms(),
882            tool: "analyze_directory",
883            duration_ms: 1,
884            output_chars: 10,
885            param_path_depth: 1,
886            max_depth: None,
887            result: "ok",
888            error_type: None,
889            error_subtype: None,
890            session_id: None,
891            seq: None,
892            cache_hit: None,
893            cache_write_failure: None,
894            exit_code: None,
895            timed_out: false,
896            cache_tier: None,
897            output_truncated: None,
898            chars_threshold_breach: false,
899            file_ext: None,
900            ..Default::default()
901        };
902        tx.send(make_event()).unwrap();
903        drop(tx);
904        writer.run().await;
905
906        // Check that a .lock file exists next to the JSONL file
907        let jsonl_entries: Vec<_> = std::fs::read_dir(dir.path())
908            .unwrap()
909            .filter_map(|e| e.ok())
910            .filter(|e| {
911                e.path()
912                    .extension()
913                    .and_then(|x| x.to_str())
914                    .map(|x| x.eq_ignore_ascii_case("jsonl"))
915                    .unwrap_or(false)
916            })
917            .collect();
918        assert_eq!(jsonl_entries.len(), 1);
919        let lock_path = format!("{}.lock", jsonl_entries[0].path().display());
920        assert!(
921            std::path::Path::new(&lock_path).exists(),
922            "lock file must exist next to JSONL file"
923        );
924    }
925
926    #[tokio::test]
927    async fn test_flush_batch_concurrent_writes() {
928        // Edge case: two writers writing to the same metrics directory
929        // should both complete without panic (advisory lock protects against corruption).
930        let dir = TempDir::new().unwrap();
931        let base = dir.path().to_path_buf();
932
933        // Writer 1
934        let (tx1, rx1) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
935        let writer1 = MetricsWriter::new(rx1, Some(base.clone()));
936        let make_event = || MetricEvent {
937            ts: unix_ms(),
938            tool: "analyze_directory",
939            duration_ms: 1,
940            output_chars: 10,
941            param_path_depth: 1,
942            max_depth: None,
943            result: "ok",
944            error_type: None,
945            error_subtype: None,
946            session_id: None,
947            seq: None,
948            cache_hit: None,
949            cache_write_failure: None,
950            exit_code: None,
951            timed_out: false,
952            cache_tier: None,
953            output_truncated: None,
954            chars_threshold_breach: false,
955            file_ext: None,
956            ..Default::default()
957        };
958        tx1.send(make_event()).unwrap();
959        tx1.send(make_event()).unwrap();
960        drop(tx1);
961
962        // Writer 2
963        let (tx2, rx2) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
964        let writer2 = MetricsWriter::new(rx2, Some(base));
965        tx2.send(make_event()).unwrap();
966        tx2.send(make_event()).unwrap();
967        drop(tx2);
968
969        // Run both writers concurrently
970        let h1 = tokio::spawn(writer1.run());
971        let h2 = tokio::spawn(writer2.run());
972        let (r1, r2) = tokio::join!(h1, h2);
973        r1.unwrap();
974        r2.unwrap();
975
976        // Both writers succeeded; verify the JSONL file has all 4 events
977        let jsonl_entries: Vec<_> = std::fs::read_dir(dir.path())
978            .unwrap()
979            .filter_map(|e| e.ok())
980            .filter(|e| {
981                e.path()
982                    .extension()
983                    .and_then(|x| x.to_str())
984                    .map(|x| x.eq_ignore_ascii_case("jsonl"))
985                    .unwrap_or(false)
986            })
987            .collect();
988        assert_eq!(jsonl_entries.len(), 1);
989        let content = std::fs::read_to_string(jsonl_entries[0].path()).unwrap();
990        let lines: Vec<&str> = content.lines().collect();
991        assert_eq!(lines.len(), 4, "expected 4 JSONL lines from 2 writers");
992    }
993}