Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/core/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ use super::constants::{CONFIG_TOML, DEFAULT_HISTORY_DAYS, RTK_DATA_DIR};
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::OnceLock;

static CONFIG_CACHE: OnceLock<Config> = OnceLock::new();

#[derive(Debug, Serialize, Deserialize, Default)]
pub struct Config {
Expand Down Expand Up @@ -163,6 +166,15 @@ impl Config {
}
}

/// Load config with process-lifetime caching.
///
/// C-08 hot-path fix: hook invocations call this instead of `load()` so
/// `config.toml` is read at most once per `rtk hook` process. Falls back to
/// `Config::default()` on load failure so hook execution is never blocked.
pub fn load_cached() -> &'static Config {
CONFIG_CACHE.get_or_init(|| Config::load().unwrap_or_default())
}

pub fn save(&self) -> Result<()> {
let path = get_config_path()?;

Expand Down
30 changes: 16 additions & 14 deletions src/core/telemetry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,26 @@ pub fn maybe_ping() {
return;
}

// Check opt-out: env var
// Check opt-out: env var (cheap — before any disk I/O)
if std::env::var("RTK_TELEMETRY_DISABLED").unwrap_or_default() == "1" {
return;
}

// Load config once (avoid double disk read)
// C-08 fix: check the day-marker BEFORE loading config.
// On the hot path (hook invocation), the marker is almost always fresh,
// so we skip the config.toml disk read entirely on 99% of calls.
let marker = telemetry_marker_path();
if let Ok(metadata) = std::fs::metadata(&marker) {
if let Ok(modified) = metadata.modified() {
if let Ok(elapsed) = modified.elapsed() {
if elapsed.as_secs() < PING_INTERVAL_SECS {
return;
}
}
}
}

// Marker is stale (or absent) — load config to check consent before pinging.
let cfg = match config::Config::load() {
Ok(c) => c,
Err(_) => return,
Expand All @@ -45,18 +59,6 @@ pub fn maybe_ping() {
return;
}

// Check last ping time
let marker = telemetry_marker_path();
if let Ok(metadata) = std::fs::metadata(&marker) {
if let Ok(modified) = metadata.modified() {
if let Ok(elapsed) = modified.elapsed() {
if elapsed.as_secs() < PING_INTERVAL_SECS {
return;
}
}
}
}

// Touch marker file immediately (before sending) to avoid double-ping
touch_marker(&marker);

Expand Down
21 changes: 12 additions & 9 deletions src/hooks/hook_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -94,15 +94,10 @@ pub fn maybe_warn() {

/// Single source of truth: delegates to `status()` then rate-limits the warning.
fn check_and_warn() -> Option<()> {
let warning = match status() {
HookStatus::Ok => return Some(()),
HookStatus::Missing => {
"[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings"
}
HookStatus::Outdated => "[rtk] /!\\ Hook outdated — run `rtk init -g` to update",
};

// Rate limit: warn once per day
// C-08 fix: check the rate-limit marker BEFORE calling status().
// status() reads settings.json unconditionally — skipping it on the hot path
// (recent marker → already warned today) avoids that disk read on every
// hook invocation.
let marker = warn_marker_path()?;
if let Ok(meta) = std::fs::metadata(&marker) {
if let Ok(modified) = meta.modified() {
Expand All @@ -112,6 +107,14 @@ fn check_and_warn() -> Option<()> {
}
}

let warning = match status() {
HookStatus::Ok => return Some(()),
HookStatus::Missing => {
"[rtk] /!\\ No hook installed — run `rtk init -g` for automatic token savings"
}
HookStatus::Outdated => "[rtk] /!\\ Hook outdated — run `rtk init -g` to update",
};

eprintln!("{}", warning);

// Touch marker after warning is printed
Expand Down
16 changes: 10 additions & 6 deletions src/hooks/hook_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ enum HookFormat {
/// Run the Copilot preToolUse hook.
/// Auto-detects VS Code Copilot Chat vs Copilot CLI format.
pub fn run_copilot() -> Result<()> {
std::env::set_var("RTK_HOOK_MODE", "1");
let input = read_stdin_limited()?;

let input = input.trim();
Expand Down Expand Up @@ -107,9 +108,9 @@ fn get_rewritten(cmd: &str) -> Option<String> {
return None;
}

let (excluded, transparent_prefixes) = crate::core::config::Config::load()
.map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes))
.unwrap_or_default();
let cfg = crate::core::config::Config::load_cached();
let excluded = cfg.hooks.exclude_commands.clone();
let transparent_prefixes = cfg.hooks.transparent_prefixes.clone();

let rewritten = rewrite_command(cmd, &excluded, &transparent_prefixes)?;

Expand Down Expand Up @@ -181,6 +182,7 @@ fn handle_copilot_cli(cmd: &str) -> Result<()> {

/// Run the Gemini CLI BeforeTool hook.
pub fn run_gemini() -> Result<()> {
std::env::set_var("RTK_HOOK_MODE", "1");
let input = read_stdin_limited()?;

let json: Value = serde_json::from_str(&input).context("Failed to parse hook input as JSON")?;
Expand Down Expand Up @@ -211,9 +213,9 @@ pub fn run_gemini() -> Result<()> {
return Ok(());
}

let (excluded, transparent_prefixes) = crate::core::config::Config::load()
.map(|c| (c.hooks.exclude_commands, c.hooks.transparent_prefixes))
.unwrap_or_default();
let cfg = crate::core::config::Config::load_cached();
let excluded = cfg.hooks.exclude_commands.clone();
let transparent_prefixes = cfg.hooks.transparent_prefixes.clone();

match rewrite_command(cmd, &excluded, &transparent_prefixes) {
Some(ref rewritten) => {
Expand Down Expand Up @@ -355,6 +357,7 @@ fn process_claude_payload(v: &Value) -> PayloadAction {

/// Run the Claude Code PreToolUse hook natively.
pub fn run_claude() -> Result<()> {
std::env::set_var("RTK_HOOK_MODE", "1");
let input = read_stdin_limited()?;

let input = input.trim();
Expand Down Expand Up @@ -413,6 +416,7 @@ fn strip_leading_bom(input: &str) -> &str {

/// Run the Cursor Agent hook natively.
pub fn run_cursor() -> Result<()> {
std::env::set_var("RTK_HOOK_MODE", "1");
let input = read_stdin_limited()?;

let input = strip_leading_bom(&input).trim();
Expand Down