1use 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
40fn 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
51fn 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)), ),
60 None => (None, None),
61 }
62}
63
64use super::constants::{DEFAULT_HISTORY_DAYS, HISTORY_DB, RTK_DATA_DIR};
65
66pub struct Tracker {
92 conn: Connection,
93}
94
95#[derive(Debug)]
99pub struct CommandRecord {
100 pub timestamp: DateTime<Utc>,
102 pub rtk_cmd: String,
104 pub saved_tokens: usize,
106 pub savings_pct: f64,
108}
109
110#[derive(Debug)]
115pub struct GainSummary {
116 pub total_commands: usize,
118 pub total_input: usize,
120 pub total_output: usize,
122 pub total_saved: usize,
124 pub avg_savings_pct: f64,
126 pub total_time_ms: u64,
128 pub avg_time_ms: u64,
130 pub by_command: Vec<(String, usize, usize, f64, u64)>,
132 pub by_day: Vec<(String, usize)>,
134}
135
136#[derive(Debug, Serialize)]
155pub struct DayStats {
156 pub date: String,
158 pub commands: usize,
160 pub input_tokens: usize,
162 pub output_tokens: usize,
164 pub saved_tokens: usize,
166 pub savings_pct: f64,
168 pub total_time_ms: u64,
170 pub avg_time_ms: u64,
172}
173
174#[derive(Debug, Serialize)]
179pub struct WeekStats {
180 pub week_start: String,
182 pub week_end: String,
184 pub commands: usize,
186 pub input_tokens: usize,
188 pub output_tokens: usize,
190 pub saved_tokens: usize,
192 pub savings_pct: f64,
194 pub total_time_ms: u64,
196 pub avg_time_ms: u64,
198}
199
200#[derive(Debug, Serialize)]
204pub struct MonthStats {
205 pub month: String,
207 pub commands: usize,
209 pub input_tokens: usize,
211 pub output_tokens: usize,
213 pub saved_tokens: usize,
215 pub savings_pct: f64,
217 pub total_time_ms: u64,
219 pub avg_time_ms: u64,
221}
222
223type CommandStats = (String, usize, usize, f64, u64);
225
226impl Tracker {
227 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 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 let _ = conn.execute(
283 "ALTER TABLE commands ADD COLUMN exec_time_ms INTEGER DEFAULT 0",
284 [],
285 );
286 let _ = conn.execute(
288 "ALTER TABLE commands ADD COLUMN project_path TEXT DEFAULT ''",
289 [],
290 );
291 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 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 #[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 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(); 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)", params![
423 Utc::now().to_rfc3339(),
424 original_cmd,
425 rtk_cmd,
426 project_path, 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 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 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 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 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 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 #[allow(dead_code)]
564 pub fn get_summary(&self) -> Result<GainSummary> {
565 self.get_summary_filtered(None) }
567
568 pub fn get_summary_filtered(&self, project_path: Option<&str>) -> Result<GainSummary> {
573 let (project_exact, project_glob) = project_filter_params(project_path); 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)", )?;
585
586 let rows = stmt.query_map(params![project_exact, project_glob], |row| {
587 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)?; let by_day = self.get_by_day(project_path)?; 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>, ) -> Result<Vec<CommandStats>> {
637 let (project_exact, project_glob) = project_filter_params(project_path); 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", )?;
646
647 let rows = stmt.query_map(params![project_exact, project_glob], |row| {
648 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>, ) -> Result<Vec<(String, usize)>> {
665 let (project_exact, project_glob) = project_filter_params(project_path); 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", )?;
674
675 let rows = stmt.query_map(params![project_exact, project_glob], |row| {
676 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 pub fn get_all_days(&self) -> Result<Vec<DayStats>> {
704 self.get_all_days_filtered(None) }
706
707 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); 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", )?;
723
724 let rows = stmt.query_map(params![project_exact, project_glob], |row| {
725 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 pub fn get_by_week(&self) -> Result<Vec<WeekStats>> {
777 self.get_by_week_filtered(None) }
779
780 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); 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", )?;
797
798 let rows = stmt.query_map(params![project_exact, project_glob], |row| {
799 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 pub fn get_by_month(&self) -> Result<Vec<MonthStats>> {
852 self.get_by_month_filtered(None) }
854
855 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); 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", )?;
871
872 let rows = stmt.query_map(params![project_exact, project_glob], |row| {
873 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 #[allow(dead_code)]
928 pub fn get_recent(&self, limit: usize) -> Result<Vec<CommandRecord>> {
929 self.get_recent_filtered(limit, None) }
931
932 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); 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", )?;
946
947 let rows = stmt.query_map(
948 params![project_exact, project_glob, limit as i64], |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 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 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 Ok(cmd.split_whitespace().nth(1).unwrap_or(&cmd).to_string())
985 })?;
986 Ok(rows.filter_map(|r| r.ok()).collect())
987 }
988
989 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 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 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 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 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 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 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 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 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 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 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 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 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 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
1199fn 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 if let Ok(custom_path) = std::env::var("RTK_DB_PATH") {
1223 return Ok(PathBuf::from(custom_path));
1224 }
1225
1226 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 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#[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#[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
1257pub 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
1275fn tracking_disabled_for(tracking_disabled: Option<&str>, hosted: Option<&str>) -> bool {
1279 cfg!(feature = "hosted") || tracking_disabled == Some("1") || hosted == Some("1")
1280}
1281
1282pub fn estimate_tokens(text: &str) -> usize {
1302 (text.len() as f64 / 4.0).ceil() as usize
1304}
1305
1306pub struct TimedExecution {
1324 start: Instant,
1325}
1326
1327impl TimedExecution {
1328 pub fn start() -> Self {
1344 Self {
1345 start: Instant::now(),
1346 }
1347 }
1348
1349 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 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 if let Ok(tracker) = Tracker::new() {
1419 let _ = tracker.record(original_cmd, rtk_cmd, 0, 0, elapsed_ms);
1420 }
1421 }
1422}
1423
1424pub 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 #[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 #[test]
1468 fn test_estimate_tokens() {
1469 assert_eq!(estimate_tokens(""), 0);
1470 assert_eq!(estimate_tokens("abcd"), 1); assert_eq!(estimate_tokens("abcde"), 2); assert_eq!(estimate_tokens("a"), 1); assert_eq!(estimate_tokens("12345678"), 2); }
1475
1476 #[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 #[test]
1489 fn test_tracker_record_and_recent() {
1490 let tracker = Tracker::new().expect("Failed to create tracker");
1491
1492 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 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 #[test]
1513 fn test_track_passthrough_no_dilution() {
1514 let tracker = Tracker::new().expect("Failed to create tracker");
1515
1516 let pid = std::process::id();
1518 let cmd1 = format!("rtk cmd1_test_{}", pid);
1519 let cmd2 = format!("rtk cmd2_passthrough_test_{}", pid);
1520
1521 tracker
1523 .record("cmd1", &cmd1, 1000, 200, 10)
1524 .expect("Failed to record cmd1");
1525
1526 tracker
1528 .record("cmd2", &cmd2, 0, 0, 5)
1529 .expect("Failed to record passthrough");
1530
1531 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 assert_eq!(record1.saved_tokens, 800);
1545 assert_eq!(record1.savings_pct, 80.0);
1546
1547 assert_eq!(record2.saved_tokens, 0);
1549 assert_eq!(record2.savings_pct, 0.0);
1550
1551 }
1554
1555 #[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 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 #[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 assert_eq!(pt.savings_pct, 0.0);
1584 assert_eq!(pt.saved_tokens, 0);
1585 }
1586
1587 #[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 #[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 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 #[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 #[test]
1636 fn test_project_filter_params_underscore_safe() {
1637 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 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 #[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 #[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 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 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 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 tracker
1708 .record_parse_failure(&format!("bad_cmd_reset_test_{}", pid), "parse error", false)
1709 .expect("Failed to record parse failure");
1710
1711 tracker.reset_all().expect("Failed to reset");
1713
1714 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}