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 std::sync::{Mutex, OnceLock};
468    use tempfile::TempDir;
469
470    /// Serializes tests that mutate `APTU_CODER_METRICS_EXPORT_FILE` to prevent parallel
471    /// pollution. Recovers from poison caused by panicking tests.
472    fn metrics_export_lock() -> std::sync::MutexGuard<'static, ()> {
473        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
474        let m = LOCK.get_or_init(|| Mutex::new(()));
475        m.lock().unwrap_or_else(|e| e.into_inner())
476    }
477
478    #[test]
479    fn test_migrate_legacy_only_old_exists() {
480        // Arrange
481        let tmp_home = TempDir::new().unwrap();
482        let home_str = tmp_home.path().to_str().unwrap();
483        let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
484        let new_path = tmp_home.path().join(".local/share/aptu-coder");
485        fs::create_dir_all(&old_path).unwrap();
486        assert!(!new_path.exists());
487
488        // Act
489        let result = migrate_legacy_metrics_dir_impl(home_str);
490
491        // Assert
492        assert!(result.is_ok());
493        assert!(!old_path.exists(), "old dir should be moved");
494        assert!(new_path.is_dir(), "new dir should exist");
495    }
496
497    #[test]
498    fn test_migrate_legacy_both_exist() {
499        // Arrange
500        let tmp_home = TempDir::new().unwrap();
501        let home_str = tmp_home.path().to_str().unwrap();
502        let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
503        let new_path = tmp_home.path().join(".local/share/aptu-coder");
504        fs::create_dir_all(&old_path).unwrap();
505        fs::create_dir_all(&new_path).unwrap();
506
507        // Act
508        let result = migrate_legacy_metrics_dir_impl(home_str);
509
510        // Assert
511        assert!(result.is_ok());
512        assert!(old_path.is_dir(), "old dir should remain");
513        assert!(new_path.is_dir(), "new dir should remain");
514    }
515
516    #[test]
517    fn test_migrate_legacy_neither_exists() {
518        // Arrange
519        let tmp_home = TempDir::new().unwrap();
520        let home_str = tmp_home.path().to_str().unwrap();
521        let old_path = tmp_home.path().join(".local/share/code-analyze-mcp");
522        let new_path = tmp_home.path().join(".local/share/aptu-coder");
523
524        // Act
525        let result = migrate_legacy_metrics_dir_impl(home_str);
526
527        // Assert
528        assert!(result.is_ok());
529        assert!(!old_path.exists(), "old dir should not exist");
530        assert!(!new_path.exists(), "new dir should not exist");
531    }
532
533    #[test]
534    fn test_date_to_days_since_epoch_known_dates() {
535        assert_eq!(date_to_days_since_epoch(1970, 1, 1), 0);
536        assert_eq!(date_to_days_since_epoch(2020, 1, 1), 18_262);
537        assert_eq!(date_to_days_since_epoch(2000, 2, 29), 11_016);
538    }
539
540    #[test]
541    fn test_date_to_days_since_epoch_edge_cases() {
542        // Year boundary: last day of year -> first day of next year
543        assert_eq!(date_to_days_since_epoch(1970, 12, 31), 364);
544        assert_eq!(date_to_days_since_epoch(1971, 1, 1), 365);
545        assert_eq!(date_to_days_since_epoch(2023, 12, 31), 19_722);
546        assert_eq!(date_to_days_since_epoch(2024, 1, 1), 19_723);
547
548        // Month transition: Jan->Feb and Feb->Mar
549        assert_eq!(date_to_days_since_epoch(2023, 1, 31), 19_388);
550        assert_eq!(date_to_days_since_epoch(2023, 2, 1), 19_389);
551
552        // Leap year 2024 (divisible by 4, not by 100): Feb 28 -> Feb 29 -> Mar 1
553        assert_eq!(date_to_days_since_epoch(2024, 2, 28), 19_781);
554        assert_eq!(date_to_days_since_epoch(2024, 2, 29), 19_782);
555        assert_eq!(date_to_days_since_epoch(2024, 3, 1), 19_783);
556
557        // Leap year 2000 (divisible by 400): Feb 28 -> Feb 29 -> Mar 1
558        assert_eq!(date_to_days_since_epoch(2000, 2, 28), 11_015);
559        assert_eq!(date_to_days_since_epoch(2000, 3, 1), 11_017);
560
561        // Non-leap century 2100 (divisible by 100 but not 400): no Feb 29
562        assert_eq!(date_to_days_since_epoch(2100, 2, 28), 47_540);
563        assert_eq!(date_to_days_since_epoch(2100, 3, 1), 47_541);
564    }
565
566    #[test]
567    fn test_current_date_str_roundtrip() {
568        // current_date_str() and date_to_days_since_epoch() must agree on today.
569        let s = current_date_str();
570        let year: u32 = s[0..4].parse().expect("year numeric");
571        let month: u32 = s[5..7].parse().expect("month numeric");
572        let day: u32 = s[8..10].parse().expect("day numeric");
573        let from_str = date_to_days_since_epoch(year, month, day);
574        let from_unix = u32::try_from(unix_ms() / 86_400_000).unwrap_or(u32::MAX);
575        assert_eq!(from_str, from_unix);
576    }
577
578    #[test]
579    fn test_current_date_str_format() {
580        let s = current_date_str();
581        assert_eq!(s.len(), 10);
582        assert_eq!(s.as_bytes()[4], b'-');
583        assert_eq!(s.as_bytes()[7], b'-');
584        let year: u32 = s[0..4].parse().expect("year must be numeric");
585        assert!(year >= 2020 && year <= 2100);
586    }
587
588    #[tokio::test]
589    async fn test_metrics_writer_batching() {
590        let _guard = metrics_export_lock();
591        let dir = TempDir::new().unwrap();
592        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
593        let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
594        let make_event = || MetricEvent {
595            ts: unix_ms(),
596            tool: "analyze_directory",
597            duration_ms: 1,
598            output_chars: 10,
599            param_path_depth: 1,
600            max_depth: None,
601            result: "ok",
602            error_type: None,
603            error_subtype: None,
604            session_id: None,
605            seq: None,
606            cache_hit: None,
607            cache_write_failure: None,
608            exit_code: None,
609            timed_out: false,
610            cache_tier: None,
611            output_truncated: None,
612            chars_threshold_breach: false,
613            file_ext: None,
614            ..Default::default()
615        };
616        tx.send(make_event()).unwrap();
617        tx.send(make_event()).unwrap();
618        tx.send(make_event()).unwrap();
619        drop(tx);
620        writer.run().await;
621        let entries: Vec<_> = std::fs::read_dir(dir.path())
622            .unwrap()
623            .filter_map(|e| e.ok())
624            .filter(|e| {
625                e.path()
626                    .extension()
627                    .and_then(|x| x.to_str())
628                    .map(|x| x.eq_ignore_ascii_case("jsonl"))
629                    .unwrap_or(false)
630            })
631            .collect();
632        assert_eq!(entries.len(), 1);
633        let content = std::fs::read_to_string(entries[0].path()).unwrap();
634        let lines: Vec<&str> = content.lines().collect();
635        assert_eq!(lines.len(), 3);
636    }
637
638    #[tokio::test]
639    async fn test_cleanup_old_files_deletes_old_keeps_recent() {
640        let _guard = metrics_export_lock();
641        let dir = TempDir::new().unwrap();
642        let old_file = dir.path().join("metrics-1970-01-01.jsonl");
643        let today = current_date_str();
644        let recent_file = dir.path().join(format!("metrics-{}.jsonl", today));
645        std::fs::write(&old_file, "old\n").unwrap();
646        std::fs::write(&recent_file, "recent\n").unwrap();
647        cleanup_old_files(dir.path()).await;
648        assert!(!old_file.exists());
649        assert!(recent_file.exists());
650    }
651
652    #[test]
653    fn test_path_file_ext_known() {
654        // Arrange / Act / Assert: known extension returns the lowercased extension key
655        assert_eq!(path_file_ext("src/main.rs"), Some("rs"));
656    }
657
658    #[test]
659    fn test_path_file_ext_unknown() {
660        // Arrange / Act / Assert: unrecognized extension returns Some("other")
661        assert_eq!(path_file_ext("file.xyz"), Some("other"));
662    }
663
664    #[test]
665    fn test_path_file_ext_no_ext() {
666        // Arrange / Act / Assert: path with no extension returns None
667        assert_eq!(path_file_ext("Makefile"), None);
668    }
669
670    #[test]
671    fn test_path_file_ext_case_insensitive() {
672        // Arrange / Act / Assert: uppercase extension is normalized to lowercase key
673        assert_eq!(path_file_ext("src/main.RS"), Some("rs"));
674    }
675
676    #[test]
677    fn test_path_file_ext_multi_dot() {
678        // Arrange / Act / Assert: multi-dot filename uses the last extension
679        assert_eq!(path_file_ext("file.test.rs"), Some("rs"));
680    }
681
682    #[test]
683    fn test_path_language_known_ext() {
684        // Arrange / Act / Assert: known extension returns Some(language name)
685        assert_eq!(path_language("src/main.rs"), Some("rust".to_string()));
686    }
687
688    #[test]
689    fn test_path_language_unknown_ext() {
690        // Arrange / Act / Assert: unknown extension returns None
691        assert_eq!(path_language("file.xyz"), None);
692    }
693
694    #[test]
695    fn test_path_language_no_ext() {
696        // Arrange / Act / Assert: path without extension returns None
697        assert_eq!(path_language("Makefile"), None);
698    }
699
700    #[tokio::test]
701    async fn test_metrics_export_file_created() {
702        let _guard = metrics_export_lock();
703        // Arrange: create temp dir and set export env var
704        let dir = TempDir::new().unwrap();
705        let export_file = dir.path().join("metrics_export.json");
706        let export_path = export_file.to_str().unwrap().to_string();
707        unsafe {
708            std::env::set_var("APTU_CODER_METRICS_EXPORT_FILE", &export_path);
709        }
710
711        // Act: run writer with a couple of events and drop the sender to trigger shutdown
712        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
713        let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
714
715        let make_event = || MetricEvent {
716            ts: unix_ms(),
717            tool: "analyze_directory",
718            duration_ms: 1,
719            output_chars: 10,
720            param_path_depth: 1,
721            max_depth: None,
722            result: "ok",
723            error_type: None,
724            error_subtype: None,
725            session_id: Some("test-session-1".to_string()),
726            seq: None,
727            cache_hit: None,
728            cache_write_failure: None,
729            exit_code: None,
730            timed_out: false,
731            cache_tier: None,
732            output_truncated: None,
733            chars_threshold_breach: false,
734            file_ext: None,
735            ..Default::default()
736        };
737
738        tx.send(make_event()).unwrap();
739        tx.send(make_event()).unwrap();
740        drop(tx);
741        writer.run().await;
742
743        // Assert: export file was created with JSON content
744        assert!(
745            export_file.exists(),
746            "export file should exist at {}",
747            export_path
748        );
749        let content = std::fs::read_to_string(&export_file).unwrap();
750        let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
751        assert_eq!(parsed["session_id"], "test-session-1");
752        assert!(parsed["total_duration_ms"].as_u64().unwrap() >= 2);
753        assert_eq!(parsed["tool_calls"][0]["tool"], "analyze_directory");
754        assert_eq!(parsed["tool_calls"][0]["call_count"], 2);
755
756        // Cleanup
757        unsafe {
758            std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
759        }
760    }
761
762    #[tokio::test]
763    async fn test_metrics_export_env_var_unset() {
764        let _guard = metrics_export_lock();
765        // Edge case: no APTU_CODER_METRICS_EXPORT_FILE -> no export file written
766        let dir = TempDir::new().unwrap();
767        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
768        let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
769
770        let make_event = || MetricEvent {
771            ts: unix_ms(),
772            tool: "analyze_directory",
773            duration_ms: 1,
774            output_chars: 10,
775            param_path_depth: 1,
776            max_depth: None,
777            result: "ok",
778            error_type: None,
779            error_subtype: None,
780            session_id: None,
781            seq: None,
782            cache_hit: None,
783            cache_write_failure: None,
784            exit_code: None,
785            timed_out: false,
786            cache_tier: None,
787            output_truncated: None,
788            chars_threshold_breach: false,
789            file_ext: None,
790            ..Default::default()
791        };
792
793        tx.send(make_event()).unwrap();
794        drop(tx);
795        writer.run().await;
796
797        // No export file should exist in the dir
798        let entries: Vec<_> = std::fs::read_dir(dir.path())
799            .unwrap()
800            .filter_map(|e| e.ok())
801            .filter(|e| {
802                e.path()
803                    .file_name()
804                    .and_then(|n| n.to_str())
805                    .map(|n| n.contains("metrics.json"))
806                    .unwrap_or(false)
807            })
808            .collect();
809        assert_eq!(entries.len(), 0, "no export file should be created");
810    }
811
812    #[tokio::test]
813    async fn test_metrics_export_relative_path_rejected() {
814        let _guard = metrics_export_lock();
815        // Edge case: relative path in APTU_CODER_METRICS_EXPORT_FILE -> warning, no file
816        let dir = TempDir::new().unwrap();
817        unsafe {
818            std::env::set_var("APTU_CODER_METRICS_EXPORT_FILE", "relative/export.json");
819        }
820
821        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
822        let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
823
824        let make_event = || MetricEvent {
825            ts: unix_ms(),
826            tool: "analyze_file",
827            duration_ms: 1,
828            output_chars: 10,
829            param_path_depth: 1,
830            max_depth: None,
831            result: "ok",
832            error_type: None,
833            error_subtype: None,
834            session_id: None,
835            seq: None,
836            cache_hit: None,
837            cache_write_failure: None,
838            exit_code: None,
839            timed_out: false,
840            cache_tier: None,
841            output_truncated: None,
842            chars_threshold_breach: false,
843            file_ext: None,
844            ..Default::default()
845        };
846
847        tx.send(make_event()).unwrap();
848        drop(tx);
849        writer.run().await;
850
851        // No export file should be created for relative path
852        let entries: Vec<_> = std::fs::read_dir(dir.path())
853            .unwrap()
854            .filter_map(|e| e.ok())
855            .filter(|e| {
856                e.path()
857                    .file_name()
858                    .and_then(|n| n.to_str())
859                    .map(|n| n.contains("metrics.json"))
860                    .unwrap_or(false)
861            })
862            .collect();
863        assert_eq!(
864            entries.len(),
865            0,
866            "no export file should be created for relative path"
867        );
868
869        // Cleanup
870        unsafe {
871            std::env::remove_var("APTU_CODER_METRICS_EXPORT_FILE");
872        }
873    }
874
875    #[tokio::test]
876    async fn test_lock_file_created() {
877        let _guard = metrics_export_lock();
878        // Assert: lock file is created next to JSONL file with deterministic name
879        let dir = TempDir::new().unwrap();
880        let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
881        let writer = MetricsWriter::new(rx, Some(dir.path().to_path_buf()));
882        let make_event = || MetricEvent {
883            ts: unix_ms(),
884            tool: "analyze_directory",
885            duration_ms: 1,
886            output_chars: 10,
887            param_path_depth: 1,
888            max_depth: None,
889            result: "ok",
890            error_type: None,
891            error_subtype: None,
892            session_id: None,
893            seq: None,
894            cache_hit: None,
895            cache_write_failure: None,
896            exit_code: None,
897            timed_out: false,
898            cache_tier: None,
899            output_truncated: None,
900            chars_threshold_breach: false,
901            file_ext: None,
902            ..Default::default()
903        };
904        tx.send(make_event()).unwrap();
905        drop(tx);
906        writer.run().await;
907
908        // Check that a .lock file exists next to the JSONL file
909        let jsonl_entries: Vec<_> = std::fs::read_dir(dir.path())
910            .unwrap()
911            .filter_map(|e| e.ok())
912            .filter(|e| {
913                e.path()
914                    .extension()
915                    .and_then(|x| x.to_str())
916                    .map(|x| x.eq_ignore_ascii_case("jsonl"))
917                    .unwrap_or(false)
918            })
919            .collect();
920        assert_eq!(jsonl_entries.len(), 1);
921        let lock_path = format!("{}.lock", jsonl_entries[0].path().display());
922        assert!(
923            std::path::Path::new(&lock_path).exists(),
924            "lock file must exist next to JSONL file"
925        );
926    }
927
928    #[tokio::test]
929    async fn test_flush_batch_concurrent_writes() {
930        // Edge case: two writers writing to the same metrics directory
931        // should both complete without panic (advisory lock protects against corruption).
932        let dir = TempDir::new().unwrap();
933        let base = dir.path().to_path_buf();
934
935        // Writer 1
936        let (tx1, rx1) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
937        let writer1 = MetricsWriter::new(rx1, Some(base.clone()));
938        let make_event = || MetricEvent {
939            ts: unix_ms(),
940            tool: "analyze_directory",
941            duration_ms: 1,
942            output_chars: 10,
943            param_path_depth: 1,
944            max_depth: None,
945            result: "ok",
946            error_type: None,
947            error_subtype: None,
948            session_id: None,
949            seq: None,
950            cache_hit: None,
951            cache_write_failure: None,
952            exit_code: None,
953            timed_out: false,
954            cache_tier: None,
955            output_truncated: None,
956            chars_threshold_breach: false,
957            file_ext: None,
958            ..Default::default()
959        };
960        tx1.send(make_event()).unwrap();
961        tx1.send(make_event()).unwrap();
962        drop(tx1);
963
964        // Writer 2
965        let (tx2, rx2) = tokio::sync::mpsc::unbounded_channel::<MetricEvent>();
966        let writer2 = MetricsWriter::new(rx2, Some(base));
967        tx2.send(make_event()).unwrap();
968        tx2.send(make_event()).unwrap();
969        drop(tx2);
970
971        // Run both writers concurrently
972        let h1 = tokio::spawn(writer1.run());
973        let h2 = tokio::spawn(writer2.run());
974        let (r1, r2) = tokio::join!(h1, h2);
975        r1.unwrap();
976        r2.unwrap();
977
978        // Both writers succeeded; verify the JSONL file has all 4 events
979        let jsonl_entries: Vec<_> = std::fs::read_dir(dir.path())
980            .unwrap()
981            .filter_map(|e| e.ok())
982            .filter(|e| {
983                e.path()
984                    .extension()
985                    .and_then(|x| x.to_str())
986                    .map(|x| x.eq_ignore_ascii_case("jsonl"))
987                    .unwrap_or(false)
988            })
989            .collect();
990        assert_eq!(jsonl_entries.len(), 1);
991        let content = std::fs::read_to_string(jsonl_entries[0].path()).unwrap();
992        let lines: Vec<&str> = content.lines().collect();
993        assert_eq!(lines.len(), 4, "expected 4 JSONL lines from 2 writers");
994    }
995}