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

1686 lines
54 KiB
Rust

//! 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 = "<stdin>";
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 <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-blocks <COUNT> Maximum number of blocks to emit
--max-lines <COUNT> Maximum number of lines per block
--dedupe Collapse duplicate blocks
--sort <MODE> 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<String>,
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<usize>,
end_line: Option<usize>,
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<usize>,
}
/// 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<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(),
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<ExitCode, CliError> {
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<Vec<InputItem>, 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::<Vec<_>>())
}
fn parse_input_items(buffer: &str, input_format: InputFormat) -> Result<Vec<InputItem>, 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::<Value>(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::<Value>(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::<Value>(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<Vec<InputItem>, 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<String, Value>,
) -> Result<Option<Vec<InputItem>>, 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<Vec<ContextBlock>, 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<Vec<ContextBlock>, 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<Vec<ContextBlock>, 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<String, Value>,
value: &Value,
max_lines: usize,
indexer: &mut CodeIndexer,
) -> Result<Vec<ContextBlock>, 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<String, Value>,
max_lines: usize,
) -> Result<ContextBlock, CliError> {
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::<Vec<_>>();
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<Option<usize>, 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<ContextBlock, CliError> {
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<ContextBlock, CliError> {
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<ContextBlock, CliError> {
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<String, Value>,
max_lines: usize,
) -> Result<ContextBlock, CliError> {
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<String, Value>,
max_lines: usize,
) -> Result<ContextBlock, CliError> {
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::<Vec<_>>();
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::<Vec<_>>();
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<usize>,
end_line: Option<usize>,
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<ContextBlock>) -> Vec<ContextBlock> {
let mut seen = BTreeSet::<(String, String, Option<usize>, Option<usize>, 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<ContextBlock>) -> Vec<ContextBlock> {
let mut seen = BTreeSet::<(String, Option<usize>, Option<usize>, 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<SystemTime>, &str, Option<usize>) {
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<ContextBlock>, prefix: Option<String>) -> Vec<ContextBlock> {
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<String, Value>) -> Option<String> {
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<LineHit, CliError> {
let segments = raw.split(':').collect::<Vec<_>>();
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::<usize>().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::<usize>().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::<usize>()
.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<String, CliError> {
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<String, CliError> {
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> {
value
.parse::<usize>()
.map_err(|error| CliError::usage(format!("invalid {flag} value '{value}': {error}")))
}
fn parse_positive_usize_flag(flag: &str, value: &str) -> Result<usize, CliError> {
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<SortMode, CliError> {
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::<Vec<_>>()
.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::<String>();
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(&notes, "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: "<stdin>".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<stdin> 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<_>>(),
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<_>>(),
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")
));
}
}