//! 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 ` / `--tail` to jump elsewhere. Usage: chunkcat [OPTIONS] [PATH...] Options: --format Structured output format: text, json, toon --json Shortcut for --format json --toon Shortcut for --format toon --input-format Override stdin parsing mode: auto, lines, jsonl --color Control ANSI color output: auto, never --quiet Suppress non-essential status output --max-lines Maximum number of lines per chunk --overlap Number of overlapping lines between chunks --inventory Print chunk inventory instead of default first-chunk text --chunk 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, /// Print the last chunk instead of a numbered chunk. tail: bool, /// File path to inspect when stdin is empty. paths: Vec, } #[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, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct ChunkReport { path: String, total_lines: usize, max_lines: usize, overlap: usize, chunk_count: usize, chunks: Vec, selected_chunk: Option, } #[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, } /// 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(args: I) -> Result<(ParseOutcome, Cli), CliError> where I: IntoIterator, T: Into, { 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 { 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 { if value.trim_start().starts_with('-') { return Err(CliError::usage(format!( "invalid {flag} value '{value}': must be a non-negative integer" ))); } value .parse::() .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) } fn run(cli: &Cli) -> Result { 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 { 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 { 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, CliError> { common::read_existing_stdin_path_records(buffer, input_format, "chunkcat")? .map_or_else(|| Ok(Vec::new()), Ok) } fn single_path(paths: &[PathBuf]) -> Result { 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 { 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 { (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 { 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 { 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::>(); 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 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::>(); 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 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::>() .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::>(); 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") )); } }