//! The `ctxpack` command packs mixed inputs into compact context blocks. use codeindex::{CodeIndexer, detect_language}; use common::{ CliError, CommonArgs, ExitCode, InputFormat, RenderMode, 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; use serde_json::Value; const KIND_DEFINITION: &str = "definition"; const KIND_CODESHAPE: &str = "codeshape"; const KIND_FILE: &str = "file"; const KIND_SNIPPET: &str = "snippet"; const HEADER_BLOCK: &str = "block"; const HEADER_FILE: &str = "file"; const HEADER_SNIPPET: &str = "snippet"; const PATH_STDIN: &str = ""; use std::collections::BTreeSet; use std::env; use std::ffi::OsString; use std::fmt::Write as _; use std::fs; use std::io::{self, BufRead, Read}; use std::path::{Path, PathBuf}; use std::time::SystemTime; const HELP: &str = "\ Pack files, hits, snippets, and definition records into compact context blocks. Usage: ctxpack [OPTIONS] [INPUT...] 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-blocks Maximum number of blocks to emit --max-lines Maximum number of lines per block --dedupe Collapse duplicate blocks --sort Block order: input, path, recent -h, --help Show this help text -V, --version Show the command version Examples: ctxpack .\\fixtures\\polyglot\\repo\\src\\lib.rs:26 refs helper .\\fixtures\\polyglot\\repo --json | ctxpack --input-format auto rg -nH \"helper\" .\\fixtures\\polyglot\\repo\\src\\lib.rs | hitsnip --def --json | ctxpack --input-format auto diagpick .\\fixtures\\diag\\rust-errors.txt --json | ctxpack --input-format auto --json | ConvertFrom-Json '.\\fixtures\\polyglot\\repo\\src\\lib.rs' | ctxpack --json | ConvertFrom-Json Notes: JSON input may be a direct block record or a wrapper object with arrays such as: items, records, diagnostics, hits, blocks, files, source, snippet, or enclosing_definition Wrapper arrays expand in-place; scalar source/snippet/definition records become one block each Definition records dedupe by source location automatically; --dedupe also collapses identical non-definition blocks When JSON records contain both snippet and definition payloads, ctxpack prefers the definition block "; #[derive(Debug, Clone)] struct Cli { common: CommonArgs, inputs: Vec, max_blocks: usize, max_lines: usize, dedupe: bool, sort_mode: SortMode, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ParseOutcome { Help, Version, Run, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum SortMode { Input, Path, Recent, } #[derive(Debug, Clone, PartialEq, Eq, Serialize)] struct ContextBlock { path: String, kind: String, header: String, start_line: Option, end_line: Option, truncated: bool, text: String, } #[derive(Debug, Clone, PartialEq, Eq)] enum InputItem { Text(String), Json(Value), } #[derive(Debug, Clone, PartialEq, Eq)] struct LineHit { path: String, line: usize, column: Option, } /// 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!("ctxpack {}", 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(), inputs: Vec::new(), max_blocks: 32, max_lines: 40, dedupe: false, sort_mode: SortMode::Input, }; 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") => { cli.common.input_format = parse_input_format(&parser_value_string(&mut parser, "--input-format")?)?; } Long("color") => { cli.common.color = parse_color_choice(&parser_value_string(&mut parser, "--color")?)?; } Long("quiet") => cli.common.quiet = true, Long("max-blocks") => { cli.max_blocks = parse_positive_usize_flag( "--max-blocks", &parser_value_string(&mut parser, "--max-blocks")?, )?; } Long("max-lines") => { cli.max_lines = parse_positive_usize_flag( "--max-lines", &parser_value_string(&mut parser, "--max-lines")?, )?; } Long("dedupe") => cli.dedupe = true, Long("sort") => { cli.sort_mode = parse_sort_mode(&parser_value_string(&mut parser, "--sort")?)?; } ArgValue(value) => cli.inputs.push(os_value_string(value, "input")?), _ => { return Err(CliError::usage( "unsupported argument; use --help to see available options", )); } } } Ok((ParseOutcome::Run, cli)) } fn run(cli: &Cli) -> Result { let items = load_inputs(cli)?; if items.is_empty() { return Err(CliError::usage( "provide at least one input or pipe inputs into stdin", )); } let mut indexer = CodeIndexer::new(); let mut blocks = Vec::new(); for item in items { blocks.extend(materialize_blocks(cli, &item, &mut indexer)?); } blocks = dedupe_definition_blocks(blocks); if cli.dedupe { blocks = dedupe_blocks(blocks); } sort_blocks(&mut blocks, cli.sort_mode); if blocks.len() > cli.max_blocks { blocks.truncate(cli.max_blocks); } match cli.common.render_mode() { RenderMode::Json => print_json(&blocks)?, RenderMode::Toon => print_structured(&blocks, RenderMode::Toon)?, RenderMode::Text => { if blocks.is_empty() { if !cli.common.quiet { println!("0 context blocks"); } } else { print!("{}", render_blocks(&blocks)); } } } Ok(if blocks.is_empty() { ExitCode::NoResults } else { ExitCode::Success }) } fn load_inputs(cli: &Cli) -> Result, CliError> { if should_read_stdin(!cli.inputs.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 items = parse_input_items(&buffer, cli.common.input_format)?; if !items.is_empty() { return Ok(items); } } Ok(cli .inputs .iter() .cloned() .map(InputItem::Text) .collect::>()) } fn parse_input_items(buffer: &str, input_format: InputFormat) -> Result, CliError> { let trimmed = buffer.trim(); if trimmed.is_empty() { return Ok(Vec::new()); } if input_format != InputFormat::Lines && matches!(trimmed.as_bytes().first(), Some(b'[' | b'{')) && let Ok(value) = serde_json::from_str::(trimmed) { return expand_json_value(value); } let mut items = Vec::new(); for (index, line) in io::Cursor::new(buffer).lines().enumerate() { let raw = line.map_err(|error| CliError::runtime(format!("failed to read line: {error}")))?; let trimmed = raw.trim(); if trimmed.is_empty() { continue; } match input_format { InputFormat::Lines => items.push(InputItem::Text(trimmed.to_string())), InputFormat::Jsonl => { let value = serde_json::from_str::(trimmed).map_err(|error| { CliError::runtime(format!( "invalid JSONL input at line {}: {error}", index + 1 )) })?; items.extend(expand_json_value(value)?); } InputFormat::Auto => match serde_json::from_str::(trimmed) { Ok(value) => items.extend(expand_json_value(value)?), Err(_) => items.push(InputItem::Text(trimmed.to_string())), }, } } Ok(items) } fn expand_json_value(value: Value) -> Result, CliError> { match value { Value::Array(items) => Ok(items.into_iter().map(InputItem::Json).collect()), Value::Object(object) => array_field_items(&object)? .map_or_else(|| Ok(vec![InputItem::Json(Value::Object(object))]), Ok), _ => Err(CliError::runtime( "JSON input must be an object or array of objects", )), } } fn array_field_items( object: &serde_json::Map, ) -> Result>, CliError> { for key in ["items", "records", "diagnostics", "blocks", "hits", "files"] { let Some(candidate) = object.get(key) else { continue; }; if candidate.is_null() { continue; } let Value::Array(items) = candidate else { return Err(CliError::runtime(format!( "context JSON field '{key}' must be an array when present" ))); }; return Ok(Some(items.iter().cloned().map(InputItem::Json).collect())); } Ok(None) } fn materialize_blocks( cli: &Cli, item: &InputItem, indexer: &mut CodeIndexer, ) -> Result, CliError> { match item { InputItem::Text(value) => materialize_text_input(value, cli.max_lines, indexer), InputItem::Json(value) => materialize_json_value(value, cli.max_lines, indexer), } } fn materialize_text_input( value: &str, max_lines: usize, indexer: &mut CodeIndexer, ) -> Result, CliError> { let path = PathBuf::from(value); if path.exists() && path.is_file() { return Ok(vec![block_from_file(&path, max_lines)?]); } if let Ok(hit) = parse_text_hit(value) { return Ok(vec![block_from_hit(&hit, max_lines, indexer)?]); } Err(CliError::runtime(format!( "unsupported context input: {value}" ))) } fn materialize_json_value( value: &Value, max_lines: usize, indexer: &mut CodeIndexer, ) -> Result, CliError> { let Value::Object(object) = value else { return Err(CliError::runtime( "context JSON input must be an object or array of objects", )); }; Ok(decorate_block_headers( materialize_json_object(object, value, max_lines, indexer)?, diagnostic_label(object), )) } fn materialize_json_object( object: &serde_json::Map, value: &Value, max_lines: usize, indexer: &mut CodeIndexer, ) -> Result, CliError> { if let Some(definition) = object .get("definition") .filter(|value| !value.is_null()) .or_else(|| { object .get("enclosing_definition") .filter(|value| !value.is_null()) }) { return materialize_json_value(definition, max_lines, indexer); } if let Some(snippet) = object .get("snippet") .or_else(|| object.get("source")) .filter(|value| !value.is_null()) { return Ok(vec![block_from_source_object( snippet, object.get("path").and_then(Value::as_str), max_lines, )?]); } if object.get("text").is_some() && object.get("path").is_some() { return Ok(vec![block_from_text_object(object, max_lines)?]); } if object.get("items").is_some() && object.get("path").is_some() { return Ok(vec![block_from_codeshape_file_object(object, max_lines)?]); } if object.get("lines").is_some() && object.get("path").is_some() { return Ok(vec![block_from_source_object(value, None, max_lines)?]); } if let (Some(path), Some(line)) = ( object.get("path").and_then(Value::as_str), object.get("line").and_then(Value::as_u64), ) { return Ok(vec![block_from_hit( &LineHit { path: path.to_string(), line: usize::try_from(line).map_err(|error| { CliError::runtime(format!("invalid JSON line number for context: {error}")) })?, column: object .get("column") .and_then(Value::as_u64) .map(usize::try_from) .transpose() .map_err(|error| { CliError::runtime(format!( "invalid JSON column number for context: {error}" )) })?, }, max_lines, indexer, )?]); } if let (Some(path), Some(start_line), Some(end_line)) = ( object.get("path").and_then(Value::as_str), object.get("start_line").and_then(Value::as_u64), object.get("end_line").and_then(Value::as_u64), ) { return Ok(vec![block_from_ranged_file_object( path, usize::try_from(start_line).map_err(|error| { CliError::runtime(format!("invalid JSON start_line for context: {error}")) })?, usize::try_from(end_line).map_err(|error| { CliError::runtime(format!("invalid JSON end_line for context: {error}")) })?, object, max_lines, )?]); } if let Some(path) = object.get("path").and_then(Value::as_str) { let file = PathBuf::from(path); if file.exists() && file.is_file() { return Ok(vec![block_from_file(&file, max_lines)?]); } } Err(CliError::runtime( "unsupported JSON shape for context input", )) } fn block_from_codeshape_file_object( object: &serde_json::Map, max_lines: usize, ) -> Result { let path = object .get("path") .and_then(Value::as_str) .ok_or_else(|| CliError::runtime("codeshape file object is missing a path"))?; let items = object .get("items") .and_then(Value::as_array) .ok_or_else(|| CliError::runtime("codeshape file object is missing an items array"))?; let language = object .get("language") .and_then(Value::as_str) .unwrap_or("unknown"); let rendered_lines = items .iter() .map(render_codeshape_item_line) .collect::>(); let start_line = items .first() .and_then(|item| item.get("start_line")) .map(|value| optional_usize_value(value, "codeshape start_line")) .transpose()? .flatten(); let end_line = items .last() .and_then(|item| item.get("end_line")) .map(|value| optional_usize_value(value, "codeshape end_line")) .transpose()? .flatten(); Ok(build_block( path.to_string(), KIND_CODESHAPE.to_owned(), format!("codeshape {language}"), start_line, end_line, &rendered_lines.join("\n"), max_lines, )) } fn render_codeshape_item_line(item: &Value) -> String { let depth = item .get("depth") .and_then(Value::as_u64) .and_then(|value| usize::try_from(value).ok()) .unwrap_or(0); let indent = " ".repeat(depth.saturating_sub(1)); let kind = item.get("kind").and_then(Value::as_str).unwrap_or("item"); let name = item .get("qualified_name") .and_then(Value::as_str) .or_else(|| item.get("name").and_then(Value::as_str)) .unwrap_or("?"); let signature = item.get("signature").and_then(Value::as_str).unwrap_or(""); let start_line = item.get("start_line").and_then(Value::as_u64); let mut line = format!("{indent}{kind} {name}"); if !signature.is_empty() && signature != name { let _ = write!(line, " signature={signature}"); } if let Some(start_line) = start_line { let _ = write!(line, " line={start_line}"); } line } fn optional_usize_value(value: &Value, label: &str) -> Result, CliError> { value .as_u64() .map(usize::try_from) .transpose() .map_err(|error| CliError::runtime(format!("invalid {label}: {error}"))) } fn block_from_file(path: &Path, max_lines: usize) -> Result { let source = fs::read_to_string(path).map_err(|error| { CliError::runtime(format!("failed to read {}: {error}", path.display())) })?; Ok(build_block( path.display().to_string(), KIND_FILE.to_owned(), path.file_name() .and_then(|value| value.to_str()) .unwrap_or(HEADER_FILE) .to_owned(), Some(1), source.lines().count().checked_sub(0), &source, max_lines, )) } fn block_from_hit( hit: &LineHit, max_lines: usize, indexer: &mut CodeIndexer, ) -> Result { let path = PathBuf::from(&hit.path); let source = fs::read_to_string(&path).map_err(|error| { CliError::runtime(format!("failed to read {}: {error}", path.display())) })?; if detect_language(&path).is_some() && let Some(symbol) = indexer.find_enclosing_symbol(&path, &source, hit.line, hit.column)? { return Ok(build_block( symbol.path, KIND_DEFINITION.to_owned(), symbol.qualified_name, Some(symbol.start_line), Some(symbol.end_line), &symbol.text, max_lines, )); } Ok(build_snippet_block(&hit.path, &source, hit.line, max_lines)) } fn block_from_source_object( value: &Value, path_override: Option<&str>, max_lines: usize, ) -> Result { let Value::Object(object) = value else { return Err(CliError::runtime("invalid source snippet object")); }; let path = path_override .or_else(|| object.get("path").and_then(Value::as_str)) .unwrap_or(PATH_STDIN) .to_string(); let start_line = object .get("start_line") .map(|value| optional_usize_value(value, "start_line for context")) .transpose()? .flatten(); let end_line = object .get("end_line") .map(|value| optional_usize_value(value, "end_line for context")) .transpose()? .flatten(); let lines = object .get("lines") .and_then(Value::as_array) .ok_or_else(|| CliError::runtime("source snippet is missing a lines array"))?; let mut text = String::new(); for line in lines { let line_text = line .get("text") .and_then(Value::as_str) .ok_or_else(|| CliError::runtime("source snippet line is missing text"))?; if !text.is_empty() { text.push('\n'); } text.push_str(line_text); } Ok(build_block( path, KIND_SNIPPET.to_owned(), HEADER_SNIPPET.to_owned(), start_line, end_line, &text, max_lines, )) } fn block_from_text_object( object: &serde_json::Map, max_lines: usize, ) -> Result { let path = object .get("path") .and_then(Value::as_str) .ok_or_else(|| CliError::runtime("context text object is missing path"))?; let text = object .get("text") .and_then(Value::as_str) .ok_or_else(|| CliError::runtime("context text object is missing text"))?; let header = object .get("qualified_name") .or_else(|| object.get("signature")) .or_else(|| object.get("name")) .and_then(Value::as_str) .unwrap_or(HEADER_BLOCK); let start_line = object .get("start_line") .map(|value| optional_usize_value(value, "start_line for context")) .transpose()? .flatten(); let end_line = object .get("end_line") .map(|value| optional_usize_value(value, "end_line for context")) .transpose()? .flatten(); Ok(build_block( path.to_string(), KIND_DEFINITION.to_owned(), header.to_string(), start_line, end_line, text, max_lines, )) } fn block_from_ranged_file_object( path: &str, start_line: usize, end_line: usize, object: &serde_json::Map, max_lines: usize, ) -> Result { let file = PathBuf::from(path); let source = fs::read_to_string(&file).map_err(|error| { CliError::runtime(format!("failed to read {}: {error}", file.display())) })?; let lines = source.lines().collect::>(); let start_index = start_line.saturating_sub(1).min(lines.len()); let end_index = end_line.min(lines.len()); let text = if start_index < end_index { lines[start_index..end_index].join("\n") } else { String::new() }; let header = object .get("qualified_name") .or_else(|| object.get("signature")) .or_else(|| object.get("name")) .and_then(Value::as_str) .unwrap_or(KIND_DEFINITION); Ok(build_block( path.to_string(), KIND_DEFINITION.to_owned(), header.to_string(), Some(start_line), Some(end_line), &text, max_lines, )) } fn build_snippet_block(path: &str, source: &str, line: usize, max_lines: usize) -> ContextBlock { let lines = source.lines().collect::>(); if lines.is_empty() { return build_block( path.to_string(), KIND_SNIPPET.to_owned(), format!("line {line} (past EOF, empty file)"), Some(0), Some(0), "", max_lines, ); } let effective_line = line.clamp(1, lines.len()); let (start_line, end_line) = if line > lines.len() { (effective_line, effective_line) } else { let radius = max_lines.saturating_sub(1) / 2; let start_line = effective_line.saturating_sub(radius).max(1); let end_line = (start_line + max_lines.saturating_sub(1)).min(lines.len()); (start_line, end_line) }; let text = lines .get(start_line.saturating_sub(1)..end_line) .unwrap_or_default() .join("\n"); let header = if effective_line == line { format!("line {line}") } else { format!("line {line} (past EOF, clamped to {effective_line})") }; build_block( path.to_string(), KIND_SNIPPET.to_owned(), header, Some(start_line), Some(end_line), &text, max_lines, ) } fn build_block( path: String, kind: String, header: String, start_line: Option, end_line: Option, text: &str, max_lines: usize, ) -> ContextBlock { let line_budget = if kind == KIND_DEFINITION { max_lines.saturating_mul(3).max(24) } else { max_lines }; let (rendered_lines, rendered_line_count, truncated) = render_limited_lines(text, line_budget); let actual_start = start_line; let actual_end = actual_start .map(|start| start + rendered_line_count.saturating_sub(1)) .or(end_line); ContextBlock { path, kind, header, start_line: actual_start, end_line: actual_end, truncated, text: rendered_lines, } } fn render_limited_lines(text: &str, line_budget: usize) -> (String, usize, bool) { let mut rendered = String::new(); let mut rendered_count = 0usize; for (index, line) in text.lines().enumerate() { if index >= line_budget { return (rendered, rendered_count, true); } if index > 0 { rendered.push('\n'); } rendered.push_str(line); rendered_count += 1; } (rendered, rendered_count, false) } fn dedupe_blocks(blocks: Vec) -> Vec { let mut seen = BTreeSet::<(String, String, Option, Option, String)>::new(); let mut deduped = Vec::new(); for block in blocks { let key = ( block.path.clone(), block.kind.clone(), block.start_line, block.end_line, block.text.clone(), ); if seen.insert(key) { deduped.push(block); } } deduped } fn dedupe_definition_blocks(blocks: Vec) -> Vec { let mut seen = BTreeSet::<(String, Option, Option, String)>::new(); let mut deduped = Vec::with_capacity(blocks.len()); for block in blocks { if block.kind == "definition" { let key = ( block.path.clone(), block.start_line, block.end_line, block.text.clone(), ); if seen.insert(key) { deduped.push(block); } continue; } deduped.push(block); } deduped } fn sort_blocks(blocks: &mut [ContextBlock], sort_mode: SortMode) { match sort_mode { SortMode::Input => {} SortMode::Path => { blocks.sort_by(|left, right| { ( left.path.as_str(), left.start_line.unwrap_or(usize::MAX), left.end_line.unwrap_or(usize::MAX), left.header.as_str(), ) .cmp(&( right.path.as_str(), right.start_line.unwrap_or(usize::MAX), right.end_line.unwrap_or(usize::MAX), right.header.as_str(), )) }); } SortMode::Recent => { blocks.sort_by(|left, right| recent_sort_key(right).cmp(&recent_sort_key(left))); } } } fn recent_sort_key(block: &ContextBlock) -> (Option, &str, Option) { let modified = fs::metadata(&block.path) .and_then(|metadata| metadata.modified()) .ok(); (modified, block.path.as_str(), block.start_line) } fn render_blocks(blocks: &[ContextBlock]) -> String { let mut output = String::new(); let current_dir = env::current_dir().ok(); for (index, block) in blocks.iter().enumerate() { let display_path = compact_display_path(&block.path, current_dir.as_deref()); if index > 0 { output.push('\n'); } if let (Some(start_line), Some(end_line)) = (block.start_line, block.end_line) { writeln!( output, "{}:{}-{} kind={} header={}", display_path, start_line, end_line, block.kind, block.header ) .expect("writing to a String cannot fail"); for (offset, line) in block.text.lines().enumerate() { writeln!(output, "{}: {}", start_line + offset, line) .expect("writing to a String cannot fail"); } } else { writeln!( output, "{} kind={} header={}", display_path, block.kind, block.header ) .expect("writing to a String cannot fail"); writeln!(output, "{}", block.text).expect("writing to a String cannot fail"); } if block.truncated { writeln!(output, "[truncated]").expect("writing to a String cannot fail"); } } output } fn compact_display_path(path: &str, current_dir: Option<&Path>) -> String { let candidate = PathBuf::from(path); if !candidate.is_absolute() { return path.to_string(); } let Some(current_dir) = current_dir else { return path.to_string(); }; candidate.strip_prefix(current_dir).map_or_else( |_| path.to_string(), |relative| relative.display().to_string(), ) } fn decorate_block_headers(blocks: Vec, prefix: Option) -> Vec { let Some(prefix) = prefix else { return blocks; }; blocks .into_iter() .map(|mut block| { block.header = format!("{prefix} :: {}", block.header); block }) .collect() } fn diagnostic_label(object: &serde_json::Map) -> Option { let severity = object.get("severity").and_then(Value::as_str)?; let code = object.get("code").and_then(Value::as_str); let message = object .get("message") .and_then(Value::as_str) .map(compact_header_text) .filter(|message| !message.is_empty()); let mut label = String::from("diag "); label.push_str(severity); if let Some(code) = code.filter(|code| !code.is_empty()) { let _ = write!(label, "[{code}]"); } if let Some(message) = message { let _ = write!(label, " {message}"); } Some(label) } fn compact_header_text(text: &str) -> String { const MAX_CHARS: usize = 72; let mut single_line = String::new(); for word in text.split_whitespace() { if !single_line.is_empty() { single_line.push(' '); } single_line.push_str(word); } if single_line.chars().count() <= MAX_CHARS { return single_line; } let end_index = single_line .char_indices() .nth(MAX_CHARS) .map_or(single_line.len(), |(index, _)| index); format!("{}...", &single_line[..end_index]) } fn parse_text_hit(raw: &str) -> Result { let segments = raw.split(':').collect::>(); if segments.len() < 2 { return Err(CliError::usage(format!("invalid hit format: {raw}"))); } let numeric_index = segments .iter() .enumerate() .rev() .find_map(|(index, segment)| segment.parse::().ok().map(|value| (index, value))) .ok_or_else(|| CliError::usage(format!("invalid hit format: {raw}")))?; let (path, line, column) = if numeric_index.0 > 0 && segments[numeric_index.0 - 1].parse::().is_ok() && !segments[..numeric_index.0 - 1].join(":").is_empty() { let path = segments[..numeric_index.0 - 1].join(":"); let line = segments[numeric_index.0 - 1] .parse::() .map_err(|error| CliError::usage(format!("invalid hit line in '{raw}': {error}")))?; (path, line, Some(numeric_index.1)) } else { let path = segments[..numeric_index.0].join(":"); (path, numeric_index.1, None) }; if line == 0 || column == Some(0) || path.is_empty() { return Err(CliError::usage(format!("invalid hit format: {raw}"))); } Ok(LineHit { path, line, column }) } fn parser_value_string(parser: &mut lexopt::Parser, flag: &str) -> Result { let value = parser .value() .map_err(|error| CliError::usage(error.to_string()))?; os_value_string(value, flag) } fn os_value_string(value: OsString, flag: &str) -> Result { 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 { value .parse::() .map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}"))) } fn parse_positive_usize_flag(flag: &str, value: &str) -> Result { let parsed = parse_usize_flag(flag, value)?; if parsed == 0 { return Err(CliError::usage(format!("{flag} must be greater than 0"))); } Ok(parsed) } fn parse_sort_mode(value: &str) -> Result { match value { "input" => Ok(SortMode::Input), "path" => Ok(SortMode::Path), "recent" => Ok(SortMode::Recent), other => Err(CliError::usage(format!( "invalid --sort value '{other}'; expected input, path, or recent" ))), } } #[cfg(test)] mod tests { use super::*; use std::{thread, time::Duration}; fn fixture_path(relative: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("..") .join("..") .join("fixtures") .join("polyglot") .join("repo") .join(relative) } #[test] fn parse_text_hit_supports_rg_style_lines() { let hit = parse_text_hit(r"C:\repo\demo.rs:18:9:pub fn run() {}").expect("hit"); assert_eq!(hit.path, r"C:\repo\demo.rs"); assert_eq!(hit.line, 18); assert_eq!(hit.column, Some(9)); } #[test] fn build_block_truncates_to_max_lines() { let block = build_block( "demo.rs".to_string(), "file".to_string(), "demo".to_string(), Some(1), Some(4), "a\nb\nc\nd", 2, ); assert_eq!(block.end_line, Some(2)); assert!(block.truncated); assert_eq!(block.text, "a\nb"); } #[test] fn build_block_keeps_larger_definition_windows() { let long_text = (1..=30) .map(|value| format!("line {value}")) .collect::>() .join("\n"); let block = build_block( "demo.rs".to_string(), "definition".to_string(), "fn helper()".to_string(), Some(1), Some(30), &long_text, 2, ); assert!(block.text.contains("line 24")); assert_eq!(block.end_line, Some(24)); assert!(block.truncated); } #[test] fn materialize_text_hit_prefers_enclosing_definition() { let mut indexer = CodeIndexer::new(); let blocks = materialize_text_input( &format!("{}:26", fixture_path("src/lib.rs").display()), 20, &mut indexer, ) .expect("blocks"); assert_eq!(blocks.len(), 1); assert_eq!(blocks[0].kind, "definition"); assert_eq!(blocks[0].header, "call_helper"); } #[test] fn materialize_json_snippet_uses_lines_array() { let placeholder = ['{', 'v', 'a', 'l', 'u', 'e', '}'] .into_iter() .collect::(); let line_text = format!("println!(\"{placeholder}\");"); let value = serde_json::json!({ "path": "demo.rs", "start_line": 4, "end_line": 5, "lines": [ { "number": 4, "text": "let value = 1;" }, { "number": 5, "text": line_text } ] }); let mut indexer = CodeIndexer::new(); let blocks = materialize_json_value(&value, 20, &mut indexer).expect("blocks"); assert_eq!(blocks[0].kind, "snippet"); assert_eq!(blocks[0].start_line, Some(4)); assert!(blocks[0].text.contains("println!")); } #[test] fn materialize_json_value_prefers_nested_definition_records() { let mut indexer = CodeIndexer::new(); let blocks = materialize_json_value( &serde_json::json!({ "path": "demo.rs", "line": 7, "definition": { "path": "demo.rs", "qualified_name": "demo::helper", "signature": "fn helper()", "start_line": 3, "end_line": 5, "text": "fn helper() {\n 1\n}" }, "snippet": { "start_line": 7, "end_line": 7, "lines": [ { "number": 7, "text": "helper();" } ] } }), 4, &mut indexer, ) .expect("blocks"); assert_eq!(blocks.len(), 1); assert_eq!(blocks[0].kind, "definition"); assert_eq!(blocks[0].header, "demo::helper"); } #[test] fn materialize_json_value_preserves_diagnostic_identity_in_headers() { let mut indexer = CodeIndexer::new(); let blocks = materialize_json_value( &serde_json::json!({ "path": "demo.rs", "line": 7, "column": 2, "severity": "error", "code": "E0425", "message": "cannot find value `missing` in this scope", "source": { "start_line": 7, "end_line": 7, "lines": [ { "text": "missing();" } ] } }), 4, &mut indexer, ) .expect("blocks"); assert_eq!(blocks.len(), 1); assert!(blocks[0].header.contains("diag error[E0425]")); assert!(blocks[0].header.contains("cannot find value")); assert!(blocks[0].header.contains("snippet")); } #[test] fn expand_json_value_supports_wrapper_records_array() { let items = expand_json_value(serde_json::json!({ "records": [ { "path": "demo.rs", "line": 7 } ] })) .expect("items"); assert_eq!(items.len(), 1); assert!(matches!(items[0], InputItem::Json(_))); } #[test] fn expand_json_value_supports_codeshape_files_array() { let items = expand_json_value(serde_json::json!({ "files": [ { "path": "demo.rs", "language": "rust", "items": [ { "kind": "function", "name": "helper", "qualified_name": "helper", "signature": "fn helper()", "depth": 1, "start_line": 3, "end_line": 5 } ] } ] })) .expect("items"); assert_eq!(items.len(), 1); assert!(matches!(items[0], InputItem::Json(_))); } #[test] fn materialize_json_value_supports_codeshape_file_objects() { let mut indexer = CodeIndexer::new(); let blocks = materialize_json_value( &serde_json::json!({ "path": "demo.rs", "language": "rust", "items": [ { "kind": "module", "name": "demo", "qualified_name": "demo", "signature": "mod demo", "depth": 1, "start_line": 1, "end_line": 10 }, { "kind": "function", "name": "helper", "qualified_name": "demo::helper", "signature": "fn helper()", "depth": 2, "start_line": 3, "end_line": 5 } ] }), 20, &mut indexer, ) .expect("blocks"); assert_eq!(blocks.len(), 1); assert_eq!(blocks[0].kind, "codeshape"); assert_eq!(blocks[0].header, "codeshape rust"); assert!(blocks[0].text.contains("module demo")); assert!(blocks[0].text.contains("function demo::helper")); } #[test] fn materialize_json_value_ignores_null_optional_fields() { let mut indexer = CodeIndexer::new(); let blocks = materialize_json_value( &serde_json::json!({ "path": fixture_path("src/lib.rs").display().to_string(), "line": 26, "column": 11, "snippet": null, "source": null, "enclosing_definition": null }), 12, &mut indexer, ) .expect("blocks"); assert_eq!(blocks.len(), 1); assert_eq!(blocks[0].kind, "definition"); } #[test] fn parse_input_items_auto_mixes_wrappers_and_text_lines() { let items = parse_input_items( "{\"hits\":[{\"path\":\"demo.rs\",\"line\":7}]}\nplain.txt\n{\"path\":\"demo.rs\",\"name\":\"helper\",\"text\":\"fn helper() {}\"}\n", InputFormat::Auto, ) .expect("items"); assert_eq!(items.len(), 3); assert!(matches!(items[0], InputItem::Json(_))); assert_eq!(items[1], InputItem::Text("plain.txt".to_string())); assert!(matches!(items[2], InputItem::Json(_))); } #[test] fn materialize_json_value_supports_source_and_text_objects() { let mut indexer = CodeIndexer::new(); let source_blocks = materialize_json_value( &serde_json::json!({ "path": "outer.rs", "source": { "start_line": 3, "end_line": 4, "lines": [ { "text": "let alpha = 1;" }, { "text": "let beta = 2;" } ] } }), 20, &mut indexer, ) .expect("source blocks"); assert_eq!(source_blocks[0].path, "outer.rs"); assert_eq!(source_blocks[0].kind, "snippet"); assert_eq!(source_blocks[0].start_line, Some(3)); assert!(source_blocks[0].text.contains("beta")); let text_blocks = materialize_json_value( &serde_json::json!({ "path": "demo.rs", "signature": "fn helper()", "text": "fn helper() {}" }), 20, &mut indexer, ) .expect("text blocks"); assert_eq!(text_blocks[0].kind, "definition"); assert_eq!(text_blocks[0].header, "fn helper()"); assert_eq!(text_blocks[0].start_line, None); } #[test] fn dedupe_and_recent_sort_keep_unique_newest_blocks_first() { let temp = std::env::temp_dir().join(format!( "ctxpack-tests-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock") .as_nanos() )); fs::create_dir_all(&temp).expect("temp dir"); let older = temp.join("older.rs"); let newer = temp.join("newer.rs"); fs::write(&older, "fn older() {}\n").expect("older"); thread::sleep(Duration::from_millis(20)); fs::write(&newer, "fn newer() {}\n").expect("newer"); let mut blocks = dedupe_blocks(vec![ ContextBlock { path: older.display().to_string(), kind: "file".to_string(), header: "older".to_string(), start_line: Some(1), end_line: Some(1), truncated: false, text: "fn older() {}".to_string(), }, ContextBlock { path: older.display().to_string(), kind: "file".to_string(), header: "older".to_string(), start_line: Some(1), end_line: Some(1), truncated: false, text: "fn older() {}".to_string(), }, ContextBlock { path: newer.display().to_string(), kind: "file".to_string(), header: "newer".to_string(), start_line: Some(1), end_line: Some(1), truncated: false, text: "fn newer() {}".to_string(), }, ]); assert_eq!(blocks.len(), 2); sort_blocks(&mut blocks, SortMode::Recent); assert_eq!(blocks[0].path, newer.display().to_string()); assert_eq!(blocks[1].path, older.display().to_string()); fs::remove_dir_all(temp).expect("cleanup"); } #[test] fn dedupe_definition_blocks_only_collapses_definition_duplicates() { let blocks = dedupe_definition_blocks(vec![ ContextBlock { path: "src/lib.rs".to_string(), kind: "definition".to_string(), header: "fn helper()".to_string(), start_line: Some(10), end_line: Some(12), truncated: false, text: "fn helper() {\n run();\n}".to_string(), }, ContextBlock { path: "src/lib.rs".to_string(), kind: "definition".to_string(), header: "fn helper()".to_string(), start_line: Some(10), end_line: Some(12), truncated: false, text: "fn helper() {\n run();\n}".to_string(), }, ContextBlock { path: "src/lib.rs".to_string(), kind: "snippet".to_string(), header: "line 11".to_string(), start_line: Some(11), end_line: Some(11), truncated: false, text: "run();".to_string(), }, ContextBlock { path: "src/lib.rs".to_string(), kind: "snippet".to_string(), header: "line 11".to_string(), start_line: Some(11), end_line: Some(11), truncated: false, text: "run();".to_string(), }, ]); assert_eq!(blocks.len(), 3); assert_eq!(blocks[0].kind, "definition"); assert_eq!(blocks[1].kind, "snippet"); assert_eq!(blocks[2].kind, "snippet"); } #[test] fn parse_input_items_and_json_helpers_reject_invalid_shapes() { assert!(matches!( expand_json_value(serde_json::json!(42)), Err(CliError::Runtime(message)) if message.contains("JSON input must be an object or array of objects") )); assert!(matches!( expand_json_value(serde_json::json!({ "hits": { "path": "demo.rs", "line": 7 } })), Err(CliError::Runtime(message)) if message.contains("context JSON field 'hits' must be an array") )); assert!(matches!( parse_input_items("42\n", InputFormat::Auto), Err(CliError::Runtime(message)) if message.contains("JSON input must be an object or array of objects") )); } #[test] fn diagnostic_header_helpers_compact_message_text() { let label = diagnostic_label( serde_json::json!({ "severity": "warning", "code": "W1", "message": "first line\nsecond line with extra spacing" }) .as_object() .expect("object"), ) .expect("label"); assert_eq!( label, "diag warning[W1] first line second line with extra spacing" ); assert!(compact_header_text(&"x".repeat(100)).ends_with("...")); } #[test] fn materialize_text_and_json_file_inputs_cover_file_and_snippet_paths() { let temp = std::env::temp_dir().join(format!( "ctxpack-inputs-{}", std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .expect("clock") .as_nanos() )); fs::create_dir_all(&temp).expect("temp dir"); let notes = temp.join("notes.txt"); fs::write(¬es, "alpha\nbeta\ngamma\ndelta\n").expect("notes"); let mut indexer = CodeIndexer::new(); let file_blocks = materialize_text_input(notes.to_str().expect("path"), 3, &mut indexer).expect("file"); assert_eq!(file_blocks[0].kind, "file"); assert_eq!(file_blocks[0].header, "notes.txt"); assert_eq!(file_blocks[0].start_line, Some(1)); assert_eq!(file_blocks[0].end_line, Some(3)); assert!(file_blocks[0].truncated); let hit_blocks = materialize_text_input(&format!("{}:3", notes.display()), 3, &mut indexer) .expect("hit"); assert_eq!(hit_blocks[0].kind, "snippet"); assert_eq!(hit_blocks[0].header, "line 3"); assert_eq!(hit_blocks[0].start_line, Some(2)); assert_eq!(hit_blocks[0].end_line, Some(4)); assert!(hit_blocks[0].text.contains("gamma")); let json_blocks = materialize_json_value( &serde_json::json!({ "path": notes.display().to_string() }), 2, &mut indexer, ) .expect("json file"); assert_eq!(json_blocks[0].kind, "file"); assert_eq!(json_blocks[0].header, "notes.txt"); assert_eq!(json_blocks[0].start_line, Some(1)); assert_eq!(json_blocks[0].end_line, Some(2)); assert!(json_blocks[0].truncated); fs::remove_dir_all(temp).expect("cleanup"); } #[test] fn build_snippet_block_clamps_past_eof_requests() { let block = build_snippet_block("notes.txt", "alpha\nbeta\n", 10, 3); assert_eq!(block.header, "line 10 (past EOF, clamped to 2)"); assert_eq!(block.start_line, Some(2)); assert_eq!(block.end_line, Some(2)); assert_eq!(block.text, "beta"); } #[test] fn materialize_json_value_and_render_blocks_cover_error_and_formatting_paths() { let mut indexer = CodeIndexer::new(); assert!(matches!( materialize_json_value( &serde_json::json!({ "path": "demo.rs", "lines": { "text": "fn helper() {}" } }), 5, &mut indexer, ), Err(CliError::Runtime(message)) if message.contains("source snippet is missing a lines array") )); assert!(matches!( materialize_json_value( &serde_json::json!({ "text": "fn helper() {}" }), 5, &mut indexer, ), Err(CliError::Runtime(message)) if message.contains("unsupported JSON shape for context input") )); let rendered = render_blocks(&[ ContextBlock { path: "demo.rs".to_string(), kind: "definition".to_string(), header: "helper".to_string(), start_line: Some(4), end_line: Some(5), truncated: false, text: "fn helper() {\n}".to_string(), }, ContextBlock { path: "".to_string(), kind: "snippet".to_string(), header: "snippet".to_string(), start_line: None, end_line: None, truncated: true, text: "line one\nline two".to_string(), }, ]); assert_eq!( rendered, "demo.rs:4-5 kind=definition header=helper\n4: fn helper() {\n5: }\n\n kind=snippet header=snippet\nline one\nline two\n[truncated]\n" ); } #[test] fn sort_blocks_supports_input_and_path_modes() { let first = ContextBlock { path: "b.rs".to_string(), kind: "definition".to_string(), header: "beta".to_string(), start_line: Some(4), end_line: Some(4), truncated: false, text: "beta".to_string(), }; let second = ContextBlock { path: "a.rs".to_string(), kind: "definition".to_string(), header: "alpha".to_string(), start_line: Some(2), end_line: Some(2), truncated: false, text: "alpha".to_string(), }; let mut input_sorted = vec![first.clone(), second.clone()]; sort_blocks(&mut input_sorted, SortMode::Input); assert_eq!( input_sorted .iter() .map(|block| block.path.as_str()) .collect::>(), vec!["b.rs", "a.rs"] ); let mut path_sorted = vec![first, second]; sort_blocks(&mut path_sorted, SortMode::Path); assert_eq!( path_sorted .iter() .map(|block| block.path.as_str()) .collect::>(), vec!["a.rs", "b.rs"] ); } #[test] fn parse_text_hit_and_cli_validation_reject_invalid_zero_values() { assert!(matches!( parse_text_hit("demo.rs:0"), Err(CliError::Usage(message)) if message.contains("invalid hit format") )); assert!(matches!( parse_text_hit("demo.rs:4:0"), Err(CliError::Usage(message)) if message.contains("invalid hit format") )); assert!(matches!( parse_cli_from(["ctxpack", "--max-blocks", "0", "demo.rs"]), Err(CliError::Usage(message)) if message.contains("--max-blocks must be greater than 0") )); assert!(matches!( parse_cli_from(["ctxpack", "--max-lines", "0", "demo.rs"]), Err(CliError::Usage(message)) if message.contains("--max-lines must be greater than 0") )); } }