diff --git a/CHANGELOG.md b/CHANGELOG.md index 31a475c2..1c786309 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -136,6 +136,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Features +* **infra:** add first-class compact filters for `terraform`, `tofu` (OpenTofu), `nix`, and `ansible-playbook`, with discover rewrite support and gain tracking * **toml-dsl:** declarative TOML filter engine — add command filters without writing Rust ([#299](https://github.com/rtk-ai/rtk/issues/299)) * 8 primitives: `strip_ansi`, `replace`, `match_output`, `strip/keep_lines_matching`, `truncate_lines_at`, `head/tail_lines`, `max_lines`, `on_empty` * lookup chain: `.rtk/filters.toml` (project-local) → `~/.config/rtk/filters.toml` (user-global) → built-in filters diff --git a/README.md b/README.md index d818e2af..a84e896c 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,14 @@ rtk kubectl logs # Deduplicated logs rtk kubectl services # Compact service list ``` +### Infrastructure +```bash +rtk terraform plan # Resource-level plan summary +rtk tofu plan # OpenTofu plan summary (Terraform-equivalent) +rtk nix search nixpkgs hello # Trimmed search results (drop eval noise) +rtk ansible-playbook site.yml # Play/task/recap focused output +``` + ### Data & Analytics ```bash rtk json config.json # Structure without values diff --git a/src/ansible_cmd.rs b/src/ansible_cmd.rs new file mode 100644 index 00000000..e5eff1ca --- /dev/null +++ b/src/ansible_cmd.rs @@ -0,0 +1,158 @@ +use crate::tracking; +use crate::utils::{resolved_command, strip_ansi, truncate}; +use anyhow::{Context, Result}; + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = resolved_command("ansible-playbook"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: ansible-playbook {}", args.join(" ")); + } + + let output = cmd + .output() + .context("Failed to run ansible-playbook. Is Ansible installed?")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + let filtered = filter_ansible_output(&raw, output.status.success()); + + println!("{}", filtered); + + timer.track( + &format!("ansible-playbook {}", args.join(" ")), + &format!("rtk ansible-playbook {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +fn filter_ansible_output(raw: &str, success: bool) -> String { + let clean = strip_ansi(raw); + let mut out: Vec = Vec::new(); + let mut in_recap = false; + let mut fallback: Vec = Vec::new(); + + for line in clean.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if trimmed.starts_with("PLAY RECAP") { + out.push("PLAY RECAP".to_string()); + in_recap = true; + continue; + } + + if in_recap { + if trimmed.contains("ok=") { + out.push(trimmed.to_string()); + } + continue; + } + + if trimmed.starts_with("PLAY [") + || trimmed.starts_with("TASK [") + || trimmed.starts_with("RUNNING HANDLER [") + { + out.push(trimmed.to_string()); + continue; + } + + if trimmed.starts_with("changed:") + || trimmed.starts_with("fatal:") + || trimmed.starts_with("failed:") + || trimmed.starts_with("unreachable:") + || trimmed.contains("FAILED!") + { + out.push(truncate_result_line(trimmed)); + continue; + } + + let lower = trimmed.to_lowercase(); + if lower.starts_with("error:") + || lower.contains("no hosts matched") + || lower.contains("could not match supplied host pattern") + { + out.push(trimmed.to_string()); + continue; + } + + if !trimmed.starts_with("ok:") && !trimmed.starts_with("skipping:") { + fallback.push(trimmed.to_string()); + } + } + + if out.is_empty() { + if success { + return "ok ansible-playbook".to_string(); + } + + if fallback.is_empty() { + return "failed ansible-playbook".to_string(); + } + + return fallback.into_iter().take(20).collect::>().join("\n"); + } + + out.join("\n") +} + +fn truncate_result_line(line: &str) -> String { + if let Some((prefix, _)) = line.split_once(" => ") { + return prefix.to_string(); + } + truncate(line, 200) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filter_ansible_keeps_task_changed_and_recap() { + let raw = r#" +PLAY [web] ******************************************************************** + +TASK [Gathering Facts] ******************************************************** +ok: [host1] + +TASK [Install nginx] ********************************************************** +changed: [host1] + +PLAY RECAP ******************************************************************** +host1 : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0 +"#; + + let filtered = filter_ansible_output(raw, true); + assert!(filtered.contains("PLAY [web]")); + assert!(filtered.contains("TASK [Install nginx]")); + assert!(filtered.contains("changed: [host1]")); + assert!(filtered.contains("host1 : ok=2 changed=1")); + assert!(!filtered.contains("ok: [host1]")); + } + + #[test] + fn test_filter_ansible_keeps_failure_signal() { + let raw = r#" +TASK [Deploy app] ************************************************************* +fatal: [host1]: FAILED! => {"msg":"permission denied"} +"#; + let filtered = filter_ansible_output(raw, false); + assert!(filtered.contains("TASK [Deploy app]")); + assert!(filtered.contains("fatal: [host1]: FAILED!")); + assert!(!filtered.contains("permission denied")); + } +} diff --git a/src/discover/registry.rs b/src/discover/registry.rs index d04a112a..901bda36 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -1633,6 +1633,63 @@ mod tests { ); } + #[test] + fn test_classify_terraform_apply() { + assert!(matches!( + classify_command("terraform apply -auto-approve"), + Classification::Supported { + rtk_equivalent: "rtk terraform", + .. + } + )); + } + + #[test] + fn test_rewrite_terraform_apply() { + assert_eq!( + rewrite_command("terraform apply -auto-approve", &[]), + Some("rtk terraform apply -auto-approve".into()) + ); + } + + #[test] + fn test_classify_nix_search() { + assert!(matches!( + classify_command("nix search nixpkgs hello"), + Classification::Supported { + rtk_equivalent: "rtk nix", + .. + } + )); + } + + #[test] + fn test_rewrite_nix_search() { + assert_eq!( + rewrite_command("nix search nixpkgs hello", &[]), + Some("rtk nix search nixpkgs hello".into()) + ); + } + + #[test] + fn test_classify_tofu_plan() { + assert!(matches!( + classify_command("tofu plan -lock=false"), + Classification::Supported { + rtk_equivalent: "rtk tofu", + .. + } + )); + } + + #[test] + fn test_rewrite_tofu_plan() { + assert_eq!( + rewrite_command("tofu plan -lock=false", &[]), + Some("rtk tofu plan -lock=false".into()) + ); + } + // --- Python tooling --- #[test] diff --git a/src/discover/rules.rs b/src/discover/rules.rs index 00c79301..1c891fc8 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -76,8 +76,9 @@ pub const PATTERNS: &[&str] = &[ r"^sops\b", r"^swift\s+build\b", r"^systemctl\s+status\b", - r"^terraform\s+plan", - r"^tofu\s+(fmt|init|plan|validate)(\s|$)", + r"^terraform(\s|$)", + r"^nix(\s|$)", + r"^tofu(\s|$)", r"^trunk\s+build", r"^uv\s+(sync|pip\s+install)\b", r"^yamllint\b", @@ -575,6 +576,14 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[], subcmd_status: &[], }, + RtkRule { + rtk_cmd: "rtk nix", + rewrite_prefixes: &["nix"], + category: "Infra", + savings_pct: 70.0, + subcmd_savings: &[], + subcmd_status: &[], + }, RtkRule { rtk_cmd: "rtk tofu", rewrite_prefixes: &["tofu"], diff --git a/src/main.rs b/src/main.rs index 2bbc4bb2..caf4bbce 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod ansible_cmd; mod aws_cmd; mod binlog; mod cargo_cmd; @@ -37,6 +38,7 @@ mod log_cmd; mod ls; mod mypy_cmd; mod next_cmd; +mod nix_cmd; mod npm_cmd; mod parser; mod pip_cmd; @@ -54,6 +56,7 @@ mod session_cmd; mod summary; mod tee; mod telemetry; +mod terraform_cmd; mod toml_filter; mod tracking; mod tree; @@ -217,6 +220,35 @@ enum Commands { args: Vec, }, + /// Ansible playbook with compact task/recap output + #[command(name = "ansible-playbook")] + AnsiblePlaybook { + /// ansible-playbook arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// Terraform CLI with compact output (drop refresh/progress noise) + Terraform { + /// Terraform arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// OpenTofu CLI with compact output (Terraform-equivalent filtering) + Tofu { + /// OpenTofu arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// Nix CLI with compact output (drop evaluation/progress noise) + Nix { + /// Nix arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// pnpm commands with ultra-compact output Pnpm { #[command(subcommand)] @@ -1463,6 +1495,22 @@ fn main() -> Result<()> { psql_cmd::run(&args, cli.verbose)?; } + Commands::AnsiblePlaybook { args } => { + ansible_cmd::run(&args, cli.verbose)?; + } + + Commands::Terraform { args } => { + terraform_cmd::run(&args, cli.verbose)?; + } + + Commands::Tofu { args } => { + terraform_cmd::run_with_binary("tofu", &args, cli.verbose)?; + } + + Commands::Nix { args } => { + nix_cmd::run(&args, cli.verbose)?; + } + Commands::Pnpm { command } => match command { PnpmCommands::List { depth, args } => { pnpm_cmd::run(pnpm_cmd::PnpmCommand::List { depth }, &args, cli.verbose)?; @@ -2217,6 +2265,10 @@ fn is_operational_command(cmd: &Commands) -> bool { | Commands::Smart { .. } | Commands::Git { .. } | Commands::Gh { .. } + | Commands::AnsiblePlaybook { .. } + | Commands::Terraform { .. } + | Commands::Tofu { .. } + | Commands::Nix { .. } | Commands::Pnpm { .. } | Commands::Err { .. } | Commands::Test { .. } @@ -2458,6 +2510,34 @@ mod tests { } } + #[test] + fn test_ansible_playbook_subcommand_parses() { + let result = Cli::try_parse_from(["rtk", "ansible-playbook", "site.yml", "-i", "hosts"]); + assert!(result.is_ok()); + if let Ok(cli) = result { + match cli.command { + Commands::AnsiblePlaybook { args } => { + assert_eq!(args, vec!["site.yml", "-i", "hosts"]); + } + _ => panic!("Expected AnsiblePlaybook command"), + } + } + } + + #[test] + fn test_tofu_subcommand_parses() { + let result = Cli::try_parse_from(["rtk", "tofu", "plan", "-lock=false"]); + assert!(result.is_ok()); + if let Ok(cli) = result { + match cli.command { + Commands::Tofu { args } => { + assert_eq!(args, vec!["plan", "-lock=false"]); + } + _ => panic!("Expected Tofu command"), + } + } + } + #[test] fn test_meta_commands_reject_bad_flags() { // RTK meta-commands should produce parse errors (not fall through to raw execution). diff --git a/src/nix_cmd.rs b/src/nix_cmd.rs new file mode 100644 index 00000000..8085637a --- /dev/null +++ b/src/nix_cmd.rs @@ -0,0 +1,205 @@ +use crate::tracking; +use crate::utils::{resolved_command, strip_ansi}; +use anyhow::{Context, Result}; + +const MAX_SEARCH_RESULTS: usize = 20; +const MAX_FALLBACK_LINES: usize = 40; + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = resolved_command("nix"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: nix {}", args.join(" ")); + } + + let output = cmd.output().context("Failed to run nix")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + let filtered = filter_nix_output(&raw); + + println!("{}", filtered); + + timer.track( + &format!("nix {}", args.join(" ")), + &format!("rtk nix {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +fn filter_nix_output(raw: &str) -> String { + let clean = strip_ansi(raw); + let mut entries: Vec = Vec::new(); + let mut summaries: Vec = Vec::new(); + let mut diagnostics: Vec = Vec::new(); + let mut fallback: Vec = Vec::new(); + + let mut pending_entry: Option = None; + + for line in clean.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + if let Some(entry) = pending_entry.take() { + entries.push(entry); + } + continue; + } + + if let Some(entry) = pending_entry.clone() { + if line.starts_with(" ") && !trimmed.starts_with('*') { + entries.push(format!("{entry} - {trimmed}")); + pending_entry = None; + continue; + } + + entries.push(entry); + pending_entry = None; + } + + if let Some(rest) = trimmed.strip_prefix("* ") { + let normalized = normalize_entry_name(rest.trim()); + if is_low_signal_entry(&normalized) { + pending_entry = None; + continue; + } + pending_entry = Some(normalized); + continue; + } + + let lower = trimmed.to_lowercase(); + if lower.starts_with("error:") || lower.starts_with("warning:") { + diagnostics.push(trimmed.to_string()); + continue; + } + + if is_noise_line(trimmed) { + continue; + } + + if trimmed.starts_with("these ") + && (trimmed.contains("will be built") + || trimmed.contains("will be fetched") + || trimmed.contains("will be downloaded")) + { + summaries.push(trimmed.to_string()); + continue; + } + + fallback.push(trimmed.to_string()); + } + + if let Some(entry) = pending_entry.take() { + entries.push(entry); + } + + if !entries.is_empty() { + let shown = entries.len().min(MAX_SEARCH_RESULTS); + let mut out = vec![format!( + "Nix: {} results (showing {})", + entries.len(), + shown + )]; + out.extend(entries.into_iter().take(shown)); + if !diagnostics.is_empty() { + out.push(String::new()); + out.extend(diagnostics); + } + return out.join("\n"); + } + + let mut out: Vec = Vec::new(); + out.extend(summaries); + out.extend(diagnostics); + + if out.is_empty() { + out.extend(fallback.into_iter().take(MAX_FALLBACK_LINES)); + } + + if out.is_empty() { + "ok nix".to_string() + } else { + out.join("\n") + } +} + +fn is_noise_line(line: &str) -> bool { + line.starts_with("evaluating '") + || line.starts_with("copying path '") + || line.starts_with("copying ") + || line.starts_with("downloading ") + || line.starts_with("building '") + || line.starts_with("unpacking ") + || line.starts_with("querying info about ") + || line.starts_with("warning: ignoring the client-specified setting") +} + +fn normalize_entry_name(entry: &str) -> String { + // nix search entries are often prefixed with: legacyPackages.. + if let Some(rest) = entry.strip_prefix("legacyPackages.") { + if let Some(dot_idx) = rest.find('.') { + return rest[dot_idx + 1..].to_string(); + } + } + entry.to_string() +} + +fn is_low_signal_entry(entry: &str) -> bool { + entry.starts_with("tests.") || entry.contains(".tests.") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filter_nix_search_drops_evaluating_noise() { + let raw = r#" +evaluating 'legacyPackages.x86_64-linux.hello'... +* legacyPackages.x86_64-linux.hello (2.12.2) + Program that produces a familiar, friendly greeting +evaluating 'legacyPackages.x86_64-linux.hello-wayland'... +* legacyPackages.x86_64-linux.hello-wayland (0-unstable) + Hello world Wayland client +"#; + let filtered = filter_nix_output(raw); + assert!(filtered.contains("Nix: 2 results")); + assert!(filtered.contains("hello (2.12.2) - Program")); + assert!(!filtered.contains("evaluating")); + } + + #[test] + fn test_filter_nix_keeps_errors() { + let raw = r#" +evaluating 'legacyPackages.x86_64-linux.foo'... +error: attribute 'foo' missing +"#; + let filtered = filter_nix_output(raw); + assert!(filtered.contains("error: attribute 'foo' missing")); + } + + #[test] + fn test_filter_nix_drops_test_entries_and_normalizes_prefix() { + let raw = r#" +* legacyPackages.x86_64-linux.tests.foo + test fixture +* legacyPackages.x86_64-linux.hello (2.12.2) + Program that produces a familiar, friendly greeting +"#; + let filtered = filter_nix_output(raw); + assert!(!filtered.contains("tests.foo")); + assert!(filtered.contains("hello (2.12.2)")); + assert!(!filtered.contains("legacyPackages.x86_64-linux")); + } +} diff --git a/src/terraform_cmd.rs b/src/terraform_cmd.rs new file mode 100644 index 00000000..96b7b3ef --- /dev/null +++ b/src/terraform_cmd.rs @@ -0,0 +1,147 @@ +use crate::tracking; +use crate::utils::{resolved_command, strip_ansi}; +use anyhow::{Context, Result}; +use regex::Regex; + +const MAX_FALLBACK_LINES: usize = 60; + +pub fn run(args: &[String], verbose: u8) -> Result<()> { + run_with_binary("terraform", args, verbose) +} + +pub fn run_with_binary(binary: &str, args: &[String], verbose: u8) -> Result<()> { + let timer = tracking::TimedExecution::start(); + + let mut cmd = resolved_command(binary); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: {} {}", binary, args.join(" ")); + } + + let output = cmd + .output() + .with_context(|| format!("Failed to run {}", binary))?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + let filtered = filter_terraform_output(&raw); + + println!("{}", filtered); + + timer.track( + &format!("{} {}", binary, args.join(" ")), + &format!("rtk {} {}", binary, args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +fn filter_terraform_output(raw: &str) -> String { + let clean = strip_ansi(raw); + let mut kept: Vec = Vec::new(); + let mut fallback: Vec = Vec::new(); + + for line in clean.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + + if is_terraform_noise(trimmed) { + continue; + } + + if is_high_signal(trimmed) { + kept.push(trimmed.to_string()); + continue; + } + + fallback.push(trimmed.to_string()); + } + + if kept.is_empty() { + kept.extend(fallback.into_iter().take(MAX_FALLBACK_LINES)); + } + + if kept.is_empty() { + "ok terraform".to_string() + } else { + kept.join("\n") + } +} + +fn is_terraform_noise(line: &str) -> bool { + line.starts_with("Acquiring state lock") + || line.starts_with("Releasing state lock") + || line.contains("Refreshing state...") + || line.starts_with("Reading...") + || line.starts_with("Read complete after") + || line.starts_with("Still creating...") + || line.starts_with("Still modifying...") + || line.starts_with("Still destroying...") +} + +fn is_high_signal(line: &str) -> bool { + lazy_static::lazy_static! { + static ref RESOURCE_ACTION_RE: Regex = Regex::new( + r"^#\s+.+\s+will be\s+(created|destroyed|read during apply|updated in-place|replaced)$" + ).unwrap(); + } + + line.starts_with("Error:") + || line.starts_with("Warning:") + || line.starts_with("No changes.") + || line.starts_with("Terraform will perform") + || line.starts_with("Plan:") + || line.starts_with("Apply complete!") + || line.starts_with("Destroy complete!") + || line.starts_with("Changes to Outputs") + || line.starts_with("Outputs:") + || RESOURCE_ACTION_RE.is_match(line) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filter_terraform_plan_removes_refresh_noise() { + let raw = r#" +Acquiring state lock. This may take a few moments... +module.app.aws_instance.web: Refreshing state... [id=i-123] +Terraform will perform the following actions: + # module.app.aws_instance.web will be updated in-place + ~ resource "aws_instance" "web" { + instance_type = "t3.micro" -> "t3.small" + } +Plan: 0 to add, 1 to change, 0 to destroy. +"#; + let filtered = filter_terraform_output(raw); + assert!(filtered.contains("Terraform will perform the following actions:")); + assert!(filtered.contains("# module.app.aws_instance.web will be updated in-place")); + assert!(filtered.contains("Plan: 0 to add, 1 to change, 0 to destroy.")); + assert!(!filtered.contains("Refreshing state")); + assert!(!filtered.contains("instance_type")); + assert!(!filtered.contains("resource \"aws_instance\"")); + } + + #[test] + fn test_filter_terraform_keeps_errors() { + let raw = r#" +module.app.aws_s3_bucket.assets: Refreshing state... [id=bucket] +Error: Unsupported argument + on main.tf line 12, in resource "aws_s3_bucket" "assets": +"#; + let filtered = filter_terraform_output(raw); + assert!(filtered.contains("Error: Unsupported argument")); + } +}