diff --git a/src/core/config.rs b/src/core/config.rs index ed0f00c6c..fbd836c55 100644 --- a/src/core/config.rs +++ b/src/core/config.rs @@ -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 = OnceLock::new(); #[derive(Debug, Serialize, Deserialize, Default)] pub struct Config { @@ -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()?; diff --git a/src/core/telemetry.rs b/src/core/telemetry.rs index 97e057be1..50f43fabe 100644 --- a/src/core/telemetry.rs +++ b/src/core/telemetry.rs @@ -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, @@ -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); diff --git a/src/hooks/hook_check.rs b/src/hooks/hook_check.rs index 4dd26d82b..8d2bbd7b2 100644 --- a/src/hooks/hook_check.rs +++ b/src/hooks/hook_check.rs @@ -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() { @@ -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 diff --git a/src/hooks/hook_cmd.rs b/src/hooks/hook_cmd.rs index 36825e3c5..fa426400a 100644 --- a/src/hooks/hook_cmd.rs +++ b/src/hooks/hook_cmd.rs @@ -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(); @@ -107,9 +108,9 @@ fn get_rewritten(cmd: &str) -> Option { 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)?; @@ -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")?; @@ -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) => { @@ -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(); @@ -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();