Files
MercuryToolbox/crates/chunkcat/src/lib.rs
T

1016 lines
32 KiB
Rust

//! The `chunkcat` command reads deterministic chunks from text files.
use std::collections::VecDeque;
use std::ffi::OsString;
use std::fmt::Write as _;
use std::fs;
use std::io::{self, BufRead, Read};
use std::path::{Path, PathBuf};
use common::{
CliError, CommonArgs, ExitCode, InputFormat, RenderMode, map_result_count, parse_color_choice,
parse_format_choice, parse_input_format, print_json, print_quick_help_error, print_structured,
should_read_stdin,
};
use lexopt::prelude::{Long, Short, Value as ArgValue};
use serde::Serialize;
const HELP: &str = "\
List and read deterministic chunks from large text files.
Default text mode prints the first chunk for quick reading. Use `--inventory` to list chunk
boundaries first, or `--chunk <INDEX>` / `--tail` to jump elsewhere.
Usage:
chunkcat [OPTIONS] [PATH...]
Options:
--format <FORMAT> Structured output format: text, json, toon
--json Shortcut for --format json
--toon Shortcut for --format toon
--input-format <FORMAT> Override stdin parsing mode: auto, lines, jsonl
--color <WHEN> Control ANSI color output: auto, never
--quiet Suppress non-essential status output
--max-lines <COUNT> Maximum number of lines per chunk
--overlap <COUNT> Number of overlapping lines between chunks
--inventory Print chunk inventory instead of default first-chunk text
--chunk <INDEX> Zero-based chunk index to print
--tail Print the last chunk instead of a numbered chunk
-h, --help Show this help text
-V, --version Show the command version
Examples:
chunkcat .\\fixtures\\reading\\sample.rs --max-lines 8
chunkcat .\\fixtures\\reading\\sample.rs --max-lines 8 --inventory
chunkcat .\\fixtures\\reading\\sample.rs --max-lines 8 --chunk 0
chunkcat .\\fixtures\\reading\\sample.rs --max-lines 8 --chunk 2 --json | ConvertFrom-Json
chunkcat .\\BepInEx\\LogOutput.log --max-lines 20 --tail
fd -e rs . .\\fixtures\\reading | chunkcat --input-format lines --chunk 0
";
/// CLI arguments for the `chunkcat` binary.
#[derive(Debug, Clone)]
struct Cli {
/// Shared output and stdin policy flags.
common: CommonArgs,
/// Maximum number of lines per chunk.
max_lines: usize,
/// Number of lines to overlap between adjacent chunks.
overlap: usize,
/// Print chunk inventory instead of the default first-chunk text view.
inventory: bool,
/// Zero-based chunk index to print instead of the chunk inventory.
chunk: Option<usize>,
/// Print the last chunk instead of a numbered chunk.
tail: bool,
/// File path to inspect when stdin is empty.
paths: Vec<PathBuf>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParseOutcome {
Help,
Version,
Run,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct ChunkDescriptor {
index: usize,
start_line: usize,
end_line: usize,
line_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct ChunkLine {
number: usize,
text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct SelectedChunk {
index: usize,
start_line: usize,
end_line: usize,
line_count: usize,
lines: Vec<ChunkLine>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
struct ChunkReport {
path: String,
total_lines: usize,
max_lines: usize,
overlap: usize,
chunk_count: usize,
chunks: Vec<ChunkDescriptor>,
selected_chunk: Option<SelectedChunk>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LineSelection {
Window { start_line: usize, end_line: usize },
Tail { keep_lines: usize },
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LoadedText {
total_lines: usize,
lines: Vec<ChunkLine>,
}
/// Parses CLI arguments and returns a process exit code.
#[must_use]
pub fn main_entry() -> i32 {
match parse_cli_from(std::env::args_os()) {
Ok((ParseOutcome::Help, _)) => {
print!("{HELP}");
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Version, _)) => {
println!("chunkcat {}", env!("CARGO_PKG_VERSION"));
ExitCode::Success.as_i32()
}
Ok((ParseOutcome::Run, cli)) => match run(&cli) {
Ok(code) => code.as_i32(),
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
},
Err(error) => {
print_quick_help_error(&error, HELP);
error.exit_code().as_i32()
}
}
}
fn parse_cli_from<I, T>(args: I) -> Result<(ParseOutcome, Cli), CliError>
where
I: IntoIterator<Item = T>,
T: Into<OsString>,
{
let mut parser = lexopt::Parser::from_iter(args);
let mut cli = Cli {
common: CommonArgs::default(),
max_lines: 200,
overlap: 0,
inventory: false,
chunk: None,
tail: false,
paths: Vec::new(),
};
while let Some(argument) = parser
.next()
.map_err(|error| CliError::usage(error.to_string()))?
{
match argument {
Long("help") | Short('h') => return Ok((ParseOutcome::Help, cli)),
Long("version") | Short('V') => return Ok((ParseOutcome::Version, cli)),
Long("json") => cli.common.set_render_mode(RenderMode::Json),
Long("toon") => cli.common.set_render_mode(RenderMode::Toon),
Long("format") => {
let value = parser_value_string(&mut parser, "--format")?;
cli.common.set_render_mode(parse_format_choice(&value)?);
}
Long("input-format") => {
let value = parser_value_string(&mut parser, "--input-format")?;
cli.common.input_format = parse_input_format(&value)?;
}
Long("color") => {
let value = parser_value_string(&mut parser, "--color")?;
cli.common.color = parse_color_choice(&value)?;
}
Long("quiet") => cli.common.quiet = true,
Long("max-lines") => {
cli.max_lines = parse_usize_flag(
"--max-lines",
&parser_value_string(&mut parser, "--max-lines")?,
)?;
}
Long("overlap") => {
cli.overlap =
parse_usize_flag("--overlap", &parser_value_string(&mut parser, "--overlap")?)?;
}
Long("inventory") => cli.inventory = true,
Long("chunk") => {
cli.chunk = Some(parse_usize_flag(
"--chunk",
&parser_value_string(&mut parser, "--chunk")?,
)?);
}
Long("tail") => cli.tail = true,
ArgValue(path) => cli.paths.push(PathBuf::from(path)),
_ => {
return Err(CliError::usage(
"unsupported argument; use --help to see available options",
));
}
}
}
Ok((ParseOutcome::Run, cli))
}
fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result<String, CliError> {
let value = parser
.value()
.map_err(|error| CliError::usage(error.to_string()))?;
value.into_string().map_err(|invalid| {
CliError::usage(format!(
"{flag} expects UTF-8 text, got '{}'",
invalid.to_string_lossy()
))
})
}
fn parse_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
if value.trim_start().starts_with('-') {
return Err(CliError::usage(format!(
"invalid {flag} value '{value}': must be a non-negative integer"
)));
}
value
.parse::<usize>()
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
}
fn run(cli: &Cli) -> Result<ExitCode, CliError> {
validate_options(cli.max_lines, cli.overlap)?;
validate_selection_mode(cli)?;
let path = collect_path(cli)?;
let render_mode = cli.common.render_mode();
let prefer_default_chunk = matches!(render_mode, RenderMode::Text) && !cli.inventory;
let loaded = read_text_file(&path, line_selection(cli))?;
let path_text = path.display().to_string();
let chunk_count = chunk_count(loaded.total_lines, cli.max_lines, cli.overlap);
let selected_index = selected_chunk_index(cli, chunk_count, prefer_default_chunk);
if matches!(render_mode, RenderMode::Text) && !cli.inventory {
if let Some(index) = selected_index {
let selected_chunk = select_chunk(
loaded.total_lines,
cli.max_lines,
cli.overlap,
&loaded.lines,
index,
)?;
print!("{}", render_selected_chunk(&path_text, &selected_chunk));
return Ok(map_result_count(1));
}
}
let chunks = plan_chunks(loaded.total_lines, cli.max_lines, cli.overlap);
let selected_chunk = selected_index
.map(|index| {
select_chunk(
loaded.total_lines,
cli.max_lines,
cli.overlap,
&loaded.lines,
index,
)
})
.transpose()?;
let report = ChunkReport {
path: path_text,
total_lines: loaded.total_lines,
max_lines: cli.max_lines,
overlap: cli.overlap,
chunk_count,
chunks,
selected_chunk,
};
match render_mode {
RenderMode::Json => print_json(&report)?,
RenderMode::Toon => print_structured(&report, RenderMode::Toon)?,
RenderMode::Text => {
if let Some(selected_chunk) = &report.selected_chunk {
print!("{}", render_selected_chunk(&report.path, selected_chunk));
} else {
print!("{}", render_inventory(&report));
}
}
}
let result_count = report
.selected_chunk
.as_ref()
.map_or(report.chunk_count, |_| 1);
Ok(map_result_count(result_count))
}
fn validate_options(max_lines: usize, overlap: usize) -> Result<(), CliError> {
if max_lines == 0 {
return Err(CliError::usage("--max-lines must be greater than 0"));
}
if overlap >= max_lines {
return Err(CliError::usage(
"--overlap must be smaller than --max-lines",
));
}
Ok(())
}
fn validate_selection_mode(cli: &Cli) -> Result<(), CliError> {
if cli.inventory && (cli.chunk.is_some() || cli.tail) {
return Err(CliError::usage(
"--inventory cannot be combined with --chunk or --tail",
));
}
Ok(())
}
const fn selected_chunk_index(
cli: &Cli,
chunk_count: usize,
prefer_default_chunk: bool,
) -> Option<usize> {
if cli.tail {
chunk_count.checked_sub(1)
} else if chunk_count > 0 && cli.chunk.is_none() && (chunk_count == 1 || prefer_default_chunk) {
Some(0)
} else {
cli.chunk
}
}
fn line_selection(cli: &Cli) -> LineSelection {
if cli.tail {
LineSelection::Tail {
keep_lines: cli.max_lines,
}
} else {
let chunk_index = cli.chunk.unwrap_or(0);
let (start_line, end_line) = chunk_line_window(chunk_index, cli.max_lines, cli.overlap);
LineSelection::Window {
start_line,
end_line,
}
}
}
fn collect_path(cli: &Cli) -> Result<PathBuf, CliError> {
if should_read_stdin(!cli.paths.is_empty(), cli.common.stdin_is_terminal()) {
let mut buffer = String::new();
io::stdin()
.read_to_string(&mut buffer)
.map_err(|error| CliError::runtime(format!("failed to read stdin: {error}")))?;
let stdin_paths = parse_paths_from_string(&buffer, cli.common.input_format)?;
if !stdin_paths.is_empty() {
return single_path(&stdin_paths);
}
}
single_path(&common::expand_input_patterns(&cli.paths, "chunkcat")?)
}
fn parse_paths_from_string(
buffer: &str,
input_format: InputFormat,
) -> Result<Vec<PathBuf>, CliError> {
common::read_existing_stdin_path_records(buffer, input_format, "chunkcat")?
.map_or_else(|| Ok(Vec::new()), Ok)
}
fn single_path(paths: &[PathBuf]) -> Result<PathBuf, CliError> {
match paths {
[path] => Ok(path.clone()),
[] => Err(CliError::usage(
"provide exactly one path or pipe one path into stdin",
)),
_ => Err(CliError::usage("chunkcat accepts exactly one path in v1")),
}
}
fn read_text_file(path: &Path, selection: LineSelection) -> Result<LoadedText, CliError> {
let metadata = fs::metadata(path).map_err(|error| {
CliError::runtime(format!(
"failed to read metadata for {}: {error}",
path.display()
))
})?;
if !metadata.is_file() {
return Err(CliError::usage(format!(
"{} is not a regular file",
path.display()
)));
}
let file = fs::File::open(path).map_err(|error| {
CliError::runtime(format!("failed to read {}: {error}", path.display()))
})?;
let mut reader = io::BufReader::new(file);
let mut buffer = Vec::new();
let mut total_lines = 0_usize;
let mut lines = Vec::new();
let mut tail_lines = VecDeque::new();
loop {
buffer.clear();
let bytes_read = reader.read_until(b'\n', &mut buffer).map_err(|error| {
CliError::runtime(format!("failed to read {}: {error}", path.display()))
})?;
if bytes_read == 0 {
break;
}
let line_text = text_line_from_bytes(path, &buffer)?;
total_lines += 1;
match selection {
LineSelection::Window {
start_line,
end_line,
} => {
if total_lines >= start_line && total_lines <= end_line {
lines.push(ChunkLine {
number: total_lines,
text: line_text.to_string(),
});
}
}
LineSelection::Tail { keep_lines } => {
if keep_lines > 0 {
tail_lines.push_back(ChunkLine {
number: total_lines,
text: line_text.to_string(),
});
while tail_lines.len() > keep_lines {
tail_lines.pop_front();
}
}
}
}
}
if matches!(selection, LineSelection::Tail { .. }) {
lines = tail_lines.into_iter().collect();
}
Ok(LoadedText { total_lines, lines })
}
fn text_line_from_bytes<'a>(path: &Path, bytes: &'a [u8]) -> Result<&'a str, CliError> {
if bytes.contains(&0) {
return Err(binary_file_error(path));
}
let text = std::str::from_utf8(bytes).map_err(|_| binary_file_error(path))?;
let text = text.strip_suffix('\n').map_or(text, |without_lf| {
without_lf.strip_suffix('\r').unwrap_or(without_lf)
});
Ok(text.trim_start_matches('\u{feff}'))
}
fn binary_file_error(path: &Path) -> CliError {
CliError::usage(format!(
"{} looks like a binary file; chunkcat only reads text files",
path.display()
))
}
fn plan_chunks(total_lines: usize, max_lines: usize, overlap: usize) -> Vec<ChunkDescriptor> {
(0..chunk_count(total_lines, max_lines, overlap))
.filter_map(|index| chunk_descriptor_at(total_lines, max_lines, overlap, index))
.collect()
}
const fn chunk_count(total_lines: usize, max_lines: usize, overlap: usize) -> usize {
if total_lines == 0 {
0
} else {
let stride = max_lines - overlap;
((total_lines - 1) / stride) + 1
}
}
fn chunk_descriptor_at(
total_lines: usize,
max_lines: usize,
overlap: usize,
index: usize,
) -> Option<ChunkDescriptor> {
let chunk_count = chunk_count(total_lines, max_lines, overlap);
if index >= chunk_count {
return None;
}
let (start_line, requested_end_line) = chunk_line_window(index, max_lines, overlap);
let end_line = requested_end_line.min(total_lines);
Some(ChunkDescriptor {
index,
start_line,
end_line,
line_count: end_line - start_line + 1,
})
}
const fn chunk_line_window(chunk_index: usize, max_lines: usize, overlap: usize) -> (usize, usize) {
let stride = max_lines - overlap;
let start_line = chunk_index.saturating_mul(stride).saturating_add(1);
let end_line = start_line.saturating_add(max_lines - 1);
(start_line, end_line)
}
fn select_chunk(
total_lines: usize,
max_lines: usize,
overlap: usize,
lines: &[ChunkLine],
chunk_index: usize,
) -> Result<SelectedChunk, CliError> {
let descriptor =
chunk_descriptor_at(total_lines, max_lines, overlap, chunk_index).ok_or_else(|| {
CliError::usage(format!(
"chunk index {chunk_index} is out of range for {} chunks",
chunk_count(total_lines, max_lines, overlap)
))
})?;
let selected_lines = lines
.iter()
.filter(|line| line.number >= descriptor.start_line && line.number <= descriptor.end_line)
.cloned()
.collect::<Vec<_>>();
if selected_lines.len() != descriptor.line_count {
return Err(CliError::runtime(format!(
"failed to collect lines {}:{} from streamed input",
descriptor.start_line, descriptor.end_line
)));
}
Ok(SelectedChunk {
index: descriptor.index,
start_line: descriptor.start_line,
end_line: descriptor.end_line,
line_count: descriptor.line_count,
lines: selected_lines,
})
}
fn render_inventory(report: &ChunkReport) -> String {
let mut rendered = String::new();
writeln!(
rendered,
"path={} total_lines={} chunks={} max_lines={} overlap={}",
report.path, report.total_lines, report.chunk_count, report.max_lines, report.overlap
)
.expect("writing to a String cannot fail");
writeln!(
rendered,
"summary_only=true hint=use --chunk <index> or --tail to print content"
)
.expect("writing to a String cannot fail");
for chunk in &report.chunks {
writeln!(
rendered,
"{} lines={}:{} count={}",
chunk.index, chunk.start_line, chunk.end_line, chunk.line_count
)
.expect("writing to a String cannot fail");
}
rendered
}
fn render_selected_chunk(path: &str, chunk: &SelectedChunk) -> String {
let mut rendered = String::new();
writeln!(
rendered,
"path={} chunk={} lines={}:{} count={}",
path, chunk.index, chunk.start_line, chunk.end_line, chunk.line_count
)
.expect("writing to a String cannot fail");
for line in &chunk.lines {
writeln!(rendered, "{}: {}", line.number, line.text)
.expect("writing to a String cannot fail");
}
rendered
}
#[cfg(test)]
mod tests {
use std::fs;
use common::ColorChoice;
use tempfile::tempdir;
use super::*;
fn common_args(json: bool, input_format: InputFormat) -> CommonArgs {
CommonArgs {
json,
format: None,
input_format,
color: ColorChoice::Never,
quiet: false,
}
}
#[test]
fn parse_paths_support_lines_and_jsonl() {
let temp = tempdir().expect("tempdir");
let first = temp.path().join("sample.rs");
let second = temp.path().join("other.rs");
fs::write(&first, "a").expect("first");
fs::write(&second, "b").expect("second");
assert_eq!(
parse_paths_from_string(&format!("{}\n", first.display()), InputFormat::Lines)
.expect("lines"),
vec![first.clone()]
);
assert_eq!(
parse_paths_from_string(
&format!(
"{}\n{{\"path\":{}}}\n",
serde_json::to_string(&first.display().to_string()).expect("json path"),
serde_json::to_string(&second.display().to_string()).expect("json path")
),
InputFormat::Jsonl,
)
.expect("jsonl"),
vec![second, first]
);
}
#[test]
fn planning_chunks_respects_overlap_and_short_final_chunk() {
let chunks = plan_chunks(35, 8, 2);
assert_eq!(chunks.len(), 6);
assert_eq!(chunks[0].start_line, 1);
assert_eq!(chunks[0].end_line, 8);
assert_eq!(chunks[2].start_line, 13);
assert_eq!(chunks[5].start_line, 31);
assert_eq!(chunks[5].end_line, 35);
}
#[test]
fn selecting_and_rendering_chunks_include_line_numbers() {
let lines = (1..=12)
.map(|line| ChunkLine {
number: line,
text: format!("line {line}"),
})
.collect::<Vec<_>>();
let chunks = plan_chunks(lines.len(), 5, 1);
let selected = select_chunk(lines.len(), 5, 1, &lines, 1).expect("selected chunk");
assert_eq!(selected.start_line, 5);
assert_eq!(selected.end_line, 9);
assert_eq!(selected.lines[0].number, 5);
assert!(render_selected_chunk("demo.txt", &selected).contains("5: line 5"));
let inventory = render_inventory(&ChunkReport {
path: "demo.txt".to_string(),
total_lines: 12,
max_lines: 5,
overlap: 1,
chunk_count: chunks.len(),
chunks,
selected_chunk: None,
});
assert!(inventory.contains("chunks=3"));
assert!(inventory.contains("summary_only=true"));
assert!(inventory.contains("use --chunk <index> or --tail to print content"));
assert!(inventory.contains("1 lines=5:9"));
}
#[test]
fn read_text_file_rejects_binary_files() {
let temp = tempdir().expect("tempdir");
let text_path = temp.path().join("sample.txt");
let binary_path = temp.path().join("sample.bin");
fs::write(&text_path, b"alpha\nbeta\n").expect("text fixture");
fs::write(&binary_path, b"\x00\x01\xff").expect("binary fixture");
let loaded = read_text_file(
&text_path,
LineSelection::Window {
start_line: 1,
end_line: 2,
},
)
.expect("text lines");
assert_eq!(
loaded.lines,
vec![
ChunkLine {
number: 1,
text: "alpha".to_string(),
},
ChunkLine {
number: 2,
text: "beta".to_string(),
},
]
);
let error = read_text_file(
&binary_path,
LineSelection::Window {
start_line: 1,
end_line: 0,
},
)
.expect_err("binary should fail");
assert!(matches!(
error,
CliError::Usage(message)
if message.contains("looks like a binary file")
));
}
#[test]
fn read_text_file_keeps_only_requested_window() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("large.txt");
let mut content = String::new();
for line in 1..=1_000 {
writeln!(content, "line {line}").expect("fixture line");
}
fs::write(&path, content).expect("fixture");
let loaded = read_text_file(
&path,
LineSelection::Window {
start_line: 400,
end_line: 404,
},
)
.expect("streamed text");
assert_eq!(loaded.total_lines, 1_000);
assert_eq!(
loaded.lines,
vec![
ChunkLine {
number: 400,
text: "line 400".to_string(),
},
ChunkLine {
number: 401,
text: "line 401".to_string(),
},
ChunkLine {
number: 402,
text: "line 402".to_string(),
},
ChunkLine {
number: 403,
text: "line 403".to_string(),
},
ChunkLine {
number: 404,
text: "line 404".to_string(),
},
]
);
}
#[test]
fn read_text_file_keeps_bounded_tail_candidates() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("tail.txt");
fs::write(
&path,
(1..=12)
.map(|line| format!("line {line}"))
.collect::<Vec<_>>()
.join("\n"),
)
.expect("fixture");
let loaded =
read_text_file(&path, LineSelection::Tail { keep_lines: 4 }).expect("streamed text");
assert_eq!(loaded.total_lines, 12);
assert_eq!(loaded.lines.len(), 4);
assert_eq!(loaded.lines[0].number, 9);
assert_eq!(loaded.lines[3].text, "line 12");
}
#[test]
fn read_text_file_rejects_binary_content_after_selected_window() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("late-binary.txt");
fs::write(&path, b"line 1\nline 2\nline 3\n\x00\n").expect("fixture");
let error = read_text_file(
&path,
LineSelection::Window {
start_line: 1,
end_line: 1,
},
)
.expect_err("late binary should fail");
assert!(matches!(
error,
CliError::Usage(message)
if message.contains("looks like a binary file")
));
}
#[test]
fn run_supports_inventory_and_selected_chunk_modes() {
let temp = tempdir().expect("tempdir");
let path = temp.path().join("sample.txt");
fs::write(&path, b"one\ntwo\nthree\nfour\nfive\n").expect("fixture");
let inventory_exit = run(&Cli {
common: common_args(false, InputFormat::Auto),
max_lines: 3,
overlap: 1,
inventory: true,
chunk: None,
tail: false,
paths: vec![path.clone()],
})
.expect("inventory run");
assert_eq!(inventory_exit, ExitCode::Success);
let selected_exit = run(&Cli {
common: common_args(true, InputFormat::Auto),
max_lines: 3,
overlap: 1,
inventory: false,
chunk: Some(1),
tail: false,
paths: vec![path],
})
.expect("selected run");
assert_eq!(selected_exit, ExitCode::Success);
}
#[test]
fn option_validation_and_path_selection_report_usage_errors() {
let error = validate_options(0, 0).expect_err("zero max lines should fail");
assert!(matches!(
error,
CliError::Usage(message)
if message.contains("--max-lines must be greater than 0")
));
let error = validate_options(4, 4).expect_err("overlap should fail");
assert!(matches!(
error,
CliError::Usage(message)
if message.contains("--overlap must be smaller")
));
let missing = single_path(&[]).expect_err("missing path should fail");
assert!(matches!(
missing,
CliError::Usage(message)
if message.contains("provide exactly one path")
));
let multiple = single_path(&[PathBuf::from("a"), PathBuf::from("b")])
.expect_err("multiple paths should fail");
assert!(matches!(
multiple,
CliError::Usage(message)
if message.contains("exactly one path")
));
let negative = parse_usize_flag("--max-lines", "-1").expect_err("negative flag");
assert!(matches!(
negative,
CliError::Usage(message)
if message.contains("must be a non-negative integer")
&& message.contains("--max-lines")
&& message.contains("-1")
));
}
#[test]
fn parsing_and_selection_cover_error_branches() {
let jsonl_error =
parse_paths_from_string("nope\n", InputFormat::Jsonl).expect_err("bad jsonl");
assert!(matches!(
jsonl_error,
CliError::Usage(message)
if message.contains("stdin JSONL path line 1 is not valid JSON")
));
let auto_paths = parse_paths_from_string("{\"name\":\"demo\"}\n", InputFormat::Auto)
.expect("auto fallback");
assert!(auto_paths.is_empty());
let lines = (1..=5)
.map(|line| ChunkLine {
number: line,
text: format!("line {line}"),
})
.collect::<Vec<_>>();
let error = select_chunk(lines.len(), 3, 1, &lines, 9).expect_err("out of range chunk");
assert!(matches!(
error,
CliError::Usage(message)
if message.contains("out of range")
));
}
#[test]
fn read_text_file_and_run_cover_directory_and_empty_file_cases() {
let temp = tempdir().expect("tempdir");
let empty_path = temp.path().join("empty.txt");
fs::write(&empty_path, "").expect("empty fixture");
let directory_error = read_text_file(
temp.path(),
LineSelection::Window {
start_line: 1,
end_line: 0,
},
)
.expect_err("directory should fail");
assert!(matches!(
directory_error,
CliError::Usage(message)
if message.contains("not a regular file")
));
let empty_exit = run(&Cli {
common: common_args(false, InputFormat::Auto),
max_lines: 5,
overlap: 0,
inventory: false,
chunk: None,
tail: false,
paths: vec![empty_path],
})
.expect("empty run");
assert_eq!(empty_exit, ExitCode::NoResults);
}
#[test]
fn tail_mode_selects_last_chunk() {
let chunks = plan_chunks(15, 4, 0);
let cli = Cli {
common: common_args(false, InputFormat::Auto),
max_lines: 4,
overlap: 0,
inventory: false,
chunk: None,
tail: true,
paths: vec![PathBuf::from("demo.txt")],
};
assert_eq!(selected_chunk_index(&cli, chunks.len(), false), Some(3));
}
#[test]
fn single_chunk_files_auto_select_first_chunk() {
let chunks = plan_chunks(12, 20, 0);
let cli = Cli {
common: common_args(false, InputFormat::Auto),
max_lines: 20,
overlap: 0,
inventory: false,
chunk: None,
tail: false,
paths: vec![PathBuf::from("demo.txt")],
};
assert_eq!(selected_chunk_index(&cli, chunks.len(), false), Some(0));
}
#[test]
fn multi_chunk_text_mode_defaults_to_the_first_chunk() {
let chunks = plan_chunks(12, 4, 0);
let cli = Cli {
common: common_args(false, InputFormat::Auto),
max_lines: 4,
overlap: 0,
inventory: false,
chunk: None,
tail: false,
paths: vec![PathBuf::from("demo.txt")],
};
assert_eq!(selected_chunk_index(&cli, chunks.len(), true), Some(0));
assert_eq!(selected_chunk_index(&cli, chunks.len(), false), None);
}
#[test]
fn inventory_mode_rejects_explicit_chunk_selection() {
let cli = Cli {
common: common_args(false, InputFormat::Auto),
max_lines: 4,
overlap: 0,
inventory: true,
chunk: Some(1),
tail: false,
paths: vec![PathBuf::from("demo.txt")],
};
assert!(matches!(
validate_selection_mode(&cli),
Err(CliError::Usage(message)) if message.contains("--inventory")
));
}
}