diff --git a/.github/workflows/security-check.yml b/.github/workflows/security-check.yml index 75859305..63f44cbc 100644 --- a/.github/workflows/security-check.yml +++ b/.github/workflows/security-check.yml @@ -2,7 +2,7 @@ name: Security Check on: pull_request: - branches: [ master ] + branches: [ master, develop ] permissions: contents: read diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 79b68569..de7e7364 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -293,7 +293,7 @@ SHARED utils.rs Helpers N/A ✓ tee.rs Full output recovery N/A ✓ ``` -**Total: 57 modules** (38 command modules + 19 infrastructure modules) +**Total: 59 modules** (39 command modules + 20 infrastructure modules) ### Module Count Breakdown @@ -1488,4 +1488,4 @@ When implementing a new command, consider: **Last Updated**: 2026-02-22 **Architecture Version**: 2.2 -**rtk Version**: 0.27.1 +**rtk Version**: 0.27.2 diff --git a/CLAUDE.md b/CLAUDE.md index d1428d04..c287d0ba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -16,7 +16,7 @@ This is a fork with critical fixes for git argument parsing and modern JavaScrip **Verify correct installation:** ```bash -rtk --version # Should show "rtk 0.27.1" (or newer) +rtk --version # Should show "rtk 0.27.2" (or newer) rtk gain # Should show token savings stats (NOT "command not found") ``` diff --git a/README.md b/README.md index 927d2d80..e4155316 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,7 @@ Download from [releases](https://github.com/rtk-ai/rtk/releases): ### Verify Installation ```bash -rtk --version # Should show "rtk 0.27.1" +rtk --version # Should show "rtk 0.27.2" rtk gain # Should show token savings stats ``` diff --git a/src/chezmoi_cmd.rs b/src/chezmoi_cmd.rs new file mode 100644 index 00000000..8ef32567 --- /dev/null +++ b/src/chezmoi_cmd.rs @@ -0,0 +1,1018 @@ +use crate::tracking; +use anyhow::{Context, Result}; +use lazy_static::lazy_static; +use regex::Regex; +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::process::Command; + +#[derive(Debug, Clone)] +pub enum ChezmoiCommand { + Diff, + Apply, + Status, + Managed, + Add, + ReAdd, + Update, + Unmanaged, + Doctor, +} + +pub fn run(cmd: ChezmoiCommand, args: &[String], verbose: u8) -> Result<()> { + match cmd { + ChezmoiCommand::Diff => run_diff(args, verbose), + ChezmoiCommand::Apply => run_apply(args, verbose), + ChezmoiCommand::Status => run_status(args), + ChezmoiCommand::Managed => run_managed(args), + ChezmoiCommand::Add => run_add_or_readd("add", args, verbose), + ChezmoiCommand::ReAdd => run_add_or_readd("re-add", args, verbose), + ChezmoiCommand::Update => run_update(args, verbose), + ChezmoiCommand::Unmanaged => run_unmanaged(args), + ChezmoiCommand::Doctor => run_doctor(args), + } +} + +pub fn run_passthrough(args: &[OsString], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + let _ = verbose; + + let status = Command::new("chezmoi") + .args(args) + .status() + .context("Failed to run chezmoi")?; + + let args_str = tracking::args_display(args); + timer.track_passthrough( + &format!("chezmoi {}", args_str), + &format!("rtk chezmoi {} (passthrough)", args_str), + ); + + if !status.success() { + std::process::exit(status.code().unwrap_or(1)); + } + Ok(()) +} + +fn run_diff(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("chezmoi"); + cmd.arg("diff"); + for arg in args { + cmd.arg(arg); + } + + let output = cmd.output().context("Failed to run chezmoi diff")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.is_empty() { + eprint!("{}", stderr); + } + std::process::exit(output.status.code().unwrap_or(1)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + if stdout.trim().is_empty() { + let msg = "chezmoi: up to date"; + println!("{}", msg); + timer.track("chezmoi diff", "rtk chezmoi diff", &stdout, msg); + return Ok(()); + } + + let filtered = filter_chezmoi_diff(&stdout, verbose); + println!("{}", filtered); + + let raw_cmd = format!("chezmoi diff {}", args.join(" ")); + let rtk_cmd = format!("rtk chezmoi diff {}", args.join(" ")); + timer.track(raw_cmd.trim_end(), rtk_cmd.trim_end(), &stdout, &filtered); + + Ok(()) +} + +fn run_apply(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("chezmoi"); + cmd.arg("apply"); + // Always pass -v to capture what was applied + if !args.iter().any(|a| a == "-v" || a == "--verbose") { + cmd.arg("-v"); + } + for arg in args { + cmd.arg(arg); + } + + let output = cmd.output().context("Failed to run chezmoi apply")?; + + // chezmoi apply prints applied files to stderr with -v + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + if !stderr.is_empty() { + eprint!("{}", stderr); + } + if !stdout.is_empty() { + print!("{}", stdout); + } + std::process::exit(output.status.code().unwrap_or(1)); + } + + // Combine stdout+stderr for filtering (chezmoi uses stderr for verbose output) + let combined = format!("{}{}", stdout, stderr); + let filtered = filter_chezmoi_apply(&combined, verbose); + println!("{}", filtered); + + let raw_cmd = format!("chezmoi apply {}", args.join(" ")); + let rtk_cmd = format!("rtk chezmoi apply {}", args.join(" ")); + timer.track(raw_cmd.trim_end(), rtk_cmd.trim_end(), &combined, &filtered); + + Ok(()) +} + +fn run_status(args: &[String]) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("chezmoi"); + cmd.arg("status"); + for arg in args { + cmd.arg(arg); + } + + let output = cmd.output().context("Failed to run chezmoi status")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.is_empty() { + eprint!("{}", stderr); + } + std::process::exit(output.status.code().unwrap_or(1)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + + if stdout.trim().is_empty() { + let msg = "chezmoi: up to date"; + println!("{}", msg); + timer.track("chezmoi status", "rtk chezmoi status", &stdout, msg); + return Ok(()); + } + + let filtered = filter_chezmoi_status(&stdout); + println!("{}", filtered); + timer.track("chezmoi status", "rtk chezmoi status", &stdout, &filtered); + + Ok(()) +} + +fn run_managed(args: &[String]) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("chezmoi"); + cmd.arg("managed"); + for arg in args { + cmd.arg(arg); + } + + let output = cmd.output().context("Failed to run chezmoi managed")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.is_empty() { + eprint!("{}", stderr); + } + std::process::exit(output.status.code().unwrap_or(1)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let filtered = filter_chezmoi_managed(&stdout); + println!("{}", filtered); + timer.track("chezmoi managed", "rtk chezmoi managed", &stdout, &filtered); + + Ok(()) +} + +fn run_add_or_readd(subcmd: &str, args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("chezmoi"); + cmd.arg(subcmd); + // Always pass -v to capture what was processed + if !args.iter().any(|a| a == "-v" || a == "--verbose") { + cmd.arg("-v"); + } + for arg in args { + cmd.arg(arg); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to run chezmoi {}", subcmd))?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + if !stderr.is_empty() { + eprint!("{}", stderr); + } + if !stdout.is_empty() { + print!("{}", stdout); + } + std::process::exit(output.status.code().unwrap_or(1)); + } + + // chezmoi prints processed files to stderr with -v + let combined = format!("{}{}", stdout, stderr); + let action = if subcmd == "re-add" { + "re-added" + } else { + "added" + }; + let filtered = filter_chezmoi_add(&combined, action, verbose); + println!("{}", filtered); + + let raw_cmd = format!("chezmoi {} {}", subcmd, args.join(" ")); + let rtk_cmd = format!("rtk chezmoi {} {}", subcmd, args.join(" ")); + timer.track(raw_cmd.trim_end(), rtk_cmd.trim_end(), &combined, &filtered); + + Ok(()) +} + +fn run_update(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("chezmoi"); + cmd.arg("update"); + // -v lets us see which files were applied + if !args.iter().any(|a| a == "-v" || a == "--verbose") { + cmd.arg("-v"); + } + for arg in args { + cmd.arg(arg); + } + + let output = cmd.output().context("Failed to run chezmoi update")?; + + // chezmoi update writes git output to stderr and apply output to stderr too + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + if !stderr.is_empty() { + eprint!("{}", stderr); + } + if !stdout.is_empty() { + print!("{}", stdout); + } + std::process::exit(output.status.code().unwrap_or(1)); + } + + let combined = format!("{}{}", stdout, stderr); + let filtered = filter_chezmoi_update(&combined, verbose); + println!("{}", filtered); + + let raw_cmd = format!("chezmoi update {}", args.join(" ")); + let rtk_cmd = format!("rtk chezmoi update {}", args.join(" ")); + timer.track(raw_cmd.trim_end(), rtk_cmd.trim_end(), &combined, &filtered); + + Ok(()) +} + +fn run_unmanaged(args: &[String]) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("chezmoi"); + cmd.arg("unmanaged"); + for arg in args { + cmd.arg(arg); + } + + let output = cmd.output().context("Failed to run chezmoi unmanaged")?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + if !stderr.is_empty() { + eprint!("{}", stderr); + } + std::process::exit(output.status.code().unwrap_or(1)); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + let filtered = filter_chezmoi_unmanaged(&stdout); + println!("{}", filtered); + timer.track( + "chezmoi unmanaged", + "rtk chezmoi unmanaged", + &stdout, + &filtered, + ); + + Ok(()) +} + +fn run_doctor(args: &[String]) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = Command::new("chezmoi"); + cmd.arg("doctor"); + for arg in args { + cmd.arg(arg); + } + + let output = cmd.output().context("Failed to run chezmoi doctor")?; + + // doctor exits non-zero when warnings/errors are found — still show filtered output + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let combined = format!("{}{}", stdout, stderr); + + let filtered = filter_chezmoi_doctor(&combined); + println!("{}", filtered); + + timer.track("chezmoi doctor", "rtk chezmoi doctor", &combined, &filtered); + + // Preserve exit code so callers can detect issues + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +// --- Filters --- + +lazy_static! { + static ref DIFF_FILE_RE: Regex = Regex::new(r"^diff --git a/(.+) b/.+$").unwrap(); + static ref DIFF_HUNK_RE: Regex = Regex::new(r"^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@").unwrap(); + // "1 file changed, 2 insertions(+), 3 deletions(-)" or "2 files changed, ..." + static ref GIT_STAT_RE: Regex = + Regex::new(r"(\d+) files? changed").unwrap(); + // "install /path/to/file" or "update /path/to/file" from chezmoi -v apply output + static ref APPLY_LINE_RE: Regex = + Regex::new(r"^(?:install|update|remove|mkdir|chmod|run)\s+").unwrap(); + // chezmoi doctor line: "ok check (detail)" / "warning ..." / "error ..." + static ref DOCTOR_LINE_RE: Regex = + Regex::new(r"^(ok|warning|error)\s+(.+)$").unwrap(); +} + +struct FileSummary { + path: String, + added: usize, + removed: usize, + is_new: bool, + is_deleted: bool, +} + +pub fn filter_chezmoi_diff(output: &str, _verbose: u8) -> String { + let mut files: Vec = Vec::new(); + let mut current: Option = None; + let mut in_hunk = false; + + for line in output.lines() { + if let Some(cap) = DIFF_FILE_RE.captures(line) { + if let Some(prev) = current.take() { + files.push(prev); + } + current = Some(FileSummary { + path: cap[1].to_string(), + added: 0, + removed: 0, + is_new: false, + is_deleted: false, + }); + in_hunk = false; + } else if line.starts_with("new file") { + if let Some(ref mut f) = current { + f.is_new = true; + } + } else if line.starts_with("deleted file") { + if let Some(ref mut f) = current { + f.is_deleted = true; + } + } else if DIFF_HUNK_RE.is_match(line) { + in_hunk = true; + } else if in_hunk { + if line.starts_with('+') && !line.starts_with("+++") { + if let Some(ref mut f) = current { + f.added += 1; + } + } else if line.starts_with('-') && !line.starts_with("---") { + if let Some(ref mut f) = current { + f.removed += 1; + } + } + } + } + + if let Some(prev) = current.take() { + files.push(prev); + } + + if files.is_empty() { + return "chezmoi: up to date".to_string(); + } + + let mut out = format!( + "chezmoi diff: {} file{}\n", + files.len(), + if files.len() == 1 { "" } else { "s" } + ); + + for f in &files { + let status = if f.is_new { + "A" + } else if f.is_deleted { + "D" + } else { + "M" + }; + let stats = if f.is_new { + format!("+{}", f.added) + } else if f.is_deleted { + format!("-{}", f.removed) + } else { + format!("+{}/-{}", f.added, f.removed) + }; + out.push_str(&format!(" {} {:<50} {}\n", status, f.path, stats)); + } + + out.trim_end().to_string() +} + +pub fn filter_chezmoi_apply(output: &str, verbose: u8) -> String { + let lines: Vec<&str> = output + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .collect(); + + if lines.is_empty() { + return "ok ✓ (no changes)".to_string(); + } + + if verbose > 0 { + let mut out = format!( + "ok ✓ {} file{} applied\n", + lines.len(), + if lines.len() == 1 { "" } else { "s" } + ); + for line in &lines { + out.push_str(&format!(" {}\n", line)); + } + return out.trim_end().to_string(); + } + + format!( + "ok ✓ {} file{} applied", + lines.len(), + if lines.len() == 1 { "" } else { "s" } + ) +} + +pub fn filter_chezmoi_add(output: &str, action: &str, verbose: u8) -> String { + let lines: Vec<&str> = output + .lines() + .map(|l| l.trim()) + .filter(|l| !l.is_empty()) + .collect(); + + if lines.is_empty() { + return format!("ok ✓ (nothing to {})", action); + } + + if verbose > 0 { + let mut out = format!( + "ok ✓ {} file{} {}\n", + lines.len(), + if lines.len() == 1 { "" } else { "s" }, + action + ); + for line in &lines { + out.push_str(&format!(" {}\n", line)); + } + return out.trim_end().to_string(); + } + + format!( + "ok ✓ {} file{} {}", + lines.len(), + if lines.len() == 1 { "" } else { "s" }, + action + ) +} + +pub fn filter_chezmoi_update(output: &str, verbose: u8) -> String { + // Fast path: nothing was pulled + if output.lines().any(|l| l.trim() == "Already up to date.") { + return "ok ✓ already up to date".to_string(); + } + + // Count files changed reported by git (stat summary line) + let git_files: usize = GIT_STAT_RE + .captures_iter(output) + .filter_map(|c| c[1].parse::().ok()) + .sum(); + + // Count apply lines (files chezmoi actually wrote) + let applied: Vec<&str> = output + .lines() + .filter(|l| APPLY_LINE_RE.is_match(l.trim())) + .collect(); + + let mut summary = match git_files { + 0 => "ok ✓ updated".to_string(), + n => format!( + "ok ✓ updated ({} source file{} changed)", + n, + if n == 1 { "" } else { "s" } + ), + }; + + if !applied.is_empty() { + summary.push_str(&format!( + ", {} dotfile{} applied", + applied.len(), + if applied.len() == 1 { "" } else { "s" } + )); + } + + if verbose > 0 && !applied.is_empty() { + summary.push('\n'); + for line in &applied { + summary.push_str(&format!(" {}\n", line.trim())); + } + return summary.trim_end().to_string(); + } + + summary +} + +pub fn filter_chezmoi_unmanaged(output: &str) -> String { + let files: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect(); + + if files.is_empty() { + return "chezmoi unmanaged: none".to_string(); + } + + let mut groups: BTreeMap = BTreeMap::new(); + for file in &files { + let dir = if let Some(idx) = file.find('/') { + file[..idx].to_string() + } else { + ".".to_string() + }; + *groups.entry(dir).or_insert(0) += 1; + } + + let mut out = format!( + "chezmoi unmanaged: {} file{}\n", + files.len(), + if files.len() == 1 { "" } else { "s" } + ); + for (dir, count) in &groups { + let label = if dir == "." { + format!(" . ({})", count) + } else { + format!(" {}/ ({})", dir, count) + }; + out.push_str(&label); + out.push('\n'); + } + + out.trim_end().to_string() +} + +pub fn filter_chezmoi_doctor(output: &str) -> String { + let mut warnings: Vec<&str> = Vec::new(); + let mut errors: Vec<&str> = Vec::new(); + let mut ok_count = 0usize; + + for line in output.lines() { + let trimmed = line.trim(); + if let Some(cap) = DOCTOR_LINE_RE.captures(trimmed) { + match &cap[1] { + "ok" => ok_count += 1, + "warning" => warnings.push(line), + "error" => errors.push(line), + _ => {} + } + } + } + + // All clear + if warnings.is_empty() && errors.is_empty() { + return format!("chezmoi doctor: ok ({} checks passed)", ok_count); + } + + let mut out = format!( + "chezmoi doctor: {} ok, {} warning{}, {} error{}\n", + ok_count, + warnings.len(), + if warnings.len() == 1 { "" } else { "s" }, + errors.len(), + if errors.len() == 1 { "" } else { "s" }, + ); + for line in &errors { + out.push_str(line.trim()); + out.push('\n'); + } + for line in &warnings { + out.push_str(line.trim()); + out.push('\n'); + } + + out.trim_end().to_string() +} + +pub fn filter_chezmoi_status(output: &str) -> String { + let lines: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect(); + + if lines.is_empty() { + return "chezmoi: up to date".to_string(); + } + + let mut added = 0usize; + let mut modified = 0usize; + let mut deleted = 0usize; + let mut other = 0usize; + let mut file_lines: Vec = Vec::new(); + + for line in &lines { + let mut chars = line.chars(); + let code = chars.next().unwrap_or(' '); + let path = if line.len() >= 3 { + line[2..].trim() + } else { + line.trim() + }; + + match code { + 'A' => { + added += 1; + file_lines.push(format!(" A {}", path)); + } + 'M' => { + modified += 1; + file_lines.push(format!(" M {}", path)); + } + 'D' => { + deleted += 1; + file_lines.push(format!(" D {}", path)); + } + _ => { + other += 1; + file_lines.push(format!(" {} {}", code, path)); + } + } + } + + let mut parts: Vec = Vec::new(); + if added > 0 { + parts.push(format!("{} added", added)); + } + if modified > 0 { + parts.push(format!("{} modified", modified)); + } + if deleted > 0 { + parts.push(format!("{} deleted", deleted)); + } + if other > 0 { + parts.push(format!("{} other", other)); + } + + let mut out = format!("chezmoi status: {}\n", parts.join(", ")); + for fl in &file_lines { + out.push_str(fl); + out.push('\n'); + } + + out.trim_end().to_string() +} + +pub fn filter_chezmoi_managed(output: &str) -> String { + let files: Vec<&str> = output.lines().filter(|l| !l.trim().is_empty()).collect(); + + if files.is_empty() { + return "chezmoi: no managed files".to_string(); + } + + let mut groups: BTreeMap = BTreeMap::new(); + for file in &files { + let dir = if let Some(idx) = file.find('/') { + file[..idx].to_string() + } else { + ".".to_string() + }; + *groups.entry(dir).or_insert(0) += 1; + } + + let mut out = format!("chezmoi managed: {} files\n", files.len()); + for (dir, count) in &groups { + let label = if dir == "." { + format!(" . ({})", count) + } else { + format!(" {}/ ({})", dir, count) + }; + out.push_str(&label); + out.push('\n'); + } + + out.trim_end().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn count_tokens(text: &str) -> usize { + text.split_whitespace().count() + } + + #[test] + fn test_filter_diff_empty() { + assert_eq!(filter_chezmoi_diff("", 0), "chezmoi: up to date"); + } + + #[test] + fn test_filter_diff_modified_file() { + let input = "diff --git a/.zshrc b/.zshrc\nindex abc..def 100644\n--- a/.zshrc\n+++ b/.zshrc\n@@ -10,6 +10,7 @@ export PATH\n alias gs=\"git status\"\n+alias gd=\"git diff\"\n alias gc=\"git commit\"\n"; + let output = filter_chezmoi_diff(input, 0); + assert!(output.contains(".zshrc"), "should show file path"); + assert!(output.contains("M"), "should show M for modified"); + assert!(output.contains("+1"), "should show added lines"); + assert!(count_tokens(&output) < count_tokens(input)); + } + + #[test] + fn test_filter_diff_new_file() { + let input = "diff --git a/.config/nvim/new.lua b/.config/nvim/new.lua\nnew file mode 100644\nindex 0000000..abc1234\n--- /dev/null\n+++ b/.config/nvim/new.lua\n@@ -0,0 +1,4 @@\n+-- New config\n+local M = {}\n+M.setup = function() end\n+return M\n"; + let output = filter_chezmoi_diff(input, 0); + assert!(output.contains("A"), "should show A for new file"); + assert!(output.contains(".config/nvim/new.lua")); + assert!(output.contains("+4")); + } + + #[test] + fn test_filter_diff_deleted_file() { + let input = "diff --git a/.config/old b/.config/old\ndeleted file mode 100644\nindex abc..0000000\n--- a/.config/old\n+++ /dev/null\n@@ -1,3 +0,0 @@\n-line one\n-line two\n-line three\n"; + let output = filter_chezmoi_diff(input, 0); + assert!(output.contains("D"), "should show D for deleted"); + assert!(output.contains("-3")); + } + + #[test] + fn test_filter_diff_token_savings() { + let mut input = String::new(); + for i in 0..5 { + input.push_str(&format!( + "diff --git a/.config/file{i} b/.config/file{i}\nindex abc..def 100644\n--- a/.config/file{i}\n+++ b/.config/file{i}\n@@ -1,20 +1,25 @@\n" + )); + for _ in 0..20 { + input.push_str(" context line here with some content\n"); + } + for j in 0..5 { + input.push_str(&format!("+new line {j} added to this file\n")); + } + } + let output = filter_chezmoi_diff(&input, 0); + let savings = 100.0 - (count_tokens(&output) as f64 / count_tokens(&input) as f64 * 100.0); + assert!( + savings >= 60.0, + "Expected >=60% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_filter_apply_empty() { + assert_eq!(filter_chezmoi_apply("", 0), "ok ✓ (no changes)"); + } + + #[test] + fn test_filter_apply_basic() { + let input = "install .zshrc\ninstall .config/nvim/init.lua\n"; + let output = filter_chezmoi_apply(input, 0); + assert_eq!(output, "ok ✓ 2 files applied"); + } + + #[test] + fn test_filter_apply_singular() { + let input = "install .zshrc\n"; + let output = filter_chezmoi_apply(input, 0); + assert_eq!(output, "ok ✓ 1 file applied"); + } + + #[test] + fn test_filter_apply_verbose() { + let input = "install .zshrc\ninstall .config/nvim/init.lua\n"; + let output = filter_chezmoi_apply(input, 1); + assert!(output.contains("2 files applied")); + assert!(output.contains(".zshrc")); + assert!(output.contains(".config/nvim/init.lua")); + } + + #[test] + fn test_filter_status_empty() { + assert_eq!(filter_chezmoi_status(""), "chezmoi: up to date"); + } + + #[test] + fn test_filter_status_basic() { + let input = "M .zshrc\nA .config/new-file\nD .config/old-file\n"; + let output = filter_chezmoi_status(input); + assert!(output.contains("1 added")); + assert!(output.contains("1 modified")); + assert!(output.contains("1 deleted")); + assert!(output.contains("A .config/new-file") || output.contains("A .config/new-file")); + } + + #[test] + fn test_filter_managed_empty() { + assert_eq!(filter_chezmoi_managed(""), "chezmoi: no managed files"); + } + + #[test] + fn test_filter_managed_basic() { + let input = ".zshrc\n.config/nvim/init.lua\n.config/nvim/lua/plugins.lua\n.gitconfig\n"; + let output = filter_chezmoi_managed(input); + assert!(output.contains("4 files")); + assert!(output.contains(".config/")); + } + + #[test] + fn test_filter_managed_token_savings() { + let mut input = String::new(); + for i in 0..50 { + input.push_str(&format!(".config/app{}/config\n", i)); + } + let output = filter_chezmoi_managed(&input); + let savings = 100.0 - (count_tokens(&output) as f64 / count_tokens(&input) as f64 * 100.0); + assert!( + savings >= 60.0, + "Expected >=60% savings, got {:.1}%", + savings + ); + } + + #[test] + fn test_filter_add_empty() { + assert_eq!( + filter_chezmoi_add("", "added", 0), + "ok ✓ (nothing to added)" + ); + } + + #[test] + fn test_filter_add_basic() { + let input = "install /home/user/.local/share/chezmoi/dot_zshrc\n"; + let output = filter_chezmoi_add(input, "added", 0); + assert_eq!(output, "ok ✓ 1 file added"); + } + + #[test] + fn test_filter_add_multiple() { + let input = "install /home/user/.local/share/chezmoi/dot_zshrc\ninstall /home/user/.local/share/chezmoi/dot_gitconfig\n"; + let output = filter_chezmoi_add(input, "added", 0); + assert_eq!(output, "ok ✓ 2 files added"); + } + + #[test] + fn test_filter_readd_basic() { + let input = "update /home/user/.local/share/chezmoi/dot_zshrc\n"; + let output = filter_chezmoi_add(input, "re-added", 0); + assert_eq!(output, "ok ✓ 1 file re-added"); + } + + #[test] + fn test_filter_add_verbose() { + let input = "install /home/user/.local/share/chezmoi/dot_zshrc\n"; + let output = filter_chezmoi_add(input, "added", 1); + assert!(output.contains("1 file added")); + assert!(output.contains("dot_zshrc")); + } + + // --- update --- + + #[test] + fn test_filter_update_already_up_to_date() { + let input = "Already up to date.\n"; + assert_eq!(filter_chezmoi_update(input, 0), "ok ✓ already up to date"); + } + + #[test] + fn test_filter_update_with_changes() { + let input = "From https://github.com/user/dotfiles\n abc..def main -> origin/main\nUpdating abc..def\nFast-forward\n dot_zshrc | 3 ++-\n 1 file changed, 2 insertions(+), 1 deletion(-)\ninstall /home/user/.zshrc\n"; + let output = filter_chezmoi_update(input, 0); + assert!(output.contains("ok ✓ updated")); + assert!(output.contains("1 source file")); + assert!(output.contains("1 dotfile applied")); + } + + #[test] + fn test_filter_update_multiple_files() { + let input = "2 files changed, 5 insertions(+), 2 deletions(-)\ninstall /home/user/.zshrc\ninstall /home/user/.gitconfig\n"; + let output = filter_chezmoi_update(input, 0); + assert!(output.contains("2 source files")); + assert!(output.contains("2 dotfiles applied")); + } + + #[test] + fn test_filter_update_verbose_shows_files() { + let input = "1 file changed, 1 insertion(+)\ninstall /home/user/.zshrc\n"; + let output = filter_chezmoi_update(input, 1); + assert!(output.contains("1 dotfile applied")); + assert!(output.contains(".zshrc")); + } + + #[test] + fn test_filter_update_token_savings() { + // Simulate a typical git pull + apply output + let input = "remote: Enumerating objects: 8, done.\nremote: Counting objects: 100% (8/8), done.\nremote: Compressing objects: 100% (4/4), done.\nremote: Total 5 (delta 2), reused 0 (delta 0)\nUnpacking objects: 100% (5/5), done.\nFrom https://github.com/user/dotfiles\n abc1234..def5678 main -> origin/main\nUpdating abc1234..def5678\nFast-forward\n dot_zshrc | 5 ++---\n dot_config/nvim/init | 3 ++-\n 2 files changed, 6 insertions(+), 2 deletions(-)\ninstall /home/user/.zshrc\ninstall /home/user/.config/nvim/init.lua\n"; + let output = filter_chezmoi_update(input, 0); + let savings = 100.0 - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0); + assert!( + savings >= 60.0, + "Expected >=60% savings, got {:.1}%", + savings + ); + } + + // --- unmanaged --- + + #[test] + fn test_filter_unmanaged_empty() { + assert_eq!(filter_chezmoi_unmanaged(""), "chezmoi unmanaged: none"); + } + + #[test] + fn test_filter_unmanaged_basic() { + let input = ".cache/foo\n.cache/bar\nDownloads/file.zip\n"; + let output = filter_chezmoi_unmanaged(input); + assert!(output.contains("3 files")); + assert!(output.contains(".cache/")); + assert!(output.contains("Downloads/")); + } + + #[test] + fn test_filter_unmanaged_token_savings() { + let mut input = String::new(); + for i in 0..40 { + input.push_str(&format!(".cache/something/file{}\n", i)); + } + let output = filter_chezmoi_unmanaged(&input); + let savings = 100.0 - (count_tokens(&output) as f64 / count_tokens(&input) as f64 * 100.0); + assert!( + savings >= 60.0, + "Expected >=60% savings, got {:.1}%", + savings + ); + } + + // --- doctor --- + + #[test] + fn test_filter_doctor_all_ok() { + let input = "ok version (v2.45.0)\nok executable (/usr/bin/chezmoi)\nok source-dir (/home/user/.local/share/chezmoi)\n"; + let output = filter_chezmoi_doctor(input); + assert_eq!(output, "chezmoi doctor: ok (3 checks passed)"); + } + + #[test] + fn test_filter_doctor_with_warning() { + let input = "ok version (v2.45.0)\nok executable (/usr/bin/chezmoi)\nwarning key (no key found)\n"; + let output = filter_chezmoi_doctor(input); + assert!(output.contains("2 ok")); + assert!(output.contains("1 warning")); + assert!(output.contains("0 error")); + assert!(output.contains("key (no key found)")); + } + + #[test] + fn test_filter_doctor_with_error() { + let input = "ok version (v2.45.0)\nerror source-dir (/home/user/.local/share/chezmoi: no such file or directory)\nwarning key (no key found)\n"; + let output = filter_chezmoi_doctor(input); + assert!(output.contains("1 error")); + assert!(output.contains("1 warning")); + assert!(output.contains("source-dir")); + } + + #[test] + fn test_filter_doctor_token_savings() { + let mut input = String::new(); + for i in 0..15 { + input.push_str(&format!("ok check-{} (details about check {} here with long path /home/user/.config/something)\n", i, i)); + } + input.push_str("warning key (no GPG key configured)\n"); + let output = filter_chezmoi_doctor(&input); + let savings = 100.0 - (count_tokens(&output) as f64 / count_tokens(&input) as f64 * 100.0); + assert!( + savings >= 60.0, + "Expected >=60% savings, got {:.1}%", + savings + ); + } +} diff --git a/src/discover/rules.rs b/src/discover/rules.rs index c7a20407..d9bd815e 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -48,6 +48,8 @@ pub const PATTERNS: &[&str] = &[ r"^aws\s+", // PostgreSQL r"^psql(\s|$)", + // Chezmoi dotfile manager + r"^chezmoi\s+(diff|apply|status|managed|add|re-add|update|unmanaged|doctor)", ]; pub const RULES: &[RtkRule] = &[ @@ -317,6 +319,24 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + // Chezmoi dotfile manager + RtkRule { + rtk_cmd: "rtk chezmoi", + rewrite_prefixes: &["chezmoi"], + category: "Dotfiles", + savings_pct: 80.0, + subcmd_savings: &[ + ("diff", 85.0), + ("apply", 90.0), + ("managed", 70.0), + ("add", 85.0), + ("re-add", 85.0), + ("update", 90.0), + ("unmanaged", 70.0), + ("doctor", 80.0), + ], + subcmd_status: &[], + }, ]; /// Commands to ignore (shell builtins, trivial, already rtk). diff --git a/src/main.rs b/src/main.rs index 7a1c0159..12844d53 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ mod aws_cmd; mod cargo_cmd; mod cc_economics; mod ccusage; +mod chezmoi_cmd; mod config; mod container; mod curl_cmd; @@ -609,6 +610,12 @@ enum Commands { since: u64, }, + /// chezmoi dotfile manager with compact output + Chezmoi { + #[command(subcommand)] + command: ChezmoiCommands, + }, + /// Rewrite a raw command to its RTK equivalent (single source of truth for hooks) /// /// Exits 0 and prints the rewritten command if supported. @@ -622,6 +629,68 @@ enum Commands { }, } +#[derive(Subcommand)] +enum ChezmoiCommands { + /// Show compact diff of pending dotfile changes + Diff { + /// chezmoi diff arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Apply dotfile changes → "ok ✓ N files applied" + Apply { + /// chezmoi apply arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Show status of managed dotfiles (like git status) + Status { + /// chezmoi status arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// List managed dotfiles grouped by directory + Managed { + /// chezmoi managed arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Add files to chezmoi source state → "ok ✓ N files added" + Add { + /// chezmoi add arguments (files/directories to add) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Re-add managed files from target to source state → "ok ✓ N files re-added" + #[command(name = "re-add")] + ReAdd { + /// chezmoi re-add arguments (files/directories to re-add) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Pull from git remote and apply changes → compact summary + Update { + /// chezmoi update arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// List unmanaged files grouped by directory + Unmanaged { + /// chezmoi unmanaged arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Run chezmoi doctor, show only warnings and errors + Doctor { + /// chezmoi doctor arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Passthrough: any unsupported chezmoi subcommand + #[command(external_subcommand)] + Other(Vec), +} + #[derive(Subcommand)] enum GitCommands { /// Condensed diff output @@ -1757,6 +1826,39 @@ fn main() -> Result<()> { golangci_cmd::run(&args, cli.verbose)?; } + Commands::Chezmoi { command } => match command { + ChezmoiCommands::Diff { args } => { + chezmoi_cmd::run(chezmoi_cmd::ChezmoiCommand::Diff, &args, cli.verbose)?; + } + ChezmoiCommands::Apply { args } => { + chezmoi_cmd::run(chezmoi_cmd::ChezmoiCommand::Apply, &args, cli.verbose)?; + } + ChezmoiCommands::Status { args } => { + chezmoi_cmd::run(chezmoi_cmd::ChezmoiCommand::Status, &args, cli.verbose)?; + } + ChezmoiCommands::Managed { args } => { + chezmoi_cmd::run(chezmoi_cmd::ChezmoiCommand::Managed, &args, cli.verbose)?; + } + ChezmoiCommands::Add { args } => { + chezmoi_cmd::run(chezmoi_cmd::ChezmoiCommand::Add, &args, cli.verbose)?; + } + ChezmoiCommands::ReAdd { args } => { + chezmoi_cmd::run(chezmoi_cmd::ChezmoiCommand::ReAdd, &args, cli.verbose)?; + } + ChezmoiCommands::Update { args } => { + chezmoi_cmd::run(chezmoi_cmd::ChezmoiCommand::Update, &args, cli.verbose)?; + } + ChezmoiCommands::Unmanaged { args } => { + chezmoi_cmd::run(chezmoi_cmd::ChezmoiCommand::Unmanaged, &args, cli.verbose)?; + } + ChezmoiCommands::Doctor { args } => { + chezmoi_cmd::run(chezmoi_cmd::ChezmoiCommand::Doctor, &args, cli.verbose)?; + } + ChezmoiCommands::Other(args) => { + chezmoi_cmd::run_passthrough(&args, cli.verbose)?; + } + }, + Commands::HookAudit { since } => { hook_audit_cmd::run(since, cli.verbose)?; } diff --git a/src/telemetry.rs b/src/telemetry.rs index 86e3ceff..9227352a 100644 --- a/src/telemetry.rs +++ b/src/telemetry.rs @@ -3,15 +3,18 @@ use crate::tracking; use sha2::{Digest, Sha256}; use std::path::PathBuf; -const TELEMETRY_URL: Option<&str> = option_env!("RTK_TELEMETRY_URL"); +const TELEMETRY_URL: &str = match option_env!("RTK_TELEMETRY_URL") { + Some(url) => url, + None => "https://telemetry.rtk-ai.app/ping", +}; const TELEMETRY_TOKEN: Option<&str> = option_env!("RTK_TELEMETRY_TOKEN"); const PING_INTERVAL_SECS: u64 = 23 * 3600; // 23 hours /// Send a telemetry ping if enabled and not already sent today. /// Fire-and-forget: errors are silently ignored. pub fn maybe_ping() { - // No URL compiled in → telemetry disabled - if TELEMETRY_URL.is_none() { + // Empty URL → telemetry disabled + if TELEMETRY_URL.is_empty() { return; } @@ -47,23 +50,28 @@ pub fn maybe_ping() { } fn send_ping() -> Result<(), Box> { - let url = TELEMETRY_URL.ok_or("no telemetry URL")?; + let url = TELEMETRY_URL; let device_hash = generate_device_hash(); let version = env!("CARGO_PKG_VERSION").to_string(); let os = std::env::consts::OS.to_string(); let arch = std::env::consts::ARCH.to_string(); + let install_method = detect_install_method(); // Get stats from tracking DB - let (commands_24h, top_commands, savings_pct) = get_stats(); + let (commands_24h, top_commands, savings_pct, tokens_saved_24h, tokens_saved_total) = + get_stats(); let payload = serde_json::json!({ "device_hash": device_hash, "version": version, "os": os, "arch": arch, + "install_method": install_method, "commands_24h": commands_24h, "top_commands": top_commands, "savings_pct": savings_pct, + "tokens_saved_24h": tokens_saved_24h, + "tokens_saved_total": tokens_saved_total, }); let mut req = ureq::post(url).set("Content-Type", "application/json"); @@ -94,22 +102,58 @@ fn generate_device_hash() -> String { format!("{:x}", hasher.finalize()) } -fn get_stats() -> (i64, Vec, Option) { +fn get_stats() -> (i64, Vec, Option, i64, i64) { let tracker = match tracking::Tracker::new() { Ok(t) => t, - Err(_) => return (0, vec![], None), + Err(_) => return (0, vec![], None, 0, 0), }; + let since_24h = chrono::Utc::now() - chrono::Duration::hours(24); + // Get 24h command count and top commands from tracking DB - let commands_24h = tracker - .count_commands_since(chrono::Utc::now() - chrono::Duration::hours(24)) - .unwrap_or(0); + let commands_24h = tracker.count_commands_since(since_24h).unwrap_or(0); let top_commands = tracker.top_commands(5).unwrap_or_default(); let savings_pct = tracker.overall_savings_pct().ok(); - (commands_24h, top_commands, savings_pct) + let tokens_saved_24h = tracker.tokens_saved_24h(since_24h).unwrap_or(0); + + let tokens_saved_total = tracker.total_tokens_saved().unwrap_or(0); + + ( + commands_24h, + top_commands, + savings_pct, + tokens_saved_24h, + tokens_saved_total, + ) +} + +/// Detect how RTK was installed by inspecting the binary path. +fn detect_install_method() -> &'static str { + let exe = match std::env::current_exe() { + Ok(p) => p, + Err(_) => return "unknown", + }; + + // Resolve symlinks to find the real binary location + let real_path = std::fs::canonicalize(&exe) + .unwrap_or(exe) + .to_string_lossy() + .to_string(); + + if real_path.contains("/Cellar/rtk/") || real_path.contains("/homebrew/") { + "homebrew" + } else if real_path.contains("/.cargo/bin/") { + "cargo" + } else if real_path.contains("/.local/bin/") { + "script" + } else if real_path.contains("/nix/store/") { + "nix" + } else { + "other" + } } fn telemetry_marker_path() -> PathBuf { @@ -141,4 +185,26 @@ mod tests { let path = telemetry_marker_path(); assert!(path.to_string_lossy().contains("rtk")); } + + #[test] + fn test_detect_install_method_returns_known_value() { + let method = detect_install_method(); + assert!( + ["homebrew", "cargo", "script", "nix", "other"].contains(&method), + "unexpected install method: {}", + method + ); + } + + #[test] + fn test_get_stats_returns_tuple() { + let (cmds, top, pct, saved_24h, saved_total) = get_stats(); + assert!(cmds >= 0); + assert!(top.len() <= 5); + assert!(saved_24h >= 0); + assert!(saved_total >= 0); + if let Some(p) = pct { + assert!(p >= 0.0 && p <= 100.0); + } + } } diff --git a/src/tracking.rs b/src/tracking.rs index bccf0654..66363a6d 100644 --- a/src/tracking.rs +++ b/src/tracking.rs @@ -925,6 +925,27 @@ impl Tracker { Ok(0.0) } } + + /// Get total tokens saved across all tracked commands (for telemetry). + pub fn total_tokens_saved(&self) -> Result { + 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) -> Result { + 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 {