Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,14 @@ rtk kubectl logs <pod> # 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
Expand Down
158 changes: 158 additions & 0 deletions src/ansible_cmd.rs
Original file line number Diff line number Diff line change
@@ -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<String> = Vec::new();
let mut in_recap = false;
let mut fallback: Vec<String> = 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::<Vec<_>>().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"));
}
}
57 changes: 57 additions & 0 deletions src/discover/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
13 changes: 11 additions & 2 deletions src/discover/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"],
Expand Down
80 changes: 80 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
mod ansible_cmd;
mod aws_cmd;
mod binlog;
mod cargo_cmd;
Expand Down Expand Up @@ -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;
Expand All @@ -54,6 +56,7 @@ mod session_cmd;
mod summary;
mod tee;
mod telemetry;
mod terraform_cmd;
mod toml_filter;
mod tracking;
mod tree;
Expand Down Expand Up @@ -217,6 +220,35 @@ enum Commands {
args: Vec<String>,
},

/// 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<String>,
},

/// Terraform CLI with compact output (drop refresh/progress noise)
Terraform {
/// Terraform arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},

/// OpenTofu CLI with compact output (Terraform-equivalent filtering)
Tofu {
/// OpenTofu arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},

/// Nix CLI with compact output (drop evaluation/progress noise)
Nix {
/// Nix arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},

/// pnpm commands with ultra-compact output
Pnpm {
#[command(subcommand)]
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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 { .. }
Expand Down Expand Up @@ -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).
Expand Down
Loading