1016 lines
32 KiB
Rust
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")
|
|
));
|
|
}
|
|
}
|