mirror of
https://github.com/rtk-ai/rtk.git
synced 2026-09-19 07:33:17 +08:00
8fae5b067d
* fix: P1 exit codes, grep regex perf, SQLite WAL (#631) * fix: P1 exit codes, grep regex perf, SQLite concurrency Exit code propagation (same pattern as existing modules): - wget_cmd: run() and run_stdout() now exit on failure - container: docker_logs, kubectl_pods/services/logs now check status before parsing JSON (was showing "No pods found" on error) - pnpm_cmd: replace bail!() with eprint + process::exit in run_list and run_install Performance: - grep_cmd: compile context regex once before loop instead of per-line in clean_line() (was N compilations per grep call) Data integrity: - tracking: add PRAGMA journal_mode=WAL and busy_timeout=5000 to prevent SQLite corruption with concurrent Claude Code instances Signed-off-by: Patrick <patrick@rtk.ai> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * fix: address review findings on P1 fixes - tracking: WAL pragma non-fatal (NFS/read-only compat) - wget: forward raw stderr on failure, track raw==raw (no fake savings) - container: remove stderr shadow in docker_logs, add empty-stderr guard on all 4 new exit code paths for consistency with prisma pattern Signed-off-by: Patrick <patrick@rtk.ai> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> --------- Signed-off-by: Patrick <patrick@rtk.ai> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * fix: raise output caps for P0 bugs (#617, #618, #620) (#630) * fix: raise output caps for grep, git status, and parser fallback (#617, #618, #620) - grep: per-file match cap 10 → 25, global max 50 → 200 - git status: file list caps 5/5/3 → 15/15/10 - parser fallback: truncate 500 → 2000 chars across all modules These P0 bugs caused LLM retry loops when RTK returned less signal than the raw command, making RTK worse than not using it. Fixes #617, #618, #620 Signed-off-by: Patrick <patrick@rtk.ai> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * fix: update README example and add truncation tests for modified/untracked - parser/README.md: update example from 500 → 2000 to match code - git.rs: add test_format_status_modified_truncation (cap 15) - git.rs: add test_format_status_untracked_truncation (cap 10) Signed-off-by: Patrick <patrick@rtk.ai> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * refactor: extract output caps into [limits] config section Move hardcoded caps into config.toml so users can tune them: [limits] grep_max_results = 200 # global grep match limit grep_max_per_file = 25 # per-file match limit status_max_files = 15 # staged/modified file list cap status_max_untracked = 10 # untracked file list cap passthrough_max_chars = 2000 # parser fallback truncation All 8 modules now read from config::limits() instead of hardcoded values. Defaults unchanged from previous commit. Signed-off-by: Patrick <patrick@rtk.ai> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> --------- Signed-off-by: Patrick <patrick@rtk.ai> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * feat(.claude): add /rtk-triage skill — cross-analysis of PRs and issues (#662) * feat(.claude): add /rtk-triage skill — orchestrated PR+issue cross-analysis New skill that runs issue-triage + pr-triage in parallel then produces a cross-analysis layer that neither skill can do individually: - Double coverage detection: identifies when 2+ PRs target the same issue (via body scan + file overlap), recommends which to keep/close - Security gap detection: for security review issues, maps each finding to a PR (or flags it as uncovered) - P0/P1 bugs without PR: groups by pattern to suggest sprint batching - Our dirty PRs: identifies probable cause (conflict with sibling PR, needs rebase, missing linked issue) Output is saved automatically to claudedocs/RTK-YYYY-MM-DD.md. Usage: /rtk-triage (French, auto-save) /rtk-triage en (English output) Signed-off-by: Florian Bruniaux <florian@bel-etage.com> Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com> * docs(architecture): update module count to 66 Sync ARCHITECTURE.md with current main.rs state. Previous count (60) was stale since several modules were added (dotnet_cmd, dotnet_format_report, dotnet_trx, npm_cmd, gt_cmd, etc.). Signed-off-by: Florian Bruniaux <florian@bel-etage.com> Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com> --------- Signed-off-by: Florian Bruniaux <florian@bel-etage.com> Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com> * fix: subcommand routing drops unrecognized subcommands (#600) (#601) - git stash: pass unknown subcommands (save, branch, clear) through instead of silently falling back to git stash push - git branch: add --show-current, --set-upstream-to, --format, --sort to flag detection so they don't get overridden by -a injection - pip: replace bail!() with passthrough for unknown subcommands (freeze, download, wheel, etc.) Fixes #600 Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * fix: resolve cargo fmt + 54 clippy warnings blocking CI (#663) cargo fmt diffs in config.rs, git.rs, playwright_cmd.rs were failing the fmt CI check, which cascaded to block clippy/test/security on PRs #632, #635, #638. Also fixes all clippy warnings: dead code annotations, iterator simplifications, assert patterns, and unnecessary allocations. Signed-off-by: Patrick Szymkowiak <patrick@rtk-ai.app> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * fix: discover absolute paths + git global options (#485, #163) (#518) * fix: discover classifies absolute paths like /usr/bin/grep (#485) Normalize absolute binary paths before classification: /usr/bin/grep → grep, /bin/ls → ls, /usr/local/bin/git → git Adds strip_absolute_path() helper + 5 tests. Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * fix: discover and rewrite support git global options -C, --no-pager, etc. (#163) Strip git global options (-C <path>, -c <key=val>, --git-dir, --work-tree, --no-pager, --no-optional-locks, --bare, --literal-pathspecs) before classification so git -C /tmp status is recognized as rtk git. Rewrite preserves global options: git -C /tmp status → rtk git -C /tmp status Adds GIT_GLOBAL_OPT lazy_static regex + strip_git_global_opts() helper + 6 tests. Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> --------- Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * fix: prevent double `--` separator in cargo clippy with -p flags (#519) When running `rtk cargo clippy -p my-crate -- -D warnings`, Clap with `trailing_var_arg = true` preserves the `--` in parsed args when flags precede it. `restore_double_dash()` then added a second `--`, producing `cargo clippy -p my-crate -- -- -D warnings`. This caused rustc to interpret `-D` as a filename instead of a lint flag. Fix: skip restoration when args already contain `--` (Clap preserved it). Fixes #496 Signed-off-by: Ousama Ben Younes <benyounes.ousama@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * ci: add PR template + target branch check (#521) - PR template reminds contributors to target develop - CI workflow labels PRs targeting master with 'wrong-base' and posts a comment - Excludes develop→master PRs (maintainer releases) Signed-off-by: Patrick <patrick@rtk-ai.com> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * fix: prevent rtk read from corrupting JSON/YAML/data files (#522) Add Language::Data variant for data formats (JSON, YAML, TOML, XML, CSV, etc.) with empty comment patterns to prevent comment stripping. AggressiveFilter falls back to MinimalFilter for data files. Fixes #464 Signed-off-by: Ousama Ben Younes <benyounes.ousama@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> * fix: skip rewriting find/fd in pipes to preserve xargs compatibility (#439) (#563) rtk find outputs a grouped format incompatible with pipe consumers like xargs, grep, wc, sort. Skip rewrite when find/fd is followed by a pipe, preserving native one-per-line output. Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * fix: add hint when git diff is truncated + fix --no-compact passthrough (#427) (#564) When compact_diff truncates output, append a hint line so Claude knows how to get the full diff: [full diff: rtk git diff --no-compact] Also fix --no-compact flag being passed to git (causing usage error) and remove decorative emoji from compact_diff output. Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * fix: propagate exit codes in git diff, status+args, commit, and branch (#632) 4 P1 bugs where git exit codes were swallowed: - git diff: failure silently printed empty stat output - git status (with args): failure was filtered instead of propagated - git commit: failure printed "FAILED" but returned Ok(()) breaking pre-commit hooks - git branch (list mode): failure was silently ignored All now follow the established pattern: eprint stderr, track raw==raw, process::exit(code). Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * feat: add 5 new TOML filters (ollama, nx, gradle, spring-boot, jira) (#635) * feat: add 5 new TOML built-in filters (ollama, nx, gradle, spring-boot, jira) New filters for commands not covered by Rust modules: - ollama: strip ANSI spinners, keep final text response (#624) - nx: strip Nx monorepo noise, keep build results (#444) - gradle/gradlew: strip UP-TO-DATE tasks, keep build summary (#147) - spring-boot: strip banner and verbose logs, keep startup/errors (#147) - jira: strip blanks, truncate wide columns (#524) All 5 filters pass inline tests via rtk verify (123/123). Updated builtin filter count: 47 -> 52. Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * feat: add 5 more TOML filters (turbo, mise, just, task, yadm) New filters for task runners and git wrapper: - turbo: strip cache/Tasks/Duration noise, keep task output (#531) - mise: strip install/download progress, keep task results (#607) - just: strip blanks and recipe headers, keep output (#607) - task: strip task headers and up-to-date lines, keep results (#607) - yadm: strip hint lines, compact git-like output (#567) All verified with fake binaries through catch-all TOML engine. 137/137 TOML tests pass, 934 Rust tests pass. Updated builtin filter count: 52 -> 57. Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> --------- Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> * fix: replace emojis with plain text in git status output (#603) (#638) Git status output used emojis (📌, 📝, ❓, ✅, ⚠️) that confuse non-Claude LLMs (GPT, etc.) causing retry loops. Replace with plain text labels (branch:, modified:, staged:, untracked:, conflicts:). Also add "clean — nothing to commit" when working tree is clean, so LLMs understand the repo state without ambiguity. Before: 📌 master After: branch: master clean — nothing to commit Fixes #603 Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> --------- Signed-off-by: Patrick <patrick@rtk.ai> Signed-off-by: Patrick szymkowiak <patrick.szymkowiak@innovtech.eu> Signed-off-by: Florian Bruniaux <florian@bel-etage.com> Signed-off-by: Florian BRUNIAUX <florian@bruniaux.com> Signed-off-by: Patrick Szymkowiak <patrick@rtk-ai.app> Signed-off-by: Ousama Ben Younes <benyounes.ousama@gmail.com> Signed-off-by: Patrick <patrick@rtk-ai.com> Co-authored-by: Florian BRUNIAUX <florian@bruniaux.com> Co-authored-by: Ben Younes <benyounes.ousama@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1430 lines
48 KiB
Rust
1430 lines
48 KiB
Rust
//! Token savings tracking and analytics system.
|
|
//!
|
|
//! This module provides comprehensive tracking of RTK command executions,
|
|
//! recording token savings, execution times, and providing aggregation APIs
|
|
//! for daily/weekly/monthly statistics.
|
|
//!
|
|
//! # Architecture
|
|
//!
|
|
//! - Storage: SQLite database (~/.local/share/rtk/tracking.db)
|
|
//! - Retention: 90-day automatic cleanup
|
|
//! - Metrics: Input/output tokens, savings %, execution time
|
|
//!
|
|
//! # Quick Start
|
|
//!
|
|
//! ```no_run
|
|
//! use rtk::tracking::{TimedExecution, Tracker};
|
|
//!
|
|
//! // Track a command execution
|
|
//! let timer = TimedExecution::start();
|
|
//! let input = "raw output";
|
|
//! let output = "filtered output";
|
|
//! timer.track("ls -la", "rtk ls", input, output);
|
|
//!
|
|
//! // Query statistics
|
|
//! let tracker = Tracker::new().unwrap();
|
|
//! let summary = tracker.get_summary().unwrap();
|
|
//! println!("Saved {} tokens", summary.total_saved);
|
|
//! ```
|
|
//!
|
|
//! See [docs/tracking.md](../docs/tracking.md) for full documentation.
|
|
|
|
use anyhow::Result;
|
|
use chrono::{DateTime, Utc};
|
|
use rusqlite::{params, Connection};
|
|
use serde::Serialize;
|
|
use std::ffi::OsString;
|
|
use std::path::PathBuf;
|
|
use std::time::Instant;
|
|
|
|
// ── Project path helpers ── // added: project-scoped tracking support
|
|
|
|
/// Get the canonical project path string for the current working directory.
|
|
fn current_project_path_string() -> String {
|
|
std::env::current_dir()
|
|
.ok()
|
|
.and_then(|p| p.canonicalize().ok())
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Build SQL filter params for project-scoped queries.
|
|
/// Returns (exact_match, glob_prefix) for WHERE clause.
|
|
/// Uses GLOB instead of LIKE to avoid `_` and `%` in paths acting as wildcards. // changed: GLOB
|
|
fn project_filter_params(project_path: Option<&str>) -> (Option<String>, Option<String>) {
|
|
match project_path {
|
|
Some(p) => (
|
|
Some(p.to_string()),
|
|
Some(format!("{}{}*", p, std::path::MAIN_SEPARATOR)), // changed: GLOB pattern with * wildcard
|
|
),
|
|
None => (None, None),
|
|
}
|
|
}
|
|
|
|
/// Number of days to retain tracking history before automatic cleanup.
|
|
const HISTORY_DAYS: i64 = 90;
|
|
|
|
/// Main tracking interface for recording and querying command history.
|
|
///
|
|
/// Manages SQLite database connection and provides methods for:
|
|
/// - Recording command executions with token counts and timing
|
|
/// - Querying aggregated statistics (summary, daily, weekly, monthly)
|
|
/// - Retrieving recent command history
|
|
///
|
|
/// # Database Location
|
|
///
|
|
/// - Linux: `~/.local/share/rtk/tracking.db`
|
|
/// - macOS: `~/Library/Application Support/rtk/tracking.db`
|
|
/// - Windows: `%APPDATA%\rtk\tracking.db`
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use rtk::tracking::Tracker;
|
|
///
|
|
/// let tracker = Tracker::new()?;
|
|
/// tracker.record("ls -la", "rtk ls", 1000, 200, 50)?;
|
|
///
|
|
/// let summary = tracker.get_summary()?;
|
|
/// println!("Total saved: {} tokens", summary.total_saved);
|
|
/// # Ok::<(), anyhow::Error>(())
|
|
/// ```
|
|
pub struct Tracker {
|
|
conn: Connection,
|
|
}
|
|
|
|
/// Individual command record from tracking history.
|
|
///
|
|
/// Contains timestamp, command name, and savings metrics for a single execution.
|
|
#[derive(Debug)]
|
|
pub struct CommandRecord {
|
|
/// UTC timestamp when command was executed
|
|
pub timestamp: DateTime<Utc>,
|
|
/// RTK command that was executed (e.g., "rtk ls")
|
|
pub rtk_cmd: String,
|
|
/// Number of tokens saved (input - output)
|
|
pub saved_tokens: usize,
|
|
/// Savings percentage ((saved / input) * 100)
|
|
pub savings_pct: f64,
|
|
}
|
|
|
|
/// Aggregated statistics across all recorded commands.
|
|
///
|
|
/// Provides overall metrics and breakdowns by command and by day.
|
|
/// Returned by [`Tracker::get_summary`].
|
|
#[derive(Debug)]
|
|
pub struct GainSummary {
|
|
/// Total number of commands recorded
|
|
pub total_commands: usize,
|
|
/// Total input tokens across all commands
|
|
pub total_input: usize,
|
|
/// Total output tokens across all commands
|
|
pub total_output: usize,
|
|
/// Total tokens saved (input - output)
|
|
pub total_saved: usize,
|
|
/// Average savings percentage across all commands
|
|
pub avg_savings_pct: f64,
|
|
/// Total execution time across all commands (milliseconds)
|
|
pub total_time_ms: u64,
|
|
/// Average execution time per command (milliseconds)
|
|
pub avg_time_ms: u64,
|
|
/// Top 10 commands by tokens saved: (cmd, count, saved, avg_pct, avg_time_ms)
|
|
pub by_command: Vec<(String, usize, usize, f64, u64)>,
|
|
/// Last 30 days of activity: (date, saved_tokens)
|
|
pub by_day: Vec<(String, usize)>,
|
|
}
|
|
|
|
/// Daily statistics for token savings and execution metrics.
|
|
///
|
|
/// Serializable to JSON for export via `rtk gain --daily --format json`.
|
|
///
|
|
/// # JSON Schema
|
|
///
|
|
/// ```json
|
|
/// {
|
|
/// "date": "2026-02-03",
|
|
/// "commands": 42,
|
|
/// "input_tokens": 15420,
|
|
/// "output_tokens": 3842,
|
|
/// "saved_tokens": 11578,
|
|
/// "savings_pct": 75.08,
|
|
/// "total_time_ms": 8450,
|
|
/// "avg_time_ms": 201
|
|
/// }
|
|
/// ```
|
|
#[derive(Debug, Serialize)]
|
|
pub struct DayStats {
|
|
/// ISO date (YYYY-MM-DD)
|
|
pub date: String,
|
|
/// Number of commands executed this day
|
|
pub commands: usize,
|
|
/// Total input tokens for this day
|
|
pub input_tokens: usize,
|
|
/// Total output tokens for this day
|
|
pub output_tokens: usize,
|
|
/// Total tokens saved this day
|
|
pub saved_tokens: usize,
|
|
/// Savings percentage for this day
|
|
pub savings_pct: f64,
|
|
/// Total execution time for this day (milliseconds)
|
|
pub total_time_ms: u64,
|
|
/// Average execution time per command (milliseconds)
|
|
pub avg_time_ms: u64,
|
|
}
|
|
|
|
/// Weekly statistics for token savings and execution metrics.
|
|
///
|
|
/// Serializable to JSON for export via `rtk gain --weekly --format json`.
|
|
/// Weeks start on Sunday (SQLite default).
|
|
#[derive(Debug, Serialize)]
|
|
pub struct WeekStats {
|
|
/// Week start date (YYYY-MM-DD)
|
|
pub week_start: String,
|
|
/// Week end date (YYYY-MM-DD)
|
|
pub week_end: String,
|
|
/// Number of commands executed this week
|
|
pub commands: usize,
|
|
/// Total input tokens for this week
|
|
pub input_tokens: usize,
|
|
/// Total output tokens for this week
|
|
pub output_tokens: usize,
|
|
/// Total tokens saved this week
|
|
pub saved_tokens: usize,
|
|
/// Savings percentage for this week
|
|
pub savings_pct: f64,
|
|
/// Total execution time for this week (milliseconds)
|
|
pub total_time_ms: u64,
|
|
/// Average execution time per command (milliseconds)
|
|
pub avg_time_ms: u64,
|
|
}
|
|
|
|
/// Monthly statistics for token savings and execution metrics.
|
|
///
|
|
/// Serializable to JSON for export via `rtk gain --monthly --format json`.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct MonthStats {
|
|
/// Month identifier (YYYY-MM)
|
|
pub month: String,
|
|
/// Number of commands executed this month
|
|
pub commands: usize,
|
|
/// Total input tokens for this month
|
|
pub input_tokens: usize,
|
|
/// Total output tokens for this month
|
|
pub output_tokens: usize,
|
|
/// Total tokens saved this month
|
|
pub saved_tokens: usize,
|
|
/// Savings percentage for this month
|
|
pub savings_pct: f64,
|
|
/// Total execution time for this month (milliseconds)
|
|
pub total_time_ms: u64,
|
|
/// Average execution time per command (milliseconds)
|
|
pub avg_time_ms: u64,
|
|
}
|
|
|
|
/// Type alias for command statistics tuple: (command, count, saved_tokens, avg_savings_pct, avg_time_ms)
|
|
type CommandStats = (String, usize, usize, f64, u64);
|
|
|
|
impl Tracker {
|
|
/// Create a new tracker instance.
|
|
///
|
|
/// Opens or creates the SQLite database at the platform-specific location.
|
|
/// Automatically creates the `commands` table if it doesn't exist and runs
|
|
/// any necessary schema migrations.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns error if:
|
|
/// - Cannot determine database path
|
|
/// - Cannot create parent directories
|
|
/// - Cannot open/create SQLite database
|
|
/// - Schema creation/migration fails
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use rtk::tracking::Tracker;
|
|
///
|
|
/// let tracker = Tracker::new()?;
|
|
/// # Ok::<(), anyhow::Error>(())
|
|
/// ```
|
|
pub fn new() -> Result<Self> {
|
|
let db_path = get_db_path()?;
|
|
if let Some(parent) = db_path.parent() {
|
|
std::fs::create_dir_all(parent)?;
|
|
}
|
|
|
|
let conn = Connection::open(&db_path)?;
|
|
// WAL mode + busy_timeout for concurrent access (multiple Claude Code instances).
|
|
// Non-fatal: NFS/read-only filesystems may not support WAL.
|
|
let _ = conn.execute_batch(
|
|
"PRAGMA journal_mode=WAL;
|
|
PRAGMA busy_timeout=5000;",
|
|
);
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS commands (
|
|
id INTEGER PRIMARY KEY,
|
|
timestamp TEXT NOT NULL,
|
|
original_cmd TEXT NOT NULL,
|
|
rtk_cmd TEXT NOT NULL,
|
|
input_tokens INTEGER NOT NULL,
|
|
output_tokens INTEGER NOT NULL,
|
|
saved_tokens INTEGER NOT NULL,
|
|
savings_pct REAL NOT NULL
|
|
)",
|
|
[],
|
|
)?;
|
|
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_timestamp ON commands(timestamp)",
|
|
[],
|
|
)?;
|
|
|
|
// Migration: add exec_time_ms column if it doesn't exist
|
|
let _ = conn.execute(
|
|
"ALTER TABLE commands ADD COLUMN exec_time_ms INTEGER DEFAULT 0",
|
|
[],
|
|
);
|
|
// Migration: add project_path column with DEFAULT '' for new rows // changed: added DEFAULT
|
|
let _ = conn.execute(
|
|
"ALTER TABLE commands ADD COLUMN project_path TEXT DEFAULT ''",
|
|
[],
|
|
);
|
|
// One-time migration: normalize NULLs from pre-default schema // changed: guarded with EXISTS
|
|
let has_nulls: bool = conn
|
|
.query_row(
|
|
"SELECT EXISTS(SELECT 1 FROM commands WHERE project_path IS NULL)",
|
|
[],
|
|
|row| row.get(0),
|
|
)
|
|
.unwrap_or(false);
|
|
if has_nulls {
|
|
let _ = conn.execute(
|
|
"UPDATE commands SET project_path = '' WHERE project_path IS NULL",
|
|
[],
|
|
);
|
|
}
|
|
// Index for fast project-scoped gain queries // added
|
|
let _ = conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_project_path_timestamp ON commands(project_path, timestamp)",
|
|
[],
|
|
);
|
|
|
|
conn.execute(
|
|
"CREATE TABLE IF NOT EXISTS parse_failures (
|
|
id INTEGER PRIMARY KEY,
|
|
timestamp TEXT NOT NULL,
|
|
raw_command TEXT NOT NULL,
|
|
error_message TEXT NOT NULL,
|
|
fallback_succeeded INTEGER NOT NULL DEFAULT 0
|
|
)",
|
|
[],
|
|
)?;
|
|
conn.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_pf_timestamp ON parse_failures(timestamp)",
|
|
[],
|
|
)?;
|
|
|
|
Ok(Self { conn })
|
|
}
|
|
|
|
/// Record a command execution with token counts and timing.
|
|
///
|
|
/// Calculates savings metrics and stores the record in the database.
|
|
/// Automatically cleans up records older than 90 days after insertion.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// - `original_cmd`: The standard command (e.g., "ls -la")
|
|
/// - `rtk_cmd`: The RTK command used (e.g., "rtk ls")
|
|
/// - `input_tokens`: Estimated tokens from standard command output
|
|
/// - `output_tokens`: Actual tokens from RTK output
|
|
/// - `exec_time_ms`: Execution time in milliseconds
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use rtk::tracking::Tracker;
|
|
///
|
|
/// let tracker = Tracker::new()?;
|
|
/// tracker.record("ls -la", "rtk ls", 1000, 200, 50)?;
|
|
/// # Ok::<(), anyhow::Error>(())
|
|
/// ```
|
|
pub fn record(
|
|
&self,
|
|
original_cmd: &str,
|
|
rtk_cmd: &str,
|
|
input_tokens: usize,
|
|
output_tokens: usize,
|
|
exec_time_ms: u64,
|
|
) -> Result<()> {
|
|
let saved = input_tokens.saturating_sub(output_tokens);
|
|
let pct = if input_tokens > 0 {
|
|
(saved as f64 / input_tokens as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let project_path = current_project_path_string(); // added: record cwd
|
|
|
|
self.conn.execute(
|
|
"INSERT INTO commands (timestamp, original_cmd, rtk_cmd, project_path, input_tokens, output_tokens, saved_tokens, savings_pct, exec_time_ms)
|
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", // added: project_path
|
|
params![
|
|
Utc::now().to_rfc3339(),
|
|
original_cmd,
|
|
rtk_cmd,
|
|
project_path, // added
|
|
input_tokens as i64,
|
|
output_tokens as i64,
|
|
saved as i64,
|
|
pct,
|
|
exec_time_ms as i64
|
|
],
|
|
)?;
|
|
|
|
self.cleanup_old()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn cleanup_old(&self) -> Result<()> {
|
|
let cutoff = Utc::now() - chrono::Duration::days(HISTORY_DAYS);
|
|
self.conn.execute(
|
|
"DELETE FROM commands WHERE timestamp < ?1",
|
|
params![cutoff.to_rfc3339()],
|
|
)?;
|
|
self.conn.execute(
|
|
"DELETE FROM parse_failures WHERE timestamp < ?1",
|
|
params![cutoff.to_rfc3339()],
|
|
)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Record a parse failure for analytics.
|
|
pub fn record_parse_failure(
|
|
&self,
|
|
raw_command: &str,
|
|
error_message: &str,
|
|
fallback_succeeded: bool,
|
|
) -> Result<()> {
|
|
self.conn.execute(
|
|
"INSERT INTO parse_failures (timestamp, raw_command, error_message, fallback_succeeded)
|
|
VALUES (?1, ?2, ?3, ?4)",
|
|
params![
|
|
Utc::now().to_rfc3339(),
|
|
raw_command,
|
|
error_message,
|
|
fallback_succeeded as i32,
|
|
],
|
|
)?;
|
|
self.cleanup_old()?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Get parse failure summary for `rtk gain --failures`.
|
|
pub fn get_parse_failure_summary(&self) -> Result<ParseFailureSummary> {
|
|
let total: i64 = self
|
|
.conn
|
|
.query_row("SELECT COUNT(*) FROM parse_failures", [], |row| row.get(0))?;
|
|
|
|
let succeeded: i64 = self.conn.query_row(
|
|
"SELECT COUNT(*) FROM parse_failures WHERE fallback_succeeded = 1",
|
|
[],
|
|
|row| row.get(0),
|
|
)?;
|
|
|
|
let recovery_rate = if total > 0 {
|
|
(succeeded as f64 / total as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
// Top commands by frequency
|
|
let mut stmt = self.conn.prepare(
|
|
"SELECT raw_command, COUNT(*) as cnt
|
|
FROM parse_failures
|
|
GROUP BY raw_command
|
|
ORDER BY cnt DESC
|
|
LIMIT 10",
|
|
)?;
|
|
let top_commands = stmt
|
|
.query_map([], |row| {
|
|
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
|
|
})?
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
// Recent 10
|
|
let mut stmt = self.conn.prepare(
|
|
"SELECT timestamp, raw_command, error_message, fallback_succeeded
|
|
FROM parse_failures
|
|
ORDER BY timestamp DESC
|
|
LIMIT 10",
|
|
)?;
|
|
let recent = stmt
|
|
.query_map([], |row| {
|
|
Ok(ParseFailureRecord {
|
|
timestamp: row.get(0)?,
|
|
raw_command: row.get(1)?,
|
|
error_message: row.get(2)?,
|
|
fallback_succeeded: row.get::<_, i32>(3)? != 0,
|
|
})
|
|
})?
|
|
.collect::<Result<Vec<_>, _>>()?;
|
|
|
|
Ok(ParseFailureSummary {
|
|
total: total as usize,
|
|
recovery_rate,
|
|
top_commands,
|
|
recent,
|
|
})
|
|
}
|
|
|
|
/// Get overall summary statistics across all recorded commands.
|
|
///
|
|
/// Returns aggregated metrics including:
|
|
/// - Total commands, tokens (input/output/saved)
|
|
/// - Average savings percentage and execution time
|
|
/// - Top 10 commands by tokens saved
|
|
/// - Last 30 days of activity
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use rtk::tracking::Tracker;
|
|
///
|
|
/// let tracker = Tracker::new()?;
|
|
/// let summary = tracker.get_summary()?;
|
|
/// println!("Saved {} tokens ({:.1}%)",
|
|
/// summary.total_saved, summary.avg_savings_pct);
|
|
/// # Ok::<(), anyhow::Error>(())
|
|
/// ```
|
|
#[allow(dead_code)]
|
|
pub fn get_summary(&self) -> Result<GainSummary> {
|
|
self.get_summary_filtered(None) // delegate to filtered variant
|
|
}
|
|
|
|
/// Get summary statistics filtered by project path. // added
|
|
///
|
|
/// When `project_path` is `Some`, matches the exact working directory
|
|
/// or any subdirectory (prefix match with path separator).
|
|
pub fn get_summary_filtered(&self, project_path: Option<&str>) -> Result<GainSummary> {
|
|
let (project_exact, project_glob) = project_filter_params(project_path); // added
|
|
let mut total_commands = 0usize;
|
|
let mut total_input = 0usize;
|
|
let mut total_output = 0usize;
|
|
let mut total_saved = 0usize;
|
|
let mut total_time_ms = 0u64;
|
|
|
|
let mut stmt = self.conn.prepare(
|
|
"SELECT input_tokens, output_tokens, saved_tokens, exec_time_ms
|
|
FROM commands
|
|
WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)", // added: project filter
|
|
)?;
|
|
|
|
let rows = stmt.query_map(params![project_exact, project_glob], |row| {
|
|
// added: params
|
|
Ok((
|
|
row.get::<_, i64>(0)? as usize,
|
|
row.get::<_, i64>(1)? as usize,
|
|
row.get::<_, i64>(2)? as usize,
|
|
row.get::<_, i64>(3)? as u64,
|
|
))
|
|
})?;
|
|
|
|
for row in rows {
|
|
let (input, output, saved, time_ms) = row?;
|
|
total_commands += 1;
|
|
total_input += input;
|
|
total_output += output;
|
|
total_saved += saved;
|
|
total_time_ms += time_ms;
|
|
}
|
|
|
|
let avg_savings_pct = if total_input > 0 {
|
|
(total_saved as f64 / total_input as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
|
|
let avg_time_ms = if total_commands > 0 {
|
|
total_time_ms / total_commands as u64
|
|
} else {
|
|
0
|
|
};
|
|
|
|
let by_command = self.get_by_command(project_path)?; // added: pass project filter
|
|
let by_day = self.get_by_day(project_path)?; // added: pass project filter
|
|
|
|
Ok(GainSummary {
|
|
total_commands,
|
|
total_input,
|
|
total_output,
|
|
total_saved,
|
|
avg_savings_pct,
|
|
total_time_ms,
|
|
avg_time_ms,
|
|
by_command,
|
|
by_day,
|
|
})
|
|
}
|
|
|
|
fn get_by_command(
|
|
&self,
|
|
project_path: Option<&str>, // added
|
|
) -> Result<Vec<CommandStats>> {
|
|
let (project_exact, project_glob) = project_filter_params(project_path); // added
|
|
let mut stmt = self.conn.prepare(
|
|
"SELECT rtk_cmd, COUNT(*), SUM(saved_tokens), AVG(savings_pct), AVG(exec_time_ms)
|
|
FROM commands
|
|
WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
|
|
GROUP BY rtk_cmd
|
|
ORDER BY SUM(saved_tokens) DESC
|
|
LIMIT 10", // added: project filter in WHERE
|
|
)?;
|
|
|
|
let rows = stmt.query_map(params![project_exact, project_glob], |row| {
|
|
// added: params
|
|
Ok((
|
|
row.get::<_, String>(0)?,
|
|
row.get::<_, i64>(1)? as usize,
|
|
row.get::<_, i64>(2)? as usize,
|
|
row.get::<_, f64>(3)?,
|
|
row.get::<_, f64>(4)? as u64,
|
|
))
|
|
})?;
|
|
|
|
Ok(rows.collect::<Result<Vec<_>, _>>()?)
|
|
}
|
|
|
|
fn get_by_day(
|
|
&self,
|
|
project_path: Option<&str>, // added
|
|
) -> Result<Vec<(String, usize)>> {
|
|
let (project_exact, project_glob) = project_filter_params(project_path); // added
|
|
let mut stmt = self.conn.prepare(
|
|
"SELECT DATE(timestamp), SUM(saved_tokens)
|
|
FROM commands
|
|
WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
|
|
GROUP BY DATE(timestamp)
|
|
ORDER BY DATE(timestamp) DESC
|
|
LIMIT 30", // added: project filter in WHERE
|
|
)?;
|
|
|
|
let rows = stmt.query_map(params![project_exact, project_glob], |row| {
|
|
// added: params
|
|
Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)? as usize))
|
|
})?;
|
|
|
|
let mut result: Vec<_> = rows.collect::<Result<Vec<_>, _>>()?;
|
|
result.reverse();
|
|
Ok(result)
|
|
}
|
|
|
|
/// Get daily statistics for all recorded days.
|
|
///
|
|
/// Returns one [`DayStats`] per day with commands executed, tokens saved,
|
|
/// and execution time metrics. Results are ordered chronologically (oldest first).
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use rtk::tracking::Tracker;
|
|
///
|
|
/// let tracker = Tracker::new()?;
|
|
/// let days = tracker.get_all_days()?;
|
|
/// for day in days.iter().take(7) {
|
|
/// println!("{}: {} commands, {} tokens saved",
|
|
/// day.date, day.commands, day.saved_tokens);
|
|
/// }
|
|
/// # Ok::<(), anyhow::Error>(())
|
|
/// ```
|
|
pub fn get_all_days(&self) -> Result<Vec<DayStats>> {
|
|
self.get_all_days_filtered(None) // delegate to filtered variant
|
|
}
|
|
|
|
/// Get daily statistics filtered by project path. // added
|
|
pub fn get_all_days_filtered(&self, project_path: Option<&str>) -> Result<Vec<DayStats>> {
|
|
let (project_exact, project_glob) = project_filter_params(project_path); // added
|
|
let mut stmt = self.conn.prepare(
|
|
"SELECT
|
|
DATE(timestamp) as date,
|
|
COUNT(*) as commands,
|
|
SUM(input_tokens) as input,
|
|
SUM(output_tokens) as output,
|
|
SUM(saved_tokens) as saved,
|
|
SUM(exec_time_ms) as total_time
|
|
FROM commands
|
|
WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
|
|
GROUP BY DATE(timestamp)
|
|
ORDER BY DATE(timestamp) DESC", // added: project filter
|
|
)?;
|
|
|
|
let rows = stmt.query_map(params![project_exact, project_glob], |row| {
|
|
// added: params
|
|
let input = row.get::<_, i64>(2)? as usize;
|
|
let saved = row.get::<_, i64>(4)? as usize;
|
|
let commands = row.get::<_, i64>(1)? as usize;
|
|
let total_time = row.get::<_, i64>(5)? as u64;
|
|
let savings_pct = if input > 0 {
|
|
(saved as f64 / input as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
let avg_time_ms = if commands > 0 {
|
|
total_time / commands as u64
|
|
} else {
|
|
0
|
|
};
|
|
|
|
Ok(DayStats {
|
|
date: row.get(0)?,
|
|
commands,
|
|
input_tokens: input,
|
|
output_tokens: row.get::<_, i64>(3)? as usize,
|
|
saved_tokens: saved,
|
|
savings_pct,
|
|
total_time_ms: total_time,
|
|
avg_time_ms,
|
|
})
|
|
})?;
|
|
|
|
let mut result: Vec<_> = rows.collect::<Result<Vec<_>, _>>()?;
|
|
result.reverse();
|
|
Ok(result)
|
|
}
|
|
|
|
/// Get weekly statistics grouped by week.
|
|
///
|
|
/// Returns one [`WeekStats`] per week with aggregated metrics.
|
|
/// Weeks start on Sunday (SQLite default). Results ordered chronologically.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use rtk::tracking::Tracker;
|
|
///
|
|
/// let tracker = Tracker::new()?;
|
|
/// let weeks = tracker.get_by_week()?;
|
|
/// for week in weeks {
|
|
/// println!("{} to {}: {} tokens saved",
|
|
/// week.week_start, week.week_end, week.saved_tokens);
|
|
/// }
|
|
/// # Ok::<(), anyhow::Error>(())
|
|
/// ```
|
|
pub fn get_by_week(&self) -> Result<Vec<WeekStats>> {
|
|
self.get_by_week_filtered(None) // delegate to filtered variant
|
|
}
|
|
|
|
/// Get weekly statistics filtered by project path. // added
|
|
pub fn get_by_week_filtered(&self, project_path: Option<&str>) -> Result<Vec<WeekStats>> {
|
|
let (project_exact, project_glob) = project_filter_params(project_path); // added
|
|
let mut stmt = self.conn.prepare(
|
|
"SELECT
|
|
DATE(timestamp, 'weekday 0', '-6 days') as week_start,
|
|
DATE(timestamp, 'weekday 0') as week_end,
|
|
COUNT(*) as commands,
|
|
SUM(input_tokens) as input,
|
|
SUM(output_tokens) as output,
|
|
SUM(saved_tokens) as saved,
|
|
SUM(exec_time_ms) as total_time
|
|
FROM commands
|
|
WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
|
|
GROUP BY week_start
|
|
ORDER BY week_start DESC", // added: project filter
|
|
)?;
|
|
|
|
let rows = stmt.query_map(params![project_exact, project_glob], |row| {
|
|
// added: params
|
|
let input = row.get::<_, i64>(3)? as usize;
|
|
let saved = row.get::<_, i64>(5)? as usize;
|
|
let commands = row.get::<_, i64>(2)? as usize;
|
|
let total_time = row.get::<_, i64>(6)? as u64;
|
|
let savings_pct = if input > 0 {
|
|
(saved as f64 / input as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
let avg_time_ms = if commands > 0 {
|
|
total_time / commands as u64
|
|
} else {
|
|
0
|
|
};
|
|
|
|
Ok(WeekStats {
|
|
week_start: row.get(0)?,
|
|
week_end: row.get(1)?,
|
|
commands,
|
|
input_tokens: input,
|
|
output_tokens: row.get::<_, i64>(4)? as usize,
|
|
saved_tokens: saved,
|
|
savings_pct,
|
|
total_time_ms: total_time,
|
|
avg_time_ms,
|
|
})
|
|
})?;
|
|
|
|
let mut result: Vec<_> = rows.collect::<Result<Vec<_>, _>>()?;
|
|
result.reverse();
|
|
Ok(result)
|
|
}
|
|
|
|
/// Get monthly statistics grouped by month.
|
|
///
|
|
/// Returns one [`MonthStats`] per month (YYYY-MM format) with aggregated metrics.
|
|
/// Results ordered chronologically.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use rtk::tracking::Tracker;
|
|
///
|
|
/// let tracker = Tracker::new()?;
|
|
/// let months = tracker.get_by_month()?;
|
|
/// for month in months {
|
|
/// println!("{}: {} tokens saved ({:.1}%)",
|
|
/// month.month, month.saved_tokens, month.savings_pct);
|
|
/// }
|
|
/// # Ok::<(), anyhow::Error>(())
|
|
/// ```
|
|
pub fn get_by_month(&self) -> Result<Vec<MonthStats>> {
|
|
self.get_by_month_filtered(None) // delegate to filtered variant
|
|
}
|
|
|
|
/// Get monthly statistics filtered by project path. // added
|
|
pub fn get_by_month_filtered(&self, project_path: Option<&str>) -> Result<Vec<MonthStats>> {
|
|
let (project_exact, project_glob) = project_filter_params(project_path); // added
|
|
let mut stmt = self.conn.prepare(
|
|
"SELECT
|
|
strftime('%Y-%m', timestamp) as month,
|
|
COUNT(*) as commands,
|
|
SUM(input_tokens) as input,
|
|
SUM(output_tokens) as output,
|
|
SUM(saved_tokens) as saved,
|
|
SUM(exec_time_ms) as total_time
|
|
FROM commands
|
|
WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
|
|
GROUP BY month
|
|
ORDER BY month DESC", // added: project filter
|
|
)?;
|
|
|
|
let rows = stmt.query_map(params![project_exact, project_glob], |row| {
|
|
// added: params
|
|
let input = row.get::<_, i64>(2)? as usize;
|
|
let saved = row.get::<_, i64>(4)? as usize;
|
|
let commands = row.get::<_, i64>(1)? as usize;
|
|
let total_time = row.get::<_, i64>(5)? as u64;
|
|
let savings_pct = if input > 0 {
|
|
(saved as f64 / input as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
};
|
|
let avg_time_ms = if commands > 0 {
|
|
total_time / commands as u64
|
|
} else {
|
|
0
|
|
};
|
|
|
|
Ok(MonthStats {
|
|
month: row.get(0)?,
|
|
commands,
|
|
input_tokens: input,
|
|
output_tokens: row.get::<_, i64>(3)? as usize,
|
|
saved_tokens: saved,
|
|
savings_pct,
|
|
total_time_ms: total_time,
|
|
avg_time_ms,
|
|
})
|
|
})?;
|
|
|
|
let mut result: Vec<_> = rows.collect::<Result<Vec<_>, _>>()?;
|
|
result.reverse();
|
|
Ok(result)
|
|
}
|
|
|
|
/// Get recent command history.
|
|
///
|
|
/// Returns up to `limit` most recent command records, ordered by timestamp (newest first).
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// - `limit`: Maximum number of records to return
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use rtk::tracking::Tracker;
|
|
///
|
|
/// let tracker = Tracker::new()?;
|
|
/// let recent = tracker.get_recent(10)?;
|
|
/// for cmd in recent {
|
|
/// println!("{}: {} saved {:.1}%",
|
|
/// cmd.timestamp, cmd.rtk_cmd, cmd.savings_pct);
|
|
/// }
|
|
/// # Ok::<(), anyhow::Error>(())
|
|
/// ```
|
|
#[allow(dead_code)]
|
|
pub fn get_recent(&self, limit: usize) -> Result<Vec<CommandRecord>> {
|
|
self.get_recent_filtered(limit, None) // delegate to filtered variant
|
|
}
|
|
|
|
/// Get recent command history filtered by project path. // added
|
|
pub fn get_recent_filtered(
|
|
&self,
|
|
limit: usize,
|
|
project_path: Option<&str>,
|
|
) -> Result<Vec<CommandRecord>> {
|
|
let (project_exact, project_glob) = project_filter_params(project_path); // added
|
|
let mut stmt = self.conn.prepare(
|
|
"SELECT timestamp, rtk_cmd, saved_tokens, savings_pct
|
|
FROM commands
|
|
WHERE (?1 IS NULL OR project_path = ?1 OR project_path GLOB ?2)
|
|
ORDER BY timestamp DESC
|
|
LIMIT ?3", // added: project filter
|
|
)?;
|
|
|
|
let rows = stmt.query_map(
|
|
params![project_exact, project_glob, limit as i64], // added: project params
|
|
|row| {
|
|
Ok(CommandRecord {
|
|
timestamp: DateTime::parse_from_rfc3339(&row.get::<_, String>(0)?)
|
|
.map(|dt| dt.with_timezone(&Utc))
|
|
.unwrap_or_else(|_| Utc::now()),
|
|
rtk_cmd: row.get(1)?,
|
|
saved_tokens: row.get::<_, i64>(2)? as usize,
|
|
savings_pct: row.get(3)?,
|
|
})
|
|
},
|
|
)?;
|
|
|
|
Ok(rows.collect::<Result<Vec<_>, _>>()?)
|
|
}
|
|
|
|
/// Count commands since a given timestamp (for telemetry).
|
|
pub fn count_commands_since(&self, since: chrono::DateTime<chrono::Utc>) -> Result<i64> {
|
|
let ts = since.format("%Y-%m-%dT%H:%M:%S").to_string();
|
|
let count: i64 = self.conn.query_row(
|
|
"SELECT COUNT(*) FROM commands WHERE timestamp >= ?1",
|
|
params![ts],
|
|
|row| row.get(0),
|
|
)?;
|
|
Ok(count)
|
|
}
|
|
|
|
/// Get top N commands by frequency (for telemetry).
|
|
pub fn top_commands(&self, limit: usize) -> Result<Vec<String>> {
|
|
let mut stmt = self.conn.prepare(
|
|
"SELECT rtk_cmd, COUNT(*) as cnt FROM commands
|
|
GROUP BY rtk_cmd ORDER BY cnt DESC LIMIT ?1",
|
|
)?;
|
|
let rows = stmt.query_map(params![limit as i64], |row| {
|
|
let cmd: String = row.get(0)?;
|
|
// Extract just the command name (e.g. "rtk git status" → "git")
|
|
Ok(cmd.split_whitespace().nth(1).unwrap_or(&cmd).to_string())
|
|
})?;
|
|
Ok(rows.filter_map(|r| r.ok()).collect())
|
|
}
|
|
|
|
/// Get overall savings percentage (for telemetry).
|
|
pub fn overall_savings_pct(&self) -> Result<f64> {
|
|
let (total_input, total_saved): (i64, i64) = self.conn.query_row(
|
|
"SELECT COALESCE(SUM(input_tokens), 0), COALESCE(SUM(saved_tokens), 0) FROM commands",
|
|
[],
|
|
|row| Ok((row.get(0)?, row.get(1)?)),
|
|
)?;
|
|
if total_input > 0 {
|
|
Ok((total_saved as f64 / total_input as f64) * 100.0)
|
|
} else {
|
|
Ok(0.0)
|
|
}
|
|
}
|
|
|
|
/// Get total tokens saved across all tracked commands (for telemetry).
|
|
pub fn total_tokens_saved(&self) -> Result<i64> {
|
|
let saved: i64 = self.conn.query_row(
|
|
"SELECT COALESCE(SUM(saved_tokens), 0) FROM commands",
|
|
[],
|
|
|row| row.get(0),
|
|
)?;
|
|
Ok(saved)
|
|
}
|
|
|
|
/// Get tokens saved in the last 24 hours (for telemetry).
|
|
pub fn tokens_saved_24h(&self, since: chrono::DateTime<chrono::Utc>) -> Result<i64> {
|
|
let ts = since.format("%Y-%m-%dT%H:%M:%S").to_string();
|
|
let saved: i64 = self.conn.query_row(
|
|
"SELECT COALESCE(SUM(saved_tokens), 0) FROM commands WHERE timestamp >= ?1",
|
|
params![ts],
|
|
|row| row.get(0),
|
|
)?;
|
|
Ok(saved)
|
|
}
|
|
}
|
|
|
|
fn get_db_path() -> Result<PathBuf> {
|
|
// Priority 1: Environment variable RTK_DB_PATH
|
|
if let Ok(custom_path) = std::env::var("RTK_DB_PATH") {
|
|
return Ok(PathBuf::from(custom_path));
|
|
}
|
|
|
|
// Priority 2: Configuration file
|
|
if let Ok(config) = crate::config::Config::load() {
|
|
if let Some(db_path) = config.tracking.database_path {
|
|
return Ok(db_path);
|
|
}
|
|
}
|
|
|
|
// Priority 3: Default platform-specific location
|
|
let data_dir = dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("."));
|
|
Ok(data_dir.join("rtk").join("history.db"))
|
|
}
|
|
|
|
/// Individual parse failure record.
|
|
#[derive(Debug)]
|
|
pub struct ParseFailureRecord {
|
|
pub timestamp: String,
|
|
pub raw_command: String,
|
|
#[allow(dead_code)]
|
|
pub error_message: String,
|
|
pub fallback_succeeded: bool,
|
|
}
|
|
|
|
/// Aggregated parse failure summary.
|
|
#[derive(Debug)]
|
|
pub struct ParseFailureSummary {
|
|
pub total: usize,
|
|
pub recovery_rate: f64,
|
|
pub top_commands: Vec<(String, usize)>,
|
|
pub recent: Vec<ParseFailureRecord>,
|
|
}
|
|
|
|
/// Record a parse failure without ever crashing.
|
|
/// Silently ignores all errors — used in the fallback path.
|
|
pub fn record_parse_failure_silent(raw_command: &str, error_message: &str, succeeded: bool) {
|
|
if let Ok(tracker) = Tracker::new() {
|
|
let _ = tracker.record_parse_failure(raw_command, error_message, succeeded);
|
|
}
|
|
}
|
|
|
|
/// Estimate token count from text using ~4 chars = 1 token heuristic.
|
|
///
|
|
/// This is a fast approximation suitable for tracking purposes.
|
|
/// For precise counts, integrate with your LLM's tokenizer API.
|
|
///
|
|
/// # Formula
|
|
///
|
|
/// `tokens = ceil(chars / 4)`
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```
|
|
/// use rtk::tracking::estimate_tokens;
|
|
///
|
|
/// assert_eq!(estimate_tokens(""), 0);
|
|
/// assert_eq!(estimate_tokens("abcd"), 1); // 4 chars = 1 token
|
|
/// assert_eq!(estimate_tokens("abcde"), 2); // 5 chars = ceil(1.25) = 2
|
|
/// assert_eq!(estimate_tokens("hello world"), 3); // 11 chars = ceil(2.75) = 3
|
|
/// ```
|
|
pub fn estimate_tokens(text: &str) -> usize {
|
|
// ~4 chars per token on average
|
|
(text.len() as f64 / 4.0).ceil() as usize
|
|
}
|
|
|
|
/// Helper struct for timing command execution
|
|
/// Helper for timing command execution and tracking results.
|
|
///
|
|
/// Preferred API for tracking commands. Automatically measures execution time
|
|
/// and records token savings. Use instead of the deprecated [`track`] function.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use rtk::tracking::TimedExecution;
|
|
///
|
|
/// let timer = TimedExecution::start();
|
|
/// let input = execute_standard_command()?;
|
|
/// let output = execute_rtk_command()?;
|
|
/// timer.track("ls -la", "rtk ls", &input, &output);
|
|
/// # Ok::<(), anyhow::Error>(())
|
|
/// ```
|
|
pub struct TimedExecution {
|
|
start: Instant,
|
|
}
|
|
|
|
impl TimedExecution {
|
|
/// Start timing a command execution.
|
|
///
|
|
/// Creates a new timer that starts measuring elapsed time immediately.
|
|
/// Call [`track`](Self::track) or [`track_passthrough`](Self::track_passthrough)
|
|
/// when the command completes.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use rtk::tracking::TimedExecution;
|
|
///
|
|
/// let timer = TimedExecution::start();
|
|
/// // ... execute command ...
|
|
/// timer.track("cmd", "rtk cmd", "input", "output");
|
|
/// ```
|
|
pub fn start() -> Self {
|
|
Self {
|
|
start: Instant::now(),
|
|
}
|
|
}
|
|
|
|
/// Track the command with elapsed time and token counts.
|
|
///
|
|
/// Records the command execution with:
|
|
/// - Elapsed time since [`start`](Self::start)
|
|
/// - Token counts estimated from input/output strings
|
|
/// - Calculated savings metrics
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// - `original_cmd`: Standard command (e.g., "ls -la")
|
|
/// - `rtk_cmd`: RTK command used (e.g., "rtk ls")
|
|
/// - `input`: Standard command output (for token estimation)
|
|
/// - `output`: RTK command output (for token estimation)
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use rtk::tracking::TimedExecution;
|
|
///
|
|
/// let timer = TimedExecution::start();
|
|
/// let input = "long output...";
|
|
/// let output = "short output";
|
|
/// timer.track("ls -la", "rtk ls", input, output);
|
|
/// ```
|
|
pub fn track(&self, original_cmd: &str, rtk_cmd: &str, input: &str, output: &str) {
|
|
let elapsed_ms = self.start.elapsed().as_millis() as u64;
|
|
let input_tokens = estimate_tokens(input);
|
|
let output_tokens = estimate_tokens(output);
|
|
|
|
if let Ok(tracker) = Tracker::new() {
|
|
let _ = tracker.record(
|
|
original_cmd,
|
|
rtk_cmd,
|
|
input_tokens,
|
|
output_tokens,
|
|
elapsed_ms,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Track passthrough commands (timing-only, no token counting).
|
|
///
|
|
/// For commands that stream output or run interactively where output
|
|
/// cannot be captured. Records execution time but sets tokens to 0
|
|
/// (does not dilute savings statistics).
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// - `original_cmd`: Standard command (e.g., "git tag --list")
|
|
/// - `rtk_cmd`: RTK command used (e.g., "rtk git tag --list")
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use rtk::tracking::TimedExecution;
|
|
///
|
|
/// let timer = TimedExecution::start();
|
|
/// // ... execute streaming command ...
|
|
/// timer.track_passthrough("git tag", "rtk git tag");
|
|
/// ```
|
|
pub fn track_passthrough(&self, original_cmd: &str, rtk_cmd: &str) {
|
|
let elapsed_ms = self.start.elapsed().as_millis() as u64;
|
|
// input_tokens=0, output_tokens=0 won't dilute savings statistics
|
|
if let Ok(tracker) = Tracker::new() {
|
|
let _ = tracker.record(original_cmd, rtk_cmd, 0, 0, elapsed_ms);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Format OsString args for tracking display.
|
|
///
|
|
/// Joins arguments with spaces, converting each to UTF-8 (lossy).
|
|
/// Useful for displaying command arguments in tracking records.
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```
|
|
/// use std::ffi::OsString;
|
|
/// use rtk::tracking::args_display;
|
|
///
|
|
/// let args = vec![OsString::from("status"), OsString::from("--short")];
|
|
/// assert_eq!(args_display(&args), "status --short");
|
|
/// ```
|
|
pub fn args_display(args: &[OsString]) -> String {
|
|
args.iter()
|
|
.map(|a| a.to_string_lossy())
|
|
.collect::<Vec<_>>()
|
|
.join(" ")
|
|
}
|
|
|
|
/// Track a command execution (legacy function, use [`TimedExecution`] for new code).
|
|
///
|
|
/// # Deprecation Notice
|
|
///
|
|
/// This function is deprecated. Use [`TimedExecution`] instead for automatic
|
|
/// timing and cleaner API.
|
|
///
|
|
/// # Arguments
|
|
///
|
|
/// - `original_cmd`: Standard command (e.g., "ls -la")
|
|
/// - `rtk_cmd`: RTK command used (e.g., "rtk ls")
|
|
/// - `input`: Standard command output (for token estimation)
|
|
/// - `output`: RTK command output (for token estimation)
|
|
///
|
|
/// # Migration
|
|
///
|
|
/// ```no_run
|
|
/// # use rtk::tracking::{track, TimedExecution};
|
|
/// // Old (deprecated)
|
|
/// track("ls -la", "rtk ls", "input", "output");
|
|
///
|
|
/// // New (preferred)
|
|
/// let timer = TimedExecution::start();
|
|
/// timer.track("ls -la", "rtk ls", "input", "output");
|
|
/// ```
|
|
#[deprecated(note = "Use TimedExecution instead")]
|
|
#[allow(dead_code)]
|
|
pub fn track(original_cmd: &str, rtk_cmd: &str, input: &str, output: &str) {
|
|
let input_tokens = estimate_tokens(input);
|
|
let output_tokens = estimate_tokens(output);
|
|
|
|
if let Ok(tracker) = Tracker::new() {
|
|
let _ = tracker.record(original_cmd, rtk_cmd, input_tokens, output_tokens, 0);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
// 1. estimate_tokens — verify ~4 chars/token ratio
|
|
#[test]
|
|
fn test_estimate_tokens() {
|
|
assert_eq!(estimate_tokens(""), 0);
|
|
assert_eq!(estimate_tokens("abcd"), 1); // 4 chars = 1 token
|
|
assert_eq!(estimate_tokens("abcde"), 2); // 5 chars = ceil(1.25) = 2
|
|
assert_eq!(estimate_tokens("a"), 1); // 1 char = ceil(0.25) = 1
|
|
assert_eq!(estimate_tokens("12345678"), 2); // 8 chars = 2 tokens
|
|
}
|
|
|
|
// 2. args_display — format OsString vec
|
|
#[test]
|
|
fn test_args_display() {
|
|
let args = vec![OsString::from("status"), OsString::from("--short")];
|
|
assert_eq!(args_display(&args), "status --short");
|
|
assert_eq!(args_display(&[]), "");
|
|
|
|
let single = vec![OsString::from("log")];
|
|
assert_eq!(args_display(&single), "log");
|
|
}
|
|
|
|
// 3. Tracker::record + get_recent — round-trip DB
|
|
#[test]
|
|
fn test_tracker_record_and_recent() {
|
|
let tracker = Tracker::new().expect("Failed to create tracker");
|
|
|
|
// Use unique test identifier to avoid conflicts with other tests
|
|
let test_cmd = format!("rtk git status test_{}", std::process::id());
|
|
|
|
tracker
|
|
.record("git status", &test_cmd, 100, 20, 50)
|
|
.expect("Failed to record");
|
|
|
|
let recent = tracker.get_recent(10).expect("Failed to get recent");
|
|
|
|
// Find our specific test record
|
|
let test_record = recent
|
|
.iter()
|
|
.find(|r| r.rtk_cmd == test_cmd)
|
|
.expect("Test record not found in recent commands");
|
|
|
|
assert_eq!(test_record.saved_tokens, 80);
|
|
assert_eq!(test_record.savings_pct, 80.0);
|
|
}
|
|
|
|
// 4. track_passthrough doesn't dilute stats (input=0, output=0)
|
|
#[test]
|
|
fn test_track_passthrough_no_dilution() {
|
|
let tracker = Tracker::new().expect("Failed to create tracker");
|
|
|
|
// Use unique test identifiers
|
|
let pid = std::process::id();
|
|
let cmd1 = format!("rtk cmd1_test_{}", pid);
|
|
let cmd2 = format!("rtk cmd2_passthrough_test_{}", pid);
|
|
|
|
// Record one real command with 80% savings
|
|
tracker
|
|
.record("cmd1", &cmd1, 1000, 200, 10)
|
|
.expect("Failed to record cmd1");
|
|
|
|
// Record passthrough (0, 0)
|
|
tracker
|
|
.record("cmd2", &cmd2, 0, 0, 5)
|
|
.expect("Failed to record passthrough");
|
|
|
|
// Verify both records exist in recent history
|
|
let recent = tracker.get_recent(20).expect("Failed to get recent");
|
|
|
|
let record1 = recent
|
|
.iter()
|
|
.find(|r| r.rtk_cmd == cmd1)
|
|
.expect("cmd1 record not found");
|
|
let record2 = recent
|
|
.iter()
|
|
.find(|r| r.rtk_cmd == cmd2)
|
|
.expect("passthrough record not found");
|
|
|
|
// Verify cmd1 has 80% savings
|
|
assert_eq!(record1.saved_tokens, 800);
|
|
assert_eq!(record1.savings_pct, 80.0);
|
|
|
|
// Verify passthrough has 0% savings
|
|
assert_eq!(record2.saved_tokens, 0);
|
|
assert_eq!(record2.savings_pct, 0.0);
|
|
|
|
// This validates that passthrough (0 input, 0 output) doesn't dilute stats
|
|
// because the savings calculation is correct for both cases
|
|
}
|
|
|
|
// 5. TimedExecution::track records with exec_time > 0
|
|
#[test]
|
|
fn test_timed_execution_records_time() {
|
|
let timer = TimedExecution::start();
|
|
std::thread::sleep(std::time::Duration::from_millis(10));
|
|
timer.track("test cmd", "rtk test", "raw input data", "filtered");
|
|
|
|
// Verify via DB that record exists
|
|
let tracker = Tracker::new().expect("Failed to create tracker");
|
|
let recent = tracker.get_recent(5).expect("Failed to get recent");
|
|
assert!(recent.iter().any(|r| r.rtk_cmd == "rtk test"));
|
|
}
|
|
|
|
// 6. TimedExecution::track_passthrough records with 0 tokens
|
|
#[test]
|
|
fn test_timed_execution_passthrough() {
|
|
let timer = TimedExecution::start();
|
|
timer.track_passthrough("git tag", "rtk git tag (passthrough)");
|
|
|
|
let tracker = Tracker::new().expect("Failed to create tracker");
|
|
let recent = tracker.get_recent(5).expect("Failed to get recent");
|
|
|
|
let pt = recent
|
|
.iter()
|
|
.find(|r| r.rtk_cmd.contains("passthrough"))
|
|
.expect("Passthrough record not found");
|
|
|
|
// savings_pct should be 0 for passthrough
|
|
assert_eq!(pt.savings_pct, 0.0);
|
|
assert_eq!(pt.saved_tokens, 0);
|
|
}
|
|
|
|
// 7. get_db_path respects environment variable RTK_DB_PATH
|
|
#[test]
|
|
fn test_custom_db_path_env() {
|
|
use std::env;
|
|
|
|
let custom_path = "/tmp/rtk_test_custom.db";
|
|
env::set_var("RTK_DB_PATH", custom_path);
|
|
|
|
let db_path = get_db_path().expect("Failed to get db path");
|
|
assert_eq!(db_path, PathBuf::from(custom_path));
|
|
|
|
env::remove_var("RTK_DB_PATH");
|
|
}
|
|
|
|
// 8. get_db_path falls back to default when no custom config
|
|
#[test]
|
|
fn test_default_db_path() {
|
|
use std::env;
|
|
|
|
// Ensure no env var is set
|
|
env::remove_var("RTK_DB_PATH");
|
|
|
|
let db_path = get_db_path().expect("Failed to get db path");
|
|
assert!(db_path.ends_with("rtk/history.db"));
|
|
}
|
|
|
|
// 9. project_filter_params uses GLOB pattern with * wildcard // added
|
|
#[test]
|
|
fn test_project_filter_params_glob_pattern() {
|
|
let (exact, glob) = project_filter_params(Some("/home/user/project"));
|
|
assert_eq!(exact.unwrap(), "/home/user/project");
|
|
// Must use * (GLOB) not % (LIKE) for subdirectory prefix matching
|
|
let glob_val = glob.unwrap();
|
|
assert!(glob_val.ends_with('*'), "GLOB pattern must end with *");
|
|
assert!(!glob_val.contains('%'), "Must not contain LIKE wildcard %");
|
|
assert_eq!(
|
|
glob_val,
|
|
format!("/home/user/project{}*", std::path::MAIN_SEPARATOR)
|
|
);
|
|
}
|
|
|
|
// 10. project_filter_params returns None for None input // added
|
|
#[test]
|
|
fn test_project_filter_params_none() {
|
|
let (exact, glob) = project_filter_params(None);
|
|
assert!(exact.is_none());
|
|
assert!(glob.is_none());
|
|
}
|
|
|
|
// 11. GLOB pattern safe with underscores in path names // added
|
|
#[test]
|
|
fn test_project_filter_params_underscore_safe() {
|
|
// In LIKE, _ matches any single char; in GLOB, _ is literal
|
|
let (exact, glob) = project_filter_params(Some("/home/user/my_project"));
|
|
assert_eq!(exact.unwrap(), "/home/user/my_project");
|
|
let glob_val = glob.unwrap();
|
|
// _ must be preserved literally (GLOB treats _ as literal, LIKE does not)
|
|
assert!(glob_val.contains("my_project"));
|
|
assert_eq!(
|
|
glob_val,
|
|
format!("/home/user/my_project{}*", std::path::MAIN_SEPARATOR)
|
|
);
|
|
}
|
|
|
|
// 12. record_parse_failure + get_parse_failure_summary roundtrip
|
|
#[test]
|
|
fn test_parse_failure_roundtrip() {
|
|
let tracker = Tracker::new().expect("Failed to create tracker");
|
|
let test_cmd = format!("git -C /path status test_{}", std::process::id());
|
|
|
|
tracker
|
|
.record_parse_failure(&test_cmd, "unrecognized subcommand", true)
|
|
.expect("Failed to record parse failure");
|
|
|
|
let summary = tracker
|
|
.get_parse_failure_summary()
|
|
.expect("Failed to get summary");
|
|
|
|
assert!(summary.total >= 1);
|
|
assert!(summary.recent.iter().any(|r| r.raw_command == test_cmd));
|
|
}
|
|
|
|
// 13. recovery_rate calculation
|
|
#[test]
|
|
fn test_parse_failure_recovery_rate() {
|
|
let tracker = Tracker::new().expect("Failed to create tracker");
|
|
let pid = std::process::id();
|
|
|
|
// 2 successes, 1 failure
|
|
tracker
|
|
.record_parse_failure(&format!("cmd_ok1_{}", pid), "err", true)
|
|
.unwrap();
|
|
tracker
|
|
.record_parse_failure(&format!("cmd_ok2_{}", pid), "err", true)
|
|
.unwrap();
|
|
tracker
|
|
.record_parse_failure(&format!("cmd_fail_{}", pid), "err", false)
|
|
.unwrap();
|
|
|
|
let summary = tracker.get_parse_failure_summary().unwrap();
|
|
// We can't assert exact rate because other tests may have added records,
|
|
// but we can verify recovery_rate is between 0 and 100
|
|
assert!(summary.recovery_rate >= 0.0 && summary.recovery_rate <= 100.0);
|
|
}
|
|
}
|