Skip to content
Closed
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
72 changes: 72 additions & 0 deletions src/cmds/system/read.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ pub fn run(
}

filtered = apply_line_window(&filtered, max_lines, tail_lines, &lang);
filtered = apply_read_ceilings(&filtered, Some(file));

let (raw, rtk_output) = if line_numbers {
(
Expand Down Expand Up @@ -133,6 +134,7 @@ pub fn run_stdin(
}

filtered = apply_line_window(&filtered, max_lines, tail_lines, &lang);
filtered = apply_read_ceilings(&filtered, None);

let (raw, rtk_output) = if line_numbers {
(
Expand All @@ -149,6 +151,76 @@ pub fn run_stdin(
Ok(())
}

/// Cap a single absurd line and a runaway total, without losing anything.
///
/// `rtk read` defaults to `--level none`, which is the right default for source
/// code: filtering it hides code the reader came for. But "do not filter" was
/// also being read as "do not bound", and the two are different promises. A
/// 15.8 MB log of 29 lines - one of them 7.8 million characters - was returned
/// whole, and a single `cat` of it cost more than a day of ordinary commands.
///
/// So the content is never reinterpreted, only bounded, and every bound says
/// what it dropped and where the rest is. The file itself is the backup: it is
/// still on disk, unchanged, and the hint names the command that reads on.
/// `rtk proxy cat <file>` remains the unbounded escape hatch.
fn apply_read_ceilings(content: &str, source: Option<&Path>) -> String {
let limits = crate::core::config::limits();
let max_line = limits.read_max_line_chars;
let max_total = limits.read_max_total_chars;

let mut out = String::new();
let mut kept_lines = 0usize;
let mut truncated_lines = 0usize;
let mut stopped_at: Option<usize> = None;

for (index, line) in content.lines().enumerate() {
if out.len() >= max_total {
stopped_at = Some(index + 1);
break;
}
let char_len = line.chars().count();
if max_line > 0 && char_len > max_line {
let head: String = line.chars().take(max_line).collect();
out.push_str(&head);
out.push_str(&format!(" …+{} chars\n", char_len - max_line));
truncated_lines += 1;
} else {
out.push_str(line);
out.push('\n');
}
kept_lines += 1;
}

if truncated_lines == 0 && stopped_at.is_none() {
return content.to_owned();
}

if let Some(next_line) = stopped_at {
let remaining = content.lines().count().saturating_sub(kept_lines);
match source {
Some(path) => out.push_str(&format!(
"…+{} more lines [read on: rtk read {} --tail-lines {}]\n",
remaining,
path.display(),
remaining
)),
None => match crate::core::tee::force_tee_tail_hint(content, "rtk_read", next_line) {
Some(hint) => out.push_str(&format!("…+{} more lines {}\n", remaining, hint)),
None => out.push_str(&format!("…+{} more lines\n", remaining)),
},
}
}
if truncated_lines > 0 {
out.push_str(&format!(
"[{} line(s) cut at {} chars; full text: rtk proxy cat{}]\n",
truncated_lines,
max_line,
source.map(|p| format!(" {}", p.display())).unwrap_or_default()
));
}
out
}

fn format_with_line_numbers(content: &str) -> String {
let lines: Vec<&str> = content.lines().collect();
let width = lines.len().to_string().len();
Expand Down
15 changes: 15 additions & 0 deletions src/core/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,19 @@ pub struct LimitsConfig {
pub status_max_untracked: usize,
/// Max chars for parser passthrough fallback (default: 2000)
pub passthrough_max_chars: usize,
/// Max chars of a single line in `rtk read` (default: 2000)
///
/// A source line this long is generated, minified or a packed log record,
/// never something a reader follows to the end. Without a cap one such line
/// is the whole command: a 7.8-million-character line was read in full here.
pub read_max_line_chars: usize,
/// Max chars of the whole `rtk read` output (default: 400000)
///
/// Set well above any hand-written file - the largest source in the project
/// that motivated this is ~110 KB - so ordinary reading is untouched and
/// only runaway files are cut. What is cut stays recoverable: the file is
/// still on disk and the hint says how to read the rest.
pub read_max_total_chars: usize,
}

impl Default for LimitsConfig {
Expand All @@ -141,6 +154,8 @@ impl Default for LimitsConfig {
status_max_files: 15,
status_max_untracked: 10,
passthrough_max_chars: 2000,
read_max_line_chars: 2000,
read_max_total_chars: 400_000,
}
}
}
Expand Down
101 changes: 101 additions & 0 deletions tests/read_ceilings_test.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
//! `rtk read` defaults to `--level none`, which is the right default for source
//! code. These tests pin the difference between "do not filter" and "do not
//! bound": ordinary files must come back byte for byte, while a single absurd
//! line or a runaway total must be cut with a hint that recovers the rest.

use std::process::Command;

fn rtk() -> Command {
Command::new(env!("CARGO_BIN_EXE_rtk"))
}

fn write_temp(name: &str, content: &str) -> (tempfile::TempDir, std::path::PathBuf) {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join(name);
std::fs::write(&path, content).expect("write");
(dir, path)
}

fn read(path: &std::path::Path) -> String {
let out = rtk()
.args(["read", path.to_str().unwrap()])
.output()
.expect("rtk read");
String::from_utf8_lossy(&out.stdout).into_owned()
}

#[test]
fn ordinary_source_file_is_returned_unchanged() {
let source = (0..400)
.map(|i| format!("fn function_number_{i}() -> usize {{ {i} }}"))
.collect::<Vec<_>>()
.join("\n");
let content = format!("{source}\n");
let (_dir, path) = write_temp("ordinary.rs", &content);

assert_eq!(
read(&path),
content,
"a normal source file must not be touched"
);
}

#[test]
fn absurd_line_is_cut_and_says_how_to_recover_it() {
let content = format!("const DATA: &str = \"{}\";\nfn main() {{}}\n", "x".repeat(500_000));
let (_dir, path) = write_temp("generated.rs", &content);

let out = read(&path);

assert!(
out.len() < 10_000,
"a 500 000-char line must be cut, got {} bytes",
out.len()
);
assert!(
out.contains("fn main() {}"),
"the rest of the file must survive:\n{out}"
);
assert!(
out.contains("rtk proxy cat"),
"the cut must name the way back to the full text:\n{out}"
);
}

#[test]
fn runaway_total_is_cut_and_says_how_to_read_on() {
// Comfortably past the 400 000-char default ceiling, in ordinary short lines.
let content = (0..40_000)
.map(|i| format!("line {i} of a very long generated file"))
.collect::<Vec<_>>()
.join("\n");
let (_dir, path) = write_temp("huge.log", &format!("{content}\n"));

let out = read(&path);

assert!(
out.len() < content.len() / 2,
"a runaway file must be cut, got {} of {} bytes",
out.len(),
content.len()
);
assert!(
out.contains("more lines"),
"the cut must report how many lines it dropped:\n{}",
&out[out.len().saturating_sub(400)..]
);
assert!(
out.contains("--tail-lines"),
"the cut must name the command that reads on:\n{}",
&out[out.len().saturating_sub(400)..]
);
}

#[test]
fn empty_and_tiny_files_are_untouched() {
let (_dir, empty) = write_temp("empty.txt", "");
assert_eq!(read(&empty), "", "an empty file stays empty");

let (_dir2, tiny) = write_temp("tiny.txt", "one line\n");
assert_eq!(read(&tiny), "one line\n", "a tiny file stays byte for byte");
}