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 src/cmds/system/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
- `search.rs` backs both `rtk grep` and `rtk rg`: it runs the invoked engine (never substituting one for the other) and groups its output, reading `core/config` for `limits.grep_max_results` and `limits.grep_max_per_file`. Format-altering flags (`-c`, `-l`, `-L`, `-o`, `-Z`) bypass RTK filtering and run raw.
- `local_llm.rs` (`rtk smart`) uses `core/filter` for heuristic file summarization
- `format_cmd.rs` is a cross-ecosystem dispatcher: auto-detects and routes to `prettier_cmd` or `ruff_cmd` (black is handled inline, not as a separate module)
- `textutil_cmd.rs` (`rtk textutil`, macOS) reuses the `read` blank-line/trailing-whitespace pass for `txt` conversions sent to stdout, folds `-info` into one line, and passes any other invocation through raw

## Cross-command

Expand Down
182 changes: 182 additions & 0 deletions src/cmds/system/textutil_cmd.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
//! Proxy for macOS `textutil`. Document-to-text conversions sent to stdout get
//! the same blank-line / trailing-whitespace cleanup `rtk read` applies, `-info`
//! collapses to a single line, and every other invocation runs through untouched.

use crate::core::filter::{self, Language};
use crate::core::runner::{self, RunOptions};
use crate::core::truncate::CAP_DOCUMENT;
use crate::core::utils::resolved_command;
use anyhow::Result;
use std::ffi::OsString;

const MAX_DOC_LINES: usize = CAP_DOCUMENT;

pub fn run(args: &[String], verbose: u8) -> Result<i32> {
if verbose > 0 {
eprintln!("Running: textutil {}", args.join(" "));
}

match classify(args) {
Mode::Passthrough => {
let os_args: Vec<OsString> = args.iter().map(OsString::from).collect();
runner::run_passthrough("textutil", &os_args, verbose)
}
mode => {
let mut cmd = resolved_command("textutil");
for arg in args {
cmd.arg(arg);
}
let filter: fn(&str) -> String = match mode {
Mode::Info => filter_info,
_ => filter_text,
};
runner::run_filtered(
cmd,
"textutil",
&args.join(" "),
filter,
RunOptions::stdout_only(),
)
}
}
}

#[derive(Clone, Copy, PartialEq)]
enum Mode {
Info,
Text,
Passthrough,
}

/// Only two invocations produce plain text we can safely touch: `-info` (a
/// property block) and a `txt` conversion written to stdout. Markup conversions,
/// file output and anything unrecognized fall through to raw passthrough.
fn classify(args: &[String]) -> Mode {
if args.iter().any(|a| a == "-info") {
return Mode::Info;
}
if args.iter().any(|a| a == "-stdout") && converts_to_text(args) {
return Mode::Text;
}
Mode::Passthrough
}

fn converts_to_text(args: &[String]) -> bool {
let mut args = args.iter();
while let Some(arg) = args.next() {
if arg == "-convert" || arg == "-cat" {
return matches!(args.next(), Some(fmt) if fmt.eq_ignore_ascii_case("txt"));
}
}
false
}

/// Collapse runs of blank lines, drop trailing whitespace, and cap the body with
/// a `[N more lines]` marker — the treatment `rtk read` gives a plain text file.
fn filter_text(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
let mut prev_blank = false;
for line in raw.lines() {
let line = line.trim_end();
if line.is_empty() {
if !prev_blank {
out.push('\n');
}
prev_blank = true;
continue;
}
prev_blank = false;
out.push_str(line);
out.push('\n');
}
filter::smart_truncate(out.trim_matches('\n'), MAX_DOC_LINES, &Language::Unknown)
}

/// Fold `textutil -info`'s multi-line property block onto one line, keeping the
/// file's base name rather than its full path.
fn filter_info(raw: &str) -> String {
let mut name = String::new();
let mut fields: Vec<String> = Vec::new();
for line in raw.lines() {
let (label, value) = match line.split_once(':') {
Some(pair) => (pair.0.trim(), pair.1.trim()),
None => continue,
};
if value.is_empty() {
continue;
}
if label.eq_ignore_ascii_case("file") {
name = value.rsplit(['/', '\\']).next().unwrap_or(value).to_string();
} else {
fields.push(format!("{}: {}", label, value));
}
}
match (name.is_empty(), fields.is_empty()) {
(true, true) => raw.trim().to_string(),
(true, false) => fields.join(", "),
(false, true) => name,
(false, false) => format!("{} ({})", name, fields.join(", ")),
}
}

#[cfg(test)]
mod tests {
use super::*;

fn count_tokens(s: &str) -> usize {
s.split_whitespace().count()
}

fn args(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}

#[test]
fn classify_text_conversion_to_stdout() {
assert!(classify(&args(&["-convert", "txt", "-stdout", "resume.docx"])) == Mode::Text);
assert!(classify(&args(&["-cat", "txt", "-stdout", "a.rtf", "b.rtf"])) == Mode::Text);
}

#[test]
fn classify_file_output_is_passthrough() {
assert!(classify(&args(&["-convert", "txt", "resume.docx"])) == Mode::Passthrough);
}

#[test]
fn classify_markup_conversion_is_passthrough() {
assert!(classify(&args(&["-convert", "html", "-stdout", "resume.docx"])) == Mode::Passthrough);
}

#[test]
fn classify_info() {
assert!(classify(&args(&["-info", "resume.docx"])) == Mode::Info);
}

#[test]
fn text_collapses_blanks_and_trailing_whitespace() {
let input = "First line \n\n\n\nSecond line\t\n";
assert_eq!(filter_text(input), "First line\n\nSecond line");
}

#[test]
fn text_saves_tokens_on_a_long_document() {
let input = include_str!("../../../tests/fixtures/textutil_convert_txt_raw.txt");
let output = filter_text(input);
let savings = 100.0 - (count_tokens(&output) as f64 / count_tokens(input) as f64 * 100.0);
assert!(
savings >= 60.0,
"textutil text filter: expected >=60% savings, got {:.1}%",
savings
);
assert!(output.contains("more lines"));
}

#[test]
fn info_folds_to_one_line() {
let input = include_str!("../../../tests/fixtures/textutil_info_raw.txt");
let output = filter_info(input);
assert_eq!(output.lines().count(), 1);
assert!(output.contains("report.docx"));
assert!(!output.contains('/'), "the directory path should be dropped");
}
}
2 changes: 1 addition & 1 deletion src/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ When the truncated output is a **flat list** and the hidden items start at a pre

### Truncation Caps (`truncate`)

`src/core/truncate.rs` defines four global cap policies — `CAP_ERRORS`, `CAP_WARNINGS`, `CAP_LIST`, `CAP_INVENTORY` — for the data classes RTK filters truncate. Each filter binds the right CAP to a local `const MAX_*` so the cap is one named jump away from the call site. These CAPs are the staging point for filter-level cap configuration (planned, not yet implemented): once the config surface lands, overriding `CAP_LIST` in `~/.config/rtk/config.toml` will tune every list filter in one place instead of editing 20+ files.
`src/core/truncate.rs` defines five global cap policies — `CAP_ERRORS`, `CAP_WARNINGS`, `CAP_LIST`, `CAP_INVENTORY`, `CAP_DOCUMENT` — for the data classes RTK filters truncate. Each filter binds the right CAP to a local `const MAX_*` so the cap is one named jump away from the call site. These CAPs are the staging point for filter-level cap configuration (planned, not yet implemented): once the config surface lands, overriding `CAP_LIST` in `~/.config/rtk/config.toml` will tune every list filter in one place instead of editing 20+ files.

**Config policy.** Configured values are accepted as-is, including `0`, which means "summary only" — the filter still prints the count and the `[full output: …]` recovery hint, just no individual items. Caps are never refused and rtk never aborts on them, in keeping with the never-block-the-user fallback philosophy.

Expand Down
3 changes: 3 additions & 0 deletions src/core/truncate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ pub const CAP_WARNINGS: usize = 10;
pub const CAP_LIST: usize = 20;
/// Inventories (`pip list`, `docker images`): exhaustive lookups.
pub const CAP_INVENTORY: usize = 50;
/// Document bodies (`textutil` conversions): a generous preview before the
/// `[N more lines]` marker, since prose has lower signal density than a list.
pub const CAP_DOCUMENT: usize = 60;

/// A cap reduced for a verbose data class. Falls back to `cap` when `by >= cap`
/// so a deviation can never empty the list; `0` stays `0`. `const fn`, underflow-safe.
Expand Down
9 changes: 9 additions & 0 deletions src/discover/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -911,6 +911,15 @@ pub const RULES: &[RtkRule] = &[
subcmd_savings: &[],
subcmd_status: &[],
},
RtkRule {
pattern: r"^textutil(?:\s|$)",
rtk_cmd: "rtk textutil",
rewrite_prefixes: &["textutil"],
category: "Files",
savings_pct: 70.0,
subcmd_savings: &[],
subcmd_status: &[],
},
];

pub const IGNORED_PREFIXES: &[&str] = &[
Expand Down
13 changes: 12 additions & 1 deletion src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ use cmds::ruby::{rake_cmd, rspec_cmd, rubocop_cmd};
use cmds::rust::{cargo_cmd, runner};
use cmds::system::{
deps, env_cmd, find_cmd, format_cmd, json_cmd, local_llm, log_cmd, ls, pipe_cmd, read, search,
summary, tree, wc_cmd,
summary, textutil_cmd, tree, wc_cmd,
};

use anyhow::{Context, Result};
Expand Down Expand Up @@ -91,6 +91,13 @@ enum Commands {
args: Vec<String>,
},

/// Convert documents to text with token-optimized output (proxy to macOS textutil)
Textutil {
/// Arguments passed to textutil (supports native flags like -convert, -stdout, -cat, -info)
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},

/// Read file with intelligent filtering
Read {
/// Files to read (supports multiple, like cat)
Expand Down Expand Up @@ -1499,6 +1506,8 @@ fn run_cli() -> Result<i32> {

Commands::Tree { args } => tree::run(&args, cli.verbose)?,

Commands::Textutil { args } => textutil_cmd::run(&args, cli.verbose)?,

// ISSUE #989: support multiple files (cat file1 file2 → rtk read file1 file2)
Commands::Read {
files,
Expand Down Expand Up @@ -2559,6 +2568,7 @@ fn is_operational_command(cmd: &Commands) -> bool {
cmd,
Commands::Ls { .. }
| Commands::Tree { .. }
| Commands::Textutil { .. }
| Commands::Read { .. }
| Commands::Smart { .. }
| Commands::Git { .. }
Expand Down Expand Up @@ -2931,6 +2941,7 @@ mod tests {
const PASSTHROUGH: &[&str] = &[
"ls",
"tree",
"textutil",
"read",
"rg",
"git",
Expand Down
92 changes: 92 additions & 0 deletions tests/fixtures/textutil_convert_txt_raw.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
Quarterly Platform Reliability Review

Prepared by the Infrastructure team for the engineering leadership group. This
document summarizes the availability work completed during the quarter, the
incidents we learned from, and the commitments we are carrying into the next
planning cycle.


Summary

Overall availability for the customer-facing API finished the quarter at 99.94
percent, slightly ahead of the 99.9 percent target. The improvement came from
three areas: faster failover in the data layer, a more conservative deploy
process, and better saturation alerting. Latency at the 99th percentile dropped
from 740 milliseconds to 510 milliseconds after the connection-pool changes
shipped in week six.

We still carry meaningful risk in the background job system, which remains a
single region and accounted for the longest incident of the quarter. A migration
plan is described in the roadmap section below.


Availability by service

The payments service held at 99.98 percent and was unaffected by the two platform
incidents. The search service saw the largest regression, dropping to 99.86
percent during the indexing outage on the twelfth. The notification service
recovered fully after we moved its queue consumers off the shared cluster.

Read traffic was served almost entirely from cache during both incidents, which
is the main reason customer impact stayed low even when write paths were
degraded. The cache hit rate averaged 96 percent across the quarter.


Incident review

There were two incidents that breached the error budget.

The first was the indexing outage. A schema migration acquired a lock that the
indexer needed, and the indexer retried aggressively instead of backing off. The
retries amplified load on the primary database until connection limits were hit.
We have since added jittered backoff and a circuit breaker around the indexer,
and the migration tooling now runs lock-sensitive steps during low-traffic
windows.

The second incident was the job-system stall. A single worker leaked file
descriptors over several days until the host ran out, and because all workers run
in one region the backlog could not drain elsewhere. We shipped a descriptor
guard and a per-worker recycle policy, but the structural fix is multi-region,
which is not yet done.


What went well

The conservative deploy process caught three bad releases before they reached
full production. Each was rolled back automatically within four minutes based on
the new saturation signals rather than waiting for error-rate alarms.

The on-call rotation reported lower fatigue this quarter. Paging volume fell by
about a third after we tuned the noisier alerts and removed several that were not
actionable.


What did not go well

Our runbooks were out of date for the job system, which slowed the response to
the second incident. The first responder spent nearly twenty minutes confirming
basic facts that a current runbook would have answered immediately.

We also discovered that our synthetic checks did not cover the search write path,
so the indexing outage was detected by a customer report rather than by our own
monitoring. That gap is now closed.


Roadmap

The headline item for next quarter is moving the job system to multiple regions.
This is the largest remaining single point of failure and the source of our
longest incidents. The work is scoped into three phases: replicate the queue,
make the workers region-aware, and finally cut over with a controlled drain.

Secondary items include extending synthetic coverage to every write path,
finishing the runbook refresh, and rolling the connection-pool changes out to the
remaining internal services that have not yet adopted them.


Commitments

We commit to keeping customer-facing availability at or above 99.9 percent, to
completing phase one of the job-system migration, and to closing every synthetic
coverage gap identified during the incident reviews. Progress will be reported in
the monthly operations meeting.
3 changes: 3 additions & 0 deletions tests/fixtures/textutil_info_raw.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
File: /Users/allan/Documents/report.docx
Type: Word 2007 format (Office Open XML)
Size: 20480 bytes