Skip to content
Merged
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
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ Don't invent new output formats. Don't add RTK-specific headers or markers in th

If a filter fails, fall back to raw output. RTK should never prevent a command from executing or producing output. Better to pass through unfiltered than to error out. Same for hooks: exit 0 on all error paths so the agent's command runs unmodified.

Every filter needs a fallback path. Every hook must handle malformed input gracefully.
Every filter needs a fallback path. Every hook must handle malformed input gracefully. Truncation follows the same rule: capping output at N items is only acceptable if accompanied by a hint that lets the agent recover the hidden data.

### Zero Overhead

Expand Down Expand Up @@ -262,6 +262,7 @@ cargo fmt --all --check && cargo clippy --all-targets && cargo test
- [ ] Unit tests added/updated for changed code
- [ ] Snapshot tests reviewed (`cargo insta review`)
- [ ] Token savings >=60% verified
- [ ] Any truncated list has a recovery hint (`force_tee_tail_hint` or `force_tee_hint`)
- [ ] Edge cases covered
- [ ] `cargo fmt --all --check && cargo clippy --all-targets && cargo test` passes
- [ ] Manual test: run `rtk <cmd>` and inspect output
Expand Down
16 changes: 16 additions & 0 deletions src/cmds/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,21 @@ When filtering fails, fall back to raw output and warn on stderr. Never block th

Modules that parse structured output (JSON, NDJSON, state machines) must call `tee::tee_and_hint()` so users can recover full output on failure.

### Internal Truncation Recovery

When a filter caps a list at N items (e.g. `take(20)`), the remaining items must be accessible via a tee hint. **Never show `"… +N more"` without a recovery path** — the agent has no way to retrieve the hidden content.

**Choosing the right hint:**

| Content type | Function | Condition |
|---|---|---|
| Flat list — one item = one line in the tee | `force_tee_tail_hint(content, slug, MAX + 1)` | PR lists, error lines, file paths — anything where each item is a single-line string |
| Multi-line blocks | `force_tee_hint(content, slug)` | Test failures, build error blocks — items that span multiple lines so a line offset is meaningless |

**Always use a named constant for the cap.** Express the cap as `const MAX_XXX: usize = N;` local to the function, then derive `take(MAX_XXX)`, `> MAX_XXX`, and the offset `MAX_XXX + 1` from it. A bare literal like `21` silently breaks if someone changes `take(20)`.

**The tee content must match what `tail` produces.** For `force_tee_tail_hint`, build the tee from the same formatted values shown in the output — not raw/intermediate data. If the filter reformats items before displaying them, pre-build a `Vec<String>` of formatted lines and use it for both the display loop and the tee.

### Stderr Handling

Modules must capture stderr and include it in the raw string passed to `timer.track()`, so token savings reflect total output.
Expand All @@ -278,6 +293,7 @@ Adding a new filter or command requires changes in multiple places. For TOML-vs-
- Use `RunOptions::default()` when filtering combined text output
- Add `.tee("label")` when the filter parses structured output (enables raw output recovery on failure)
- **Exit codes**: handled automatically by `run_filtered()` — just return its result
- **Truncation**: if the filter caps any list at N items, emit `force_tee_tail_hint` (flat lists) or `force_tee_hint` (multi-line blocks) so the agent can recover hidden items — see [Internal Truncation Recovery](#internal-truncation-recovery). Use a named constant for the cap; derive the offset from it (`MAX_XXX + 1`)
2. **Register module**:
- Ecosystem `mod.rs` files use `automod::dir!()` — any `.rs` file in the directory becomes a public module automatically. No manual `pub mod` needed, but be aware: WIP or helper files will also be exposed. Only commit command-ready modules.
- Add variant to `Commands` enum in `main.rs` with `#[arg(trailing_var_arg = true, allow_hyphen_values = true)]`
Expand Down
16 changes: 8 additions & 8 deletions src/cmds/cloud/aws_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -494,7 +494,7 @@ fn filter_s3_ls(output: &str) -> FilterResult {

if total > limit {
let text = format!(
"{}\n... +{} more items",
"{}\n… +{} more items",
lines[..limit].join("\n"),
total - limit
);
Expand Down Expand Up @@ -553,7 +553,7 @@ fn filter_ec2_instances(json_str: &str) -> Option<FilterResult> {
}

if truncated {
result.push_str(&format!(" ... +{} more\n", total - MAX_ITEMS));
result.push_str(&format!(" … +{} more\n", total - MAX_ITEMS));
}

let text = result.trim_end().to_string();
Expand Down Expand Up @@ -759,7 +759,7 @@ fn filter_logs_events(json_str: &str) -> Option<FilterResult> {
}

if truncated {
lines.push(format!("... +{} more events", total - MAX_LOG_EVENTS));
lines.push(format!("… +{} more events", total - MAX_LOG_EVENTS));
}

let text = lines.join("\n");
Expand Down Expand Up @@ -1132,7 +1132,7 @@ fn filter_dynamodb_items(json_str: &str) -> Option<FilterResult> {
}

if truncated {
lines.push(format!("... +{} more items", total - MAX_ITEMS));
lines.push(format!("… +{} more items", total - MAX_ITEMS));
}

let text = lines.join("\n");
Expand Down Expand Up @@ -1426,7 +1426,7 @@ fn filter_logs_query_results(json_str: &str) -> Option<FilterResult> {
}

if truncated {
lines.push(format!("... +{} more rows", total - MAX_ITEMS));
lines.push(format!("… +{} more rows", total - MAX_ITEMS));
}

let text = lines.join("\n");
Expand Down Expand Up @@ -1616,7 +1616,7 @@ mod tests {
}
let input = lines.join("\n");
let result = filter_s3_ls(&input);
assert!(result.text.contains("... +20 more items"));
assert!(result.text.contains("… +20 more items"));
assert!(result.truncated);
}

Expand Down Expand Up @@ -1852,7 +1852,7 @@ mod tests {
}
let json = format!(r#"{{"DBInstances": [{}]}}"#, dbs.join(","));
let result = filter_rds_instances(&json).unwrap();
assert!(result.text.contains("... +5 more instances"));
assert!(result.text.contains("… +5 more instances"));
assert!(result.truncated);
}

Expand Down Expand Up @@ -1893,7 +1893,7 @@ mod tests {
}
let json = format!(r#"{{"events": [{}]}}"#, events.join(","));
let result = filter_logs_events(&json).unwrap();
assert!(result.text.contains("... +10 more events"));
assert!(result.text.contains("… +10 more events"));
assert!(result.truncated);
}

Expand Down
121 changes: 73 additions & 48 deletions src/cmds/cloud/container.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,12 +348,19 @@ fn format_kubectl_pods(json: &Value) -> String {

let mut out = format!("{} pods: {}\n", pods.len(), parts.join(", "));
if !issues.is_empty() {
const MAX_PODS_ISSUES: usize = 10;
out.push_str("[warn] Issues:\n");
for issue in issues.iter().take(10) {
for issue in issues.iter().take(MAX_PODS_ISSUES) {
out.push_str(&format!(" {}\n", issue));
}
if issues.len() > 10 {
out.push_str(&format!(" … +{} more", issues.len() - 10));
if issues.len() > MAX_PODS_ISSUES {
out.push_str(&format!(" … +{} more", issues.len() - MAX_PODS_ISSUES));
let all_issues = issues.join("\n");
if let Some(hint) =
crate::core::tee::force_tee_tail_hint(&all_issues, "kubectl-pods", MAX_PODS_ISSUES + 1)
{
out.push_str(&format!(" {}", hint));
}
}
}
out
Expand All @@ -374,39 +381,48 @@ fn format_kubectl_services(json: &Value) -> String {
};
let mut out = format!("{} services:\n", services.len());

for svc in services.iter().take(15) {
let ns = svc["metadata"]["namespace"].as_str().unwrap_or("-");
let name = svc["metadata"]["name"].as_str().unwrap_or("-");
let svc_type = svc["spec"]["type"].as_str().unwrap_or("-");
let ports: Vec<String> = svc["spec"]["ports"]
.as_array()
.map(|arr| {
arr.iter()
.map(|p| {
let port = p["port"].as_i64().unwrap_or(0);
let target = p["targetPort"]
.as_i64()
.or_else(|| p["targetPort"].as_str().and_then(|s| s.parse().ok()))
.unwrap_or(port);
if port == target {
format!("{}", port)
} else {
format!("{}→{}", port, target)
}
})
.collect()
})
.unwrap_or_default();
out.push_str(&format!(
" {}/{} {} [{}]\n",
ns,
name,
svc_type,
ports.join(",")
));
}
if services.len() > 15 {
out.push_str(&format!(" … +{} more", services.len() - 15));
let all_lines: Vec<String> = services
.iter()
.map(|svc| {
let ns = svc["metadata"]["namespace"].as_str().unwrap_or("-");
let name = svc["metadata"]["name"].as_str().unwrap_or("-");
let svc_type = svc["spec"]["type"].as_str().unwrap_or("-");
let ports: Vec<String> = svc["spec"]["ports"]
.as_array()
.map(|arr| {
arr.iter()
.map(|p| {
let port = p["port"].as_i64().unwrap_or(0);
let target = p["targetPort"]
.as_i64()
.or_else(|| p["targetPort"].as_str().and_then(|s| s.parse().ok()))
.unwrap_or(port);
if port == target {
format!("{}", port)
} else {
format!("{}→{}", port, target)
}
})
.collect()
})
.unwrap_or_default();
format!(" {}/{} {} [{}]", ns, name, svc_type, ports.join(","))
})
.collect();

const MAX_KUBECTL_SERVICES: usize = 15;
for line in all_lines.iter().take(MAX_KUBECTL_SERVICES) {
out.push_str(&format!("{}\n", line));
}
if all_lines.len() > MAX_KUBECTL_SERVICES {
out.push_str(&format!(" … +{} more", all_lines.len() - MAX_KUBECTL_SERVICES));
let all_text = all_lines.join("\n");
if let Some(hint) =
crate::core::tee::force_tee_tail_hint(&all_text, "kubectl-services", MAX_KUBECTL_SERVICES + 1)
{
out.push_str(&format!(" {}", hint));
}
out.push('\n');
}
out
}
Expand Down Expand Up @@ -444,6 +460,7 @@ fn kubectl_logs(args: &[String], _verbose: u8) -> Result<i32> {
/// Expects tab-separated lines: Name\tImage\tStatus\tPorts
/// (no header row — `--format` output is headerless)
pub fn format_compose_ps(raw: &str) -> String {
const MAX_COMPOSE_SERVICES: usize = 20;
let lines: Vec<&str> = raw.lines().filter(|l| !l.trim().is_empty()).collect();

if lines.is_empty() {
Expand All @@ -452,16 +469,19 @@ pub fn format_compose_ps(raw: &str) -> String {

let mut result = format!("[compose] {} services:\n", lines.len());

for line in lines.iter().take(20) {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() >= 4 {
// Pre-build all formatted lines so the tee file matches what the agent sees.
let all_formatted: Vec<String> = lines
.iter()
.filter_map(|line| {
let parts: Vec<&str> = line.split('\t').collect();
if parts.len() < 4 {
return None;
}
let name = parts[0];
let image = parts[1];
let status = parts[2];
let ports = parts[3];

let short_image = image.split('/').next_back().unwrap_or(image);

let port_str = if ports.trim().is_empty() {
String::new()
} else {
Expand All @@ -472,15 +492,20 @@ pub fn format_compose_ps(raw: &str) -> String {
format!(" [{}]", compact)
}
};
Some(format!(" {} ({}) {}{}", name, short_image, status, port_str))
})
.collect();

result.push_str(&format!(
" {} ({}) {}{}\n",
name, short_image, status, port_str
));
}
for line in all_formatted.iter().take(MAX_COMPOSE_SERVICES) {
result.push_str(line);
result.push('\n');
}
if lines.len() > 20 {
result.push_str(&format!(" … +{} more\n", lines.len() - 20));
if all_formatted.len() > MAX_COMPOSE_SERVICES {
result.push_str(&format!(" … +{} more\n", all_formatted.len() - MAX_COMPOSE_SERVICES));
let all_text = all_formatted.join("\n");
if let Some(hint) = crate::core::tee::force_tee_tail_hint(&all_text, "compose-ps", MAX_COMPOSE_SERVICES + 1) {
result.push_str(&format!(" {}\n", hint));
}
}

result.trim_end().to_string()
Expand Down
Loading
Loading