Skip to main content

rtk/core/
tracking.rs

1//! Token savings tracking and analytics system.
2//!
3//! This module provides comprehensive tracking of RTK command executions,
4//! recording token savings, execution times, and providing aggregation APIs
5//! for daily/weekly/monthly statistics.
6//!
7//! # Architecture
8//!
9//! - Storage: SQLite database (~/.local/share/rtk/tracking.db)
10//! - Retention: 90-day automatic cleanup
11//! - Metrics: Input/output tokens, savings %, execution time
12//!
13//! # Quick Start
14//!
15//! ```no_run
16//! use rtk::tracking::{TimedExecution, Tracker};
17//!
18//! // Track a command execution
19//! let timer = TimedExecution::start();
20//! let input = "raw output";
21//! let output = "filtered output";
22//! timer.track("ls -la", "rtk ls", input, output);
23//!
24//! // Query statistics
25//! let tracker = Tracker::new().unwrap();
26//! let summary = tracker.get_summary().unwrap();
27//! println!("Saved {} tokens", summary.total_saved);
28//! ```
29//!
30//! See [docs/tracking.md](../docs/tracking.md) for full documentation.
31
32use anyhow::{Context, Result};
33use chrono::{DateTime, Utc};
34use rusqlite::{params, Connection};
35use serde::Serialize;
36use std::ffi::OsString;
37use std::path::PathBuf;
38use std::time::Instant;
39
40// ── Project path helpers ── // added: project-scoped tracking support
41
42/// Get the canonical project path string for the current working directory.
43fn current_project_path_string() -> String {
44    std::env::current_dir()
45        .ok()
46        .and_then(|p| p.canonicalize().ok())
47        .map(|p| p.to_string_lossy().to_string())
48        .unwrap_or_default()
49}
50
51/// Build SQL filter params for project-scoped queries.
52/// Returns (exact_match, glob_prefix) for WHERE clause.
53/// Uses GLOB instead of LIKE to avoid `_` and `%` in paths acting as wildcards. // changed: GLOB
54fn project_filter_params(project_path: Option<&str>) -> (Option<String>, Option<String>) {
55    match project_path {
56        Some(p) => (
57            Some(p.to_string()),
58            Some(format!("{}{}*", p, std::path::MAIN_SEPARATOR)), // changed: GLOB pattern with * wildcard
59        ),
60        None => (None, None),
61    }
62}
63
64use super::constants::{DEFAULT_HISTORY_DAYS, HISTORY_DB, RTK_DATA_DIR};
65
66/// Main tracking interface for recording and querying command history.
67///
68/// Manages SQLite database connection and provides methods for:
69/// - Recording command executions with token counts and timing
70/// - Querying aggregated statistics (summary, daily, weekly, monthly)
71/// - Retrieving recent command history
72///
73/// # Database Location
74///
75/// - Linux: `~/.local/share/rtk/tracking.db`
76/// - macOS: `~/Library/Application Support/rtk/tracking.db`
77/// - Windows: `%APPDATA%\rtk\tracking.db`
78///
79/// # Examples
80///
81/// ```no_run
82/// use rtk::tracking::Tracker;
83///
84/// let tracker = Tracker::new()?;
85/// tracker.record("ls -la", "rtk ls", 1000, 200, 50)?;
86///
87/// let summary = tracker.get_summary()?;
88/// println!("Total saved: {} tokens", summary.total_saved);
89/// # Ok::<(), anyhow::Error>(())
90/// ```
91pub struct Tracker {
92    conn: Connection,
93}
94
95/// Individual command record from tracking history.
96///
97/// Contains timestamp, command name, and savings metrics for a single execution.
98#[derive(Debug)]
99pub struct CommandRecord {
100    /// UTC timestamp when command was executed
101    pub timestamp: DateTime<Utc>,
102    /// RTK command that was executed (e.g., "rtk ls")
103    pub rtk_cmd: String,
104    /// Number of tokens saved (input - output)
105    pub saved_tokens: usize,
106    /// Savings percentage ((saved / input) * 100)
107    pub savings_pct: f64,
108}
109
110/// Aggregated statistics across all recorded commands.
111///
112/// Provides overall metrics and breakdowns by command and by day.
113/// Returned by [`Tracker::get_summary`].
114#[derive(Debug)]
115pub struct GainSummary {
116    /// Total number of commands recorded
117    pub total_commands: usize,
118    /// Total input tokens across all commands
119    pub total_input: usize,
120    /// Total output tokens across all commands
121    pub total_output: usize,
122    /// Total tokens saved (input - output)
123    pub total_saved: usize,
124    /// Average savings percentage across all commands
125    pub avg_savings_pct: f64,
126    /// Total execution time across all commands (milliseconds)
127    pub total_time_ms: u64,
128    /// Average execution time per command (milliseconds)
129    pub avg_time_ms: u64,
130    /// Top 10 commands by tokens saved: (cmd, count, saved, avg_pct, avg_time_ms)
131    pub by_command: Vec<(String, usize, usize, f64, u64)>,
132    /// Last 30 days of activity: (date, saved_tokens)
133    pub by_day: Vec<(String, usize)>,
134}
135
136/// Daily statistics for token savings and execution metrics.
137///
138/// Serializable to JSON for export via `rtk gain --daily --format json`.
139///
140/// # JSON Schema
141///
142/// ```json
143/// {
144///   "date": "2026-02-03",
145///   "commands": 42,
146///   "input_tokens": 15420,
147///   "output_tokens": 3842,
148///   "saved_tokens": 11578,
149///   "savings_pct": 75.08,
150///   "total_time_ms": 8450,
151///   "avg_time_ms": 201
152/// }
153/// ```
154#[derive(Debug, Serialize)]
155pub struct DayStats {
156    /// ISO date (YYYY-MM-DD)
157    pub date: String,
158    /// Number of commands executed this day
159    pub commands: usize,
160    /// Total input tokens for this day
161    pub input_tokens: usize,
162    /// Total output tokens for this day
163    pub output_tokens: usize,
164    /// Total tokens saved this day
165    pub saved_tokens: usize,
166    /// Savings percentage for this day
167    pub savings_pct: f64,
168    /// Total execution time for this day (milliseconds)
169    pub total_time_ms: u64,
170    /// Average execution time per command (milliseconds)
171    pub avg_time_ms: u64,
172}
173
174/// Weekly statistics for token savings and execution metrics.
175///
176/// Serializable to JSON for export via `rtk gain --weekly --format json`.
177/// Weeks start on Sunday (SQLite default).
178#[derive(Debug, Serialize)]
179pub struct WeekStats {
180    /// Week start date (YYYY-MM-DD)
181    pub week_start: String,
182    /// Week end date (YYYY-MM-DD)
183    pub week_end: String,
184    /// Number of commands executed this week
185    pub commands: usize,
186    /// Total input tokens for this week
187    pub input_tokens: usize,
188    /// Total output tokens for this week
189    pub output_tokens: usize,
190    /// Total tokens saved this week
191    pub saved_tokens: usize,
192    /// Savings percentage for this week
193    pub savings_pct: f64,
194    /// Total execution time for this week (milliseconds)
195    pub total_time_ms: u64,
196    /// Average execution time per command (milliseconds)
197    pub avg_time_ms: u64,
198}
199
200/// Monthly statistics for token savings and execution metrics.
201///
202/// Serializable to JSON for export via `rtk gain --monthly --format json`.
203#[derive(Debug, Serialize)]
204pub struct MonthStats {
205    /// Month identifier (YYYY-MM)
206    pub month: String,
207    /// Number of commands executed this month
208    pub commands: usize,
209    /// Total input tokens for this month
210    pub input_tokens: usize,
211    /// Total output tokens for this month
212    pub output_tokens: usize,
213    /// Total tokens saved this month
214    pub saved_tokens: usize,
215    /// Savings percentage for this month
216    pub savings_pct: f64,
217    /// Total execution time for this month (milliseconds)
218    pub total_time_ms: u64,
219    /// Average execution time per command (milliseconds)
220    pub avg_time_ms: u64,
221}
222
223/// Type alias for command statistics tuple: (command, count, saved_tokens, avg_savings_pct, avg_time_ms)
224type CommandStats = (String, usize, usize, f64, u64);
225
226impl Tracker {
227    /// Create a new tracker instance.
228    ///
229    /// Opens or creates the SQLite database at the platform-specific location.
230    /// Automatically creates the `commands` table if it doesn't exist and runs
231    /// any necessary schema migrations.
232    ///
233    /// # Errors
234    ///
235    /// Returns error if:
236    /// - Cannot determine database path
237    /// - Cannot create parent directories
238    /// - Cannot open/create SQLite database
239    /// - Schema creation/migration fails
240    ///
241    /// # Examples
242    ///
243    /// ```no_run
244    /// use rtk::tracking::Tracker;
245    ///
246    /// let tracker = Tracker::new()?;
247    /// # Ok::<(), anyhow::Error>(())
248    /// ```
249    pub fn new() -> Result<Self> {
250        let db_path = get_db_path()?;
251        if let Some(parent) = db_path.parent() {
252            std::fs::create_dir_all(parent)?;
253        }
254
255        let conn = Connection::open(&db_path)?;
256        // WAL mode + busy_timeout for concurrent access (multiple Claude Code instances).
257        // Non-fatal: NFS/read-only filesystems may not support WAL.
258        let _ = conn.execute_batch(
259            "PRAGMA journal_mode=WAL;
260             PRAGMA busy_timeout=5000;",
261        );
262        conn.execute(
263            "CREATE TABLE IF NOT EXISTS commands (
264                id INTEGER PRIMARY KEY,
265                timestamp TEXT NOT NULL,
266                original_cmd TEXT NOT NULL,
267                rtk_cmd TEXT NOT NULL,
268                input_tokens INTEGER NOT NULL,
269                output_tokens INTEGER NOT NULL,
270                saved_tokens INTEGER NOT NULL,
271                savings_pct REAL NOT NULL
272            )",
273            [],
274        )?;
275
276        conn.execute(
277            "CREATE INDEX IF NOT EXISTS idx_timestamp ON commands(timestamp)",
278            [],
279        )?;
280
281        // Migration: add exec_time_ms column if it doesn't exist
282        let _ = conn.execute(
283            "ALTER TABLE commands ADD COLUMN exec_time_ms INTEGER DEFAULT 0",
284            [],
285        );
286        // Migration: add project_path column with DEFAULT '' for new rows // changed: added DEFAULT
287        let _ = conn.execute(
288            "ALTER TABLE commands ADD COLUMN project_path TEXT DEFAULT ''",
289            [],
290        );
291        // One-time migration: normalize NULLs from pre-default schema // changed: guarded with EXISTS
292        let has_nulls: bool = conn
293            .query_row(
294                "SELECT EXISTS(SELECT 1 FROM commands WHERE project_path IS NULL)",
295                [],
296                |row| row.get(0),
297            )
298            .unwrap_or(false);
299        if has_nulls {
300            let _ = conn.execute(
301                "UPDATE commands SET project_path = '' WHERE project_path IS NULL",
302                [],
303            );
304        }
305        // Index for fast project-scoped gain queries // added
306        let _ = conn.execute(
307            "CREATE INDEX IF NOT EXISTS idx_project_path_timestamp ON commands(project_path, timestamp)",
308            [],
309        );
310
311        conn.execute(
312            "CREATE TABLE IF NOT EXISTS parse_failures (
313                id INTEGER PRIMARY KEY,
314                timestamp TEXT NOT NULL,
315                raw_command TEXT NOT NULL,
316                error_message TEXT NOT NULL,
317                fallback_succeeded INTEGER NOT NULL DEFAULT 0
318            )",
319            [],
320        )?;
321        conn.execute(
322            "CREATE INDEX IF NOT EXISTS idx_pf_timestamp ON parse_failures(timestamp)",
323            [],
324        )?;
325
326        Ok(Self { conn })
327    }
328
329    /// Create an isolated in-memory tracker for tests.
330    #[cfg(test)]
331    pub fn new_in_memory() -> Result<Self> {
332        let conn = Connection::open_in_memory().context("Failed to open in-memory DB")?;
333        let tracker = Self { conn };
334        tracker.init_schema()?;
335        Ok(tracker)
336    }
337
338    #[cfg(test)]
339    fn init_schema(&self) -> Result<()> {
340        self.conn.execute(
341            "CREATE TABLE IF NOT EXISTS commands (
342                id INTEGER PRIMARY KEY,
343                timestamp TEXT NOT NULL,
344                original_cmd TEXT NOT NULL,
345                rtk_cmd TEXT NOT NULL,
346                input_tokens INTEGER NOT NULL,
347                output_tokens INTEGER NOT NULL,
348                saved_tokens INTEGER NOT NULL,
349                savings_pct REAL NOT NULL,
350                exec_time_ms INTEGER DEFAULT 0,
351                project_path TEXT DEFAULT ''
352            )",
353            [],
354        )?;
355        self.conn.execute(
356            "CREATE INDEX IF NOT EXISTS idx_timestamp ON commands(timestamp)",
357            [],
358        )?;
359        self.conn.execute(
360            "CREATE INDEX IF NOT EXISTS idx_project_path_timestamp ON commands(project_path, timestamp)",
361            [],
362        )?;
363        self.conn.execute(
364            "CREATE TABLE IF NOT EXISTS parse_failures (
365                id INTEGER PRIMARY KEY,
366                timestamp TEXT NOT NULL,
367                raw_command TEXT NOT NULL,
368                error_message TEXT NOT NULL,
369                fallback_succeeded INTEGER NOT NULL DEFAULT 0
370            )",
371            [],
372        )?;
373        self.conn.execute(
374            "CREATE INDEX IF NOT EXISTS idx_pf_timestamp ON parse_failures(timestamp)",
375            [],
376        )?;
377        Ok(())
378    }
379
380    /// Record a command execution with token counts and timing.
381    ///
382    /// Calculates savings metrics and stores the record in the database.
383    /// Automatically cleans up records older than 90 days after insertion.
384    ///
385    /// # Arguments
386    ///
387    /// - `original_cmd`: The standard command (e.g., "ls -la")
388    /// - `rtk_cmd`: The RTK command used (e.g., "rtk ls")
389    /// - `input_tokens`: Estimated tokens from standard command output
390    /// - `output_tokens`: Actual tokens from RTK output
391    /// - `exec_time_ms`: Execution time in milliseconds
392    ///
393    /// # Examples
394    ///
395    /// ```no_run
396    /// use rtk::tracking::Tracker;
397    ///
398    /// let tracker = Tracker::new()?;
399    /// tracker.record("ls -la", "rtk ls", 1000, 200, 50)?;
400    /// # Ok::<(), anyhow::Error>(())
401    /// ```
402    pub fn record(
403        &self,
404        original_cmd: &str,
405        rtk_cmd: &str,
406        input_tokens: usize,
407        output_tokens: usize,
408        exec_time_ms: u64,
409    ) -> Result<()> {
410        let saved = input_tokens.saturating_sub(output_tokens);
411        let pct = if input_tokens > 0 {
412            (saved as f64 / input_tokens as f64) * 100.0
413        } else {
414            0.0
415        };
416
417        let project_path = current_project_path_string(); // added: record cwd
418
419        self.conn.execute(
420            "INSERT INTO commands (timestamp, original_cmd, rtk_cmd, project_path, input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms)
421             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", // added: project_path
422            params![
423                Utc::now().to_rfc3339(),
424                original_cmd,
425                rtk_cmd,
426                project_path, // added
427                input_tokens as i64,
428                output_tokens as i64,
429                saved as i64,
430                pct,
431                exec_time_ms as i64
432            ],
433        )?;
434
435        self.cleanup_old()?;
436        Ok(())
437    }
438
439    fn cleanup_old(&self) -> Result<()> {
440        let cutoff = Utc::now() - chrono::Duration::days(DEFAULT_HISTORY_DAYS);
441        self.conn.execute(
442            "DELETE FROM commands WHERE timestamp < ?1",
443            params![cutoff.to_rfc3339()],
444        )?;
445        self.conn.execute(
446            "DELETE FROM parse_failures WHERE timestamp < ?1",
447            params![cutoff.to_rfc3339()],
448        )?;
449        Ok(())
450    }
451
452    /// Delete all tracked data (commands + parse_failures), resetting all stats to zero.
453    pub fn reset_all(&self) -> Result<()> {
454        self.conn
455            .execute_batch(
456                "BEGIN;
457                 DELETE FROM commands;
458                 DELETE FROM parse_failures;
459                 COMMIT;",
460            )
461            .context("Failed to reset tracking database")?;
462        Ok(())
463    }
464
465    /// Record a parse failure for analytics.
466    pub fn record_parse_failure(
467        &self,
468        raw_command: &str,
469        error_message: &str,
470        fallback_succeeded: bool,
471    ) -> Result<()> {
472        self.conn.execute(
473            "INSERT INTO parse_failures (timestamp, raw_command, error_message, fallback_succeeded)
474             VALUES (?1, ?2, ?3, ?4)",
475            params![
476                Utc::now().to_rfc3339(),
477                raw_command,
478                error_message,
479                fallback_succeeded as i32,
480            ],
481        )?;
482        self.cleanup_old()?;
483        Ok(())
484    }
485
486    /// Get parse failure summary for `rtk gain --failures`.
487    pub fn get_parse_failure_summary(&self) -> Result<ParseFailureSummary> {
488        let total: i64 = self
489            .conn
490            .query_row("SELECT COUNT(*) FROM parse_failures", [], |row| row.get(0))?;
491
492        let succeeded: i64 = self.conn.query_row(
493            "SELECT COUNT(*) FROM parse_failures WHERE fallback_succeeded = 1",
494            [],
495            |row| row.get(0),
496        )?;
497
498        let recovery_rate = if total > 0 {
499            (succeeded as f64 / total as f64) * 100.0
500        } else {
501            0.0
502        };
503
504        // Top commands by frequency
505        let mut stmt = self.conn.prepare(
506            "SELECT raw_command, COUNT(*) as cnt
507             FROM parse_failures
508             GROUP BY raw_command
509             ORDER BY cnt DESC
510             LIMIT 10",
511        )?;
512        let top_commands = stmt
513            .query_map([], |row| {
514                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
515            })?
516            .collect::<Result<Vec<_>, _>>()?;
517
518        // Recent 10
519        let mut stmt = self.conn.prepare(
520            "SELECT timestamp, raw_command, error_message, fallback_succeeded
521             FROM parse_failures
522             ORDER BY timestamp DESC
523             LIMIT 10",
524        )?;
525        let recent = stmt
526            .query_map([], |row| {
527                Ok(ParseFailureRecord {
528                    timestamp: row.get(0)?,
529                    raw_command: row.get(1)?,
530                    error_message: row.get(2)?,
531                    fallback_succeeded: row.get::<_, i32>(3)? != 0,
532                })
533            })?
534            .collect::<Result<Vec<_>, _>>()?;
535
536        Ok(ParseFailureSummary {
537            total: total as usize,
538            recovery_rate,
539            top_commands,
540            recent,
541        })
542    }
543
544    /// Get overall summary statistics across all recorded commands.
545    ///
546    /// Returns aggregated metrics including:
547    /// - Total commands, tokens (input/output/saved)
548    /// - Average savings percentage and execution time
549    /// - Top 10 commands by tokens saved
550    /// - Last 30 days of activity
551    ///
552    /// # Examples
553    ///
554    /// ```no_run
555    /// use rtk::tracking::Tracker;
556    ///
557    /// let tracker = Tracker::new()?;
558    /// let summary = tracker.get_summary()?;
559    /// println!("Saved {} tokens ({:.1}%)",
560    ///     summary.total_saved, summary.avg_savings_pct);
561    /// # Ok::<(), anyhow::Error>(())
562    /// ```
563    #[allow(dead_code)]
564    pub fn get_summary(&self) -> Result<GainSummary> {
565        self.get_summary_filtered(None) // delegate to filtered variant
566    }
567
568    /// Get summary statistics filtered by project path. // added
569    ///
570    /// When `project_path` is `Some`, matches the exact working directory
571    /// or any subdirectory (prefix match with path separator).
572    pub fn get_summary_filtered(&self, project_path: Option<&str>) -> Result<GainSummary> {
573        let (project_exact, project_glob) = project_filter_params(project_path); // added
574        let mut total_commands = 0usize;
575        let mut total_input = 0usize;
576        let mut total_output = 0usize;
577        let mut total_saved = 0usize;
578        let mut total_time_ms = 0u64;
579
580        let mut stmt = self.conn.prepare(
581            "SELECT input_tokens, output_tokens, saved_tokens, exec_time_ms
582             FROM commands
583             WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)", // added: project filter
584        )?;
585
586        let rows = stmt.query_map(params![project_exact, project_glob], |row| {
587            // added: params
588            Ok((
589                row.get::<_, i64>(0)? as usize,
590                row.get::<_, i64>(1)? as usize,
591                row.get::<_, i64>(2)? as usize,
592                row.get::<_, i64>(3)? as u64,
593            ))
594        })?;
595
596        for row in rows {
597            let (input, output, saved, time_ms) = row?;
598            total_commands += 1;
599            total_input += input;
600            total_output += output;
601            total_saved += saved;
602            total_time_ms += time_ms;
603        }
604
605        let avg_savings_pct = if total_input > 0 {
606            (total_saved as f64 / total_input as f64) * 100.0
607        } else {
608            0.0
609        };
610
611        let avg_time_ms = if total_commands > 0 {
612            total_time_ms / total_commands as u64
613        } else {
614            0
615        };
616
617        let by_command = self.get_by_command(project_path)?; // added: pass project filter
618        let by_day = self.get_by_day(project_path)?; // added: pass project filter
619
620        Ok(GainSummary {
621            total_commands,
622            total_input,
623            total_output,
624            total_saved,
625            avg_savings_pct,
626            total_time_ms,
627            avg_time_ms,
628            by_command,
629            by_day,
630        })
631    }
632
633    fn get_by_command(
634        &self,
635        project_path: Option<&str>, // added
636    ) -> Result<Vec<CommandStats>> {
637        let (project_exact, project_glob) = project_filter_params(project_path); // added
638        let mut stmt = self.conn.prepare(
639            "SELECT rtk_cmd, COUNT(*), SUM(saved_tokens), AVG(savings_pct), AVG(exec_time_ms)
640             FROM commands
641             WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
642             GROUP BY rtk_cmd
643             ORDER BY SUM(saved_tokens) DESC
644             LIMIT 10", // added: project filter in WHERE
645        )?;
646
647        let rows = stmt.query_map(params![project_exact, project_glob], |row| {
648            // added: params
649            Ok((
650                row.get::<_, String>(0)?,
651                row.get::<_, i64>(1)? as usize,
652                row.get::<_, i64>(2)? as usize,
653                row.get::<_, f64>(3)?,
654                row.get::<_, f64>(4)? as u64,
655            ))
656        })?;
657
658        Ok(rows.collect::<Result<Vec<_>, _>>()?)
659    }
660
661    fn get_by_day(
662        &self,
663        project_path: Option<&str>, // added
664    ) -> Result<Vec<(String, usize)>> {
665        let (project_exact, project_glob) = project_filter_params(project_path); // added
666        let mut stmt = self.conn.prepare(
667            "SELECT DATE(timestamp), SUM(saved_tokens)
668             FROM commands
669             WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
670             GROUP BY DATE(timestamp)
671             ORDER BY DATE(timestamp) DESC
672             LIMIT 30", // added: project filter in WHERE
673        )?;
674
675        let rows = stmt.query_map(params![project_exact, project_glob], |row| {
676            // added: params
677            Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
678        })?;
679
680        let mut result: Vec<_> = rows.collect::<Result<Vec<_>, _>>()?;
681        result.reverse();
682        Ok(result)
683    }
684
685    /// Get daily statistics for all recorded days.
686    ///
687    /// Returns one [`DayStats`] per day with commands executed, tokens saved,
688    /// and execution time metrics. Results are ordered chronologically (oldest first).
689    ///
690    /// # Examples
691    ///
692    /// ```no_run
693    /// use rtk::tracking::Tracker;
694    ///
695    /// let tracker = Tracker::new()?;
696    /// let days = tracker.get_all_days()?;
697    /// for day in days.iter().take(7) {
698    ///     println!("{}: {} commands, {} tokens saved",
699    ///         day.date, day.commands, day.saved_tokens);
700    /// }
701    /// # Ok::<(), anyhow::Error>(())
702    /// ```
703    pub fn get_all_days(&self) -> Result<Vec<DayStats>> {
704        self.get_all_days_filtered(None) // delegate to filtered variant
705    }
706
707    /// Get daily statistics filtered by project path. // added
708    pub fn get_all_days_filtered(&self, project_path: Option<&str>) -> Result<Vec<DayStats>> {
709        let (project_exact, project_glob) = project_filter_params(project_path); // added
710        let mut stmt = self.conn.prepare(
711            "SELECT
712                DATE(timestamp) as date,
713                COUNT(*) as commands,
714                SUM(input_tokens) as input,
715                SUM(output_tokens) as output,
716                SUM(saved_tokens) as saved,
717                SUM(exec_time_ms) as total_time
718             FROM commands
719             WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
720             GROUP BY DATE(timestamp)
721             ORDER BY DATE(timestamp) DESC", // added: project filter
722        )?;
723
724        let rows = stmt.query_map(params![project_exact, project_glob], |row| {
725            // added: params
726            let input = row.get::<_, i64>(2)? as usize;
727            let saved = row.get::<_, i64>(4)? as usize;
728            let commands = row.get::<_, i64>(1)? as usize;
729            let total_time = row.get::<_, i64>(5)? as u64;
730            let savings_pct = if input > 0 {
731                (saved as f64 / input as f64) * 100.0
732            } else {
733                0.0
734            };
735            let avg_time_ms = if commands > 0 {
736                total_time / commands as u64
737            } else {
738                0
739            };
740
741            Ok(DayStats {
742                date: row.get(0)?,
743                commands,
744                input_tokens: input,
745                output_tokens: row.get::<_, i64>(3)? as usize,
746                saved_tokens: saved,
747                savings_pct,
748                total_time_ms: total_time,
749                avg_time_ms,
750            })
751        })?;
752
753        let mut result: Vec<_> = rows.collect::<Result<Vec<_>, _>>()?;
754        result.reverse();
755        Ok(result)
756    }
757
758    /// Get weekly statistics grouped by week.
759    ///
760    /// Returns one [`WeekStats`] per week with aggregated metrics.
761    /// Weeks start on Sunday (SQLite default). Results ordered chronologically.
762    ///
763    /// # Examples
764    ///
765    /// ```no_run
766    /// use rtk::tracking::Tracker;
767    ///
768    /// let tracker = Tracker::new()?;
769    /// let weeks = tracker.get_by_week()?;
770    /// for week in weeks {
771    ///     println!("{} to {}: {} tokens saved",
772    ///         week.week_start, week.week_end, week.saved_tokens);
773    /// }
774    /// # Ok::<(), anyhow::Error>(())
775    /// ```
776    pub fn get_by_week(&self) -> Result<Vec<WeekStats>> {
777        self.get_by_week_filtered(None) // delegate to filtered variant
778    }
779
780    /// Get weekly statistics filtered by project path. // added
781    pub fn get_by_week_filtered(&self, project_path: Option<&str>) -> Result<Vec<WeekStats>> {
782        let (project_exact, project_glob) = project_filter_params(project_path); // added
783        let mut stmt = self.conn.prepare(
784            "SELECT
785                DATE(timestamp, 'weekday 0', '-6 days') as week_start,
786                DATE(timestamp, 'weekday 0') as week_end,
787                COUNT(*) as commands,
788                SUM(input_tokens) as input,
789                SUM(output_tokens) as output,
790                SUM(saved_tokens) as saved,
791                SUM(exec_time_ms) as total_time
792             FROM commands
793             WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
794             GROUP BY week_start
795             ORDER BY week_start DESC", // added: project filter
796        )?;
797
798        let rows = stmt.query_map(params![project_exact, project_glob], |row| {
799            // added: params
800            let input = row.get::<_, i64>(3)? as usize;
801            let saved = row.get::<_, i64>(5)? as usize;
802            let commands = row.get::<_, i64>(2)? as usize;
803            let total_time = row.get::<_, i64>(6)? as u64;
804            let savings_pct = if input > 0 {
805                (saved as f64 / input as f64) * 100.0
806            } else {
807                0.0
808            };
809            let avg_time_ms = if commands > 0 {
810                total_time / commands as u64
811            } else {
812                0
813            };
814
815            Ok(WeekStats {
816                week_start: row.get(0)?,
817                week_end: row.get(1)?,
818                commands,
819                input_tokens: input,
820                output_tokens: row.get::<_, i64>(4)? as usize,
821                saved_tokens: saved,
822                savings_pct,
823                total_time_ms: total_time,
824                avg_time_ms,
825            })
826        })?;
827
828        let mut result: Vec<_> = rows.collect::<Result<Vec<_>, _>>()?;
829        result.reverse();
830        Ok(result)
831    }
832
833    /// Get monthly statistics grouped by month.
834    ///
835    /// Returns one [`MonthStats`] per month (YYYY-MM format) with aggregated metrics.
836    /// Results ordered chronologically.
837    ///
838    /// # Examples
839    ///
840    /// ```no_run
841    /// use rtk::tracking::Tracker;
842    ///
843    /// let tracker = Tracker::new()?;
844    /// let months = tracker.get_by_month()?;
845    /// for month in months {
846    ///     println!("{}: {} tokens saved ({:.1}%)",
847    ///         month.month, month.saved_tokens, month.savings_pct);
848    /// }
849    /// # Ok::<(), anyhow::Error>(())
850    /// ```
851    pub fn get_by_month(&self) -> Result<Vec<MonthStats>> {
852        self.get_by_month_filtered(None) // delegate to filtered variant
853    }
854
855    /// Get monthly statistics filtered by project path. // added
856    pub fn get_by_month_filtered(&self, project_path: Option<&str>) -> Result<Vec<MonthStats>> {
857        let (project_exact, project_glob) = project_filter_params(project_path); // added
858        let mut stmt = self.conn.prepare(
859            "SELECT
860                strftime('%Y-%m', timestamp) as month,
861                COUNT(*) as commands,
862                SUM(input_tokens) as input,
863                SUM(output_tokens) as output,
864                SUM(saved_tokens) as saved,
865                SUM(exec_time_ms) as total_time
866             FROM commands
867             WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
868             GROUP BY month
869             ORDER BY month DESC", // added: project filter
870        )?;
871
872        let rows = stmt.query_map(params![project_exact, project_glob], |row| {
873            // added: params
874            let input = row.get::<_, i64>(2)? as usize;
875            let saved = row.get::<_, i64>(4)? as usize;
876            let commands = row.get::<_, i64>(1)? as usize;
877            let total_time = row.get::<_, i64>(5)? as u64;
878            let savings_pct = if input > 0 {
879                (saved as f64 / input as f64) * 100.0
880            } else {
881                0.0
882            };
883            let avg_time_ms = if commands > 0 {
884                total_time / commands as u64
885            } else {
886                0
887            };
888
889            Ok(MonthStats {
890                month: row.get(0)?,
891                commands,
892                input_tokens: input,
893                output_tokens: row.get::<_, i64>(3)? as usize,
894                saved_tokens: saved,
895                savings_pct,
896                total_time_ms: total_time,
897                avg_time_ms,
898            })
899        })?;
900
901        let mut result: Vec<_> = rows.collect::<Result<Vec<_>, _>>()?;
902        result.reverse();
903        Ok(result)
904    }
905
906    /// Get recent command history.
907    ///
908    /// Returns up to `limit` most recent command records, ordered by timestamp (newest first).
909    ///
910    /// # Arguments
911    ///
912    /// - `limit`: Maximum number of records to return
913    ///
914    /// # Examples
915    ///
916    /// ```no_run
917    /// use rtk::tracking::Tracker;
918    ///
919    /// let tracker = Tracker::new()?;
920    /// let recent = tracker.get_recent(10)?;
921    /// for cmd in recent {
922    ///     println!("{}: {} saved {:.1}%",
923    ///         cmd.timestamp, cmd.rtk_cmd, cmd.savings_pct);
924    /// }
925    /// # Ok::<(), anyhow::Error>(())
926    /// ```
927    #[allow(dead_code)]
928    pub fn get_recent(&self, limit: usize) -> Result<Vec<CommandRecord>> {
929        self.get_recent_filtered(limit, None) // delegate to filtered variant
930    }
931
932    /// Get recent command history filtered by project path. // added
933    pub fn get_recent_filtered(
934        &self,
935        limit: usize,
936        project_path: Option<&str>,
937    ) -> Result<Vec<CommandRecord>> {
938        let (project_exact, project_glob) = project_filter_params(project_path); // added
939        let mut stmt = self.conn.prepare(
940            "SELECT timestamp, rtk_cmd, saved_tokens, savings_pct
941             FROM commands
942             WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
943             ORDER BY timestamp DESC
944             LIMIT ?3", // added: project filter
945        )?;
946
947        let rows = stmt.query_map(
948            params![project_exact, project_glob, limit as i64], // added: project params
949            |row| {
950                Ok(CommandRecord {
951                    timestamp: DateTime::parse_from_rfc3339(&row.get::<_, String>(0)?)
952                        .map(|dt| dt.with_timezone(&Utc))
953                        .unwrap_or_else(|_| Utc::now()),
954                    rtk_cmd: row.get(1)?,
955                    saved_tokens: row.get::<_, i64>(2)? as usize,
956                    savings_pct: row.get(3)?,
957                })
958            },
959        )?;
960
961        Ok(rows.collect::<Result<Vec<_>, _>>()?)
962    }
963
964    /// Count commands since a given timestamp (for telemetry).
965    pub fn count_commands_since(&self, since: chrono::DateTime<chrono::Utc>) -> Result<i64> {
966        let ts = since.format("%Y-%m-%dT%H:%M:%S").to_string();
967        let count: i64 = self.conn.query_row(
968            "SELECT COUNT(*) FROM commands WHERE timestamp >= ?1",
969            params![ts],
970            |row| row.get(0),
971        )?;
972        Ok(count)
973    }
974
975    /// Get top N commands by frequency (for telemetry).
976    pub fn top_commands(&self, limit: usize) -> Result<Vec<String>> {
977        let mut stmt = self.conn.prepare(
978            "SELECT rtk_cmd, COUNT(*) as cnt FROM commands
979             GROUP BY rtk_cmd ORDER BY cnt DESC LIMIT ?1",
980        )?;
981        let rows = stmt.query_map(params![limit as i64], |row| {
982            let cmd: String = row.get(0)?;
983            // Extract just the command name (e.g. "rtk git status" → "git")
984            Ok(cmd.split_whitespace().nth(1).unwrap_or(&cmd).to_string())
985        })?;
986        Ok(rows.filter_map(|r| r.ok()).collect())
987    }
988
989    /// Get overall savings percentage (for telemetry).
990    pub fn overall_savings_pct(&self) -> Result<f64> {
991        let (total_input, total_saved): (i64, i64) = self.conn.query_row(
992            "SELECT COALESCE(SUM(input_tokens), 0), COALESCE(SUM(saved_tokens), 0) FROM commands",
993            [],
994            |row| Ok((row.get(0)?, row.get(1)?)),
995        )?;
996        if total_input > 0 {
997            Ok((total_saved as f64 / total_input as f64) * 100.0)
998        } else {
999            Ok(0.0)
1000        }
1001    }
1002
1003    /// Get total tokens saved across all tracked commands (for telemetry).
1004    pub fn total_tokens_saved(&self) -> Result<i64> {
1005        let saved: i64 = self.conn.query_row(
1006            "SELECT COALESCE(SUM(saved_tokens), 0) FROM commands",
1007            [],
1008            |row| row.get(0),
1009        )?;
1010        Ok(saved)
1011    }
1012
1013    /// Get tokens saved in the last 24 hours (for telemetry).
1014    pub fn tokens_saved_24h(&self, since: chrono::DateTime<chrono::Utc>) -> Result<i64> {
1015        let ts = since.format("%Y-%m-%dT%H:%M:%S").to_string();
1016        let saved: i64 = self.conn.query_row(
1017            "SELECT COALESCE(SUM(saved_tokens), 0) FROM commands WHERE timestamp >= ?1",
1018            params![ts],
1019            |row| row.get(0),
1020        )?;
1021        Ok(saved)
1022    }
1023
1024    /// Top N passthrough commands (0% savings) — commands missing a filter.
1025    /// Groups by first word only to avoid leaking arguments into telemetry.
1026    pub fn top_passthrough(&self, limit: usize) -> Result<Vec<(String, i64)>> {
1027        let mut stmt = self.conn.prepare(
1028            "SELECT TRIM(SUBSTR(original_cmd, 1, INSTR(original_cmd || ' ', ' ') - 1)) as tool,
1029             COUNT(*) as cnt FROM commands
1030             WHERE input_tokens = 0 AND output_tokens = 0
1031             GROUP BY tool ORDER BY cnt DESC LIMIT ?1",
1032        )?;
1033        let rows = stmt.query_map(params![limit as i64], |row| {
1034            let cmd: String = row.get(0)?;
1035            let count: i64 = row.get(1)?;
1036            Ok((cmd, count))
1037        })?;
1038        Ok(rows.filter_map(|r| r.ok()).collect())
1039    }
1040
1041    /// Count parse failures in the last 24 hours.
1042    pub fn parse_failures_since(&self, since: chrono::DateTime<chrono::Utc>) -> Result<i64> {
1043        let ts = since.format("%Y-%m-%dT%H:%M:%S").to_string();
1044        let count: i64 = self.conn.query_row(
1045            "SELECT COUNT(*) FROM parse_failures WHERE timestamp >= ?1",
1046            params![ts],
1047            |row| row.get(0),
1048        )?;
1049        Ok(count)
1050    }
1051
1052    /// Count commands with low savings (<30%) — filters that need improvement.
1053    pub fn low_savings_commands(&self, limit: usize) -> Result<Vec<(String, f64)>> {
1054        let mut stmt = self.conn.prepare(
1055            "SELECT rtk_cmd, AVG(savings_pct) as avg_sav FROM commands
1056             WHERE input_tokens > 0
1057             GROUP BY rtk_cmd
1058             HAVING avg_sav < 30.0 AND avg_sav > 0.0
1059             ORDER BY COUNT(*) DESC LIMIT ?1",
1060        )?;
1061        let rows = stmt.query_map(params![limit as i64], |row| {
1062            let cmd: String = row.get(0)?;
1063            let sav: f64 = row.get(1)?;
1064            let short = cmd.split_whitespace().take(3).collect::<Vec<_>>().join(" ");
1065            Ok((short, sav))
1066        })?;
1067        Ok(rows.filter_map(|r| r.ok()).collect())
1068    }
1069
1070    /// Average savings percentage per command (unweighted — each command name counts once).
1071    pub fn avg_savings_per_command(&self) -> Result<f64> {
1072        let avg: f64 = self.conn.query_row(
1073            "SELECT COALESCE(AVG(avg_sav), 0.0) FROM (
1074                SELECT rtk_cmd, AVG(savings_pct) as avg_sav
1075                FROM commands WHERE input_tokens > 0
1076                GROUP BY rtk_cmd
1077            )",
1078            [],
1079            |row| row.get(0),
1080        )?;
1081        Ok(avg)
1082    }
1083
1084    /// Count invocations of a specific meta-command (by rtk_cmd suffix).
1085    pub fn count_meta_command(&self, name: &str) -> Result<i64> {
1086        let pattern = format!("rtk {}", name);
1087        let count: i64 = self.conn.query_row(
1088            "SELECT COUNT(*) FROM commands WHERE rtk_cmd LIKE ?1 || '%'",
1089            params![pattern],
1090            |row| row.get(0),
1091        )?;
1092        Ok(count)
1093    }
1094
1095    /// Days since first recorded command (installation age).
1096    pub fn first_seen_days(&self) -> Result<i64> {
1097        let oldest: Option<String> =
1098            match self
1099                .conn
1100                .query_row("SELECT MIN(timestamp) FROM commands", [], |row| row.get(0))
1101            {
1102                Ok(v) => v,
1103                Err(rusqlite::Error::QueryReturnedNoRows) => None,
1104                Err(e) => return Err(anyhow::anyhow!("Failed to query first seen timestamp: {e}")),
1105            };
1106        match oldest {
1107            Some(ts) => {
1108                let first = chrono::NaiveDateTime::parse_from_str(&ts, "%Y-%m-%dT%H:%M:%S")
1109                    .or_else(|_| chrono::NaiveDateTime::parse_from_str(&ts, "%Y-%m-%d %H:%M:%S"))
1110                    .map(|dt| dt.and_utc())
1111                    .unwrap_or_else(|_| chrono::Utc::now());
1112                let days = (chrono::Utc::now() - first).num_days();
1113                Ok(days.max(0))
1114            }
1115            None => Ok(0),
1116        }
1117    }
1118
1119    /// Number of distinct active days in the last 30 days.
1120    pub fn active_days_30d(&self) -> Result<i64> {
1121        let since = (chrono::Utc::now() - chrono::Duration::days(30))
1122            .format("%Y-%m-%dT%H:%M:%S")
1123            .to_string();
1124        let count: i64 = self.conn.query_row(
1125            "SELECT COUNT(DISTINCT DATE(timestamp)) FROM commands WHERE timestamp >= ?1",
1126            params![since],
1127            |row| row.get(0),
1128        )?;
1129        Ok(count)
1130    }
1131
1132    /// Total number of recorded commands.
1133    pub fn commands_total(&self) -> Result<i64> {
1134        let count: i64 = self
1135            .conn
1136            .query_row("SELECT COUNT(*) FROM commands", [], |row| row.get(0))?;
1137        Ok(count)
1138    }
1139
1140    /// Ecosystem distribution as percentages (top categories by command prefix).
1141    pub fn ecosystem_mix(&self) -> Result<Vec<(String, f64)>> {
1142        let total: f64 = self.conn.query_row(
1143            "SELECT COUNT(*) FROM commands WHERE input_tokens > 0 AND timestamp >= datetime('now', '-90 days')",
1144            [],
1145            |row| row.get(0),
1146        )?;
1147        if total == 0.0 {
1148            return Ok(vec![]);
1149        }
1150        let mut stmt = self.conn.prepare(
1151            "SELECT rtk_cmd, COUNT(*) as cnt FROM commands
1152             WHERE input_tokens > 0 AND timestamp >= datetime('now', '-90 days')
1153             GROUP BY rtk_cmd ORDER BY cnt DESC",
1154        )?;
1155        let mut categories: std::collections::HashMap<String, f64> =
1156            std::collections::HashMap::new();
1157        let rows = stmt.query_map([], |row| {
1158            let cmd: String = row.get(0)?;
1159            let cnt: f64 = row.get(1)?;
1160            Ok((cmd, cnt))
1161        })?;
1162        for row in rows.flatten() {
1163            let cat = categorize_command(&row.0);
1164            *categories.entry(cat).or_default() += row.1;
1165        }
1166        let mut result: Vec<(String, f64)> = categories
1167            .into_iter()
1168            .map(|(cat, cnt)| (cat, (cnt / total * 100.0).round()))
1169            .collect();
1170        result.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1171        result.truncate(8);
1172        Ok(result)
1173    }
1174
1175    /// Tokens saved in the last 30 days.
1176    pub fn tokens_saved_30d(&self) -> Result<i64> {
1177        let since = (chrono::Utc::now() - chrono::Duration::days(30))
1178            .format("%Y-%m-%dT%H:%M:%S")
1179            .to_string();
1180        let saved: i64 = self.conn.query_row(
1181            "SELECT COALESCE(SUM(saved_tokens), 0) FROM commands WHERE timestamp >= ?1",
1182            params![since],
1183            |row| row.get(0),
1184        )?;
1185        Ok(saved)
1186    }
1187
1188    /// Number of distinct project paths.
1189    pub fn projects_count(&self) -> Result<i64> {
1190        let count: i64 = self.conn.query_row(
1191            "SELECT COUNT(DISTINCT project_path) FROM commands WHERE project_path != ''",
1192            [],
1193            |row| row.get(0),
1194        )?;
1195        Ok(count)
1196    }
1197}
1198
1199/// Map an rtk_cmd to an ecosystem category for telemetry.
1200fn categorize_command(rtk_cmd: &str) -> String {
1201    let parts: Vec<&str> = rtk_cmd.split_whitespace().collect();
1202    let tool = parts.get(1).copied().unwrap_or("other");
1203    match tool {
1204        "git" | "gh" | "gt" => "git",
1205        "cargo" => "cargo",
1206        "npm" | "npx" | "pnpm" | "vitest" | "tsc" | "lint" | "prettier" | "next" | "playwright"
1207        | "prisma" => "js",
1208        "pytest" | "ruff" | "mypy" | "pip" => "python",
1209        "go" | "golangci-lint" => "go",
1210        "docker" | "kubectl" => "cloud",
1211        "rspec" | "rubocop" | "rake" => "ruby",
1212        "dotnet" => "dotnet",
1213        "ls" | "tree" | "grep" | "find" | "wc" | "read" | "env" | "json" | "log" | "smart"
1214        | "diff" | "deps" | "summary" | "format" => "system",
1215        _ => "other",
1216    }
1217    .to_string()
1218}
1219
1220fn get_db_path() -> Result<PathBuf> {
1221    // Priority 1: Environment variable RTK_DB_PATH
1222    if let Ok(custom_path) = std::env::var("RTK_DB_PATH") {
1223        return Ok(PathBuf::from(custom_path));
1224    }
1225
1226    // Priority 2: Configuration file
1227    if let Ok(config) = crate::core::config::Config::load() {
1228        if let Some(db_path) = config.tracking.database_path {
1229            return Ok(db_path);
1230        }
1231    }
1232
1233    // Priority 3: Default platform-specific location
1234    let data_dir = dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("."));
1235    Ok(data_dir.join(RTK_DATA_DIR).join(HISTORY_DB))
1236}
1237
1238/// Individual parse failure record.
1239#[derive(Debug)]
1240pub struct ParseFailureRecord {
1241    pub timestamp: String,
1242    pub raw_command: String,
1243    #[allow(dead_code)]
1244    pub error_message: String,
1245    pub fallback_succeeded: bool,
1246}
1247
1248/// Aggregated parse failure summary.
1249#[derive(Debug)]
1250pub struct ParseFailureSummary {
1251    pub total: usize,
1252    pub recovery_rate: f64,
1253    pub top_commands: Vec<(String, usize)>,
1254    pub recent: Vec<ParseFailureRecord>,
1255}
1256
1257/// Record a parse failure without ever crashing.
1258/// Silently ignores all errors — used in the fallback path.
1259pub fn record_parse_failure_silent(raw_command: &str, error_message: &str, succeeded: bool) {
1260    if tracking_disabled() {
1261        return;
1262    }
1263    if let Ok(tracker) = Tracker::new() {
1264        let _ = tracker.record_parse_failure(raw_command, error_message, succeeded);
1265    }
1266}
1267
1268fn tracking_disabled() -> bool {
1269    tracking_disabled_for(
1270        std::env::var("RTK_TRACKING_DISABLED").ok().as_deref(),
1271        std::env::var("RTK_HOSTED").ok().as_deref(),
1272    )
1273}
1274
1275/// Pure gate logic, split from [`tracking_disabled`] for testability.
1276/// Hosted embeddings (compile-time `hosted` feature or `RTK_HOSTED=1`)
1277/// must not write tracking state into the host user's `$HOME`.
1278fn tracking_disabled_for(tracking_disabled: Option<&str>, hosted: Option<&str>) -> bool {
1279    cfg!(feature = "hosted") || tracking_disabled == Some("1") || hosted == Some("1")
1280}
1281
1282/// Estimate token count from text using ~4 chars = 1 token heuristic.
1283///
1284/// This is a fast approximation suitable for tracking purposes.
1285/// For precise counts, integrate with your LLM's tokenizer API.
1286///
1287/// # Formula
1288///
1289/// `tokens = ceil(chars / 4)`
1290///
1291/// # Examples
1292///
1293/// ```
1294/// use rtk::tracking::estimate_tokens;
1295///
1296/// assert_eq!(estimate_tokens(""), 0);
1297/// assert_eq!(estimate_tokens("abcd"), 1);  // 4 chars = 1 token
1298/// assert_eq!(estimate_tokens("abcde"), 2); // 5 chars = ceil(1.25) = 2
1299/// assert_eq!(estimate_tokens("hello world"), 3); // 11 chars = ceil(2.75) = 3
1300/// ```
1301pub fn estimate_tokens(text: &str) -> usize {
1302    // ~4 chars per token on average
1303    (text.len() as f64 / 4.0).ceil() as usize
1304}
1305
1306/// Helper struct for timing command execution
1307/// Helper for timing command execution and tracking results.
1308///
1309/// Preferred API for tracking commands. Automatically measures execution time
1310/// and records token savings. Use instead of the deprecated [`track`] function.
1311///
1312/// # Examples
1313///
1314/// ```ignore
1315/// use rtk::tracking::TimedExecution;
1316///
1317/// let timer = TimedExecution::start();
1318/// let input = execute_standard_command()?;
1319/// let output = execute_rtk_command()?;
1320/// timer.track("ls -la", "rtk ls", &input, &output);
1321/// # Ok::<(), anyhow::Error>(())
1322/// ```
1323pub struct TimedExecution {
1324    start: Instant,
1325}
1326
1327impl TimedExecution {
1328    /// Start timing a command execution.
1329    ///
1330    /// Creates a new timer that starts measuring elapsed time immediately.
1331    /// Call [`track`](Self::track) or [`track_passthrough`](Self::track_passthrough)
1332    /// when the command completes.
1333    ///
1334    /// # Examples
1335    ///
1336    /// ```no_run
1337    /// use rtk::tracking::TimedExecution;
1338    ///
1339    /// let timer = TimedExecution::start();
1340    /// // ... execute command ...
1341    /// timer.track("cmd", "rtk cmd", "input", "output");
1342    /// ```
1343    pub fn start() -> Self {
1344        Self {
1345            start: Instant::now(),
1346        }
1347    }
1348
1349    /// Track the command with elapsed time and token counts.
1350    ///
1351    /// Records the command execution with:
1352    /// - Elapsed time since [`start`](Self::start)
1353    /// - Token counts estimated from input/output strings
1354    /// - Calculated savings metrics
1355    ///
1356    /// # Arguments
1357    ///
1358    /// - `original_cmd`: Standard command (e.g., "ls -la")
1359    /// - `rtk_cmd`: RTK command used (e.g., "rtk ls")
1360    /// - `input`: Standard command output (for token estimation)
1361    /// - `output`: RTK command output (for token estimation)
1362    ///
1363    /// # Examples
1364    ///
1365    /// ```no_run
1366    /// use rtk::tracking::TimedExecution;
1367    ///
1368    /// let timer = TimedExecution::start();
1369    /// let input = "long output...";
1370    /// let output = "short output";
1371    /// timer.track("ls -la", "rtk ls", input, output);
1372    /// ```
1373    pub fn track(&self, original_cmd: &str, rtk_cmd: &str, input: &str, output: &str) {
1374        if tracking_disabled() {
1375            return;
1376        }
1377        let elapsed_ms = self.start.elapsed().as_millis() as u64;
1378        let input_tokens = estimate_tokens(input);
1379        let output_tokens = estimate_tokens(output);
1380
1381        if let Ok(tracker) = Tracker::new() {
1382            let _ = tracker.record(
1383                original_cmd,
1384                rtk_cmd,
1385                input_tokens,
1386                output_tokens,
1387                elapsed_ms,
1388            );
1389        }
1390    }
1391
1392    /// Track passthrough commands (timing-only, no token counting).
1393    ///
1394    /// For commands that stream output or run interactively where output
1395    /// cannot be captured. Records execution time but sets tokens to 0
1396    /// (does not dilute savings statistics).
1397    ///
1398    /// # Arguments
1399    ///
1400    /// - `original_cmd`: Standard command (e.g., "git tag --list")
1401    /// - `rtk_cmd`: RTK command used (e.g., "rtk git tag --list")
1402    ///
1403    /// # Examples
1404    ///
1405    /// ```no_run
1406    /// use rtk::tracking::TimedExecution;
1407    ///
1408    /// let timer = TimedExecution::start();
1409    /// // ... execute streaming command ...
1410    /// timer.track_passthrough("git tag", "rtk git tag");
1411    /// ```
1412    pub fn track_passthrough(&self, original_cmd: &str, rtk_cmd: &str) {
1413        if tracking_disabled() {
1414            return;
1415        }
1416        let elapsed_ms = self.start.elapsed().as_millis() as u64;
1417        // input_tokens=0, output_tokens=0 won't dilute savings statistics
1418        if let Ok(tracker) = Tracker::new() {
1419            let _ = tracker.record(original_cmd, rtk_cmd, 0, 0, elapsed_ms);
1420        }
1421    }
1422}
1423
1424/// Format OsString args for tracking display.
1425///
1426/// Joins arguments with spaces, converting each to UTF-8 (lossy).
1427/// Useful for displaying command arguments in tracking records.
1428///
1429/// # Examples
1430///
1431/// ```
1432/// use std::ffi::OsString;
1433/// use rtk::tracking::args_display;
1434///
1435/// let args = vec![OsString::from("status"), OsString::from("--short")];
1436/// assert_eq!(args_display(&args), "status --short");
1437/// ```
1438pub fn args_display(args: &[OsString]) -> String {
1439    args.iter()
1440        .map(|a| a.to_string_lossy())
1441        .collect::<Vec<_>>()
1442        .join(" ")
1443}
1444
1445#[cfg(test)]
1446mod tests {
1447    use super::*;
1448
1449    // tracking_disabled_for — hosted embeddings (env or feature) must not
1450    // write tracking state into the host user's $HOME.
1451    #[test]
1452    fn test_tracking_disabled_for_hosted_or_disabled_env() {
1453        assert!(tracking_disabled_for(None, Some("1")));
1454        assert!(tracking_disabled_for(Some("1"), None));
1455        assert!(tracking_disabled_for(Some("1"), Some("1")));
1456    }
1457
1458    #[test]
1459    #[cfg(not(feature = "hosted"))]
1460    fn test_tracking_disabled_for_default_off() {
1461        assert!(!tracking_disabled_for(None, None));
1462        assert!(!tracking_disabled_for(Some("0"), Some("0")));
1463        assert!(!tracking_disabled_for(Some(""), Some("")));
1464    }
1465
1466    // 1. estimate_tokens — verify ~4 chars/token ratio
1467    #[test]
1468    fn test_estimate_tokens() {
1469        assert_eq!(estimate_tokens(""), 0);
1470        assert_eq!(estimate_tokens("abcd"), 1); // 4 chars = 1 token
1471        assert_eq!(estimate_tokens("abcde"), 2); // 5 chars = ceil(1.25) = 2
1472        assert_eq!(estimate_tokens("a"), 1); // 1 char = ceil(0.25) = 1
1473        assert_eq!(estimate_tokens("12345678"), 2); // 8 chars = 2 tokens
1474    }
1475
1476    // 2. args_display — format OsString vec
1477    #[test]
1478    fn test_args_display() {
1479        let args = vec![OsString::from("status"), OsString::from("--short")];
1480        assert_eq!(args_display(&args), "status --short");
1481        assert_eq!(args_display(&[]), "");
1482
1483        let single = vec![OsString::from("log")];
1484        assert_eq!(args_display(&single), "log");
1485    }
1486
1487    // 3. Tracker::record + get_recent — round-trip DB
1488    #[test]
1489    fn test_tracker_record_and_recent() {
1490        let tracker = Tracker::new().expect("Failed to create tracker");
1491
1492        // Use unique test identifier to avoid conflicts with other tests
1493        let test_cmd = format!("rtk git status test_{}", std::process::id());
1494
1495        tracker
1496            .record("git status", &test_cmd, 100, 20, 50)
1497            .expect("Failed to record");
1498
1499        let recent = tracker.get_recent(10).expect("Failed to get recent");
1500
1501        // Find our specific test record
1502        let test_record = recent
1503            .iter()
1504            .find(|r| r.rtk_cmd == test_cmd)
1505            .expect("Test record not found in recent commands");
1506
1507        assert_eq!(test_record.saved_tokens, 80);
1508        assert_eq!(test_record.savings_pct, 80.0);
1509    }
1510
1511    // 4. track_passthrough doesn't dilute stats (input=0, output=0)
1512    #[test]
1513    fn test_track_passthrough_no_dilution() {
1514        let tracker = Tracker::new().expect("Failed to create tracker");
1515
1516        // Use unique test identifiers
1517        let pid = std::process::id();
1518        let cmd1 = format!("rtk cmd1_test_{}", pid);
1519        let cmd2 = format!("rtk cmd2_passthrough_test_{}", pid);
1520
1521        // Record one real command with 80% savings
1522        tracker
1523            .record("cmd1", &cmd1, 1000, 200, 10)
1524            .expect("Failed to record cmd1");
1525
1526        // Record passthrough (0, 0)
1527        tracker
1528            .record("cmd2", &cmd2, 0, 0, 5)
1529            .expect("Failed to record passthrough");
1530
1531        // Verify both records exist in recent history
1532        let recent = tracker.get_recent(20).expect("Failed to get recent");
1533
1534        let record1 = recent
1535            .iter()
1536            .find(|r| r.rtk_cmd == cmd1)
1537            .expect("cmd1 record not found");
1538        let record2 = recent
1539            .iter()
1540            .find(|r| r.rtk_cmd == cmd2)
1541            .expect("passthrough record not found");
1542
1543        // Verify cmd1 has 80% savings
1544        assert_eq!(record1.saved_tokens, 800);
1545        assert_eq!(record1.savings_pct, 80.0);
1546
1547        // Verify passthrough has 0% savings
1548        assert_eq!(record2.saved_tokens, 0);
1549        assert_eq!(record2.savings_pct, 0.0);
1550
1551        // This validates that passthrough (0 input, 0 output) doesn't dilute stats
1552        // because the savings calculation is correct for both cases
1553    }
1554
1555    // 5. TimedExecution::track records with exec_time > 0
1556    #[test]
1557    fn test_timed_execution_records_time() {
1558        let timer = TimedExecution::start();
1559        std::thread::sleep(std::time::Duration::from_millis(10));
1560        timer.track("test cmd", "rtk test", "raw input data", "filtered");
1561
1562        // Verify via DB that record exists
1563        let tracker = Tracker::new().expect("Failed to create tracker");
1564        let recent = tracker.get_recent(5).expect("Failed to get recent");
1565        assert!(recent.iter().any(|r| r.rtk_cmd == "rtk test"));
1566    }
1567
1568    // 6. TimedExecution::track_passthrough records with 0 tokens
1569    #[test]
1570    fn test_timed_execution_passthrough() {
1571        let timer = TimedExecution::start();
1572        timer.track_passthrough("git tag", "rtk git tag (passthrough)");
1573
1574        let tracker = Tracker::new().expect("Failed to create tracker");
1575        let recent = tracker.get_recent(5).expect("Failed to get recent");
1576
1577        let pt = recent
1578            .iter()
1579            .find(|r| r.rtk_cmd.contains("passthrough"))
1580            .expect("Passthrough record not found");
1581
1582        // savings_pct should be 0 for passthrough
1583        assert_eq!(pt.savings_pct, 0.0);
1584        assert_eq!(pt.saved_tokens, 0);
1585    }
1586
1587    // 7. get_db_path respects environment variable RTK_DB_PATH
1588    // 8. get_db_path falls back to default when no custom config
1589    // Combined into one test to avoid env var race between parallel tests
1590    #[test]
1591    fn test_db_path_env_and_default() {
1592        use std::env;
1593        use std::sync::Mutex;
1594        static ENV_LOCK: Mutex<()> = Mutex::new(());
1595        let _guard = ENV_LOCK.lock().unwrap();
1596
1597        let custom_path = env::temp_dir().join("rtk_test_custom.db");
1598        env::set_var("RTK_DB_PATH", &custom_path);
1599        let db_path = get_db_path().expect("Failed to get db path");
1600        assert_eq!(db_path, custom_path);
1601
1602        env::remove_var("RTK_DB_PATH");
1603        let db_path = get_db_path().expect("Failed to get db path");
1604        assert!(
1605            db_path.ends_with("rtk/history.db"),
1606            "expected default path ending with rtk/history.db, got: {}",
1607            db_path.display()
1608        );
1609    }
1610
1611    // 9. project_filter_params uses GLOB pattern with * wildcard // added
1612    #[test]
1613    fn test_project_filter_params_glob_pattern() {
1614        let (exact, glob) = project_filter_params(Some("/home/user/project"));
1615        assert_eq!(exact.unwrap(), "/home/user/project");
1616        // Must use * (GLOB) not % (LIKE) for subdirectory prefix matching
1617        let glob_val = glob.unwrap();
1618        assert!(glob_val.ends_with('*'), "GLOB pattern must end with *");
1619        assert!(!glob_val.contains('%'), "Must not contain LIKE wildcard %");
1620        assert_eq!(
1621            glob_val,
1622            format!("/home/user/project{}*", std::path::MAIN_SEPARATOR)
1623        );
1624    }
1625
1626    // 10. project_filter_params returns None for None input // added
1627    #[test]
1628    fn test_project_filter_params_none() {
1629        let (exact, glob) = project_filter_params(None);
1630        assert!(exact.is_none());
1631        assert!(glob.is_none());
1632    }
1633
1634    // 11. GLOB pattern safe with underscores in path names // added
1635    #[test]
1636    fn test_project_filter_params_underscore_safe() {
1637        // In LIKE, _ matches any single char; in GLOB, _ is literal
1638        let (exact, glob) = project_filter_params(Some("/home/user/my_project"));
1639        assert_eq!(exact.unwrap(), "/home/user/my_project");
1640        let glob_val = glob.unwrap();
1641        // _ must be preserved literally (GLOB treats _ as literal, LIKE does not)
1642        assert!(glob_val.contains("my_project"));
1643        assert_eq!(
1644            glob_val,
1645            format!("/home/user/my_project{}*", std::path::MAIN_SEPARATOR)
1646        );
1647    }
1648
1649    // 12. record_parse_failure + get_parse_failure_summary roundtrip
1650    #[test]
1651    fn test_parse_failure_roundtrip() {
1652        let tracker = Tracker::new().expect("Failed to create tracker");
1653        let test_cmd = format!("git -C /path status test_{}", std::process::id());
1654
1655        tracker
1656            .record_parse_failure(&test_cmd, "unrecognized subcommand", true)
1657            .expect("Failed to record parse failure");
1658
1659        let summary = tracker
1660            .get_parse_failure_summary()
1661            .expect("Failed to get summary");
1662
1663        assert!(summary.total >= 1);
1664        assert!(summary.recent.iter().any(|r| r.raw_command == test_cmd));
1665    }
1666
1667    // 13. recovery_rate calculation
1668    #[test]
1669    fn test_parse_failure_recovery_rate() {
1670        let tracker = Tracker::new().expect("Failed to create tracker");
1671        let pid = std::process::id();
1672
1673        // 2 successes, 1 failure
1674        tracker
1675            .record_parse_failure(&format!("cmd_ok1_{}", pid), "err", true)
1676            .unwrap();
1677        tracker
1678            .record_parse_failure(&format!("cmd_ok2_{}", pid), "err", true)
1679            .unwrap();
1680        tracker
1681            .record_parse_failure(&format!("cmd_fail_{}", pid), "err", false)
1682            .unwrap();
1683
1684        let summary = tracker.get_parse_failure_summary().unwrap();
1685        // We can't assert exact rate because other tests may have added records,
1686        // but we can verify recovery_rate is between 0 and 100
1687        assert!(summary.recovery_rate >= 0.0 && summary.recovery_rate <= 100.0);
1688    }
1689
1690    #[test]
1691    fn test_reset_all_clears_both_tables() {
1692        let tracker = Tracker::new_in_memory().expect("Failed to create in-memory tracker");
1693        let pid = std::process::id();
1694
1695        // Insert into commands
1696        tracker
1697            .record(
1698                "git status",
1699                &format!("rtk git status reset_test_{}", pid),
1700                100,
1701                20,
1702                50,
1703            )
1704            .expect("Failed to record command");
1705
1706        // Insert into parse_failures
1707        tracker
1708            .record_parse_failure(&format!("bad_cmd_reset_test_{}", pid), "parse error", false)
1709            .expect("Failed to record parse failure");
1710
1711        // Reset everything
1712        tracker.reset_all().expect("Failed to reset");
1713
1714        // Both tables should be empty
1715        let summary = tracker.get_summary().expect("Failed to get summary");
1716        assert_eq!(
1717            summary.total_commands, 0,
1718            "commands table should be empty after reset"
1719        );
1720
1721        let failures = tracker
1722            .get_parse_failure_summary()
1723            .expect("Failed to get failure summary");
1724        assert_eq!(
1725            failures.total, 0,
1726            "parse_failures table should be empty after reset"
1727        );
1728    }
1729}