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
1 change: 1 addition & 0 deletions src/analytics/session_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ mod tests {
session_id: "test".to_string(),
output_content: None,
is_error: false,
was_rtk_routed: false,
sequence_index: 0,
}
}
Expand Down
65 changes: 65 additions & 0 deletions src/core/tracking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,21 @@ pub struct CommandRecord {
pub savings_pct: f64,
}

/// Detailed tracked command row used by discover-like analytics.
#[derive(Debug)]
pub struct TrackedCommandRecord {
/// UTC timestamp when command was executed
pub timestamp: DateTime<Utc>,
/// Raw/original command before RTK routing
pub original_cmd: String,
/// RTK command that was actually executed
pub rtk_cmd: String,
/// Raw input token estimate captured by tracking
pub input_tokens: usize,
/// Filtered output token count captured by tracking
pub output_tokens: usize,
}

/// Aggregated statistics across all recorded commands.
///
/// Provides overall metrics and breakdowns by command and by day.
Expand Down Expand Up @@ -961,6 +976,38 @@ impl Tracker {
Ok(rows.collect::<Result<Vec<_>, _>>()?)
}

/// Get tracked commands suitable for discover-style analysis.
pub fn get_discover_commands_filtered(
&self,
since_days: u64,
project_path: Option<&str>,
) -> Result<Vec<TrackedCommandRecord>> {
let cutoff = Utc::now() - chrono::Duration::days(since_days as i64);
let cutoff_ts = cutoff.to_rfc3339();
let (project_exact, project_glob) = project_filter_params(project_path);
let mut stmt = self.conn.prepare(
"SELECT timestamp, original_cmd, rtk_cmd, input_tokens, output_tokens
FROM commands
WHERE timestamp >= ?1
AND (?2 IS NULL OR project_path = ?2 OR project_path GLOB ?3)
ORDER BY timestamp DESC",
)?;

let rows = stmt.query_map(params![cutoff_ts, project_exact, project_glob], |row| {
Ok(TrackedCommandRecord {
timestamp: DateTime::parse_from_rfc3339(&row.get::<_, String>(0)?)
.map(|dt| dt.with_timezone(&Utc))
.unwrap_or_else(|_| Utc::now()),
original_cmd: row.get(1)?,
rtk_cmd: row.get(2)?,
input_tokens: row.get::<_, i64>(3)? as usize,
output_tokens: row.get::<_, i64>(4)? as usize,
})
})?;

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();
Expand Down Expand Up @@ -1544,6 +1591,24 @@ mod tests {
assert_eq!(pt.saved_tokens, 0);
}

#[test]
fn test_get_discover_commands_filtered_returns_original_and_rtk_commands() {
let tracker = Tracker::new_in_memory().expect("Failed to create in-memory tracker");
tracker
.record("git status", "rtk git status", 100, 20, 10)
.expect("Failed to record tracked command");

let rows = tracker
.get_discover_commands_filtered(30, None)
.expect("Failed to query discover commands");

assert_eq!(rows.len(), 1);
assert_eq!(rows[0].original_cmd, "git status");
assert_eq!(rows[0].rtk_cmd, "rtk git status");
assert_eq!(rows[0].input_tokens, 100);
assert_eq!(rows[0].output_tokens, 20);
}

// 7. get_db_path respects environment variable RTK_DB_PATH
// 8. get_db_path falls back to default when no custom config
// Combined into one test to avoid env var race between parallel tests
Expand Down
215 changes: 120 additions & 95 deletions src/discover/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ pub mod rules;
use anyhow::Result;
use std::collections::HashMap;

use provider::{ClaudeProvider, SessionProvider};
use provider::{ClaudeProvider, CursorTrackerProvider, SessionProvider};
use registry::{
category_avg_tokens, classify_command, split_command_chain, strip_disabled_prefix,
Classification,
Expand Down Expand Up @@ -48,10 +48,8 @@ pub fn run(
format: &str,
verbose: u8,
) -> Result<()> {
let provider = ClaudeProvider;

// Determine project filter
let project_filter = if all {
let claude_project_filter = if all {
None
} else if let Some(p) = project {
Some(p.to_string())
Expand All @@ -62,13 +60,41 @@ pub fn run(
let encoded = ClaudeProvider::encode_project_path(&cwd_str);
Some(encoded)
};
let cursor_project_scope = if all {
None
} else if let Some(project) = project {
std::path::Path::new(project)
.canonicalize()
.ok()
.map(|p| p.to_string_lossy().to_string())
} else {
let cwd = std::env::current_dir()?;
Some(cwd.canonicalize().unwrap_or(cwd).to_string_lossy().to_string())
};

let claude_provider = ClaudeProvider;
let cursor_provider = CursorTrackerProvider::new(since_days, cursor_project_scope);

let mut sources: Vec<(&dyn SessionProvider, Vec<std::path::PathBuf>)> = Vec::new();
let claude_sessions =
claude_provider.discover_sessions(claude_project_filter.as_deref(), Some(since_days))?;
if !claude_sessions.is_empty() {
sources.push((&claude_provider, claude_sessions));
} else {
let cursor_sessions = cursor_provider.discover_sessions(None, None)?;
if !cursor_sessions.is_empty() {
sources.push((&cursor_provider, cursor_sessions));
}
}

let sessions = provider.discover_sessions(project_filter.as_deref(), Some(since_days))?;
let sessions_scanned: usize = sources.iter().map(|(_, sessions)| sessions.len()).sum();

if verbose > 0 {
eprintln!("Scanning {} session files...", sessions.len());
for s in &sessions {
eprintln!(" {}", s.display());
eprintln!("Scanning {} session sources...", sessions_scanned);
for (_, sessions) in &sources {
for s in sessions {
eprintln!(" {}", s.display());
}
}
}

Expand All @@ -80,100 +106,99 @@ pub fn run(
let mut supported_map: HashMap<&'static str, SupportedBucket> = HashMap::new();
let mut unsupported_map: HashMap<String, UnsupportedBucket> = HashMap::new();

for session_path in &sessions {
let extracted = match provider.extract_commands(session_path) {
Ok(cmds) => cmds,
Err(e) => {
if verbose > 0 {
eprintln!("Warning: skipping {}: {}", session_path.display(), e);
}
parse_errors += 1;
continue;
}
};

for ext_cmd in &extracted {
let parts = split_command_chain(&ext_cmd.command);
for part in parts {
total_commands += 1;

// Detect RTK_DISABLED= bypass before classification
let (env_prefix, actual_cmd) = strip_disabled_prefix(part);
if prefix_contains_rtk_disabled(env_prefix) {
// Only count if the underlying command is one RTK supports
match classify_command(actual_cmd) {
Classification::Supported { .. } => {
rtk_disabled_count += 1;
let display = truncate_command(actual_cmd);
*rtk_disabled_cmds.entry(display).or_insert(0) += 1;
}
_ => {
// RTK_DISABLED on unsupported/ignored command — not interesting
}
for (provider, sessions) in &sources {
for session_path in sessions {
let extracted = match provider.extract_commands(session_path) {
Ok(cmds) => cmds,
Err(e) => {
if verbose > 0 {
eprintln!("Warning: skipping {}: {}", session_path.display(), e);
}
parse_errors += 1;
continue;
}
};

match classify_command(part) {
Classification::Supported {
rtk_equivalent,
category,
estimated_savings_pct,
status,
} => {
let bucket = supported_map.entry(rtk_equivalent).or_insert_with(|| {
SupportedBucket {
rtk_equivalent,
category,
count: 0,
total_output_tokens: 0,
total_raw_output_tokens: 0,
command_counts: HashMap::new(),
}
});

bucket.count += 1;

// Estimate tokens for this command
let output_tokens = if let Some(len) = ext_cmd.output_len {
// Real: from tool_result content length
len / 4
} else {
// Fallback: category average
let subcmd = extract_subcmd(part);
category_avg_tokens(category, subcmd)
};
for ext_cmd in &extracted {
let parts = split_command_chain(&ext_cmd.command);
for part in parts {
total_commands += 1;

let savings =
(output_tokens as f64 * estimated_savings_pct / 100.0) as usize;
bucket.total_output_tokens += savings;
// Accumulate pre-savings tokens so we can compute a weighted effective
// savings rate across all sub-commands in this bucket later.
bucket.total_raw_output_tokens += output_tokens;

// Track the display name with status
let display_name = truncate_command(part);
let entry = bucket
.command_counts
.entry(format!("{}:{:?}", display_name, status))
.or_insert(0);
*entry += 1;
}
Classification::Unsupported { base_command } => {
let bucket = unsupported_map.entry(base_command).or_insert_with(|| {
UnsupportedBucket {
count: 0,
example: part.to_string(),
// Detect RTK_DISABLED= bypass before classification
let (env_prefix, actual_cmd) = strip_disabled_prefix(part);
if prefix_contains_rtk_disabled(env_prefix) {
match classify_command(actual_cmd) {
Classification::Supported { .. } => {
rtk_disabled_count += 1;
let display = truncate_command(actual_cmd);
*rtk_disabled_cmds.entry(display).or_insert(0) += 1;
}
});
bucket.count += 1;
_ => {}
}
continue;
}
Classification::Ignored => {
// Check if it starts with "rtk "
if part.trim().starts_with("rtk ") {
already_rtk += 1;

match classify_command(part) {
Classification::Supported {
rtk_equivalent,
category,
estimated_savings_pct,
status,
} => {
if ext_cmd.was_rtk_routed {
already_rtk += 1;
continue;
}

let bucket = supported_map.entry(rtk_equivalent).or_insert_with(|| {
SupportedBucket {
rtk_equivalent,
category,
count: 0,
total_output_tokens: 0,
total_raw_output_tokens: 0,
command_counts: HashMap::new(),
}
});

bucket.count += 1;

let output_tokens = if let Some(len) = ext_cmd.output_len {
len / 4
} else {
let subcmd = extract_subcmd(part);
category_avg_tokens(category, subcmd)
};

let savings =
(output_tokens as f64 * estimated_savings_pct / 100.0) as usize;
bucket.total_output_tokens += savings;
bucket.total_raw_output_tokens += output_tokens;

let display_name = truncate_command(part);
let entry = bucket
.command_counts
.entry(format!("{}:{:?}", display_name, status))
.or_insert(0);
*entry += 1;
}
Classification::Unsupported { base_command } => {
let bucket = unsupported_map.entry(base_command).or_insert_with(|| {
UnsupportedBucket {
count: 0,
example: part.to_string(),
}
});
bucket.count += 1;
if ext_cmd.was_rtk_routed {
already_rtk += 1;
}
}
Classification::Ignored => {
if ext_cmd.was_rtk_routed || part.trim().starts_with("rtk ") {
already_rtk += 1;
}
}
// Otherwise just skip
}
}
}
Expand Down Expand Up @@ -254,7 +279,7 @@ pub fn run(
};

let report = DiscoverReport {
sessions_scanned: sessions.len(),
sessions_scanned,
total_commands,
already_rtk,
since_days,
Expand Down
Loading