M0: compilable skeleton — Kigi 0.1.0 fork surgery

Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
2026-07-17 05:31:01 -04:00
commit d6c20fc13f
2612 changed files with 1353757 additions and 0 deletions
@@ -0,0 +1,845 @@
//! Extract base64-encoded images from tool result text so they can be
//! sent as multimodal vision tokens instead of raw text.
use std::borrow::Cow;
use std::fmt::Write as _;
use std::sync::LazyLock;
use regex::Regex;
/// One base64 image lifted out of tool result or file content. The session
/// layer converts these into multimodal `ContentPart::Image` follow-ups.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
pub struct ExtractedImage {
pub data: String,
pub mime_type: String,
}
pub struct ExtractionResult {
pub text: String,
pub images: Vec<ExtractedImage>,
}
/// Skip tiny decorative icons (favicons, spacer GIFs).
const MIN_PAYLOAD_LEN: usize = 1024;
/// Prevent OOM from pathological MCP tool output.
const MAX_PAYLOAD_LEN: usize = 10 * 1024 * 1024;
/// Cap per tool result to avoid flooding the context with vision tokens.
const MAX_IMAGES: usize = 5;
/// Prefix regex for `data:<mime>;base64,`. The payload is scanned manually
/// from prefix end so line-wrapped producers (Python `base64.encodebytes`,
/// OpenSSL, Perl `MIME::Base64`) round-trip byte-equal. The leading
/// `(?:[^a-zA-Z0-9]|^)` rejects word-internal matches like
/// `metadata:image/...`. Only raster MIME types `image_normalize` can
/// decode are matched. Groups: (1) full prefix, (2) MIME type.
static IMAGE_PREFIX_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"(?i)(?:[^a-zA-Z0-9]|^)",
r"(data:(image/(?:png|jpeg|gif|webp|bmp|tiff))",
r"(?:;[^\s,;]{1,120})*",
r";base64,)",
))
.unwrap()
});
/// Sister of [`IMAGE_PREFIX_RE`] for `data:application/pdf;base64,`.
static PDF_PREFIX_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"(?i)(?:[^a-zA-Z0-9]|^)",
r"(data:application/pdf",
r"(?:;[^\s,;]{1,120})*",
r";base64,)",
))
.unwrap()
});
fn next_prefix_after(prefix_positions: &[usize], pos: usize) -> Option<usize> {
prefix_positions.iter().copied().find(|&p| p > pos)
}
#[inline]
fn is_base64_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=')
}
/// Scan a base64 payload starting at `start`, returning the exclusive end.
///
/// Admits a greedy core run plus any number of `\r?\n[ \t]*<base64>+`
/// continuation chunks, so line-wrapped output round-trips byte-equal. A
/// chunk ending in `=` (real base64 padding) ends the scan. The scan is
/// also bounded by `end_cap` (the next URI prefix) so adjacent data URIs
/// do not bleed into each other.
///
/// Trade-off: pure base64-alphabet prose on the line after a payload IS
/// absorbed; the downstream integrity check in
/// `image_normalize::normalize_one` rejects the resulting corrupt image.
fn scan_payload_end(text: &str, start: usize, end_cap: usize) -> usize {
let bytes = text.as_bytes();
let cap = end_cap.min(bytes.len());
let mut i = start;
while i < cap && is_base64_byte(bytes[i]) {
i += 1;
}
if i > start && bytes[i - 1] == b'=' {
return i;
}
loop {
let mut p = i;
if p < cap && bytes[p] == b'\r' {
p += 1;
}
if !(p < cap && bytes[p] == b'\n') {
break;
}
p += 1;
while p < cap && matches!(bytes[p], b' ' | b'\t') {
p += 1;
}
let chunk_start = p;
while p < cap && is_base64_byte(bytes[p]) {
p += 1;
}
let chunk_len = p - chunk_start;
if chunk_len == 0 {
break;
}
i = p;
if bytes[p - 1] == b'=' {
break;
}
}
i
}
/// Strip ASCII whitespace from a base64 payload; zero-alloc when clean.
fn strip_b64_whitespace(s: &str) -> Cow<'_, str> {
if !s.bytes().any(|b| b.is_ascii_whitespace()) {
return Cow::Borrowed(s);
}
let bytes: Vec<u8> = s.bytes().filter(|b| !b.is_ascii_whitespace()).collect();
Cow::Owned(String::from_utf8(bytes).expect("ascii by char-class invariant"))
}
/// Pre-cap before stripping so a malicious oversize payload doesn't force
/// a large allocation just to be rejected. 2× headroom for line-wrap.
const GROSS_PAYLOAD_PRE_CAP: usize = MAX_PAYLOAD_LEN * 2;
fn collect_prefix_positions(text: &str) -> Vec<usize> {
let mut positions: Vec<usize> = IMAGE_PREFIX_RE
.captures_iter(text)
.filter_map(|c| c.get(1).map(|m| m.start()))
.chain(
PDF_PREFIX_RE
.captures_iter(text)
.filter_map(|c| c.get(1).map(|m| m.start())),
)
.collect();
positions.sort_unstable();
positions.dedup();
positions
}
/// Strip `data:application/pdf;base64,...` URIs from MCP tool results.
/// Each match is replaced with a placeholder showing the approximate
/// decoded size — the model cannot interpret raw PDF bytes.
fn strip_pdf_data_uris(text: &str) -> Option<String> {
let needle = b"data:application/pdf";
let has_pdf = text
.as_bytes()
.windows(needle.len())
.any(|w| w.eq_ignore_ascii_case(needle));
if !has_pdf {
return None;
}
let prefix_positions = collect_prefix_positions(text);
let mut result = String::with_capacity(text.len());
let mut last_end = 0;
let mut matched = false;
for caps in PDF_PREFIX_RE.captures_iter(text) {
let Some(prefix) = caps.get(1) else { continue };
let next_start = next_prefix_after(&prefix_positions, prefix.start()).unwrap_or(text.len());
let payload_end = scan_payload_end(text, prefix.end(), next_start);
let payload_span = &text[prefix.end()..payload_end];
let size_kb = if payload_span.len() > GROSS_PAYLOAD_PRE_CAP {
payload_span.len() * 3 / 4 / 1024
} else {
strip_b64_whitespace(payload_span).len() * 3 / 4 / 1024
};
matched = true;
result.push_str(&text[last_end..prefix.start()]);
let _ = write!(result, "[PDF attachment removed \u{2014} {size_kb} KB]");
last_end = payload_end;
}
if !matched {
return None;
}
result.push_str(&text[last_end..]);
Some(result)
}
/// Scan `s` for data-URI images, replacing each with a placeholder and
/// capturing the payload bytes for downstream multimodal injection.
///
/// Returns `None` when nothing was modified.
fn scan_and_extract(s: &str) -> Option<(String, Vec<ExtractedImage>)> {
if !s.contains("data:image") {
return None;
}
let prefix_positions = collect_prefix_positions(s);
let mut result = String::with_capacity(s.len());
let mut images = Vec::new();
let mut last_end = 0;
for caps in IMAGE_PREFIX_RE.captures_iter(s) {
let (Some(prefix), Some(mime_match)) = (caps.get(1), caps.get(2)) else {
continue;
};
let next_start = next_prefix_after(&prefix_positions, prefix.start()).unwrap_or(s.len());
let payload_end = scan_payload_end(s, prefix.end(), next_start);
let payload_span = &s[prefix.end()..payload_end];
if payload_span.len() > GROSS_PAYLOAD_PRE_CAP {
result.push_str(&s[last_end..prefix.start()]);
result.push_str("[large image removed]");
last_end = payload_end;
continue;
}
let cleaned = strip_b64_whitespace(payload_span);
let payload_len = cleaned.len() - (cleaned.len() % 4);
if payload_len < MIN_PAYLOAD_LEN {
continue;
}
let mime = mime_match.as_str().to_owned();
result.push_str(&s[last_end..prefix.start()]);
if payload_len > MAX_PAYLOAD_LEN {
result.push_str("[large image removed]");
} else if images.len() >= MAX_IMAGES {
result.push_str("[additional image omitted]");
} else {
let data = match cleaned {
Cow::Borrowed(b) => b[..payload_len].to_owned(),
Cow::Owned(mut o) => {
o.truncate(payload_len);
o
}
};
images.push(ExtractedImage {
data,
mime_type: mime,
});
result.push_str("[image content will be provided separately]");
}
last_end = payload_end;
}
if last_end == 0 {
return None;
}
result.push_str(&s[last_end..]);
Some((result, images))
}
/// Extract image data URIs from `text`, replacing each with a placeholder.
/// Small payloads and non-image data URIs survive; PDF data URIs are
/// stripped first. Owned-input convenience over [`try_extract_base64_images`]
/// — when nothing matched, the original `text` is returned unmodified.
pub fn extract_base64_images(text: String) -> ExtractionResult {
try_extract_base64_images(&text).unwrap_or_else(|| ExtractionResult {
text,
images: Vec::new(),
})
}
/// Borrowed-input variant: returns `Some` only when at least one URI was
/// matched (image captured, or PDF / oversize stripped). Returns `None`
/// on the no-op fast path so callers (e.g. the per-line scan inside
/// `extract_file_content_lines`) can avoid an allocation.
pub fn try_extract_base64_images(text: &str) -> Option<ExtractionResult> {
let after_pdf = strip_pdf_data_uris(text);
let input = after_pdf.as_deref().unwrap_or(text);
match scan_and_extract(input) {
Some((cleaned, images)) => Some(ExtractionResult {
text: cleaned,
images,
}),
// Propagate PDF-only modifications when no image URIs matched.
None => after_pdf.map(|t| ExtractionResult {
text: t,
images: Vec::new(),
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn payload(n: usize) -> String {
"A".repeat(n)
}
#[test]
fn no_images_returns_text_unchanged() {
let input = "Plain text with no images.".to_owned();
let result = extract_base64_images(input.clone());
assert_eq!(result.text, input);
assert!(result.images.is_empty());
}
#[test]
fn no_data_uri_prefix_returns_unchanged() {
let input = "iVBORw0KGgoAAAANSUhEUg== but not a data URI".to_owned();
let result = extract_base64_images(input.clone());
assert_eq!(result.text, input);
assert!(result.images.is_empty());
}
#[test]
fn single_image_extracted() {
let p = payload(2000);
let input = format!("Before ![logo](data:image/png;base64,{p}) after");
let result = extract_base64_images(input);
assert_eq!(
result.text,
"Before ![logo]([image content will be provided separately]) after"
);
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].mime_type, "image/png");
assert_eq!(result.images[0].data, p);
}
#[test]
fn multiple_images_extracted() {
let p1 = payload(2000);
let p2 = payload(3000);
let input =
format!("First data:image/png;base64,{p1} middle data:image/jpeg;base64,{p2} end");
let result = extract_base64_images(input);
assert_eq!(result.images.len(), 2);
assert_eq!(result.images[0].mime_type, "image/png");
assert_eq!(result.images[0].data, p1);
assert_eq!(result.images[1].mime_type, "image/jpeg");
assert_eq!(result.images[1].data, p2);
assert!(
result
.text
.contains("[image content will be provided separately]")
);
assert!(!result.text.contains("base64,"));
}
#[test]
fn non_image_mime_not_extracted() {
let input = format!("data:text/plain;base64,{} end", payload(2000));
let result = extract_base64_images(input.clone());
assert_eq!(result.text, input);
assert!(result.images.is_empty());
}
#[test]
fn application_pdf_stripped_not_extracted() {
let p = payload(2000);
let input = format!("data:application/pdf;base64,{p} end");
let result = extract_base64_images(input);
assert!(result.images.is_empty());
let expected_kb = 2000 * 3 / 4 / 1024;
assert!(result.text.contains(&format!(
"[PDF attachment removed \u{2014} {expected_kb} KB]"
)));
assert!(!result.text.contains("base64,"));
}
#[test]
fn below_threshold_not_extracted() {
let input = format!("data:image/gif;base64,{} end", payload(100));
let result = extract_base64_images(input.clone());
assert_eq!(result.text, input);
assert!(result.images.is_empty());
}
#[test]
fn max_images_cap() {
let p = payload(2000);
let mut input = String::new();
for i in 0..10 {
input.push_str(&format!("img{i} data:image/png;base64,{p} "));
}
let result = extract_base64_images(input);
assert_eq!(result.images.len(), MAX_IMAGES);
let omitted_count = result.text.matches("[additional image omitted]").count();
assert_eq!(omitted_count, 5);
assert_eq!(result.text.matches("[large image removed]").count(), 0);
}
#[test]
fn oversized_payload_stripped_but_not_extracted() {
let huge = payload(MAX_PAYLOAD_LEN + 4);
let input = format!("before data:image/png;base64,{huge} after");
let result = extract_base64_images(input);
assert!(result.images.is_empty());
assert!(result.text.contains("[large image removed]"));
assert!(result.text.contains("before"));
assert!(result.text.contains("after"));
assert!(!result.text.contains(&huge));
}
#[test]
fn word_internal_data_prefix_ignored() {
let input = format!("metadata:image/png;base64,{} end", payload(2000));
let result = extract_base64_images(input.clone());
assert_eq!(result.text, input);
assert!(result.images.is_empty());
}
#[test]
fn mixed_image_and_non_image() {
let img = payload(2000);
let txt = payload(2000);
let input = format!("data:image/png;base64,{img} middle data:text/html;base64,{txt} end");
let result = extract_base64_images(input);
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].mime_type, "image/png");
assert!(result.text.contains("data:text/html;base64,"));
}
#[test]
fn whitespace_in_header_rejected() {
let input = format!("data:image /png;base64,{} end", payload(2000));
let result = extract_base64_images(input.clone());
assert_eq!(result.text, input);
assert!(result.images.is_empty());
}
#[test]
fn case_insensitive_base64_marker() {
let result = extract_base64_images(format!("data:image/png;Base64,{} end", payload(2000)));
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].mime_type, "image/png");
}
#[test]
fn multi_param_header() {
let result = extract_base64_images(format!(
"data:image/jpeg;charset=utf-8;base64,{} end",
payload(2000)
));
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].mime_type, "image/jpeg");
}
#[test]
fn no_comma_after_data_prefix() {
let input = "data:image/png;base64 with no comma".to_owned();
let result = extract_base64_images(input.clone());
assert_eq!(result.text, input);
assert!(result.images.is_empty());
}
#[test]
fn preserves_surrounding_text() {
let p = payload(2000);
let input = format!("Title: Ticket\ndata:image/png;base64,{p}<Comments: ok");
let result = extract_base64_images(input);
assert!(result.text.contains("Title: Ticket"));
assert!(result.text.contains("Comments: ok"));
assert!(
result
.text
.contains("[image content will be provided separately]")
);
assert!(!result.text.contains(&p));
}
#[test]
fn svg_xml_not_extracted() {
let input = format!("data:image/svg+xml;base64,{} end", payload(2000));
let result = extract_base64_images(input.clone());
assert_eq!(result.text, input);
assert!(result.images.is_empty());
}
#[test]
fn unsupported_image_mime_not_extracted() {
let input = format!("data:image/x-icon;base64,{} end", payload(2000));
let result = extract_base64_images(input.clone());
assert_eq!(result.text, input);
assert!(result.images.is_empty());
}
#[test]
fn payload_boundary_aligned_to_base64() {
let p = payload(2000);
let input = format!("data:image/png;base64,{p}X end");
let result = extract_base64_images(input);
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].data, p);
}
#[test]
fn webp_and_bmp_extracted() {
let p = payload(2000);
for mime in ["image/webp", "image/bmp", "image/tiff"] {
let input = format!("data:{mime};base64,{p} end");
let result = extract_base64_images(input);
assert_eq!(result.images.len(), 1, "expected extraction for {mime}");
assert_eq!(result.images[0].mime_type, mime);
}
}
#[test]
fn strip_pdf_single_uri() {
let pdf_b64 = payload(4096);
let input = format!("Before data:application/pdf;base64,{pdf_b64} after");
let result = strip_pdf_data_uris(&input).unwrap();
let expected_kb = 4096 * 3 / 4 / 1024;
assert!(result.contains(&format!(
"[PDF attachment removed \u{2014} {expected_kb} KB]"
)));
assert!(result.contains("Before"));
assert!(result.contains("after"));
assert!(!result.contains("base64,"));
}
#[test]
fn strip_pdf_multiple_uris() {
let p1 = payload(2048);
let p2 = payload(8192);
let input = format!(
"first data:application/pdf;base64,{p1} middle data:application/pdf;base64,{p2} end"
);
let result = strip_pdf_data_uris(&input).unwrap();
assert_eq!(result.matches("[PDF attachment removed").count(), 2);
assert!(result.contains("first"));
assert!(result.contains("middle"));
assert!(result.contains("end"));
}
#[test]
fn strip_pdf_no_pdf_returns_none() {
let input = "Plain text with data:image/png;base64,AAAA stuff";
assert!(strip_pdf_data_uris(input).is_none());
}
#[test]
fn strip_pdf_mixed_with_images() {
let pdf_b64 = payload(4096);
let img_b64 = payload(2000);
let input = format!(
"data:application/pdf;base64,{pdf_b64} then data:image/png;base64,{img_b64} end"
);
let result = extract_base64_images(input);
assert!(result.text.contains("[PDF attachment removed"));
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].mime_type, "image/png");
}
#[test]
fn strip_pdf_case_insensitive() {
let pdf_b64 = payload(4096);
let input = format!("data:Application/PDF;Base64,{pdf_b64} end");
let result = strip_pdf_data_uris(&input).unwrap();
assert!(result.contains("[PDF attachment removed"));
assert!(!result.contains("Base64,"));
}
#[test]
fn strip_pdf_with_extra_params() {
let pdf_b64 = payload(4096);
let input = format!("data:application/pdf;charset=utf-8;base64,{pdf_b64} end");
let result = strip_pdf_data_uris(&input).unwrap();
assert!(result.contains("[PDF attachment removed"));
}
#[test]
fn strip_pdf_size_calculation() {
let pdf_b64 = payload(12288); // 12288 * 3/4 / 1024 = 9 KB
let input = format!("data:application/pdf;base64,{pdf_b64} end");
let result = strip_pdf_data_uris(&input).unwrap();
assert!(result.contains("[PDF attachment removed \u{2014} 9 KB]"));
}
#[test]
fn word_internal_pdf_prefix_ignored() {
let input = format!("metadata:application/pdf;base64,{} end", payload(2000));
assert!(strip_pdf_data_uris(&input).is_none());
}
#[test]
fn lf_wrapped_payload_extracted_in_full() {
// 76-column LF wrap (Python `base64.encodebytes` style).
let line = "A".repeat(76);
let wrapped = std::iter::repeat_n(line.as_str(), 30)
.collect::<Vec<_>>()
.join("\n");
let input = format!("data:image/png;base64,{wrapped}<end");
let result = extract_base64_images(input);
assert_eq!(result.images.len(), 1, "expected one image after LF wrap");
let expected_len = 76 * 30;
assert_eq!(result.images[0].data.len(), expected_len);
assert!(result.images[0].data.chars().all(|c| c == 'A'));
assert!(result.text.contains("end"), "trailing prose preserved");
}
#[test]
fn crlf_wrapped_payload_stripped() {
let line = "A".repeat(76);
let wrapped = std::iter::repeat_n(line.as_str(), 20)
.collect::<Vec<_>>()
.join("\r\n");
let input = format!("data:image/png;base64,{wrapped}<end");
let result = extract_base64_images(input);
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].data.len(), 76 * 20);
assert!(
!result.images[0].data.contains(['\r', '\n']),
"CR/LF should be stripped from payload"
);
}
#[test]
fn leading_line_indentation_stripped() {
let line = "A".repeat(72);
let body = format!(
"{line}\n\t{line}\n {line}\n {line}\n {line}\n\t{line}\n{line}\n{line}\n{line}\n{line}\n{line}\n{line}\n{line}\n{line}\n{line}\n{line}"
);
let input = format!("data:image/png;base64,{body}<end");
let result = extract_base64_images(input);
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].data.len(), 72 * 16);
assert!(
!result.images[0]
.data
.chars()
.any(|c| c.is_ascii_whitespace()),
"whitespace should not appear in extracted data"
);
}
/// Python `base64.encodebytes` short final padded line must round-trip.
#[test]
fn lf_wrapped_short_tail_with_padding_round_trips() {
let full_line = "A".repeat(76);
let body = std::iter::repeat_n(full_line.as_str(), 20)
.collect::<Vec<_>>()
.join("\n");
let short_tail = "AAA=";
let wrapped = format!("{body}\n{short_tail}");
let input = format!("data:image/png;base64,{wrapped}<rest text after");
let result = extract_base64_images(input);
assert_eq!(result.images.len(), 1, "padded short tail must be kept");
let expected_len = 76 * 20 + short_tail.len();
assert_eq!(result.images[0].data.len(), expected_len);
assert!(result.images[0].data.ends_with("AAA="));
assert!(result.text.contains("rest text after"));
}
/// Long 76-col-wrapped payload with 72-char unpadded trailing line
/// must round-trip byte-equal.
#[test]
fn long_encoded_payload_wraps_round_trip() {
let p = payload(133_300);
let mut wrapped = String::with_capacity(p.len() + 1800);
for (i, chunk) in p.as_bytes().chunks(76).enumerate() {
if i > 0 {
wrapped.push('\n');
}
wrapped.push_str(std::str::from_utf8(chunk).unwrap());
}
let input = format!("data:image/png;base64,{wrapped}<rest");
let result = extract_base64_images(input);
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].data, p);
}
/// 3-aligned input lengths produce SHORT unpadded trailing lines from
/// `base64.encodebytes` 76-col wrap; these tails must round-trip
/// instead of being silently truncated.
#[test]
fn unpadded_3aligned_short_tail_round_trips() {
let full_line = "A".repeat(76);
let body = std::iter::repeat_n(full_line.as_str(), 18)
.collect::<Vec<_>>()
.join("\n");
let tail = "BCDEFGHIJKLM";
let wrapped = format!("{body}\n{tail}");
let input = format!("data:image/png;base64,{wrapped}<eof");
let result = extract_base64_images(input);
assert_eq!(result.images.len(), 1, "unpadded short tail must be kept");
assert_eq!(result.images[0].data.len(), 76 * 18 + tail.len());
assert!(result.images[0].data.ends_with("BCDEFGHIJKLM"));
assert!(result.text.contains("eof"));
}
/// `GROSS_PAYLOAD_PRE_CAP` short-circuits before `strip_b64_whitespace`
/// allocates.
#[test]
fn gross_payload_pre_cap_short_circuits() {
let huge = "A".repeat(GROSS_PAYLOAD_PRE_CAP + 1024);
let input = format!("before data:image/png;base64,{huge} after");
let result = extract_base64_images(input);
assert!(result.images.is_empty(), "pre-cap must not emit image");
assert!(result.text.contains("[large image removed]"));
assert!(result.text.contains("before"));
assert!(result.text.contains("after"));
assert!(!result.text.contains(&huge));
}
/// Trade-off pin: pure-alphanumeric prose immediately after a `\n`
/// (no other terminator) IS absorbed; downstream integrity check
/// then rejects the resulting corrupt image.
#[test]
fn prose_after_newline_is_absorbed_then_trimmed() {
let p = payload(2000);
let input = format!("data:image/png;base64,{p}\nComments: ok");
let result = extract_base64_images(input);
assert_eq!(result.images.len(), 1);
// 2000 + 8 ("Comments") = 2008 (mod 4 == 0). ": ok" stays in text.
assert_eq!(result.images[0].data.len(), 2008);
assert!(result.text.contains(": ok"));
}
#[test]
fn payload_aligned_after_strip_admitted_whole() {
// 257 * 4 = 1028 — exact mod-4 boundary kept whole (no spurious trim).
let chunk = "A".repeat(257);
let wrapped = format!("{chunk}\n{chunk}\n{chunk}\n{chunk}");
let input = format!("data:image/png;base64,{wrapped} end");
let result = extract_base64_images(input);
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].data.len(), 1028);
}
#[test]
fn strip_b64_whitespace_zero_alloc_when_clean() {
let clean = "AAAABBBB";
match strip_b64_whitespace(clean) {
Cow::Borrowed(b) => assert_eq!(b, clean),
Cow::Owned(_) => panic!("expected borrowed for whitespace-free input"),
}
match strip_b64_whitespace("AA\nBB") {
Cow::Owned(o) => assert_eq!(o, "AABB"),
Cow::Borrowed(_) => panic!("expected owned for input with whitespace"),
}
}
/// Contract with `kigi_mcp::servers::format_mcp_image` dual-emit:
/// the data URI becomes a vision token; the raw `<mcp_image_base64>`
/// block survives verbatim for agent decoding (e.g. `send_file`).
#[test]
fn mcp_dual_emit_extracts_data_uri_keeps_raw_block() {
let p = payload(2000);
let input = format!(
"data:image/png;base64,{p}\n\
<mcp_image_base64 mime=\"image/png\">\n\
{p}\n\
</mcp_image_base64>"
);
let result = extract_base64_images(input);
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].mime_type, "image/png");
assert_eq!(result.images[0].data, p);
assert!(
result
.text
.contains("<mcp_image_base64 mime=\"image/png\">")
);
assert!(result.text.contains(&p));
assert!(result.text.contains("</mcp_image_base64>"));
assert!(
result
.text
.contains("[image content will be provided separately]")
);
}
// ─── try_extract_base64_images tests ──────────────────────────────
#[test]
fn try_extract_no_images_returns_none() {
assert!(try_extract_base64_images("Plain text with no images.").is_none());
}
#[test]
fn try_extract_captures_inline_image() {
let p = payload(2000);
let input = format!("before data:image/png;base64,{p} after");
let result = try_extract_base64_images(&input).expect("URI must be captured");
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].mime_type, "image/png");
assert_eq!(result.images[0].data, p);
assert!(
result
.text
.contains("[image content will be provided separately]")
);
assert!(!result.text.contains(&p));
}
#[test]
fn try_extract_below_threshold_returns_none() {
// MIN_PAYLOAD_LEN gate: tiny icons survive untouched.
let input = format!("data:image/gif;base64,{} end", payload(100));
assert!(try_extract_base64_images(&input).is_none());
}
#[test]
fn try_extract_multiple_uris() {
let p = payload(2000);
let input = format!("first data:image/png;base64,{p} mid data:image/jpeg;base64,{p} end");
let result = try_extract_base64_images(&input).expect("two URIs must be captured");
assert_eq!(result.images.len(), 2);
assert_eq!(result.images[0].mime_type, "image/png");
assert_eq!(result.images[1].mime_type, "image/jpeg");
assert_eq!(
result
.text
.matches("[image content will be provided separately]")
.count(),
2
);
}
/// PDF-only input: image-scan is a no-op, but PDF strip still runs
/// and propagates the modification through.
#[test]
fn try_extract_pdf_only_propagates_strip() {
let pdf_b64 = payload(4096);
let input = format!("Before data:application/pdf;base64,{pdf_b64} after");
let result = try_extract_base64_images(&input).expect("PDF must be stripped");
assert!(result.images.is_empty());
assert!(result.text.contains("[PDF attachment removed"));
assert!(!result.text.contains("base64,"));
}
/// Long single-line URI must be captured byte-equal before
/// `truncate_line` could cut it mid-payload.
#[test]
fn try_extract_runs_before_truncation_would_corrupt_payload() {
let p = payload(50_000);
let input = format!("![logo](data:image/png;base64,{p})");
let result = try_extract_base64_images(&input).expect("long URI must be captured");
assert_eq!(result.images.len(), 1);
assert_eq!(result.images[0].data.len(), 50_000);
assert_eq!(result.images[0].data, p);
assert!(result.text.len() < 200);
assert!(
result
.text
.contains("[image content will be provided separately]")
);
assert!(result.text.starts_with("![logo]("));
assert!(result.text.ends_with(')'));
}
}
@@ -0,0 +1,163 @@
/// Known binary file extensions — skip content reading for these.
/// PDF is intentionally excluded since it has dedicated handling.
///
/// NOTE: opencode has its own local copy of this list.
pub const BINARY_EXTENSIONS: &[&str] = &[
"7z", "a", "avi", "avif", "bin", "bmp", "class", "dat", "dll", "doc", "docx", "dylib", "exe",
"gif", "gz", "ico", "jar", "jpeg", "jpg", "lib", "mov", "mp3", "mp4", "o", "obj", "odp", "ods",
"odt", "png", "ppt", "pyc", "pyd", "pyo", "qoi", "rar", "so", "tar", "tif", "tiff", "war",
"wasm", "webp", "xls", "xlsx", "zip",
];
const SAMPLE_SIZE: usize = 8192;
const NON_PRINTABLE_THRESHOLD: f64 = 0.3;
/// Returns `true` if the file should be treated as binary.
///
/// A file is binary if its extension is in [`BINARY_EXTENSIONS`], or if
/// a significant portion of the first [`SAMPLE_SIZE`] bytes are non-printable.
pub fn is_binary(extension: &str, bytes: &[u8]) -> bool {
if BINARY_EXTENSIONS.binary_search(&extension).is_ok() {
return true;
}
if bytes.is_empty() {
return false;
}
let sample = &bytes[..bytes.len().min(SAMPLE_SIZE)];
// Any null byte → binary.
if sample.contains(&0x00) {
return true;
}
// High ratio of non-printable bytes → binary.
// Bytes 0-8 and 14-31 are control characters (excluding tab, newline, CR, etc.)
let non_printable = sample
.iter()
.filter(|&&b| b < 9 || (14..=31).contains(&b))
.count();
let ratio = non_printable as f64 / sample.len() as f64;
ratio > NON_PRINTABLE_THRESHOLD
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_known_binary_extensions() {
for ext in &[
"zip", "exe", "wasm", "dll", "so", "dylib", "png", "mp4", "jpeg", "jpg", "webp", "tiff",
] {
assert!(is_binary(ext, &[]), "extension '{ext}' should be binary");
}
}
#[test]
fn pdf_not_in_binary_extensions() {
assert!(
!BINARY_EXTENSIONS.contains(&"pdf"),
"pdf must not be in BINARY_EXTENSIONS"
);
assert!(
!is_binary("pdf", &[]),
"pdf extension alone should not be binary"
);
}
#[test]
fn pptx_not_in_binary_extensions() {
assert!(
!BINARY_EXTENSIONS.contains(&"pptx"),
"pptx must not be in BINARY_EXTENSIONS — it has dedicated handling"
);
assert!(
!is_binary("pptx", &[]),
"pptx extension alone should not be binary"
);
}
#[test]
fn allows_text_files() {
assert!(!is_binary("txt", b"Hello, world!\n"));
assert!(!is_binary("rs", b"fn main() {}\n"));
assert!(!is_binary("py", b"print('hello')\n"));
}
#[test]
fn empty_content_is_not_binary() {
assert!(!is_binary("", &[]));
assert!(!is_binary("txt", &[]));
}
#[test]
fn detects_null_bytes() {
assert!(is_binary("", &[0x48, 0x65, 0x00, 0x6C]));
}
#[test]
fn null_byte_at_sample_boundary() {
let mut data = vec![b'A'; SAMPLE_SIZE - 1];
data.push(0x00);
assert!(is_binary("", &data));
}
#[test]
fn null_byte_beyond_sample_not_detected() {
let mut data = vec![b'A'; SAMPLE_SIZE];
data.push(0x00);
assert!(!is_binary("", &data));
}
#[test]
fn threshold_boundary_not_binary() {
// Exactly 30% non-printable → ratio = 0.30, NOT > 0.3 → not binary.
let mut data: Vec<u8> = vec![0x01; 30];
data.extend(vec![b'A'; 70]);
assert_eq!(data.len(), 100);
assert!(
!is_binary("", &data),
"30/100 = 0.30 should NOT be binary (threshold is >0.3)"
);
}
#[test]
fn threshold_boundary_is_binary() {
// 31% non-printable → ratio = 0.31, > 0.3 → binary.
let mut data: Vec<u8> = vec![0x01; 31];
data.extend(vec![b'A'; 69]);
assert_eq!(data.len(), 100);
assert!(
is_binary("", &data),
"31/100 = 0.31 should be binary (threshold is >0.3)"
);
}
#[test]
fn tabs_and_newlines_are_printable() {
// Bytes 9 (tab), 10 (LF), 13 (CR) should NOT count as non-printable.
let data = b"\t\n\r\t\n\rHello World";
assert!(!is_binary("", data));
}
#[test]
fn large_text_file_is_not_binary() {
let data = "fn main() {\n println!(\"hello\");\n}\n"
.repeat(500)
.into_bytes();
assert!(!is_binary("", &data));
}
#[test]
fn extensions_are_sorted() {
let mut sorted = BINARY_EXTENSIONS.to_vec();
sorted.sort();
assert_eq!(
BINARY_EXTENSIONS,
&sorted[..],
"BINARY_EXTENSIONS should be sorted alphabetically"
);
}
}
@@ -0,0 +1,377 @@
//! Display-only helpers for shell command chrome (activity titles, execute headers).
//!
//! Path equality for peel is **lexical** (segment-wise on `/` and `\`), not
//! `canonicalize`d — so `/var` vs `/private/var` or symlink roots miss peel
//! rather than false-peel. Callers should store session cwd in the same string
//! form agents embed in `cd` tokens when possible.
//!
//! [`paths_equal_for_display`] is only meaningful **after** the absolute-shaped
//! gate in [`peel_cd_prefix`]; segment equality alone would treat `proj` and
//! `/proj` as equal, which must never drive a peel on its own.
use std::borrow::Cow;
use std::path::Path;
/// Peel a leading `cd <session_cwd> &&|;` (or Windows `cd /d`) when the target
/// equals session cwd so TUI chrome shows the real command first.
///
/// Only absolute-shaped path tokens are considered (Unix `/…`, Windows `X:\` /
/// `X:/`, or `\\` UNC) so relative `cd proj` cannot false-match `/proj`.
/// Fail-closed on ambiguous quotes, empty remainder, pipes-only, or path mismatch.
/// Does not canonicalize; works with Windows-shaped paths on any host OS.
/// Single outer `(cd … &&|;) …)` is supported; nested parens are not peeled.
pub fn strip_redundant_session_cd<'a>(command: &'a str, session_cwd: &Path) -> Cow<'a, str> {
let trimmed = command.trim_start();
let inner = trim_wrapping_parens(trimmed).unwrap_or(trimmed);
// `remainder` is always a sub-slice of `command`, so borrow in every case.
match peel_cd_prefix(inner, session_cwd) {
Some(remainder) => Cow::Borrowed(remainder),
None => Cow::Borrowed(command),
}
}
/// Path equality for display peel: segment-wise with `/` and `\` as separators,
/// trailing-separator tolerant, case-insensitive for Windows-shaped drive paths.
/// No canonicalize; works for Windows fixtures on Unix hosts.
///
/// **Not general path equality.** Call only after both sides are known
/// absolute-shaped (`is_absolute_shaped_path_token`); otherwise `proj` and
/// `/proj` compare equal by segments alone. Peel enforces that gate in
/// [`peel_cd_prefix`] before invoking this helper.
fn paths_equal_for_display(a: &Path, b: &Path) -> bool {
let a_str = a.to_string_lossy();
let b_str = b.to_string_lossy();
debug_assert!(
is_absolute_shaped_path_token(&a_str) && is_absolute_shaped_path_token(&b_str),
"paths_equal_for_display is only meaningful after absolute-shaped gate \
(got {a_str:?} vs {b_str:?})"
);
let a_win = is_windows_shaped_str(&a_str);
let b_win = is_windows_shaped_str(&b_str);
let case_insensitive = a_win || b_win;
let a_segs = path_segments(&a_str);
let b_segs = path_segments(&b_str);
if a_segs.len() != b_segs.len() {
return false;
}
for (as_, bs) in a_segs.iter().zip(b_segs.iter()) {
if case_insensitive {
if !as_.eq_ignore_ascii_case(bs) {
return false;
}
} else if as_ != bs {
return false;
}
}
true
}
/// Absolute-shaped for peel: leading `/`, `X:` drive, or `\\` UNC.
fn is_absolute_shaped_path_token(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.is_empty() {
return false;
}
if bytes[0] == b'/' {
return true;
}
is_windows_shaped_str(s)
}
fn is_windows_shaped_str(s: &str) -> bool {
let bytes = s.as_bytes();
if bytes.len() >= 2 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' {
return true;
}
bytes.len() >= 2 && bytes[0] == b'\\' && bytes[1] == b'\\'
}
/// Split on `/` or `\`, drop empty segments (trailing sep / root markers).
fn path_segments(s: &str) -> Vec<&str> {
let trimmed = s.trim_end_matches(['/', '\\']);
trimmed
.split(['/', '\\'])
.filter(|p| !p.is_empty())
.collect()
}
/// Trim a single outer `( … )` pair if the closing paren is the last non-ws char.
fn trim_wrapping_parens(s: &str) -> Option<&str> {
let t = s.trim();
if !t.starts_with('(') || !t.ends_with(')') || t.len() < 2 {
return None;
}
let inner = &t[1..t.len() - 1];
if inner.contains('(') || inner.contains(')') {
return None;
}
Some(inner.trim())
}
fn peel_cd_prefix<'a>(command: &'a str, session_cwd: &Path) -> Option<&'a str> {
let s = command.trim_start();
if s.is_empty() || s.contains('\n') || s.contains('\r') {
return None;
}
let mut rest = s;
// Only `cd` (not PowerShell Set-Location/sl/chdir).
let (w, after) = take_shell_word(rest)?;
if !w.eq_ignore_ascii_case("cd") {
return None;
}
rest = after;
// Windows cmd: `cd /d <path>`
if let Some((flag, after_flag)) = take_shell_word(rest)
&& (flag == "/d" || flag == "/D")
{
rest = after_flag;
}
let (path_token, after_path) = take_path_token(rest)?;
if path_token.contains('\n') || path_token.contains('\r') {
return None;
}
let path_unquoted = unquote_path_token(path_token)?;
if path_unquoted == "-" || path_unquoted == ".." {
return None;
}
// Relative tokens must not peel (avoids `cd proj` matching session `/proj`).
if !is_absolute_shaped_path_token(path_unquoted) {
return None;
}
// Fail closed (not panic via the absolute-shaped debug_assert) when the
// session cwd itself isn't absolute-shaped.
if !is_absolute_shaped_path_token(&session_cwd.to_string_lossy()) {
return None;
}
let cmd_path = Path::new(path_unquoted);
if !paths_equal_for_display(cmd_path, session_cwd) {
return None;
}
let after = after_path.trim_start();
let remainder = if let Some(stripped) = after.strip_prefix("&&") {
stripped.trim_start()
} else {
let stripped = after.strip_prefix(';')?;
stripped.trim_start()
};
if remainder.is_empty() {
return None;
}
Some(remainder)
}
/// First shell word: unquoted run of non-whitespace, or fail on leading quote.
fn take_shell_word(s: &str) -> Option<(&str, &str)> {
let s = s.trim_start();
if s.is_empty() {
return None;
}
let first = s.chars().next()?;
if first == '\'' || first == '"' {
return None;
}
let end = s
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(i, _)| i)
.unwrap_or(s.len());
if end == 0 {
return None;
}
Some((&s[..end], &s[end..]))
}
/// Path token: quoted string or unquoted until whitespace / separator start.
fn take_path_token(s: &str) -> Option<(&str, &str)> {
let s = s.trim_start();
if s.is_empty() {
return None;
}
let bytes = s.as_bytes();
match bytes[0] {
b'\'' | b'"' => {
let quote = bytes[0];
let mut i = 1;
while i < bytes.len() {
if bytes[i] == quote {
return Some((&s[..=i], &s[i + 1..]));
}
if bytes[i] == b'\\' && quote == b'"' && i + 1 < bytes.len() {
i += 2;
continue;
}
i += 1;
}
None
}
_ => {
let mut end = 0;
let chars: Vec<(usize, char)> = s.char_indices().collect();
for (idx, (byte_i, ch)) in chars.iter().enumerate() {
if ch.is_whitespace() || *ch == ';' || *ch == '|' {
end = *byte_i;
break;
}
if *ch == '&' && chars.get(idx + 1).is_some_and(|(_, n)| *n == '&') {
end = *byte_i;
break;
}
end = byte_i + ch.len_utf8();
}
if end == 0 {
return None;
}
Some((&s[..end], &s[end..]))
}
}
}
fn unquote_path_token(token: &str) -> Option<&str> {
let t = token.trim();
if t.len() >= 2 {
let b = t.as_bytes();
if (b[0] == b'\'' && b[t.len() - 1] == b'\'') || (b[0] == b'"' && b[t.len() - 1] == b'"') {
return Some(&t[1..t.len() - 1]);
}
if b[0] == b'\'' || b[0] == b'"' {
return None;
}
}
Some(t)
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn cwd(s: &str) -> PathBuf {
PathBuf::from(s)
}
fn expect_peel(command: &str, session: &str, want: &str) {
let got = strip_redundant_session_cd(command, &cwd(session));
assert_eq!(
got.as_ref(),
want,
"peel failed for command={command:?} cwd={session:?}"
);
}
fn expect_no_peel(command: &str, session: &str) {
let got = strip_redundant_session_cd(command, &cwd(session));
assert_eq!(
got.as_ref(),
command,
"unexpected peel for command={command:?} cwd={session:?} got={got:?}"
);
}
#[test]
fn matrix_happy_path_unix() {
expect_peel(r#"cd /proj && python -c "x""#, "/proj", r#"python -c "x""#);
expect_peel("cd /proj; ls -la", "/proj", "ls -la");
expect_peel(" cd /proj && make", "/proj", "make");
expect_peel("cd /proj && cd sub && make", "/proj", "cd sub && make");
expect_peel("cd '/proj with spaces' && ls", "/proj with spaces", "ls");
expect_peel(r#"cd "/proj with spaces" && ls"#, "/proj with spaces", "ls");
expect_peel("(cd /proj && cargo test)", "/proj", "cargo test");
expect_peel("(cd /proj; cargo test)", "/proj", "cargo test");
expect_peel("cd /proj/ && pytest", "/proj", "pytest");
expect_peel(
"cd /Users/u/code/my-project && python -c \"print(1)\"",
"/Users/u/code/my-project",
"python -c \"print(1)\"",
);
}
#[test]
fn matrix_windows_shaped_peel_on_any_host() {
let win = r"C:\Users\a\proj";
expect_peel(r"cd C:\Users\a\proj && cargo test", win, "cargo test");
expect_peel("cd C:/Users/a/proj && cargo test", win, "cargo test");
expect_peel(r"cd /d C:\Users\a\proj && cargo test", win, "cargo test");
expect_peel(r"cd /d C:\Users\a\proj; dir", win, "dir");
expect_peel(r"cd c:\users\a\proj && cargo test", win, "cargo test");
expect_peel(
r#"cd "C:\Users\a\My Project" && msbuild"#,
r"C:\Users\a\My Project",
"msbuild",
);
expect_peel(r"(cd C:\Users\a\proj && cargo test)", win, "cargo test");
}
#[test]
fn matrix_no_peel_fail_closed() {
let proj = "/proj";
let win = r"C:\Users\a\proj";
expect_no_peel("cd /other && ls", proj);
expect_no_peel("cd /proj", proj);
expect_no_peel(r#"python -c "x""#, proj);
expect_no_peel("cd subdir && ls", proj);
// Relative basename must not match absolute session cwd segments.
expect_no_peel("cd proj && ls", proj);
expect_no_peel("cd proj && ls", "/other/proj");
expect_no_peel("cd /proj | wc -l", proj);
expect_no_peel("cde /proj && ls", proj);
expect_no_peel(r"cd D:\other && cargo test", win);
expect_no_peel(r"cd /d D:\other && cargo test", win);
expect_no_peel("cd '/proj && ls", proj);
expect_no_peel("cd /proj &&", proj);
expect_no_peel("cd /proj\n&& ls", proj);
expect_no_peel("Push-Location /proj; ls", proj);
expect_no_peel("Set-Location /proj; Get-ChildItem", proj);
expect_no_peel("sl /proj && ls", proj);
expect_no_peel("chdir /proj && ls", proj);
expect_no_peel("cd - && ls", proj);
expect_no_peel("cd .. && ls", "/proj/sub");
}
#[test]
fn matrix_adversarial_and_model_noise() {
let proj = "/proj";
expect_peel("cd /proj && ls", proj, "ls");
expect_no_peel("cd /proj || true", proj);
expect_peel(
"cd /proj && echo done && cd /tmp && true",
proj,
"echo done && cd /tmp && true",
);
let long = format!("cd {} && true", "/".to_string() + &"a".repeat(200));
let long_cwd = "/".to_string() + &"a".repeat(200);
expect_peel(&long, &long_cwd, "true");
expect_peel("cd /proj && cd /proj && true", proj, "cd /proj && true");
}
#[test]
fn paths_equal_slash_and_trailing() {
assert!(paths_equal_for_display(
Path::new(r"C:\Users\a\proj"),
Path::new("C:/Users/a/proj")
));
assert!(paths_equal_for_display(
Path::new("/proj/"),
Path::new("/proj")
));
assert!(paths_equal_for_display(
Path::new(r"C:\Users\a\proj\"),
Path::new(r"C:\Users\a\proj")
));
assert!(!paths_equal_for_display(
Path::new("/proj"),
Path::new("/other")
));
// Relative vs absolute segment-"equality" is intentionally *not*
// asserted here (would trip the absolute-shaped debug_assert). Peel
// fail-closed coverage for `cd proj` vs session `/proj` lives in
// `matrix_no_peel_fail_closed`.
}
}
+81
View File
@@ -0,0 +1,81 @@
//! Environment variable helpers and process isolation for terminal execution.
//!
//! All implementations now live in the lightweight [`kigi_tty_utils`] crate
//! so that every crate in the workspace can use them without pulling in the
//! heavyweight `kigi-tools` dependency. This module re-exports the public
//! API for backward compatibility.
pub use kigi_tty_utils::{detach_from_tty, pager_env};
/// Env var set on agent-spawned terminal processes so host tools (e.g. `x ban`)
/// can distinguish agent invocations from human interactive shells.
/// Note: the CLI also uses `KIGI_AGENT` as an
/// optional agent-definition selector for launching `grok` itself; child terminal
/// processes only need the sentinel value `"1"`.
pub const KIGI_AGENT_ENV: &str = "KIGI_AGENT";
/// Sentinel value for [`KIGI_AGENT_ENV`] on agent tool terminals.
pub const KIGI_AGENT_ENV_VALUE: &str = "1";
/// Force `KIGI_AGENT=1` on an agent terminal child so request/login env cannot
/// clear the agent marker.
pub fn apply_grok_agent_marker(cmd: &mut tokio::process::Command) {
cmd.env(KIGI_AGENT_ENV, KIGI_AGENT_ENV_VALUE);
}
/// Expand the four plugin-path tokens (`${CLAUDE_PLUGIN_ROOT}` / `${KIGI_PLUGIN_ROOT}`
/// and `${CLAUDE_PLUGIN_DATA}` / `${KIGI_PLUGIN_DATA}`) in `s`. Each pair is expanded
/// only when its value is provided. Single source of truth for plugin agent bodies,
/// plugin skill/command bodies, and plugin MCP/hook config substitution.
pub fn substitute_plugin_tokens(
s: &str,
plugin_root: Option<&str>,
plugin_data: Option<&str>,
) -> String {
let mut out = s.to_string();
if let Some(root) = plugin_root {
out = out
.replace("${CLAUDE_PLUGIN_ROOT}", root)
.replace("${KIGI_PLUGIN_ROOT}", root);
}
if let Some(data) = plugin_data {
out = out
.replace("${CLAUDE_PLUGIN_DATA}", data)
.replace("${KIGI_PLUGIN_DATA}", data);
}
out
}
#[cfg(test)]
mod tests {
use super::{KIGI_AGENT_ENV, KIGI_AGENT_ENV_VALUE, substitute_plugin_tokens};
const ALL_TOKENS: &str = "${CLAUDE_PLUGIN_ROOT}/a ${KIGI_PLUGIN_ROOT}/b ${CLAUDE_PLUGIN_DATA}/c ${KIGI_PLUGIN_DATA}/d";
#[test]
fn expands_all_four_tokens_when_both_provided() {
let out = substitute_plugin_tokens(ALL_TOKENS, Some("/root"), Some("/data"));
assert_eq!(out, "/root/a /root/b /data/c /data/d");
}
#[test]
fn leaves_tokens_literal_when_both_none() {
let out = substitute_plugin_tokens(ALL_TOKENS, None, None);
assert_eq!(out, ALL_TOKENS);
}
#[test]
fn expands_only_root_when_data_none() {
let out = substitute_plugin_tokens(ALL_TOKENS, Some("/root"), None);
assert_eq!(
out,
"/root/a /root/b ${CLAUDE_PLUGIN_DATA}/c ${KIGI_PLUGIN_DATA}/d"
);
}
#[test]
fn agent_marker_constants_match_cursor_parity() {
assert_eq!(KIGI_AGENT_ENV, "KIGI_AGENT");
assert_eq!(KIGI_AGENT_ENV_VALUE, "1");
}
}
+288
View File
@@ -0,0 +1,288 @@
//! Filesystem helpers shared across tool implementations.
//!
//! Thin wrappers around `tokio::fs` that add per-call tracing spans and a hard
//! timeout. The timeout guards against hung syscalls on slow or overlayfs-backed
//! filesystems (e.g. Docker overlay mounts), where `canonicalize` or `stat` can
//! block indefinitely. On timeout or error the helpers fall back to safe defaults
//! rather than propagating errors, keeping tool execution unblocked.
use std::path::{Path, PathBuf};
use std::time::Duration;
pub(crate) const FS_SYSCALL_TIMEOUT: Duration = Duration::from_secs(30);
/// Async symlink-resolved path or the input path on failure/timeout.
///
/// Windows-safe canonicalizer: the result is passed through
/// `dunce::simplified` so Windows callers never see verbatim `\\?\` paths.
#[tracing::instrument(name = "fs.canonicalize", skip_all, fields(result))]
pub async fn canonicalize_with_timeout(path: PathBuf) -> PathBuf {
// dunce-simplified below — blessed wrapper
#[allow(clippy::disallowed_methods)]
match tokio::time::timeout(FS_SYSCALL_TIMEOUT, tokio::fs::canonicalize(&path)).await {
Ok(Ok(canonical)) => {
tracing::Span::current().record("result", "ok");
dunce::simplified(&canonical).to_path_buf()
}
Ok(Err(e)) => {
tracing::Span::current().record("result", "error");
tracing::debug!(error = %e, "canonicalize failed, using original path");
path
}
Err(_elapsed) => {
tracing::Span::current().record("result", "timeout");
tracing::warn!(
"canonicalize timed out after {}s (slow/overlayfs filesystem?), \
using original path",
FS_SYSCALL_TIMEOUT.as_secs()
);
path
}
}
}
/// Async symlink-resolved path, preserving the `io::Error` on failure.
///
/// Error-preserving sibling of [`canonicalize_with_timeout`] for call sites
/// whose control flow branches on the `io::ErrorKind` (e.g. NotFound driving a
/// unicode-filename fallback or new-file creation), which the error-swallowing
/// helpers cannot express. Like the other blessed wrappers, the Ok result is
/// passed through `dunce::simplified` so Windows callers never see verbatim
/// `\\?\` paths. Deliberately no timeout: a synthetic TimedOut error would
/// change the `ErrorKind`-matching semantics at call sites.
pub(crate) async fn try_canonicalize(path: &Path) -> std::io::Result<PathBuf> {
// dunce-simplified below — blessed wrapper
#[allow(clippy::disallowed_methods)]
tokio::fs::canonicalize(path)
.await
.map(|p| dunce::simplified(&p).to_path_buf())
}
/// OS-specific special characters that appear in generated filenames but that
/// models will never produce. Each entry maps a Unicode character to its ASCII
/// equivalent.
///
/// Separate from [`CONFUSABLE_MAP`] intentionally: CONFUSABLE_MAP is for file
/// *content* matching in `search_replace`, where characters like U+202F may be
/// legitimate. This map targets OS-generated filenames where the model can
/// never produce the exact character.
const FILENAME_SPECIAL_CHARACTER_MAP: &[(char, char)] = &[
('\u{202F}', ' '), // narrow no-break space (macOS screenshot/recording filenames)
('\u{00A0}', ' '), // no-break space
];
fn normalize_filename(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match FILENAME_SPECIAL_CHARACTER_MAP
.iter()
.find(|(from, _)| *from == c)
{
Some((_, replacement)) => out.push(*replacement),
None => out.push(c),
}
}
out
}
/// Result of a successful unicode-aware filename fallback resolution.
#[derive(Debug, Clone)]
pub struct UnicodePathMatch {
/// The actual path on disk (with the original unicode characters).
pub resolved_path: PathBuf,
/// A note explaining what happened, suitable for appending to tool output.
pub note: String,
}
/// When `path` does not exist, scan its parent directory for a file whose name
/// matches after normalizing unicode whitespace (e.g. U+202F → ASCII space).
///
/// macOS uses U+202F (narrow no-break space) before AM/PM in screenshot and
/// screen recording filenames. Models always produce regular U+0020 spaces,
/// so direct path lookups fail. This fallback bridges the gap.
///
/// Returns `None` if:
/// - the path already exists (caller should not have called this),
/// - the parent directory cannot be read,
/// - no entry matches after normalization,
/// - multiple entries match (ambiguous).
#[tracing::instrument(name = "fs.unicode_path_fallback", skip_all, fields(result))]
pub async fn try_resolve_unicode_filename(path: &Path) -> Option<UnicodePathMatch> {
tokio::time::timeout(FS_SYSCALL_TIMEOUT, try_resolve_unicode_filename_inner(path))
.await
.unwrap_or_else(|_| {
tracing::warn!(
"unicode filename fallback timed out after {}s",
FS_SYSCALL_TIMEOUT.as_secs()
);
None
})
}
async fn try_resolve_unicode_filename_inner(path: &Path) -> Option<UnicodePathMatch> {
let file_name = path.file_name()?.to_str()?;
let parent = path.parent()?;
let normalized_target = normalize_filename(file_name);
let mut read_dir = tokio::fs::read_dir(parent).await.ok()?;
let mut matches: Vec<PathBuf> = Vec::new();
while let Ok(Some(entry)) = read_dir.next_entry().await {
let entry_name = entry.file_name();
let Some(entry_name_str) = entry_name.to_str() else {
continue;
};
if entry_name_str == file_name {
tracing::Span::current().record("result", "exact_match_exists");
return None;
}
let normalized_entry = normalize_filename(entry_name_str);
if normalized_entry == normalized_target {
matches.push(entry.path());
}
}
if matches.len() == 1 {
let matched = &matches[0];
let matched_name = matched.file_name().and_then(|n| n.to_str()).unwrap_or("?");
// zip is safe: FILENAME_SPECIAL_CHARACTER_MAP is (char, char) so
// every replacement preserves char count.
let differing_chars: Vec<String> = matched_name
.chars()
.zip(file_name.chars())
.filter(|(a, b)| a != b)
.map(|(actual, _)| format!("U+{:04X}", actual as u32))
.collect();
let chars_list = if differing_chars.is_empty() {
String::new()
} else {
format!(" ({})", differing_chars.join(", "))
};
let note = format!(
"The specified filename did not exist exactly as given. A file was found \
by normalizing Unicode characters{chars_list} to their ASCII equivalents. \
The actual filename is: {matched_name}\n\
For shell commands referencing this file, use glob patterns to avoid the mismatch.",
);
tracing::Span::current().record("result", "resolved");
tracing::info!(
original = %path.display(),
resolved = %matched.display(),
"unicode filename fallback resolved path"
);
Some(UnicodePathMatch {
resolved_path: matched.clone(),
note,
})
} else {
let label = if matches.is_empty() {
"no_match"
} else {
"ambiguous"
};
tracing::Span::current().record("result", label);
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn canonicalize_falls_back_on_nonexistent_path() {
let path = PathBuf::from("/nonexistent/path/that/does/not/exist");
let result = canonicalize_with_timeout(path.clone()).await;
assert_eq!(result, path);
}
// ── try_resolve_unicode_filename ───────────────────────────────────
#[tokio::test]
async fn unicode_fallback_resolves_nnbsp_filename() {
let dir = tempfile::tempdir().unwrap();
// Create a file with U+202F (narrow no-break space) before "PM"
let actual_name = "Screenshot 2026-03-20 at 12.37.23\u{202F}PM.png";
let actual_path = dir.path().join(actual_name);
tokio::fs::write(&actual_path, b"img").await.unwrap();
// Model provides the same name with regular space
let model_name = "Screenshot 2026-03-20 at 12.37.23 PM.png";
let model_path = dir.path().join(model_name);
let result = try_resolve_unicode_filename(&model_path).await;
assert!(result.is_some(), "should resolve via unicode fallback");
let m = result.unwrap();
assert_eq!(m.resolved_path, actual_path);
assert!(m.note.contains("U+202F"));
assert!(m.note.contains(actual_name));
}
#[tokio::test]
async fn unicode_fallback_resolves_nbsp_filename() {
let dir = tempfile::tempdir().unwrap();
let actual_name = "doc\u{00A0}final.txt";
let actual_path = dir.path().join(actual_name);
tokio::fs::write(&actual_path, b"txt").await.unwrap();
let model_name = "doc final.txt";
let model_path = dir.path().join(model_name);
let result = try_resolve_unicode_filename(&model_path).await;
assert!(result.is_some());
assert_eq!(result.unwrap().resolved_path, actual_path);
}
#[tokio::test]
async fn unicode_fallback_returns_none_for_exact_match() {
let dir = tempfile::tempdir().unwrap();
let name = "normal file.txt";
let path = dir.path().join(name);
tokio::fs::write(&path, b"ok").await.unwrap();
let result = try_resolve_unicode_filename(&path).await;
assert!(result.is_none(), "exact match should return None");
}
#[tokio::test]
async fn unicode_fallback_returns_none_for_no_match() {
let dir = tempfile::tempdir().unwrap();
tokio::fs::write(dir.path().join("other.txt"), b"x")
.await
.unwrap();
let model_path = dir.path().join("nonexistent.txt");
let result = try_resolve_unicode_filename(&model_path).await;
assert!(result.is_none());
}
#[tokio::test]
async fn unicode_fallback_returns_none_for_ambiguous() {
let dir = tempfile::tempdir().unwrap();
// Two files that normalize to the same ASCII name
let a = dir.path().join("file\u{202F}name.txt");
let b = dir.path().join("file\u{00A0}name.txt");
tokio::fs::write(&a, b"a").await.unwrap();
tokio::fs::write(&b, b"b").await.unwrap();
let model_path = dir.path().join("file name.txt");
let result = try_resolve_unicode_filename(&model_path).await;
assert!(result.is_none(), "ambiguous matches should return None");
}
#[tokio::test]
async fn unicode_fallback_returns_none_for_nonexistent_parent() {
let path = PathBuf::from("/nonexistent/dir/Screenshot 2026-03-20 at 12.37.23 PM.png");
let result = try_resolve_unicode_filename(&path).await;
assert!(result.is_none());
}
}
@@ -0,0 +1,249 @@
//! Detection of `git commit` / `gh pr create` / `gh pr merge` in terminal
//! commands, shared by the bash tool's counter spans and the shell's PR-metric
//! session signals (the shell inspects `BashOutput.command` / output at its
//! tool-result chokepoint rather than receiving detection through the tool).
/// Git/GitHub operations detected in a successful terminal command.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct DetectedGitOps {
/// A non-dry-run `git commit` statement ran.
pub committed: bool,
/// A `gh pr create` statement ran; url/number parsed from output when printed.
pub pr_created: Option<PrRef>,
/// A `gh pr merge` statement ran.
pub pr_merged: bool,
}
/// Reference to a pull request parsed from command output.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PrRef {
/// Full PR URL (e.g. `https://github.com/owner/repo/pull/123`).
pub url: Option<String>,
/// PR number parsed from the URL path.
pub number: Option<u64>,
}
impl PrRef {
/// Find the last `http(s)://…/pull/<N>` URL in `text` — `gh pr create`
/// stdout, or an MCP create_pull_request result (URLs may be embedded in
/// JSON strings). Returns `None` when no PR URL is present (e.g.
/// `gh pr create --web`).
pub fn find_in(text: &str) -> Option<Self> {
let mut last = None;
for (start, _) in text.match_indices("http") {
let rest = &text[start..];
if !rest.starts_with("https://") && !rest.starts_with("http://") {
continue;
}
let end = rest
.find(|c: char| {
c.is_whitespace() || matches!(c, '"' | '\'' | '<' | '>' | '\\' | '`')
})
.unwrap_or(rest.len());
let url = rest[..end].trim_end_matches(['.', ',', ';', ':', ')', ']', '}']);
// rsplit: an owner/repo literally named "pull" must not eat the marker.
let Some((_, tail)) = url.rsplit_once("/pull/") else {
continue;
};
let digits: String = tail.chars().take_while(char::is_ascii_digit).collect();
let Ok(number) = digits.parse::<u64>() else {
continue;
};
let url_len = url.len() - tail.len() + digits.len();
last = Some(PrRef {
url: Some(url[..url_len].to_string()),
number: Some(number),
});
}
last
}
}
/// Strip invocation prefixes that precede the actual binary in a statement:
/// `env` (with `-u NAME` args), `VAR=value` assignments, and an absolute /
/// relative path on the binary itself (`/opt/homebrew/bin/gh` → `gh`).
/// Covers common `env` / `VAR=value` / absolute-path wrappers around git/gh.
fn strip_invocation_prefixes(statement: &str) -> &str {
let mut rest = statement.trim_start();
loop {
let token_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
let token = &rest[..token_end];
let is_env = token == "env";
let is_env_unset = token == "-u";
let is_assignment = token.split_once('=').is_some_and(|(name, _)| {
!name.is_empty()
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
&& !name.starts_with(|c: char| c.is_ascii_digit())
});
if (is_env || is_env_unset || is_assignment) && token_end < rest.len() {
rest = rest[token_end..].trim_start();
// `-u` consumes its NAME argument too.
if is_env_unset {
let name_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
if name_end < rest.len() {
rest = rest[name_end..].trim_start();
} else {
return rest;
}
}
continue;
}
break;
}
// Path-invoked binary: keep only the basename token.
let token_end = rest.find(char::is_whitespace).unwrap_or(rest.len());
if let Some(slash) = rest[..token_end].rfind('/')
&& matches!(&rest[slash + 1..token_end], "git" | "gh")
{
rest = &rest[slash + 1..];
}
rest
}
/// Detect `git commit` / `gh pr create` / `gh pr merge` statements in a
/// successful command. Matched per shell statement, anchored at the statement
/// start (after invocation prefixes), so `echo "git commit"`, comments, and
/// `git commit-graph` don't count.
///
/// `output_for_prompt` is scanned for the created PR's URL (`gh pr create`
/// prints it as the last stdout line; absent for `--web`, leaving an empty
/// [`PrRef`]). Callers must only pass exit-code-0 results.
pub fn detect_git_ops(command: &str, output_for_prompt: &str) -> Option<DetectedGitOps> {
let statements = || {
command
.split(['\n', ';', '&', '|'])
.map(strip_invocation_prefixes)
};
// `excluded` guards flags that make the statement a no-op for the metric
// (`--dry-run` doesn't commit/create; `--disable-auto` un-queues a merge).
let statement_runs = |prefix: &str, excluded: &str| {
statements().any(|s| {
s.strip_prefix(prefix)
.is_some_and(|r| r.is_empty() || r.starts_with(char::is_whitespace))
&& !s.contains(excluded)
})
};
let committed = statement_runs("git commit", "--dry-run");
let pr_created = statement_runs("gh pr create", "--dry-run")
.then(|| PrRef::find_in(output_for_prompt).unwrap_or_default());
let pr_merged = statement_runs("gh pr merge", "--disable-auto");
(committed || pr_created.is_some() || pr_merged).then_some(DetectedGitOps {
committed,
pr_created,
pr_merged,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn commit_and_pr_create_with_url() {
let out = "exit: 0\nhttps://github.com/xai-org/example/pull/12345\n";
let ops = detect_git_ops("git commit -m 'x' && gh pr create --fill", out).unwrap();
assert!(ops.committed);
assert!(!ops.pr_merged);
let pr = ops.pr_created.unwrap();
assert_eq!(
pr.url.as_deref(),
Some("https://github.com/xai-org/example/pull/12345")
);
assert_eq!(pr.number, Some(12345));
}
#[test]
fn pr_create_web_has_no_url() {
let ops = detect_git_ops("gh pr create --web", "exit: 0\nOpening browser...\n").unwrap();
let pr = ops.pr_created.unwrap();
assert_eq!(pr.url, None);
assert_eq!(pr.number, None);
}
#[test]
fn pr_merge_detected() {
let ops = detect_git_ops("gh pr merge 42 --squash", "exit: 0\n").unwrap();
assert!(ops.pr_merged);
assert!(ops.pr_created.is_none());
assert!(!ops.committed);
}
#[test]
fn statement_anchoring_rejects_lookalikes() {
assert!(detect_git_ops(r#"echo "git commit""#, "").is_none());
assert!(detect_git_ops("git commit-graph write", "").is_none());
assert!(detect_git_ops(r#"echo "gh pr create is fun""#, "").is_none());
assert!(detect_git_ops("git commit --dry-run", "").is_none());
assert!(detect_git_ops("gh pr create --dry-run", "").is_none());
assert!(detect_git_ops("gh pr merge 42 --disable-auto", "").is_none());
assert!(detect_git_ops("gh pr view 42", "").is_none());
assert!(detect_git_ops("grep 'gh pr create' transcript.txt", "").is_none());
}
#[test]
fn multi_statement_split() {
let ops = detect_git_ops("cd /repo; git commit -am wip", "").unwrap();
assert!(ops.committed);
assert!(detect_git_ops("ls | grep foo", "").is_none());
}
// Representative invocation-prefix shapes (env vars, absolute paths, env -u).
#[test]
fn invocation_prefixes_are_stripped() {
for cmd in [
r#"GH_TOKEN="$GITHUB_TOKEN_FORGE" gh pr create --repo o/r --base main"#,
"NO_COLOR=1 CLICOLOR_FORCE= FORCE_COLOR= gh pr create --fill",
"/usr/local/bin/gh pr create --head my-branch",
"/opt/homebrew/bin/gh pr create --fill",
"env -u GITHUB_TOKEN gh pr create --base main",
"cd /repo && GIT_AUTHOR_NAME=x /usr/bin/git commit -m msg",
] {
assert!(detect_git_ops(cmd, "").is_some(), "should match: {cmd}");
}
}
#[test]
fn prefix_stripping_does_not_overreach() {
// Assignment-only statements and non-git/gh path binaries don't match.
assert!(detect_git_ops("FOO=gh pr create", "").is_none());
assert!(detect_git_ops("/usr/bin/echo gh pr create", "").is_none());
assert!(detect_git_ops("env", "").is_none());
}
#[test]
fn pr_ref_find_in_takes_last_url_and_trims_punctuation() {
let text = "see https://github.com/o/r/pull/1.\nhttps://ghe.example.test/team/repo/pull/987/files\n";
let pr = PrRef::find_in(text).unwrap();
assert_eq!(
pr.url.as_deref(),
Some("https://ghe.example.test/team/repo/pull/987")
);
assert_eq!(pr.number, Some(987));
}
#[test]
fn pr_ref_find_in_handles_json_embedded_url() {
let text = r#"{"number":5,"html_url":"https://github.com/o/r/pull/5","state":"open"}"#;
let pr = PrRef::find_in(text).unwrap();
assert_eq!(pr.url.as_deref(), Some("https://github.com/o/r/pull/5"));
assert_eq!(pr.number, Some(5));
}
#[test]
fn pr_ref_find_in_handles_repo_named_pull() {
let pr = PrRef::find_in("https://github.com/org/pull/pull/7").unwrap();
assert_eq!(
pr.url.as_deref(),
Some("https://github.com/org/pull/pull/7")
);
assert_eq!(pr.number, Some(7));
}
#[test]
fn pr_ref_find_in_rejects_non_pr_text() {
assert_eq!(PrRef::find_in("no urls here"), None);
assert_eq!(PrRef::find_in("https://github.com/o/r/issues/5"), None);
assert_eq!(PrRef::find_in("git pull origin main"), None);
assert_eq!(PrRef::find_in("https://github.com/o/r/pull/"), None);
}
}
+203
View File
@@ -0,0 +1,203 @@
//! Shared hashing utilities for hashline anchor generation.
//!
//! Provides FNV-1a 32-bit hashing and whitespace-normalized line fingerprinting.
//! Used by the `grok_build_hashline` anchor schemes.
//!
//! ## Normalization policy
//!
//! Before hashing, lines are normalized: leading/trailing whitespace is trimmed
//! and internal whitespace runs are collapsed to a single ASCII space. This keeps
//! anchors stable across formatter-only edits (indentation, trailing whitespace,
//! tab/space normalization) while still distinguishing meaningful content changes
//! (e.g. `return x` vs `returnx`).
/// FNV-1a 32-bit offset basis.
const FNV_OFFSET: u32 = 2_166_136_261;
/// FNV-1a 32-bit prime.
const FNV_PRIME: u32 = 16_777_619;
/// Compute FNV-1a 32-bit hash of raw bytes.
///
/// This is the low-level primitive — callers that want whitespace-normalized
/// fingerprints should use [`line_hash`] instead.
pub fn fnv1a_32(data: &[u8]) -> u32 {
let mut h: u32 = FNV_OFFSET;
for &byte in data {
h ^= byte as u32;
h = h.wrapping_mul(FNV_PRIME);
}
h
}
/// Compute a whitespace-normalized FNV-1a 32-bit fingerprint of a single line.
///
/// Normalization: `trim()` + collapse internal whitespace runs to a single
/// ASCII space. The hash is computed over the normalized byte sequence.
///
/// Returns the raw `u32` hash. Use [`encode_hash`] to convert to a compact
/// letter-based anchor string.
pub fn line_hash(line: &str) -> u32 {
let mut h: u32 = FNV_OFFSET;
let mut prev_ws = false;
for byte in line.trim().bytes() {
if byte.is_ascii_whitespace() {
if !prev_ws {
h ^= b' ' as u32;
h = h.wrapping_mul(FNV_PRIME);
prev_ws = true;
}
} else {
h ^= byte as u32;
h = h.wrapping_mul(FNV_PRIME);
prev_ws = false;
}
}
h
}
/// Encode a 32-bit hash as `n` lowercase ASCII letters (az).
///
/// Each letter is derived from a different byte region of the hash to spread
/// entropy. The default anchor length for benchmarking is 3; 2 is retained
/// as a control configuration.
///
/// # Panics
///
/// Panics if `len` is 0 or greater than 4.
pub fn encode_hash(hash: u32, len: usize) -> String {
assert!(len > 0 && len <= 4, "encode_hash: len must be 1..=4");
let mut result = String::with_capacity(len);
for i in 0..len {
let byte = ((hash >> (i * 8)) % 26) as u8 + b'a';
result.push(byte as char);
}
result
}
/// Default anchor hash length (3 lowercase letters).
pub const DEFAULT_HASH_LEN: usize = 3;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fnv1a_32_empty() {
// FNV-1a of empty input is the offset basis.
assert_eq!(fnv1a_32(b""), FNV_OFFSET);
}
#[test]
fn fnv1a_32_deterministic() {
let a = fnv1a_32(b"hello world");
let b = fnv1a_32(b"hello world");
assert_eq!(a, b);
}
#[test]
fn fnv1a_32_different_inputs_differ() {
assert_ne!(fnv1a_32(b"hello"), fnv1a_32(b"world"));
}
#[test]
fn line_hash_deterministic() {
let a = line_hash(" let x = 1; ");
let b = line_hash(" let x = 1; ");
assert_eq!(a, b);
}
#[test]
fn line_hash_whitespace_normalization_indentation() {
// Different indentation → same hash.
let a = line_hash(" let x = 1;");
let b = line_hash(" let x = 1;");
let c = line_hash("\tlet x = 1;");
assert_eq!(a, b);
assert_eq!(b, c);
}
#[test]
fn line_hash_whitespace_normalization_trailing() {
let a = line_hash("let x = 1;");
let b = line_hash("let x = 1; ");
let c = line_hash("let x = 1;\t");
assert_eq!(a, b);
assert_eq!(b, c);
}
#[test]
fn line_hash_whitespace_normalization_internal_collapse() {
// Multiple internal spaces collapse to one.
let a = line_hash("let x = 1;");
let b = line_hash("let x = 1;");
assert_eq!(a, b);
}
#[test]
fn line_hash_preserves_token_boundaries() {
// "return x" vs "returnx" must differ.
let a = line_hash("return x");
let b = line_hash("returnx");
assert_ne!(a, b);
}
#[test]
fn line_hash_empty_line() {
// Empty and whitespace-only lines should hash the same.
let a = line_hash("");
let b = line_hash(" ");
let c = line_hash("\t\t");
assert_eq!(a, b);
assert_eq!(b, c);
}
#[test]
fn line_hash_content_changes_differ() {
assert_ne!(line_hash("let x = 1;"), line_hash("let y = 1;"));
assert_ne!(line_hash("let x = 1;"), line_hash("let x = 2;"));
}
#[test]
fn encode_hash_length() {
let h = fnv1a_32(b"test");
assert_eq!(encode_hash(h, 2).len(), 2);
assert_eq!(encode_hash(h, 3).len(), 3);
assert_eq!(encode_hash(h, 4).len(), 4);
}
#[test]
fn encode_hash_lowercase_letters() {
let h = fnv1a_32(b"test");
let encoded = encode_hash(h, 3);
assert!(encoded.chars().all(|c| c.is_ascii_lowercase()));
}
#[test]
fn encode_hash_deterministic() {
let h = fnv1a_32(b"test");
assert_eq!(encode_hash(h, 3), encode_hash(h, 3));
}
#[test]
#[should_panic(expected = "len must be 1..=4")]
fn encode_hash_zero_len_panics() {
encode_hash(0, 0);
}
#[test]
#[should_panic(expected = "len must be 1..=4")]
fn encode_hash_five_len_panics() {
encode_hash(0, 5);
}
#[test]
fn encode_hash_different_hashes_differ() {
let a = encode_hash(fnv1a_32(b"hello"), 3);
let b = encode_hash(fnv1a_32(b"world"), 3);
assert_ne!(a, b);
}
}
@@ -0,0 +1,286 @@
//! Shared image re-encoding with PNG+JPEG format selection.
//!
//! Both the user-attachment normalizer (`kigi-shell`) and the `read_file`
//! tool image path use this to compress images under a byte-size cap while
//! respecting per-caller dimension and quality parameters.
use std::borrow::Cow;
use image::DynamicImage;
use image::codecs::jpeg::JpegEncoder;
pub use image::imageops::FilterType;
/// Parameters that control the re-encode loop.
///
/// Each call-site provides its own set of limits so the shared encoder can
/// serve callers with different size/quality trade-offs.
#[derive(Debug, Clone, Copy)]
pub struct ReEncodeParams {
/// Maximum output size in **raw bytes** (not base64).
pub max_bytes: usize,
/// Maximum dimension (width or height) on the first attempt.
pub max_side_px: u32,
/// Maximum total output pixel count (width × height) on the first
/// attempt; `u64::MAX` disables the area cap.
pub max_pixels: u64,
/// Floor dimension — the loop gives up when `max_side` falls to or below
/// this value without producing output that fits.
pub min_side_px: u32,
/// JPEG quality steps to try at each dimension, in descending order.
pub quality_steps: &'static [u8],
/// Resize filter (e.g. `CatmullRom`, `Lanczos3`).
pub filter: FilterType,
}
impl ReEncodeParams {
/// True when either side exceeds `max_side_px` or the total pixel count
/// exceeds `max_pixels` — shared by re-encode triggers and passthrough
/// gates so the rule cannot drift between them.
pub fn exceeds_dimension_caps(&self, w: u32, h: u32) -> bool {
w > self.max_side_px
|| h > self.max_side_px
|| u64::from(w) * u64::from(h) > self.max_pixels
}
}
/// Why `re_encode_under_limit` could not produce a compliant output.
#[derive(Debug, thiserror::Error)]
pub enum ReEncodeError {
/// Exhausted all quality × dimension steps without fitting under the cap.
#[error(
"re-encode could not fit under {max_bytes} bytes after PNG+JPEG attempts (last side {last_side}px)"
)]
CouldNotFit { max_bytes: usize, last_side: u32 },
}
/// Try PNG and JPEG encodings at descending dimensions, returning whichever
/// is smallest and fits under `params.max_bytes`.
///
/// On success returns `(bytes, width, height, mime_type)`.
pub fn re_encode_under_limit(
decoded: &DynamicImage,
params: &ReEncodeParams,
) -> Result<(Vec<u8>, u32, u32, &'static str), ReEncodeError> {
// Never upscale: a small-but-heavy image is re-encoded at its own
// resolution, not enlarged to `max_side_px`. `image::resize` scales *up* to
// fill the target box, so starting at `max_side_px` would enlarge anything
// smaller — adding no detail and wasting request bytes / cache headroom.
let original_max_side = decoded.width().max(decoded.height());
let mut max_side = params.max_side_px.min(original_max_side);
let original_pixels = u64::from(decoded.width()) * u64::from(decoded.height());
if original_pixels > params.max_pixels {
max_side = max_side.min(area_capped_side(
original_max_side,
decoded.width().min(decoded.height()),
params.max_pixels,
));
}
loop {
// Only resample when actually downscaling; resizing to the current size
// would just soften the image for no reason. `resize(w, h)` preserves
// aspect ratio (fits inside w×h, not stretch-to-square).
let scaled: Cow<'_, DynamicImage> = if max_side < original_max_side {
Cow::Owned(decoded.resize(max_side, max_side, params.filter))
} else {
Cow::Borrowed(decoded)
};
let img: &DynamicImage = &scaled;
let (w, h) = (img.width(), img.height());
// --- PNG candidate ---------------------------------------------------
let png_candidate = {
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.ok()
.filter(|_| buf.len() <= params.max_bytes)
.map(|_| buf)
};
// --- JPEG candidate (best quality that fits) -------------------------
let jpeg_candidate = params.quality_steps.iter().find_map(|&quality| {
let mut buf = Vec::new();
let mut enc = JpegEncoder::new_with_quality(&mut buf, quality);
enc.encode_image(img).ok()?;
(buf.len() <= params.max_bytes).then_some(buf)
});
// --- Pick the smaller candidate --------------------------------------
match (png_candidate, jpeg_candidate) {
(Some(png), Some(jpeg)) => {
if png.len() <= jpeg.len() {
return Ok((png, w, h, "image/png"));
} else {
return Ok((jpeg, w, h, "image/jpeg"));
}
}
(Some(png), None) => return Ok((png, w, h, "image/png")),
(None, Some(jpeg)) => return Ok((jpeg, w, h, "image/jpeg")),
(None, None) => { /* fall through to smaller dimensions */ }
}
if max_side <= params.min_side_px {
return Err(ReEncodeError::CouldNotFit {
max_bytes: params.max_bytes,
last_side: max_side,
});
}
max_side = max_side * 3 / 4;
}
}
/// Largest target long side whose resize output area stays within `max_pixels`.
fn area_capped_side(long: u32, short: u32, max_pixels: u64) -> u32 {
let scale = (max_pixels as f64 / (u64::from(long) * u64::from(short)) as f64).sqrt();
let mut side = ((f64::from(long) * scale).floor() as u32).clamp(1, long);
// Nearest-rounding of the short side can overshoot the budget by ~side/2
// pixels, so step down until the predicted output fits: a decrement removes
// ~2*area/side pixels, giving ~2 iterations for ordinary aspect ratios;
// only degenerate strips whose short side pins at the 1px floor walk
// O(side), bounded by the callers' decode-pixel limits.
while side > 1 && predicted_resize_area(long, short, side) > max_pixels {
side -= 1;
}
side
}
/// Output area `image::resize` produces for a `side`×`side` bounding box,
/// mirroring `resize_dimensions` (image-0.25.9, `src/math/utils.rs`)
/// expression-for-expression; the `area_cap_exact_fit_across_aspect_ratios`
/// sweep pins the equivalence through the real resize, so a crate bump that
/// changes the rounding shows up as a test failure pointing here.
fn predicted_resize_area(long: u32, short: u32, side: u32) -> u64 {
let ratio = f64::from(side) / f64::from(long);
let scaled_long = (f64::from(long) * ratio).round().max(1.0) as u64;
let scaled_short = (f64::from(short) * ratio).round().max(1.0) as u64;
scaled_long * scaled_short
}
#[cfg(test)]
mod tests {
use image::{DynamicImage, Rgb, RgbImage};
use super::*;
/// Deterministic high-entropy image so PNG/JPEG can't trivially shrink it.
fn noise(w: u32, h: u32) -> DynamicImage {
let mut img = RgbImage::new(w, h);
let mut s: u32 = 0x1234_5678;
for p in img.pixels_mut() {
s = s.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
*p = Rgb([(s >> 16) as u8, (s >> 8) as u8, s as u8]);
}
DynamicImage::ImageRgb8(img)
}
fn params(max_bytes: usize, max_side_px: u32, max_pixels: u64) -> ReEncodeParams {
ReEncodeParams {
max_bytes,
max_side_px,
max_pixels,
min_side_px: 256,
quality_steps: &[88, 72, 56, 40, 24],
filter: FilterType::CatmullRom,
}
}
#[test]
fn does_not_upscale_images_smaller_than_the_side_cap() {
// 1280x960 is already under the test's 1568px side cap. Re-encoding
// must NOT enlarge it — output dimensions must never exceed the input.
// (Regression: the resize previously scaled small images up to
// `max_side_px`.)
let img = noise(1280, 960);
let (_bytes, w, h, _mime) =
re_encode_under_limit(&img, &params(5_000_000, 1568, u64::MAX)).unwrap();
assert!(
w <= 1280 && h <= 960,
"must not upscale a 1280x960 image, got {w}x{h}"
);
}
#[test]
fn downscales_images_larger_than_the_side_cap() {
// 2000x1500 exceeds the test's 1568px side cap and must fit inside it.
let img = noise(2000, 1500);
let (_bytes, w, h, _mime) =
re_encode_under_limit(&img, &params(5_000_000, 1568, u64::MAX)).unwrap();
assert!(
w <= 1568 && h <= 1568,
"should downscale to <=1568, got {w}x{h}"
);
assert_eq!(w, 1568, "longest side should hit the cap");
}
#[test]
fn shrinks_dimensions_only_when_bytes_force_it() {
// A small image that can't fit the byte cap at native size is
// downscaled below its own dimensions — still never above them.
let img = noise(1280, 960);
let (bytes, w, h, _mime) =
re_encode_under_limit(&img, &params(120_000, 1568, u64::MAX)).unwrap();
assert!(bytes.len() <= 120_000);
assert!(w <= 1280 && h <= 960, "must not upscale, got {w}x{h}");
}
#[test]
fn area_cap_bounds_total_pixels_for_wide_images() {
// 3438x1830 = 6.29 Mpx: the side cap is loose, so only the area budget
// binds; expected long side = floor(3438 * sqrt(2_408_448 / 6_291_540)).
let img = noise(3438, 1830);
let (_bytes, w, h, _mime) =
re_encode_under_limit(&img, &params(50_000_000, 10_000, 2_408_448)).unwrap();
let area = u64::from(w) * u64::from(h);
assert!(area <= 2_408_448, "area {area} over budget ({w}x{h})");
assert!(
area >= 2_300_000,
"should use most of the budget, got {area} ({w}x{h})"
);
assert_eq!(w, 2127, "long side ~2127 for a 3438x1830 source");
let r_in = 3438.0 / 1830.0;
let r_out = w as f64 / h as f64;
assert!(
(r_in - r_out).abs() < 0.01,
"aspect ratio {r_in} -> {r_out} ({w}x{h})"
);
}
#[test]
fn image_under_area_cap_is_not_resized() {
// 1500x1500 = 2.25 Mpx is under the 2_408_448 budget; no resample.
let img = noise(1500, 1500);
let (_bytes, w, h, _mime) =
re_encode_under_limit(&img, &params(50_000_000, 10_000, 2_408_448)).unwrap();
assert_eq!((w, h), (1500, 1500), "must not up- or downscale");
}
#[test]
fn area_cap_exact_fit_across_aspect_ratios() {
// Short-side rounding must never push the output area over the cap;
// (1600, 400, 300_000) rounds 273.5 up and exercises the decrement.
for &(sw, sh, cap) in &[
(1300u32, 900u32, 500_000u64),
(1200, 1199, 640_000),
(1600, 400, 300_000),
(900, 1300, 777_777),
(1000, 1000, 123_456),
(2600, 1800, 2_408_448),
] {
let img = noise(sw, sh);
let (_bytes, w, h, _mime) =
re_encode_under_limit(&img, &params(50_000_000, 10_000, cap)).unwrap();
let area = u64::from(w) * u64::from(h);
assert!(area <= cap, "{sw}x{sh} cap {cap}: got {w}x{h} = {area}");
assert_eq!(
w.max(h),
area_capped_side(sw.max(sh), sw.min(sh), cap),
"{sw}x{sh} cap {cap}: long side must match the predicted fit"
);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,4 @@
pub use kigi_config::{
decode_cwd_from_dirname, encode_cwd_dirname, ensure_sessions_cwd_dir, kigi_application,
kigi_home, sessions_cwd_dir,
};
@@ -0,0 +1,469 @@
//! Shared size-bounding for MCP/text tool output.
//!
//! Large payloads (e.g. Sentry attachment base64 resources) must not land
//! fully in chat state: they inflate the token estimate and trigger premature
//! auto-compact.
//!
//! # Configurable limit
//!
//! Default [`MCP_MAX_OUTPUT_BYTES`] (20_000). Effective limit (highest first):
//!
//! 1. [`TruncationCfg`](crate::types::resources::TruncationCfg) per-tool /
//! MCP-specific (`mcp_max_output_bytes` — e.g. a winning repo-level
//! `[mcp] max_output_bytes`, seeded per session by the shell) / default,
//! when present in resources
//! 2. Host-seeded effective limit via [`set_mcp_max_output_bytes`] (host
//! resolves requirements > env > config > remote config > default once at
//! bootstrap / remote-config refresh and stores the result)
//! 3. When host has not seeded (`0`): env
//! [`ENV_KIGI_MAX_MCP_OUTPUT_BYTES`] / [`ENV_MAX_MCP_OUTPUT_BYTES`]
//! 4. Built-in default
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use kigi_tool_runtime::ToolCallContext;
use crate::types::output::{MCPOutputDetails, ToolOutput};
use crate::types::tool::ToolKind;
use crate::util::query_tools::{QueryTools, examples_clause};
use crate::util::truncate::format_bytes;
/// Default inline limit for MCP tool output in chat state (bytes, not tokens).
pub const MCP_MAX_OUTPUT_BYTES: usize = 20_000;
/// Env override for the MCP inline output cap (bytes).
/// Some agents use `MAX_MCP_OUTPUT_TOKENS`; we bound by **bytes** because
/// truncation is byte-oriented (`truncate_str`).
pub const ENV_MAX_MCP_OUTPUT_BYTES: &str = "MAX_MCP_OUTPUT_BYTES";
/// Grok-native env override for the MCP inline output cap (bytes).
pub const ENV_KIGI_MAX_MCP_OUTPUT_BYTES: &str = "KIGI_MAX_MCP_OUTPUT_BYTES";
/// Process-wide effective limit. `0` = host has not seeded; fall through to
/// env / default. The shell writes the *fully resolved* stack here so free-
/// function tool dispatch (no live `Config`) sees the same value.
static EFFECTIVE_MCP_MAX_OUTPUT_BYTES: AtomicUsize = AtomicUsize::new(0);
/// Host (shell) sets the fully-resolved MCP output cap in bytes.
///
/// Pass the already-resolved limit (requirements > env > config > remote config >
/// default). Pass `0` only in tests to clear and fall through to env / default.
pub fn set_mcp_max_output_bytes(bytes: usize) {
EFFECTIVE_MCP_MAX_OUTPUT_BYTES.store(bytes, Ordering::Relaxed);
}
/// Parse a positive byte limit from an env var. Zero / unparseable → `None`.
fn parse_positive_bytes_env(name: &str) -> Option<usize> {
let raw = std::env::var(name).ok()?;
let n = raw.trim().parse::<u64>().ok()?;
usize::try_from(n).ok().filter(|n| *n > 0)
}
/// Env tier: `KIGI_MAX_MCP_OUTPUT_BYTES` then `MAX_MCP_OUTPUT_BYTES`.
///
/// Grok-native wins when both are set. Positive integers only. Used by the
/// shell resolver and as the standalone fallback when the host has not called
/// [`set_mcp_max_output_bytes`].
pub fn mcp_max_output_bytes_from_env() -> Option<usize> {
parse_positive_bytes_env(ENV_KIGI_MAX_MCP_OUTPUT_BYTES)
.or_else(|| parse_positive_bytes_env(ENV_MAX_MCP_OUTPUT_BYTES))
}
/// Effective MCP inline output cap for this process.
///
/// Host-seeded value if set; otherwise env; otherwise [`MCP_MAX_OUTPUT_BYTES`].
pub fn mcp_max_output_bytes() -> usize {
match EFFECTIVE_MCP_MAX_OUTPUT_BYTES.load(Ordering::Relaxed) {
0 => mcp_max_output_bytes_from_env().unwrap_or(MCP_MAX_OUTPUT_BYTES),
n => n,
}
}
pub(crate) const LONG_LINE_BYTES: usize = 2_000;
/// How a truncated MCP payload is saved and described to the model.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum McpDumpKind {
LongLineJson,
Json,
LongLineText,
Other,
}
impl McpDumpKind {
pub(crate) fn classify(text: &str) -> Self {
let trimmed = text.trim();
let is_json = matches!(trimmed.as_bytes().first(), Some(b'{' | b'['))
&& serde_json::from_str::<serde::de::IgnoredAny>(trimmed).is_ok();
let has_long_line = text.lines().map(str::len).max().unwrap_or(0) > LONG_LINE_BYTES;
match (is_json, has_long_line) {
(true, true) => Self::LongLineJson,
(true, false) => Self::Json,
(false, true) => Self::LongLineText,
(false, false) => Self::Other,
}
}
pub(crate) fn extension(self) -> &'static str {
match self {
Self::LongLineJson | Self::Json => "json",
Self::LongLineText | Self::Other => "txt",
}
}
pub(crate) fn steer(self, shell: &str, tools: QueryTools) -> String {
match self {
Self::LongLineJson => format!(
" The full output is valid JSON with a very long line, so \
grep/read_file are ineffective on it — use `{shell}` to query the \
saved file{eg}.",
eg = examples_clause(&tools.json_tools()),
),
Self::Json => format!(
" The full output is valid JSON saved to the file above; use \
`{shell}` to query it{eg}.",
eg = examples_clause(&tools.json_tools()),
),
Self::LongLineText => format!(
" The full output has a very long line, so grep/read_file are \
ineffective on it — use `{shell}` to slice/search the saved \
file{eg}.",
eg = examples_clause(&tools.text_tools()),
),
Self::Other => String::new(),
}
}
}
/// Resolved settings for truncating one MCP payload (inline limit, dump dir,
/// shell tool name, call id). Build with [`McpTruncateContext::from_tool_ctx`].
pub struct McpTruncateContext {
pub(crate) max_output_bytes: usize,
pub(crate) session_folder: Option<PathBuf>,
pub(crate) shell_tool: String,
pub(crate) call_id: String,
}
impl McpTruncateContext {
pub async fn from_tool_ctx(ctx: &ToolCallContext, tool_key: &str) -> Self {
let call_id = ctx.call_id.as_str().to_string();
let resolved_default = mcp_max_output_bytes();
match crate::types::tool_metadata::shared_resources(ctx) {
Ok(res) => {
let guard = res.lock().await;
let max_output_bytes = guard
.get::<crate::types::resources::TruncationCfg>()
.map(|cfg| cfg.0.mcp_max_output_bytes_for(tool_key, resolved_default))
.unwrap_or(resolved_default);
let session_folder = guard
.get::<crate::types::resources::SessionFolder>()
.map(|f| f.0.clone());
let shell_tool = guard
.get::<crate::types::template_renderer::TemplateRenderer>()
.and_then(|r| r.tool_for_kind(ToolKind::Execute))
.map(str::to_string)
.unwrap_or_else(|| "bash".to_string());
Self {
max_output_bytes,
session_folder,
shell_tool,
call_id,
}
}
Err(_) => Self {
max_output_bytes: resolved_default,
session_folder: None,
shell_tool: "bash".to_string(),
call_id,
},
}
}
}
/// Map a `call_id` to safe filename chars so a `/` or `..` in a wire-supplied
/// id (only validated as non-empty) cannot escape the session `mcp/` dir.
fn sanitized_stem(call_id: &str) -> String {
call_id
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect()
}
/// Truncate `text` in place when over the limit, dumping the full payload to
/// the session `mcp/` dir (when available) with a pointer appended.
async fn truncate_mcp_text(text: &mut String, trunc_ctx: &McpTruncateContext) {
if text.len() <= trunc_ctx.max_output_bytes {
return;
}
let total_bytes = text.len();
let kind = McpDumpKind::classify(text.as_str());
let output_file_path = trunc_ctx.session_folder.as_ref().map(|folder| {
folder.join("mcp").join(format!(
"{}.{}",
sanitized_stem(&trunc_ctx.call_id),
kind.extension()
))
});
let file_hint = if let Some(ref path) = output_file_path {
if let Some(parent) = path.parent() {
let _ = tokio::fs::create_dir_all(parent).await;
}
match tokio::fs::write(path, text.as_bytes()).await {
Ok(()) => format!(" Full output written to: {}.", path.to_string_lossy()),
Err(e) => {
tracing::warn!(
path = %path.display(),
error = %e,
"Failed to write full MCP output to file"
);
String::new()
}
}
} else {
String::new()
};
let truncated =
crate::util::truncate::truncate_str(text.as_str(), trunc_ctx.max_output_bytes).to_owned();
let steer = if file_hint.is_empty() {
String::new()
} else {
kind.steer(&trunc_ctx.shell_tool, QueryTools::detect())
};
*text = format!(
"{}\n\n[MCP output truncated: showing first {} of {}.{}{}]",
truncated,
format_bytes(trunc_ctx.max_output_bytes),
format_bytes(total_bytes),
file_hint,
steer,
);
}
/// Bound the `MCP`/`Text` variants to the inline size limit, keeping a preview
/// and dumping the full payload to disk. Other variants are returned untouched.
pub async fn truncate_tool_output(
mut output: ToolOutput,
trunc_ctx: &McpTruncateContext,
) -> ToolOutput {
match &mut output {
ToolOutput::MCP(mcp) => {
let text = match mcp.output_mut() {
MCPOutputDetails::OkayOutput(t) | MCPOutputDetails::Error(t) => t,
};
truncate_mcp_text(text, trunc_ctx).await;
}
ToolOutput::Text(text_out) => {
truncate_mcp_text(&mut text_out.text, trunc_ctx).await;
}
_ => {}
}
output
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg_with_folder(folder: PathBuf, max: usize) -> McpTruncateContext {
McpTruncateContext {
max_output_bytes: max,
session_folder: Some(folder),
shell_tool: "bash".to_string(),
call_id: "call-test".to_string(),
}
}
/// Serialize tests that mutate the process-global effective limit / env.
fn with_mcp_limit_lock<R>(f: impl FnOnce() -> R) -> R {
static LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
f()
}
#[test]
fn host_set_overrides_env_fallback() {
with_mcp_limit_lock(|| {
let prev = EFFECTIVE_MCP_MAX_OUTPUT_BYTES.load(Ordering::Relaxed);
// Clear host seed; with no env, effective limit is the built-in default.
set_mcp_max_output_bytes(0);
let prev_max = std::env::var(ENV_MAX_MCP_OUTPUT_BYTES).ok();
let prev_grok = std::env::var(ENV_KIGI_MAX_MCP_OUTPUT_BYTES).ok();
unsafe {
std::env::remove_var(ENV_MAX_MCP_OUTPUT_BYTES);
std::env::remove_var(ENV_KIGI_MAX_MCP_OUTPUT_BYTES);
}
assert_eq!(
mcp_max_output_bytes(),
MCP_MAX_OUTPUT_BYTES,
"unset host + unset env → built-in default"
);
set_mcp_max_output_bytes(10_000);
assert_eq!(mcp_max_output_bytes(), 10_000, "host seed wins over env");
set_mcp_max_output_bytes(0);
assert_eq!(
mcp_max_output_bytes(),
MCP_MAX_OUTPUT_BYTES,
"cleared host falls through to default"
);
unsafe {
match prev_max {
Some(v) => std::env::set_var(ENV_MAX_MCP_OUTPUT_BYTES, v),
None => std::env::remove_var(ENV_MAX_MCP_OUTPUT_BYTES),
}
match prev_grok {
Some(v) => std::env::set_var(ENV_KIGI_MAX_MCP_OUTPUT_BYTES, v),
None => std::env::remove_var(ENV_KIGI_MAX_MCP_OUTPUT_BYTES),
}
}
set_mcp_max_output_bytes(prev);
});
}
#[test]
fn env_parser_rejects_zero_and_junk() {
with_mcp_limit_lock(|| {
let prev_max = std::env::var(ENV_MAX_MCP_OUTPUT_BYTES).ok();
let prev_grok = std::env::var(ENV_KIGI_MAX_MCP_OUTPUT_BYTES).ok();
unsafe {
std::env::remove_var(ENV_MAX_MCP_OUTPUT_BYTES);
std::env::remove_var(ENV_KIGI_MAX_MCP_OUTPUT_BYTES);
}
assert_eq!(mcp_max_output_bytes_from_env(), None);
unsafe { std::env::set_var(ENV_MAX_MCP_OUTPUT_BYTES, "0") };
assert_eq!(mcp_max_output_bytes_from_env(), None);
unsafe { std::env::set_var(ENV_MAX_MCP_OUTPUT_BYTES, "not-a-number") };
assert_eq!(mcp_max_output_bytes_from_env(), None);
unsafe { std::env::set_var(ENV_MAX_MCP_OUTPUT_BYTES, "12345") };
assert_eq!(mcp_max_output_bytes_from_env(), Some(12_345));
// GROK_* wins over MAX_* when both set.
unsafe { std::env::set_var(ENV_KIGI_MAX_MCP_OUTPUT_BYTES, "99999") };
assert_eq!(mcp_max_output_bytes_from_env(), Some(99_999));
unsafe { std::env::remove_var(ENV_KIGI_MAX_MCP_OUTPUT_BYTES) };
assert_eq!(mcp_max_output_bytes_from_env(), Some(12_345));
unsafe {
match prev_max {
Some(v) => std::env::set_var(ENV_MAX_MCP_OUTPUT_BYTES, v),
None => std::env::remove_var(ENV_MAX_MCP_OUTPUT_BYTES),
}
match prev_grok {
Some(v) => std::env::set_var(ENV_KIGI_MAX_MCP_OUTPUT_BYTES, v),
None => std::env::remove_var(ENV_KIGI_MAX_MCP_OUTPUT_BYTES),
}
}
});
}
#[tokio::test]
async fn text_over_limit_truncates_and_dumps_full_payload() {
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_with_folder(dir.path().to_path_buf(), 100);
let full = "x".repeat(5_000);
let out = truncate_tool_output(ToolOutput::Text(full.clone().into()), &cfg).await;
let ToolOutput::Text(t) = out else {
panic!("expected Text");
};
assert!(t.text.len() < full.len());
assert!(t.text.starts_with(&"x".repeat(100)), "preview prefix kept");
assert!(t.text.contains("[MCP output truncated:"));
assert!(t.text.contains("Full output written to:"));
let dump = dir.path().join("mcp").join("call-test.txt");
assert_eq!(tokio::fs::read_to_string(&dump).await.unwrap(), full);
}
#[tokio::test]
async fn boundary_exact_limit_untouched_one_over_truncates() {
let dir = tempfile::tempdir().unwrap();
let cfg = cfg_with_folder(dir.path().to_path_buf(), 100);
let at = truncate_tool_output(ToolOutput::Text("a".repeat(100).into()), &cfg).await;
let ToolOutput::Text(t) = at else {
panic!("expected Text")
};
assert_eq!(t.text, "a".repeat(100), "exactly at limit is untouched");
assert!(
!dir.path().join("mcp").exists(),
"no dump when not truncated"
);
let over = truncate_tool_output(ToolOutput::Text("b".repeat(101).into()), &cfg).await;
let ToolOutput::Text(t) = over else {
panic!("expected Text")
};
assert!(
t.text.contains("[MCP output truncated:"),
"one over truncates"
);
}
#[tokio::test]
async fn traversal_in_call_id_cannot_escape_session_dir() {
let dir = tempfile::tempdir().unwrap();
let cfg = McpTruncateContext {
max_output_bytes: 100,
session_folder: Some(dir.path().to_path_buf()),
shell_tool: "bash".to_string(),
call_id: "../../evil".to_string(),
};
let out = truncate_tool_output(ToolOutput::Text("x".repeat(5_000).into()), &cfg).await;
let ToolOutput::Text(t) = out else {
panic!("expected Text");
};
let mcp_dir = dir.path().join("mcp");
assert!(!t.text.contains(".."), "no traversal sequence in pointer");
let entries: Vec<_> = std::fs::read_dir(&mcp_dir)
.unwrap()
.map(|e| e.unwrap().path())
.collect();
assert_eq!(entries.len(), 1, "exactly one dump file");
assert!(entries[0].starts_with(&mcp_dir), "dump stayed inside mcp/");
}
#[tokio::test]
async fn non_text_variant_passes_through() {
let cfg = McpTruncateContext {
max_output_bytes: 1,
session_folder: None,
shell_tool: "bash".to_string(),
call_id: "call-test".to_string(),
};
let out = truncate_tool_output(
ToolOutput::SearchTool(crate::types::output::SearchToolOutput {
result_count: 1,
content: "anything".to_string(),
}),
&cfg,
)
.await;
let ToolOutput::SearchTool(s) = out else {
panic!("expected SearchTool");
};
assert_eq!(s.content, "anything", "passthrough leaves content intact");
}
}
+36
View File
@@ -0,0 +1,36 @@
pub mod base64_images;
pub mod binary;
pub mod command_display;
pub mod env;
pub mod fs;
pub mod git_detect;
pub mod hash;
pub mod image_compress;
pub mod image_validate;
pub mod kigi_home;
pub mod mcp_truncate;
pub mod path_suggestions;
pub(crate) mod query_tools;
pub mod remap;
pub mod serde_base64;
pub mod spawn;
pub mod truncate;
pub mod unicode_confusables;
pub use command_display::strip_redundant_session_cd;
#[cfg(unix)]
pub use env::detach_from_tty;
pub use env::substitute_plugin_tokens;
pub use env::{KIGI_AGENT_ENV, KIGI_AGENT_ENV_VALUE, apply_grok_agent_marker, pager_env};
pub use fs::{UnicodePathMatch, canonicalize_with_timeout, try_resolve_unicode_filename};
pub use kigi_home::{kigi_application, kigi_home};
pub use kigi_tty_utils::detach_std_command;
pub use path_suggestions::format_not_found_error;
pub use remap::{remap_json_keys, remap_schema_properties, reverse_map};
pub use spawn::{
ProcessGroup, ProcessScope, detach_command, global_process_scope, new_process_group,
};
pub use truncate::{
DEFAULT_SOFT_WRAP_WIDTH, ceil_char_boundary, estimate_tokens, floor_char_boundary,
soft_wrap_line, soft_wrap_lines, truncate_line, truncate_str, truncate_str_with_marker,
};
@@ -0,0 +1,391 @@
//! Path-not-found enrichment hints for tool error messages.
//!
//! Enriches "does not exist" errors from `list_dir`, `read_file`,
//! `search_replace`, and `grep` with actionable hints.
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::Duration;
/// Ceiling for the single blocking-thread filesystem probe.
const HINT_TIMEOUT: Duration = Duration::from_millis(100);
/// Max similar-name suggestions
const MAX_SIMILAR: usize = 3;
/// Reduces noise from single-character names that would match on too many entries
const MIN_LEAF_LEN: usize = 2;
/// Minimum stem length for reverse substring matching (query contains entry).
/// Prevents short stems from over-matching.
const MIN_REVERSE_STEM_LEN: usize = 4;
/// Enrichment hints for a path that was not found.
#[derive(Debug, Clone)]
pub struct PathNotFoundHint {
/// A corrected path from "dropped repo folder" detection.
pub suggestion: Option<PathBuf>,
/// Up to [`MAX_SIMILAR`] entries from the parent directory whose names
/// are case-insensitive substring matches of the missing leaf.
pub similar: Vec<PathBuf>,
/// Always-present CWD note for model re-orientation.
pub cwd_note: String,
}
impl fmt::Display for PathNotFoundHint {
/// Formats as a suffix to append after `"Error: {path} does not exist."`.
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
if let Some(ref s) = self.suggestion {
write!(f, " Did you mean {}?", s.display())?;
} else if !self.similar.is_empty() {
let names: Vec<&str> = self
.similar
.iter()
.filter_map(|p| p.file_name().and_then(|n| n.to_str()))
.collect();
write!(
f,
"\nSimilar entries in parent directory: {}",
names.join(", ")
)?;
}
write!(f, "\n{}", self.cwd_note)
}
}
/// Build hints for a path-not-found error.
///
/// Returns [`PathNotFoundHint`].
///
/// `path` is the resolved (real) filesystem path that failed.
/// `display_cwd` is the model-facing working directory (for the CWD note).
#[tracing::instrument(name = "fs.path_not_found_hint", skip_all)]
pub async fn path_not_found_hint(path: &Path, cwd: &Path, display_cwd: &Path) -> PathNotFoundHint {
let cwd_note = format!(
"Note: your current working directory is {}",
display_cwd.display()
);
// All filesystem probing runs in a single spawn_blocking.
let path_owned = path.to_path_buf();
let cwd_owned = cwd.to_path_buf();
let result = tokio::time::timeout(
HINT_TIMEOUT,
tokio::task::spawn_blocking(move || collect_hints(&path_owned, &cwd_owned)),
)
.await;
let (suggestion, similar) = match result {
Ok(Ok(val)) => val,
_ => (None, Vec::new()),
};
// Remap resolved worktree path to display space so the model never
// sees internal paths (e.g. /worktree/abc-123/...).
let suggestion = suggestion.map(|corrected| {
corrected
.strip_prefix(cwd)
.map(|rel| display_cwd.join(rel))
.unwrap_or_else(|_| {
tracing::warn!(
corrected = %corrected.display(),
cwd = %cwd.display(),
"corrected path not under cwd; falling back to corrected path"
);
corrected
})
});
PathNotFoundHint {
suggestion,
similar,
cwd_note,
}
}
/// Format a path-not-found error message.
///
/// When `hints_enabled` is `false`, returns a bare error string.
/// When `true`, appends CWD note, "did you mean?" correction, or similar-name
/// suggestions via [`path_not_found_hint`].
///
/// `display_path` is the model-facing path (for the error message).
/// `resolved_path` is the real filesystem path (for hint lookups).
pub async fn format_not_found_error(
display_path: &Path,
resolved_path: &Path,
cwd: &Path,
display_cwd: &Path,
hints_enabled: bool,
) -> String {
let base = format!("Error: {} does not exist.", display_path.display());
if !hints_enabled {
return base;
}
let hint = path_not_found_hint(resolved_path, cwd, display_cwd).await;
format!("{base}{hint}")
}
/// Returns `(suggestion, similar)` where `suggestion` is a corrected path from
/// "dropped repo folder" detection (raw, not yet remapped to display space) and
/// `similar` is a list of substring-matched sibling entries.
fn collect_hints(path: &Path, cwd: &Path) -> (Option<PathBuf>, Vec<PathBuf>) {
if let Some(corrected) = try_suggest_under_cwd(path, cwd) {
return (Some(corrected), Vec::new());
}
(None, find_similar_entries(path))
}
/// Detect the "dropped repo folder" pattern.
///
/// If the model asks for `/parent/foo` but cwd is `/parent/repo`, check
/// whether `/parent/repo/foo` exists. Only fires when the requested path
/// is under cwd's parent but not already under cwd.
fn try_suggest_under_cwd(path: &Path, cwd: &Path) -> Option<PathBuf> {
if !path.is_absolute() || path.starts_with(cwd) {
return None;
}
let cwd_parent = cwd.parent()?;
let rel_from_parent = path.strip_prefix(cwd_parent).ok()?;
// Guard against existing paths outside of repo.
if let Some(std::path::Component::Normal(first)) = rel_from_parent.components().next() {
let sibling = cwd_parent.join(first);
if sibling != cwd && sibling.exists() {
return None;
}
}
let candidate = cwd.join(rel_from_parent);
candidate.exists().then_some(candidate)
}
/// Scan the parent directory for entries whose names are case-insensitive
/// substring matches of the missing leaf name.
fn find_similar_entries(path: &Path) -> Vec<PathBuf> {
let parent = match path.parent() {
Some(p) => p,
None => return Vec::new(),
};
let base = match path.file_name().and_then(|n| n.to_str()) {
Some(b) if b.len() >= MIN_LEAF_LEN => b.to_lowercase(),
_ => return Vec::new(),
};
// Strip extension from the query leaf for stem-level comparison.
let base_stem = Path::new(&base)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(&base)
.to_lowercase();
let read_dir = match std::fs::read_dir(parent) {
Ok(rd) => rd,
Err(_) => return Vec::new(),
};
let mut matches = Vec::new();
for entry in read_dir.flatten() {
let name = entry.file_name().to_string_lossy().to_lowercase();
if name == base {
continue;
}
let name_stem = Path::new(&name)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or(&name)
.to_lowercase();
// Find file matches that are substrings or reverse substrings up to MIN_REVERSE_STEM_LEN
let forward = name_stem.contains(&base_stem);
let reverse =
!forward && name_stem.len() >= MIN_REVERSE_STEM_LEN && base_stem.contains(&name_stem);
if forward || reverse {
matches.push(entry.path());
if matches.len() >= MAX_SIMILAR {
break;
}
}
}
matches
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
// Unit tests here cover internal invariants (guards, caps, priority,
// Display formatting). Broader integration fixtures live in
// tests/path_suggestions_production.rs.
// ── CWD note ──────────────────────────────────────────────────────
#[tokio::test]
async fn cwd_note_always_present() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path();
let missing = cwd.join("nonexistent");
let hint = path_not_found_hint(&missing, cwd, cwd).await;
assert!(hint.cwd_note.contains(&cwd.display().to_string()));
assert!(hint.suggestion.is_none());
assert!(hint.similar.is_empty());
}
// ── "dropped repo folder" detection ───────────────────────────────
#[tokio::test]
async fn dropped_repo_folder_detected() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let target = repo.join("src");
std::fs::create_dir_all(&target).unwrap();
let bad_path = tmp.path().join("src");
let hint = path_not_found_hint(&bad_path, &repo, &repo).await;
assert_eq!(hint.suggestion.as_deref(), Some(target.as_path()));
}
#[tokio::test]
async fn dropped_repo_folder_not_triggered_for_path_under_cwd() {
let tmp = TempDir::new().unwrap();
let cwd = tmp.path().to_path_buf();
let path = cwd.join("some_missing_file.rs");
let hint = path_not_found_hint(&path, &cwd, &cwd).await;
assert!(hint.suggestion.is_none());
}
#[tokio::test]
async fn dropped_repo_folder_not_triggered_for_existing_sibling() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
let repo_backup = tmp.path().join("repo_backup");
std::fs::create_dir_all(&repo).unwrap();
std::fs::create_dir_all(&repo_backup).unwrap();
std::fs::create_dir_all(repo.join("repo_backup")).unwrap();
std::fs::write(repo.join("repo_backup/config"), b"").unwrap();
let bad_path = repo_backup.join("config");
let hint = path_not_found_hint(&bad_path, &repo, &repo).await;
assert!(
hint.suggestion.is_none(),
"should not suggest path under cwd when model targets an existing sibling"
);
}
#[tokio::test]
async fn suggestion_takes_priority_over_similar_scan() {
let tmp = TempDir::new().unwrap();
let repo = tmp.path().join("repo");
std::fs::create_dir_all(repo.join("src")).unwrap();
std::fs::create_dir(tmp.path().join("src_old")).unwrap();
let bad_path = tmp.path().join("src");
let hint = path_not_found_hint(&bad_path, &repo, &repo).await;
assert!(hint.suggestion.is_some());
assert!(hint.similar.is_empty());
}
// ── similar-name scan (internal invariants) ───────────────────────
#[tokio::test]
async fn similar_name_multi_match() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("helpers.rs"), b"").unwrap();
std::fs::write(tmp.path().join("helper_test.rs"), b"").unwrap();
let missing = tmp.path().join("helper");
let hint = path_not_found_hint(&missing, tmp.path(), tmp.path()).await;
let names: Vec<String> = hint
.similar
.iter()
.filter_map(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.collect();
assert!(names.contains(&"helpers.rs".to_string()), "got: {names:?}");
assert!(
names.contains(&"helper_test.rs".to_string()),
"got: {names:?}"
);
}
#[tokio::test]
async fn similar_name_cap_at_max() {
let tmp = TempDir::new().unwrap();
for i in 0..10 {
std::fs::write(tmp.path().join(format!("test_{i}.rs")), b"").unwrap();
}
let missing = tmp.path().join("test");
let hint = path_not_found_hint(&missing, tmp.path(), tmp.path()).await;
assert_eq!(hint.similar.len(), MAX_SIMILAR);
}
#[tokio::test]
async fn similar_name_short_entry_not_matched() {
let tmp = TempDir::new().unwrap();
std::fs::write(tmp.path().join("he"), b"").unwrap();
std::fs::write(tmp.path().join("rs"), b"").unwrap();
let missing = tmp.path().join("helpers_test");
let hint = path_not_found_hint(&missing, tmp.path(), tmp.path()).await;
assert!(
hint.similar.is_empty(),
"short entries should not match: got {:?}",
hint.similar
);
}
// ── Display formatting ────────────────────────────────────────────
#[test]
fn display_with_suggestion() {
let hint = PathNotFoundHint {
suggestion: Some(PathBuf::from("/project/repo/src")),
similar: Vec::new(),
cwd_note: "Note: your current working directory is /project/repo".into(),
};
let output = hint.to_string();
assert!(output.contains("Did you mean /project/repo/src?"));
assert!(output.contains("Note: your current working directory is"));
}
#[test]
fn display_with_similar() {
let hint = PathNotFoundHint {
suggestion: None,
similar: vec![
PathBuf::from("/project/helpers.rs"),
PathBuf::from("/project/helper_test.rs"),
],
cwd_note: "Note: your current working directory is /project".into(),
};
let output = hint.to_string();
assert!(output.contains("Similar entries in parent directory:"));
assert!(output.contains("helpers.rs"));
assert!(output.contains("helper_test.rs"));
}
#[test]
fn display_empty() {
let hint = PathNotFoundHint {
suggestion: None,
similar: Vec::new(),
cwd_note: "Note: your current working directory is /project".into(),
};
let output = hint.to_string();
assert!(!output.contains("Did you mean"));
assert!(!output.contains("Similar entries"));
assert!(output.contains("Note: your current working directory is /project"));
}
}
@@ -0,0 +1,137 @@
//! `$PATH`-aware helper for steering messages that suggest shell tools.
//!
//! Hints that recommend concrete binaries (`jq`, `python3`, `sed`, …) must
//! only name tools that actually exist on the tool server, with no
//! "if available" hedge. Consumers call [`QueryTools::detect`] once and build
//! an example clause via [`examples_clause`]; when nothing relevant is
//! installed the clause is empty so the surrounding hint reads cleanly.
//!
//! Shared by the `use_tool` MCP-dump steer and the `search_replace`
//! Unicode-confusable hint.
/// Query tools present on the tool server's `$PATH`, each `Some(name)` when
/// detected; see [`kigi_config::shell::is_command_available`].
#[derive(Debug, Clone, Copy, Default)]
pub(crate) struct QueryTools {
/// `jq`, if present.
pub(crate) jq: Option<&'static str>,
/// Resolved python interpreter (`python3` preferred), if any.
pub(crate) python: Option<&'static str>,
/// `sed`, if present.
pub(crate) sed: Option<&'static str>,
/// `cut`, if present.
pub(crate) cut: Option<&'static str>,
}
impl QueryTools {
/// Probe `$PATH` for the tools the steer may suggest; resolved once.
pub(crate) fn detect() -> Self {
use kigi_config::shell::is_command_available;
use std::sync::OnceLock;
static DETECTED: OnceLock<QueryTools> = OnceLock::new();
*DETECTED.get_or_init(|| {
let present = |name: &'static str| is_command_available(name).then_some(name);
Self {
jq: present("jq"),
python: present("python3").or_else(|| present("python")),
sed: present("sed"),
cut: present("cut"),
}
})
}
/// Backtick-wrapped tools for querying structured JSON, preference order.
pub(crate) fn json_tools(self) -> Vec<String> {
Self::wrap([self.jq, self.python])
}
/// Backtick-wrapped tools for slicing/searching a long-line text file.
pub(crate) fn text_tools(self) -> Vec<String> {
Self::wrap([self.python, self.sed, self.cut])
}
/// Backtick-wrapped tools that can script an in-place file edit
/// (`cut` is excluded: it slices, it does not edit).
pub(crate) fn edit_tools(self) -> Vec<String> {
Self::wrap([self.python, self.sed])
}
/// Backtick-wrap the tools that are present, dropping absent ones.
fn wrap(tools: impl IntoIterator<Item = Option<&'static str>>) -> Vec<String> {
tools
.into_iter()
.flatten()
.map(|t| format!("`{t}`"))
.collect()
}
}
/// `" (e.g. `jq` or `python3`)"` for the present tools, or `""` when none were
/// detected — so a steer never names a tool that isn't installed.
pub(crate) fn examples_clause(tools: &[String]) -> String {
match tools {
[] => String::new(),
[a] => format!(" (e.g. {a})"),
[a, b] => format!(" (e.g. {a} or {b})"),
[rest @ .., last] => format!(" (e.g. {}, or {last})", rest.join(", ")),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn all() -> QueryTools {
QueryTools {
jq: Some("jq"),
python: Some("python3"),
sed: Some("sed"),
cut: Some("cut"),
}
}
#[test]
fn examples_clause_formats_lists() {
assert_eq!(examples_clause(&[]), "");
assert_eq!(examples_clause(&["`jq`".into()]), " (e.g. `jq`)");
assert_eq!(
examples_clause(&["`jq`".into(), "`python3`".into()]),
" (e.g. `jq` or `python3`)"
);
assert_eq!(
examples_clause(&["`python3`".into(), "`sed`".into(), "`cut`".into()]),
" (e.g. `python3`, `sed`, or `cut`)"
);
}
/// Membership and preference order per tool set; absent tools are dropped
/// (these are the invariants every consumer steer relies on).
#[test]
fn tool_sets_membership_and_order() {
assert_eq!(all().json_tools(), vec!["`jq`", "`python3`"]);
assert_eq!(all().text_tools(), vec!["`python3`", "`sed`", "`cut`"]);
assert_eq!(all().edit_tools(), vec!["`python3`", "`sed`"]);
let partial = QueryTools {
jq: None,
python: None,
sed: Some("sed"),
cut: Some("cut"),
};
assert_eq!(partial.json_tools(), Vec::<String>::new());
assert_eq!(partial.text_tools(), vec!["`sed`", "`cut`"]);
assert_eq!(partial.edit_tools(), vec!["`sed`"]);
let none = QueryTools::default();
assert!(none.json_tools().is_empty());
assert!(none.text_tools().is_empty());
assert!(none.edit_tools().is_empty());
}
/// `cut` can slice but not edit in place — it must never be suggested for
/// editing a file.
#[test]
fn edit_tools_exclude_cut() {
assert_eq!(all().edit_tools(), vec!["`python3`", "`sed`"]);
}
}
+180
View File
@@ -0,0 +1,180 @@
//! Utilities for remapping tool/parameter names in JSON values and schemas.
use std::collections::HashMap;
/// Remap top-level keys in a JSON object using a reverse map (model-facing → canonical).
///
/// Used to transform incoming tool input from the model (which may use randomized
/// parameter names) back to canonical names before deserialization.
///
/// Only remaps top-level keys. Nested objects are not affected.
/// Keys not in the map are passed through unchanged.
pub fn remap_json_keys(
raw: serde_json::Value,
reverse_map: &HashMap<String, String>,
) -> serde_json::Value {
match raw {
serde_json::Value::Object(map) => {
let mut new_map = serde_json::Map::new();
for (key, value) in map {
let canonical = reverse_map.get(&key).cloned().unwrap_or(key);
new_map.insert(canonical, value);
}
serde_json::Value::Object(new_map)
}
other => other,
}
}
/// Build a reverse map (model-facing → canonical) from a canonical → model-facing map.
///
/// Panics in debug mode if two canonical names map to the same model-facing
/// name (collision would silently drop one mapping).
pub fn reverse_map(map: &HashMap<String, String>) -> HashMap<String, String> {
let reversed: HashMap<_, _> = map.iter().map(|(k, v)| (v.clone(), k.clone())).collect();
debug_assert_eq!(
reversed.len(),
map.len(),
"tool name map has duplicate model-facing names"
);
reversed
}
/// Remap property names in a JSON Schema object.
///
/// Renames keys in the `"properties"` object and updates entries in the
/// `"required"` array according to the given map (canonical → model-facing).
/// Properties/required entries not in the map keep their canonical names.
pub fn remap_schema_properties(
schema: &serde_json::Value,
param_map: &HashMap<String, String>,
) -> serde_json::Value {
if param_map.is_empty() {
return schema.clone();
}
let mut schema = schema.clone();
// Remap keys in "properties"
if let Some(serde_json::Value::Object(props)) = schema.get("properties").cloned() {
let mut new_props = serde_json::Map::new();
for (key, value) in props {
let new_key = param_map.get(&key).cloned().unwrap_or(key);
new_props.insert(new_key, value);
}
schema["properties"] = serde_json::Value::Object(new_props);
}
// Remap entries in "required" array
if let Some(serde_json::Value::Array(items)) = schema.get("required").cloned() {
let new_items: Vec<serde_json::Value> = items
.into_iter()
.map(|item| {
if let serde_json::Value::String(s) = &item
&& let Some(mapped) = param_map.get(s.as_str())
{
return serde_json::Value::String(mapped.clone());
}
item
})
.collect();
schema["required"] = serde_json::Value::Array(new_items);
}
schema
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn remap_json_keys_basic() {
let raw = serde_json::json!({"find": "old", "replace_with": "new"});
let reverse = HashMap::from([
("find".to_string(), "old_string".to_string()),
("replace_with".to_string(), "new_string".to_string()),
]);
let result = remap_json_keys(raw, &reverse);
assert_eq!(result["old_string"], "old");
assert_eq!(result["new_string"], "new");
}
#[test]
fn remap_json_keys_unmapped_passthrough() {
let raw = serde_json::json!({"file_path": "test.rs", "unknown": true});
let reverse = HashMap::from([("find".to_string(), "old_string".to_string())]);
let result = remap_json_keys(raw, &reverse);
assert_eq!(result["file_path"], "test.rs");
assert_eq!(result["unknown"], true);
}
#[test]
fn remap_json_keys_empty_map() {
let raw = serde_json::json!({"old_string": "x"});
let result = remap_json_keys(raw.clone(), &HashMap::new());
assert_eq!(result, raw);
}
#[test]
fn remap_json_keys_non_object_passthrough() {
let raw = serde_json::json!("just a string");
let result = remap_json_keys(raw.clone(), &HashMap::from([("a".into(), "b".into())]));
assert_eq!(result, raw);
let raw_arr = serde_json::json!([1, 2, 3]);
let result = remap_json_keys(raw_arr.clone(), &HashMap::from([("a".into(), "b".into())]));
assert_eq!(result, raw_arr);
}
#[test]
fn reverse_map_basic() {
let map = HashMap::from([
("old_string".to_string(), "find".to_string()),
("new_string".to_string(), "replace_with".to_string()),
]);
let rev = reverse_map(&map);
assert_eq!(rev.get("find").unwrap(), "old_string");
assert_eq!(rev.get("replace_with").unwrap(), "new_string");
}
#[test]
fn remap_schema_properties_basic() {
let schema = serde_json::json!({
"properties": {
"old_string": {"type": "string"},
"new_string": {"type": "string"},
"file_path": {"type": "string"},
},
"required": ["file_path", "old_string", "new_string"]
});
let param_map = HashMap::from([
("old_string".to_string(), "find".to_string()),
("new_string".to_string(), "replace_with".to_string()),
]);
let result = remap_schema_properties(&schema, &param_map);
// Properties remapped
assert!(result["properties"]["find"].is_object());
assert!(result["properties"]["replace_with"].is_object());
assert!(result["properties"]["file_path"].is_object());
assert!(result["properties"].get("old_string").is_none());
// Required array remapped
let required: Vec<String> = result["required"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap().to_string())
.collect();
assert!(required.contains(&"find".to_string()));
assert!(required.contains(&"replace_with".to_string()));
assert!(required.contains(&"file_path".to_string()));
assert!(!required.contains(&"old_string".to_string()));
}
#[test]
fn remap_schema_properties_empty_map() {
let schema = serde_json::json!({"properties": {"x": {"type": "string"}}});
let result = remap_schema_properties(&schema, &HashMap::new());
assert_eq!(result, schema);
}
}
@@ -0,0 +1,173 @@
//! Encodes a byte payload as a base64 string instead of a JSON integer array
//! (~4x smaller), for bash output streamed over the hub WebSocket via
//! `BashNotificationBase.output`.
//!
//! The deserializer accepts both the base64 string and the legacy integer-array
//! form, so a new consumer can read an old producer; the serializer always emits
//! base64. The CHANGELOG (2026-05-29) covers the consumer-before-producer deploy
//! ordering this implies. Requires a self-describing format (JSON): the dual-form
//! detection and the `#[serde(flatten)]` on the notification structs both force
//! `deserialize_any`.
use std::fmt;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use serde::de::{self, Deserializer, SeqAccess, Visitor};
use serde::ser::Serializer;
/// Serialize a byte slice as a base64 string.
pub fn serialize<S>(bytes: &[u8], serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&STANDARD.encode(bytes))
}
/// Deserialize `Vec<u8>` from either a base64 string (new form) or an integer
/// array (legacy form).
pub fn deserialize<'de, D>(deserializer: D) -> Result<Vec<u8>, D::Error>
where
D: Deserializer<'de>,
{
deserializer.deserialize_any(BytesVisitor)
}
/// Accepts both a base64 string and a legacy integer array.
struct BytesVisitor;
impl<'de> Visitor<'de> for BytesVisitor {
type Value = Vec<u8>;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a base64 string or an array of bytes")
}
fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
where
E: de::Error,
{
STANDARD.decode(v).map_err(de::Error::custom)
}
fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(v.to_vec())
}
fn visit_byte_buf<E>(self, v: Vec<u8>) -> Result<Self::Value, E>
where
E: de::Error,
{
Ok(v)
}
fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
where
A: SeqAccess<'de>,
{
let mut bytes = Vec::new();
while let Some(b) = seq.next_element::<u8>()? {
bytes.push(b);
}
Ok(bytes)
}
}
#[cfg(test)]
mod tests {
use serde::{Deserialize, Serialize};
use serde_json::json;
#[derive(Debug, PartialEq, Eq, Serialize, Deserialize)]
struct Wrapper {
#[serde(with = "super")]
output: Vec<u8>,
}
#[test]
fn vec_round_trips_binary_bytes() {
let original = Wrapper {
output: vec![0x00, 0xff, 0xfe, 0x80, 0x01, b'h', b'i'],
};
let json = serde_json::to_string(&original).unwrap();
let back: Wrapper = serde_json::from_str(&json).unwrap();
assert_eq!(original, back);
}
#[test]
fn vec_serializes_to_string_not_array() {
let w = Wrapper {
output: b"hello".to_vec(),
};
let value = serde_json::to_value(&w).unwrap();
assert!(
value["output"].is_string(),
"expected base64 string, got {value:?}"
);
assert_eq!(value["output"], json!("aGVsbG8="));
}
#[test]
fn vec_reads_legacy_integer_array() {
let legacy = json!({ "output": [104, 101, 108, 108, 111] });
let w: Wrapper = serde_json::from_value(legacy).unwrap();
assert_eq!(w.output, b"hello".to_vec());
}
#[test]
fn vec_rejects_malformed_base64() {
for bad in [json!("!!!"), json!("abc")] {
let r: Result<Wrapper, _> = serde_json::from_value(json!({ "output": bad }));
assert!(r.is_err(), "malformed base64 {bad:?} must error");
}
}
#[test]
fn vec_rejects_unexpected_type() {
let r: Result<Wrapper, _> = serde_json::from_value(json!({ "output": 5 }));
assert!(r.is_err(), "numeric scalar must error");
}
#[test]
fn vec_empty_round_trips() {
let w = Wrapper { output: vec![] };
let value = serde_json::to_value(&w).unwrap();
assert_eq!(value["output"], json!(""));
let back: Wrapper = serde_json::from_value(value).unwrap();
assert_eq!(back.output, Vec::<u8>::new());
}
#[test]
fn vec_round_trips_large_binary_buffer() {
// Deterministic pseudo-random bytes including 0x00 and 0xff.
let mut data = vec![0u8; 20_000];
let mut state: u32 = 0x1234_5678;
for (i, b) in data.iter_mut().enumerate() {
state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
*b = (state >> 16) as u8;
if i % 257 == 0 {
*b = 0x00;
} else if i % 263 == 0 {
*b = 0xff;
}
}
assert!(data.contains(&0x00) && data.contains(&0xff));
// A bare `Vec<u8>` serializes as the legacy integer array — the baseline
// the base64 form must beat. Measure it before moving `data`.
let int_array_len = serde_json::to_string(&data).unwrap().len();
let w = Wrapper { output: data };
let base64_json = serde_json::to_string(&w).unwrap();
let back: Wrapper = serde_json::from_str(&base64_json).unwrap();
assert_eq!(back.output, w.output);
assert!(
base64_json.len() < int_array_len,
"base64 ({}) should be smaller than int-array ({int_array_len})",
base64_json.len(),
);
}
}
@@ -0,0 +1,10 @@
//! Cross-platform child-process lifecycle helpers for `tokio::process::Command`.
//!
//! All implementations now live in the lightweight [`kigi_tty_utils`] crate
//! so that every crate in the workspace can use them without pulling in the
//! heavyweight `kigi-tools` dependency. This module re-exports the public
//! API for backward compatibility.
pub use kigi_tty_utils::{
ProcessGroup, ProcessScope, detach_command, global_process_scope, new_process_group,
};
@@ -0,0 +1,722 @@
use std::borrow::Cow;
/// Default wrap width for soft-wrapping (used by bash, task_output).
pub const DEFAULT_SOFT_WRAP_WIDTH: usize = 2_000;
/// Default preview shown before a truncation footer.
pub const PREVIEW_SIZE: usize = 2_000;
/// Marker appended by `truncate_str_with_marker` when content is cut.
pub(crate) const TRUNCATION_MARKER: &str = "";
/// Truncate a line to at most `max_chars` characters, respecting UTF-8 boundaries.
/// Content beyond `max_chars` is **discarded** and replaced with a marker.
///
/// Returns `Cow::Borrowed` if the line is already within the limit (zero-copy fast path).
/// Returns `Cow::Owned` with a truncation marker appended if the line was cut.
///
/// Use this for tools where content beyond the limit is genuinely not useful
/// to the model (e.g., grep match context) — clipped bytes are unrecoverable
/// by the caller. For tools where all content matters (bash, task_output),
/// use `soft_wrap_line` instead.
pub fn truncate_line(line: &str, max_chars: usize) -> Cow<'_, str> {
// Fast path: if byte length ≤ max_chars, then char count ≤ max_chars
// (every char is ≥1 byte). This avoids the O(n) chars().count() for
// ASCII-only strings. For multi-byte UTF-8 this may false-negative
// (byte_len > max_chars but char_count ≤ max_chars), falling through
// to the slow path — that's a perf miss, not a correctness bug.
if line.len() <= max_chars {
return Cow::Borrowed(line);
}
let char_count = line.chars().count();
if char_count <= max_chars {
return Cow::Borrowed(line);
}
let end_byte = line
.char_indices()
.nth(max_chars)
.map(|(i, _)| i)
.unwrap_or(line.len());
Cow::Owned(format!(
"{} [... truncated ({} chars total)]",
&line[..end_byte],
char_count
))
}
/// Soft-wrap a long line by inserting newlines every `wrap_width` characters.
/// **All content is preserved** — nothing is discarded.
///
/// Returns `Cow::Borrowed` if the line is already within `wrap_width` (zero-copy).
///
/// This is the correct strategy for bash and task_output, where the total output
/// is already size-bounded (30KB) and the model benefits from seeing all of it.
/// The problem with long lines isn't size — it's that the model has no structure
/// to anchor on. Wrapping adds that structure without losing content.
pub fn soft_wrap_line(line: &str, wrap_width: usize) -> Cow<'_, str> {
// Fast path: same byte-length optimization as truncate_line (see comment there).
if line.len() <= wrap_width {
return Cow::Borrowed(line);
}
let char_count = line.chars().count();
if char_count <= wrap_width {
return Cow::Borrowed(line);
}
let num_wraps = char_count.saturating_sub(1) / wrap_width;
let mut result = String::with_capacity(line.len() + num_wraps);
let mut chars_on_current_line = 0;
for ch in line.chars() {
if chars_on_current_line >= wrap_width {
result.push('\n');
chars_on_current_line = 0;
}
result.push(ch);
chars_on_current_line += 1;
}
Cow::Owned(result)
}
/// Truncate a string to at most `max_bytes` bytes at a valid UTF-8 boundary.
/// Returns the original string if it fits. No truncation marker is added.
///
/// Walks back from `max_bytes` until a char boundary is found. At most 3
/// steps back since UTF-8 multibyte sequences are at most 4 bytes.
pub fn truncate_str(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let mut end = max_bytes;
while !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
/// Truncate output to a UTF-8-safe preview plus a model-visible footer.
///
/// The cap decides whether truncation happens. When triggered, the returned
/// value contains the first `preview_bytes` bytes snapped to a char boundary
/// followed by `[Output truncated - <N> bytes total...]`.
pub fn truncate_with_preview(
output: &str,
max_bytes: usize,
preview_bytes: usize,
footer_hint: Option<&str>,
) -> (String, bool) {
if output.len() <= max_bytes {
return (output.to_string(), false);
}
let preview = truncate_str(output, preview_bytes.min(output.len()));
let footer = match footer_hint {
Some(hint) => format!("[Output truncated - {} bytes total. {hint}]", output.len()),
None => format!("[Output truncated - {} bytes total]", output.len()),
};
(format!("{preview}\n\n{footer}"), true)
}
/// Truncate a string to at most `max_bytes` bytes at a valid UTF-8 boundary,
/// appending `TRUNCATION_MARKER` when truncation actually happens.
///
/// Total byte length of the returned string is always `<= max_bytes`.
///
/// Returns `Cow::Borrowed` when the input already fits (no marker added --
/// only signal truncation when truncation actually happened). Returns
/// `Cow::Owned` with the marker appended when content was cut. When
/// `max_bytes == TRUNCATION_MARKER.len()`, returns just the marker so the
/// truncation signal is preserved. When `max_bytes < TRUNCATION_MARKER.len()`,
/// the marker cannot fit and we fall back to the marker-free `truncate_str`
/// behavior to honor the byte budget; this branch is only reachable when the
/// caller passes a pathologically tiny budget and is not exercised by any
/// production caller (`MIN_DESC_LENGTH` and other call-site minimums keep
/// the budget well above the marker size).
///
/// Use this when the reader needs to distinguish a natural string ending
/// from a truncation (e.g., model-visible listings). For purely visual
/// width-based truncation in the TUI, see `kigi_tui`'s own helpers.
pub fn truncate_str_with_marker(s: &str, max_bytes: usize) -> Cow<'_, str> {
if s.len() <= max_bytes {
return Cow::Borrowed(s);
}
if TRUNCATION_MARKER.len() > max_bytes {
tracing::debug!(
max_bytes,
marker_len = TRUNCATION_MARKER.len(),
"truncate_str_with_marker: budget too small for marker; truncation will be silent",
);
return Cow::Borrowed(truncate_str(s, max_bytes));
}
let mut end = max_bytes - TRUNCATION_MARKER.len();
while !s.is_char_boundary(end) {
end -= 1;
}
Cow::Owned(format!("{}{}", &s[..end], TRUNCATION_MARKER))
}
/// Find the largest byte index `<= index` that is a char boundary in `s`.
///
/// Polyfill for [`str::floor_char_boundary`] (stabilized in Rust 1.91; repo
/// toolchain is 1.90). Remove once the toolchain is bumped.
pub fn floor_char_boundary(s: &str, index: usize) -> usize {
if index >= s.len() {
return s.len();
}
let mut i = index;
while i > 0 && !s.is_char_boundary(i) {
i -= 1;
}
i
}
/// Find the smallest byte index `>= index` that is a char boundary in `s`.
///
/// Polyfill for [`str::ceil_char_boundary`] (stabilized in Rust 1.91; repo
/// toolchain is 1.90). Remove once the toolchain is bumped.
pub fn ceil_char_boundary(s: &str, index: usize) -> usize {
if index >= s.len() {
return s.len();
}
let mut i = index;
while i < s.len() && !s.is_char_boundary(i) {
i += 1;
}
i
}
/// Estimate the number of tokens in a string using the bytes/4 heuristic.
/// Thin wrapper around [`kigi_token_estimation::estimate_tokens`] preserving
/// the historical `usize` return type used by tool-side callers
/// (`read_file`, `attach_file`, `inspect`, `compaction` file gates).
pub fn estimate_tokens(s: &str) -> usize {
kigi_token_estimation::estimate_tokens(s) as usize
}
/// Estimate the number of chars per token using the bytes/4 heuristic.
/// Thin wrapper around [`kigi_token_estimation::estimate_chars`].
pub fn estimate_chars(s: u64) -> u64 {
kigi_token_estimation::estimate_chars(s)
}
pub fn format_bytes(bytes: usize) -> String {
if bytes >= 1_000_000 {
format!("{:.1}MB", bytes as f64 / 1_000_000.0)
} else if bytes >= 1_000 {
format!("{:.1}KB", bytes as f64 / 1_000.0)
} else {
format!("{}B", bytes)
}
}
/// Apply soft-wrapping to every line in a multi-line string.
/// All content is preserved. Lines already within `wrap_width` are untouched.
pub fn soft_wrap_lines(text: &str, wrap_width: usize) -> String {
let mut result = String::with_capacity(text.len() + 256);
for (i, line) in text.lines().enumerate() {
if i > 0 {
result.push('\n');
}
match soft_wrap_line(line, wrap_width) {
Cow::Borrowed(s) => result.push_str(s),
Cow::Owned(s) => result.push_str(&s),
}
}
if text.ends_with('\n') && !text.is_empty() {
result.push('\n');
}
result
}
/// Truncate a string keeping the first half and last half of the character
/// budget, inserting a separator in the middle.
///
/// Returns `(result, was_truncated)`. When `s.len() <= max_chars` the
/// original string is returned unchanged and `was_truncated` is `false`.
pub fn truncate_front_and_back(s: &str, max_chars: usize) -> (String, bool) {
if s.len() <= max_chars {
return (s.to_string(), false);
}
let half = max_chars / 2;
let front_end = s
.char_indices()
.nth(half)
.map(|(i, _)| i)
.unwrap_or(s.len());
let back_start = {
let total_chars = s.chars().count();
if total_chars <= half {
0
} else {
s.char_indices()
.nth(total_chars - half)
.map(|(i, _)| i)
.unwrap_or(0)
}
};
let ellipsis = "\n\n... (output truncated) ...\n\n";
let mut result = String::with_capacity(front_end + ellipsis.len() + (s.len() - back_start));
result.push_str(&s[..front_end]);
result.push_str(ellipsis);
result.push_str(&s[back_start..]);
(result, true)
}
/// Truncate a string by keeping the first and last halves of a **character**
/// budget, inserting `"..."` in the middle. Used in the image-description
/// pipeline.
///
/// When `s.chars().count() <= max_chars` the input is returned unchanged.
/// Otherwise the result contains `⌊max_chars/2⌋` chars from the start,
/// the literal `"..."`, then `⌊max_chars/2⌋` chars from the end.
pub fn truncate_middle(s: &str, max_chars: usize) -> String {
const MARKER: &str = "...";
const MARKER_LEN: usize = MARKER.len();
let char_count = s.chars().count();
if char_count <= max_chars {
return s.to_string();
}
// The marker counts against the budget so the total never exceeds
// `max_chars`. When the budget is too small even for the marker we
// fall back to a plain head-truncation.
let remaining = max_chars.saturating_sub(MARKER_LEN);
let front_count = remaining / 2;
let back_count = remaining - front_count;
// Front: first `front_count` chars.
let front_end = s
.char_indices()
.nth(front_count)
.map(|(i, _)| i)
.unwrap_or(s.len());
// Back: last `back_count` chars.
let back_start = if char_count <= back_count {
0
} else {
s.char_indices()
.nth(char_count - back_count)
.map(|(i, _)| i)
.unwrap_or(0)
};
let mut result = String::with_capacity(front_end + MARKER_LEN + (s.len() - back_start));
result.push_str(&s[..front_end]);
result.push_str(MARKER);
result.push_str(&s[back_start..]);
result
}
/// Truncate a multi-line string at line boundaries to fit within a character
/// budget.
///
/// Returns `(result, was_truncated)`. When the content already fits, the
/// joined+trimmed content is returned unchanged.
pub fn truncate_lines_to_char_budget(content: &str, budget: usize) -> (String, bool) {
let trimmed = content.trim();
if trimmed.len() <= budget {
return (trimmed.to_string(), false);
}
// Find a valid UTF-8 char boundary at or before `budget` to avoid
// panicking on multi-byte characters.
let byte_end = budget.min(trimmed.len());
let safe_end = (0..=byte_end)
.rev()
.find(|&i| trimmed.is_char_boundary(i))
.unwrap_or(0);
let truncated = &trimmed[..safe_end];
let last_nl = truncated.rfind('\n');
match last_nl {
Some(idx) => (trimmed[..idx].trim().to_string(), true),
None => (
"... [First line would be too large to fit within character budget] ...".to_string(),
true,
),
}
}
#[cfg(test)]
mod tests {
use super::*;
// ---- estimate_tokens ----
#[test]
fn estimate_tokens_empty() {
assert_eq!(estimate_tokens(""), 0);
}
#[test]
fn estimate_tokens_four_bytes() {
assert_eq!(estimate_tokens("abcd"), 1);
}
#[test]
fn estimate_tokens_rounds_down() {
assert_eq!(estimate_tokens("abc"), 0);
assert_eq!(estimate_tokens("abcdefg"), 1);
}
#[test]
fn estimate_tokens_large() {
assert_eq!(estimate_tokens(&"x".repeat(20_000)), 5_000);
}
// ---- truncate_line ----
#[test]
fn truncate_short_line_borrowed() {
let r = truncate_line("hello", 2_000);
assert!(matches!(r, Cow::Borrowed(_)));
}
#[test]
fn truncate_exact_limit_not_truncated() {
let line = "a".repeat(2_000);
assert!(matches!(truncate_line(&line, 2_000), Cow::Borrowed(_)));
}
#[test]
fn truncate_over_limit() {
let line = "a".repeat(3_000);
let r = truncate_line(&line, 2_000);
assert!(r.contains("[... truncated (3000 chars total)]"));
assert_eq!(r.split(" [... truncated").next().unwrap().len(), 2_000);
}
#[test]
fn truncate_utf8_safe() {
let line = "😀".repeat(2_001);
let r = truncate_line(&line, 2_000);
assert_eq!(
r.split(" [... truncated").next().unwrap().chars().count(),
2_000
);
}
#[test]
fn truncate_multibyte_char_count_under() {
let line = "é".repeat(1_999); // 2 bytes each, 1 char each
assert!(matches!(truncate_line(&line, 2_000), Cow::Borrowed(_)));
}
// ---- soft_wrap_line ----
#[test]
fn wrap_short_line_borrowed() {
assert!(matches!(soft_wrap_line("hello", 2_000), Cow::Borrowed(_)));
}
#[test]
fn wrap_preserves_all_content() {
let line = "a".repeat(5_000);
let r = soft_wrap_line(&line, 2_000);
assert!(!r.contains("truncated"));
let unwrapped: String = r.chars().filter(|c| *c != '\n').collect();
assert_eq!(unwrapped.len(), 5_000);
}
#[test]
fn wrap_inserts_newlines_correctly() {
let line = "a".repeat(5_000);
let r = soft_wrap_line(&line, 2_000);
let lines: Vec<&str> = r.split('\n').collect();
assert_eq!(lines.len(), 3); // 2000 + 2000 + 1000
assert_eq!(lines[0].len(), 2_000);
assert_eq!(lines[1].len(), 2_000);
assert_eq!(lines[2].len(), 1_000);
}
#[test]
fn wrap_utf8_safe() {
let line = "😀".repeat(3_000);
let r = soft_wrap_line(&line, 2_000);
let lines: Vec<&str> = r.split('\n').collect();
assert_eq!(lines[0].chars().count(), 2_000);
assert_eq!(lines[1].chars().count(), 1_000);
}
// ---- truncate_str ----
#[test]
fn truncate_str_returns_original_when_fits() {
assert_eq!(truncate_str("hello", 10), "hello");
assert_eq!(truncate_str("", 0), "");
}
#[test]
fn truncate_str_ascii_exact_boundary() {
assert_eq!(truncate_str("hello world", 5), "hello");
}
#[test]
fn truncate_str_does_not_split_cjk() {
// "日" is 3 bytes (0xE6 0x97 0xA5). Truncating at 2 must give "".
assert_eq!(truncate_str("日本語", 2), "");
assert_eq!(truncate_str("日本語", 3), "");
assert_eq!(truncate_str("日本語", 5), "");
assert_eq!(truncate_str("日本語", 6), "日本");
}
#[test]
fn truncate_str_does_not_split_emoji() {
// 🚀 is 4 bytes. Truncating at 1,2,3 must give "".
assert_eq!(truncate_str("🚀🦀", 3), "");
assert_eq!(truncate_str("🚀🦀", 4), "🚀");
assert_eq!(truncate_str("🚀🦀", 7), "🚀");
assert_eq!(truncate_str("🚀🦀", 8), "🚀🦀");
}
#[test]
fn truncate_str_zero_budget_gives_empty() {
assert_eq!(truncate_str("hello", 0), "");
assert_eq!(truncate_str("日本語", 0), "");
}
// ---- truncate_str_with_marker ----
#[test]
fn truncate_with_marker_exact_boundary_no_marker() {
// len == max_bytes: still fits, no marker.
let r = truncate_str_with_marker("hello", 5);
assert!(matches!(r, Cow::Borrowed(_)));
assert_eq!(r, "hello");
}
#[test]
fn truncate_with_marker_appends_marker_when_cut() {
let r = truncate_str_with_marker("hello world", 10);
// 10 bytes total: 7 content + 3 marker bytes.
assert_eq!(r, "hello w…");
assert!(r.len() <= 10);
assert!(matches!(r, Cow::Owned(_)));
}
#[test]
fn truncate_with_marker_respects_utf8_boundary() {
// "日" is 3 bytes. With max_bytes=10 and the 3-byte marker,
// target byte index is 7, which falls mid-char; walk back to 6
// -> "日本" + marker.
let r = truncate_str_with_marker("日本語abc", 10);
assert_eq!(r, "日本…");
assert!(r.len() <= 10);
// Result is valid UTF-8 (3 chars: 日, 本, …).
assert_eq!(r.chars().count(), 3);
}
// ---- truncate_with_preview ----
#[test]
fn truncate_with_preview_short_output_unchanged() {
let (result, truncated) = truncate_with_preview("hello", 10, 5, None);
assert_eq!(result, "hello");
assert!(!truncated);
}
#[test]
fn truncate_with_preview_caps_large_output() {
let output = "x".repeat(5_000_000);
let (result, truncated) = truncate_with_preview(&output, 4_000, 2_000, None);
assert!(truncated);
assert!(result.len() < 2_200, "result was {} bytes", result.len());
assert!(result.starts_with(&"x".repeat(2_000)));
assert!(result.contains("[Output truncated - 5000000 bytes total]"));
}
#[test]
fn truncate_with_preview_utf8_boundary() {
let output = "😀".repeat(1_500);
let (result, truncated) = truncate_with_preview(&output, 4_000, 2_001, None);
assert!(truncated);
assert!(result.starts_with(&"😀".repeat(500)));
assert!(std::str::from_utf8(result.as_bytes()).is_ok());
}
#[test]
fn truncate_with_preview_with_footer_hint() {
let output = "x".repeat(10_000);
let (result, truncated) = truncate_with_preview(
&output,
4_000,
2_000,
Some("Use read_file for full content"),
);
assert!(truncated);
assert!(result.contains("Use read_file for full content"));
}
#[test]
fn truncate_with_preview_without_footer_hint() {
let output = "x".repeat(10_000);
let (result, truncated) = truncate_with_preview(&output, 4_000, 2_000, None);
assert!(truncated);
assert!(result.contains("[Output truncated - 10000 bytes total]"));
assert!(!result.contains("full content"));
}
// ---- soft_wrap_lines ----
#[test]
fn wrap_lines_mixed() {
let text = format!("short\n{}\nanother", "x".repeat(5_000));
let result = soft_wrap_lines(&text, 2_000);
let lines: Vec<&str> = result.split('\n').collect();
assert_eq!(lines[0], "short");
assert_eq!(lines[1].len(), 2_000); // first chunk of wrapped line
assert_eq!(lines[4], "another");
// Total content preserved
let unwrapped: String = result.chars().filter(|c| *c != '\n').collect();
let original: String = text.chars().filter(|c| *c != '\n').collect();
assert_eq!(unwrapped, original);
}
#[test]
fn wrap_lines_preserves_trailing_newline() {
assert_eq!(soft_wrap_lines("hello\n", 2_000), "hello\n");
assert_eq!(soft_wrap_lines("hello", 2_000), "hello");
}
// ---- truncate_front_and_back ----
#[test]
fn front_and_back_short_string_not_truncated() {
let (result, truncated) = truncate_front_and_back("hello world", 100);
assert_eq!(result, "hello world");
assert!(!truncated);
}
#[test]
fn front_and_back_keeps_both_ends() {
let s = "a".repeat(100);
let (result, truncated) = truncate_front_and_back(&s, 20);
assert!(truncated);
assert!(result.starts_with("aaaaaaaaaa")); // first 10
assert!(result.ends_with("aaaaaaaaaa")); // last 10
assert!(result.contains("... (output truncated) ..."));
}
#[test]
fn front_and_back_exact_boundary() {
let s = "a".repeat(20);
let (result, truncated) = truncate_front_and_back(&s, 20);
assert_eq!(result, s);
assert!(!truncated);
}
// ---- truncate_lines_to_char_budget ----
#[test]
fn lines_budget_short_content_not_truncated() {
let (result, truncated) = truncate_lines_to_char_budget("line1\nline2\nline3", 100);
assert_eq!(result, "line1\nline2\nline3");
assert!(!truncated);
}
#[test]
fn lines_budget_truncates_at_line_boundary() {
let content = "short\nmedium line\nthis is a longer line\nand another";
let (result, truncated) = truncate_lines_to_char_budget(content, 25);
assert!(truncated);
assert!(!result.contains("this is a longer"));
// Should end at a complete line
assert!(result.ends_with("medium line") || result.ends_with("short"));
}
#[test]
fn lines_budget_single_huge_line() {
let content = "a".repeat(1000);
let (result, truncated) = truncate_lines_to_char_budget(&content, 50);
assert!(truncated);
assert!(result.contains("character budget"));
}
// ---- floor_char_boundary / ceil_char_boundary ----
#[test]
fn floor_boundary_ascii() {
assert_eq!(floor_char_boundary("hello", 3), 3);
}
#[test]
fn floor_boundary_mid_cjk() {
// "日" = 3 bytes. Index 1 or 2 should snap back to 0.
assert_eq!(floor_char_boundary("日本", 1), 0);
assert_eq!(floor_char_boundary("日本", 2), 0);
assert_eq!(floor_char_boundary("日本", 3), 3);
}
#[test]
fn floor_boundary_past_end() {
assert_eq!(floor_char_boundary("hi", 100), 2);
}
#[test]
fn ceil_boundary_ascii() {
assert_eq!(ceil_char_boundary("hello", 3), 3);
}
#[test]
fn ceil_boundary_mid_cjk() {
// "日" = 3 bytes. Index 1 or 2 should snap forward to 3.
assert_eq!(ceil_char_boundary("日本", 1), 3);
assert_eq!(ceil_char_boundary("日本", 2), 3);
assert_eq!(ceil_char_boundary("日本", 3), 3);
}
#[test]
fn ceil_boundary_past_end() {
assert_eq!(ceil_char_boundary("hi", 100), 2);
}
// ---- truncate_middle ----
#[test]
fn truncate_middle_short_string_unchanged() {
assert_eq!(truncate_middle("hello", 10), "hello");
}
#[test]
fn truncate_middle_exact_limit_unchanged() {
let s = "a".repeat(20);
assert_eq!(truncate_middle(&s, 20), s);
}
#[test]
fn truncate_middle_keeps_both_ends() {
// 26 chars, budget 10 → remaining=7, front=3, back=4
let s = "abcdefghijklmnopqrstuvwxyz";
let result = truncate_middle(s, 10);
assert!(result.starts_with("abc"));
assert!(result.ends_with("wxyz"));
assert!(result.contains("..."));
// Total must not exceed budget.
assert!(
result.chars().count() <= 10,
"result exceeds budget: {} chars: {result}",
result.chars().count()
);
}
#[test]
fn truncate_middle_respects_budget() {
// Example: 50_000 chars at 12_000 limit.
let s = "a".repeat(50_000);
let result = truncate_middle(&s, 12_000);
assert_eq!(
result.chars().count(),
12_000,
"result should be exactly the budget"
);
assert!(result.contains("..."));
}
#[test]
fn truncate_middle_utf8_safe() {
let s = "😀".repeat(100);
let result = truncate_middle(&s, 10);
// remaining=7, front=3, back=4 → 3 emoji + "..." + 4 emoji = 10
assert_eq!(result.chars().count(), 10);
assert!(result.contains("..."));
}
}
@@ -0,0 +1,612 @@
//! Unicode confusable-character detection and normalization.
//!
//! Several Unicode punctuation characters are visually indistinguishable from
//! their ASCII counterparts in most terminal and editor fonts. When a file
//! contains these characters (e.g., text pasted from Slack, Notion, or Google
//! Docs), `read_file` renders them identically to ASCII, but `search_replace`
//! performs exact byte matching and therefore fails to find the model-supplied
//! ASCII `old_string`.
//!
//! This module provides a **narrow, typography-focused** confusable map and
//! helpers for:
//!
//! - detecting whether a string contains confusable characters,
//! - normalizing confusables to their ASCII equivalents (for comparison only),
//! - locating confusable characters with byte offsets and line numbers,
//! - building a byte-offset remapping table so that match positions found in a
//! normalized string can be translated back to the original byte positions.
//!
//! ## Design constraints
//!
//! The confusable set is intentionally small: only high-confidence typography
//! substitutions that are almost always accidental (smart quotes, dashes,
//! ellipsis, non-breaking space). Characters like `U+2212` (minus sign) or
//! `U+00D7` (multiplication sign) are excluded because they can carry semantic
//! meaning.
/// Narrow, typography-focused map of visually confusable Unicode characters.
///
/// Each entry maps a Unicode character to its ASCII equivalent string.
/// The replacement may be one or more ASCII characters (e.g., em-dash → `"--"`).
///
/// This list is intentionally conservative. Additions should be limited to
/// characters that are (a) visually identical to ASCII in monospace fonts and
/// (b) almost always produced by accidental rich-text auto-correction rather
/// than deliberate content authoring.
pub const CONFUSABLE_MAP: &[(char, &str)] = &[
('\u{201C}', "\""), // " left double quotation mark
('\u{201D}', "\""), // " right double quotation mark
('\u{2018}', "'"), // ' left single quotation mark
('\u{2019}', "'"), // ' right single quotation mark
('\u{2014}', "--"), // — em-dash
('\u{2013}', "-"), // en-dash
('\u{2026}', "..."), // … horizontal ellipsis
('\u{00A0}', " "), // non-breaking space
];
/// A single detected confusable character with its location metadata.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfusableHit {
/// Byte offset of the confusable character in the source string.
pub byte_offset: usize,
/// The Unicode character that was detected.
pub unicode_char: char,
/// The ASCII replacement string from [`CONFUSABLE_MAP`].
pub ascii_replacement: &'static str,
/// 1-based line number where the character appears.
pub line_number: usize,
}
/// Look up a character in the confusable map.
///
/// Returns the ASCII replacement string if `c` is a known confusable, or
/// `None` otherwise. This is an O(n) scan over the (small, constant-size)
/// map; a `HashMap` would add startup cost and an external dependency for
/// negligible gain given the current map size.
fn lookup(c: char) -> Option<&'static str> {
CONFUSABLE_MAP
.iter()
.find(|&&(ch, _)| ch == c)
.map(|&(_, replacement)| replacement)
}
/// Fast check: does `s` contain any character from [`CONFUSABLE_MAP`]?
///
/// Returns as soon as the first confusable is found (short-circuiting).
pub fn has_confusables(s: &str) -> bool {
s.chars().any(|c| lookup(c).is_some())
}
/// Replace every occurrence of a [`CONFUSABLE_MAP`] character with its ASCII
/// equivalent.
///
/// Characters not in the map are copied through unchanged (including non-ASCII
/// characters such as emoji or CJK that are not in the map).
///
/// If the input contains no confusables, this allocates a new `String` with
/// identical content. Callers that want to avoid allocation on the common case
/// should check [`has_confusables`] first.
pub fn normalize_confusables(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
match lookup(c) {
Some(replacement) => out.push_str(replacement),
None => out.push(c),
}
}
out
}
/// Detect all confusable characters in `s`, returning their positions and
/// line numbers.
///
/// Results are ordered by ascending `byte_offset`. Line numbers are 1-based.
pub fn detect_confusables(s: &str) -> Vec<ConfusableHit> {
let mut hits = Vec::new();
let mut line: usize = 1;
for (byte_offset, c) in s.char_indices() {
if let Some(replacement) = lookup(c) {
hits.push(ConfusableHit {
byte_offset,
unicode_char: c,
ascii_replacement: replacement,
line_number: line,
});
}
if c == '\n' {
line += 1;
}
}
hits
}
/// Build a normalized string together with a byte-offset mapping from the
/// normalized string back to the original.
///
/// Returns `(normalized_text, offset_map)` where:
///
/// - `normalized_text` is the result of applying [`normalize_confusables`] to
/// `s`.
/// - `offset_map` has length `normalized_text.len() + 1`. For every byte
/// index `i` in `0..=normalized_text.len()`, `offset_map[i]` is the
/// corresponding byte index in the original string `s`.
///
/// The **terminal sentinel** at `offset_map[normalized_text.len()]` equals
/// `s.len()`, ensuring that a normalized match ending exactly at the end of
/// the string can be safely remapped.
///
/// # Boundary-mapping contract (for substring remapping)
///
/// The primary consumer of this function is normalized-fallback matching.
/// The intended usage pattern is:
///
/// 1. Build `(normalized_text, offset_map)` from the file content.
/// 2. Normalize the search pattern with [`normalize_confusables`].
/// 3. Find a match at `[norm_start..norm_end]` in `normalized_text`.
/// 4. Recover the corresponding original byte span:
/// ```text
/// original_start = offset_map[norm_start]
/// original_end = offset_map[norm_end]
/// original_slice = &s[original_start..original_end]
/// ```
/// 5. The recovered slice satisfies:
/// ```text
/// normalize_confusables(original_slice) == normalized_text[norm_start..norm_end]
/// ```
///
/// This works because:
///
/// - For **confusable characters**, all replacement bytes map back to the
/// start of the original character. The *next* entry after the replacement
/// maps to the first byte past the original character, so the `[start..end]`
/// range captures the full original character.
/// - For **non-confusable characters** (including multibyte), each byte maps
/// to its own original position, preserving a 1:1 byte correspondence.
/// - The **terminal sentinel** ensures `offset_map[normalized_text.len()]`
/// is always valid, covering matches that extend to end-of-string.
///
/// # Invariants
///
/// - `offset_map[0] == 0`
/// - `offset_map[normalized_text.len()] == s.len()`
/// - The mapping is monotonically non-decreasing.
/// - For any valid normalized byte range `[a..b]`:
/// `normalize_confusables(&s[offset_map[a]..offset_map[b]]) == normalized_text[a..b]`
pub fn build_offset_map(s: &str) -> (String, Vec<usize>) {
// Pre-allocate conservatively. In the worst case the normalized string is
// longer (em-dash 3 bytes → "--" 2 bytes: actually shorter; ellipsis 3
// bytes → "..." 3 bytes: same). In practice normalized_len ≈ original_len.
let mut normalized = String::with_capacity(s.len());
// +1 for the terminal sentinel.
let mut offset_map: Vec<usize> = Vec::with_capacity(s.len() + 1);
for (orig_byte_offset, c) in s.char_indices() {
match lookup(c) {
Some(replacement) => {
// Map each byte of the replacement string back to the start of
// the original character.
for _ in 0..replacement.len() {
offset_map.push(orig_byte_offset);
}
normalized.push_str(replacement);
}
None => {
// Map each byte of the original character to its own position.
let char_len = c.len_utf8();
for i in 0..char_len {
offset_map.push(orig_byte_offset + i);
}
normalized.push(c);
}
}
}
// Terminal sentinel: one past the last byte of the normalized string maps
// to one past the last byte of the original string.
offset_map.push(s.len());
debug_assert_eq!(offset_map.len(), normalized.len() + 1);
debug_assert_eq!(*offset_map.last().unwrap(), s.len());
(normalized, offset_map)
}
#[cfg(test)]
mod tests {
use super::*;
// ── CONFUSABLE_MAP coverage ─────────────────────────────────────────
#[test]
fn normalize_left_double_quote() {
assert_eq!(normalize_confusables("\u{201C}hello"), "\"hello");
}
#[test]
fn normalize_right_double_quote() {
assert_eq!(normalize_confusables("hello\u{201D}"), "hello\"");
}
#[test]
fn normalize_left_single_quote() {
assert_eq!(normalize_confusables("\u{2018}hi"), "'hi");
}
#[test]
fn normalize_right_single_quote() {
assert_eq!(normalize_confusables("hi\u{2019}"), "hi'");
}
#[test]
fn normalize_em_dash() {
assert_eq!(normalize_confusables("foo\u{2014}bar"), "foo--bar");
}
#[test]
fn normalize_en_dash() {
assert_eq!(normalize_confusables("10\u{2013}20"), "10-20");
}
#[test]
fn normalize_ellipsis() {
assert_eq!(normalize_confusables("wait\u{2026}"), "wait...");
}
#[test]
fn normalize_nbsp() {
assert_eq!(normalize_confusables("hello\u{00A0}world"), "hello world");
}
// ── Identity / passthrough ──────────────────────────────────────────
#[test]
fn normalize_pure_ascii_is_identity() {
let ascii = "The quick brown fox jumps over the lazy dog. 0123456789 !@#$%^&*()";
assert_eq!(normalize_confusables(ascii), ascii);
}
#[test]
fn normalize_empty_string() {
assert_eq!(normalize_confusables(""), "");
}
#[test]
fn normalize_preserves_non_confusable_unicode() {
// Emoji and CJK should pass through untouched.
let s = "hello 🌍 世界";
assert_eq!(normalize_confusables(s), s);
}
// ── has_confusables ─────────────────────────────────────────────────
#[test]
fn has_confusables_false_for_ascii() {
assert!(!has_confusables("plain ASCII text"));
}
#[test]
fn has_confusables_false_for_non_confusable_unicode() {
assert!(!has_confusables("emoji 🎉 and 日本語"));
}
#[test]
fn has_confusables_true_for_smart_quotes() {
assert!(has_confusables("He said \u{201C}hello\u{201D}"));
}
#[test]
fn has_confusables_true_for_nbsp() {
assert!(has_confusables("a\u{00A0}b"));
}
#[test]
fn has_confusables_true_for_em_dash() {
assert!(has_confusables("a\u{2014}b"));
}
// ── detect_confusables ──────────────────────────────────────────────
#[test]
fn detect_returns_empty_for_ascii() {
assert!(detect_confusables("plain text").is_empty());
}
#[test]
fn detect_single_smart_quote() {
let hits = detect_confusables("say \u{201C}hi\u{201D}");
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].byte_offset, 4); // "say " is 4 bytes
assert_eq!(hits[0].unicode_char, '\u{201C}');
assert_eq!(hits[0].ascii_replacement, "\"");
assert_eq!(hits[0].line_number, 1);
// '\u{201C}' is 3 bytes, "hi" is 2 bytes → offset = 4+3+2 = 9
assert_eq!(hits[1].byte_offset, 9);
assert_eq!(hits[1].unicode_char, '\u{201D}');
assert_eq!(hits[1].ascii_replacement, "\"");
assert_eq!(hits[1].line_number, 1);
}
#[test]
fn detect_confusables_tracks_line_numbers() {
let s = "line one\nline\u{00A0}two\nline \u{201C}three\u{201D}\n";
let hits = detect_confusables(s);
assert_eq!(hits.len(), 3);
assert_eq!(hits[0].line_number, 2); // NBSP on line 2
assert_eq!(hits[1].line_number, 3); // left quote on line 3
assert_eq!(hits[2].line_number, 3); // right quote on line 3
}
#[test]
fn detect_consecutive_confusables() {
// Two em-dashes in a row
let s = "\u{2014}\u{2014}";
let hits = detect_confusables(s);
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].byte_offset, 0);
assert_eq!(hits[1].byte_offset, 3); // em-dash is 3 bytes
}
#[test]
fn detect_confusable_at_start_and_end() {
let s = "\u{2018}hello\u{2019}";
let hits = detect_confusables(s);
assert_eq!(hits.len(), 2);
assert_eq!(hits[0].byte_offset, 0);
assert_eq!(hits[0].unicode_char, '\u{2018}');
// '\u{2018}' is 3 bytes, "hello" is 5 bytes → offset = 8
assert_eq!(hits[1].byte_offset, 8);
assert_eq!(hits[1].unicode_char, '\u{2019}');
}
// ── build_offset_map ────────────────────────────────────────────────
#[test]
fn offset_map_pure_ascii() {
let s = "abc";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "abc");
// Each ASCII byte maps to itself, plus terminal sentinel.
assert_eq!(map, vec![0, 1, 2, 3]);
}
#[test]
fn offset_map_empty_string() {
let (normalized, map) = build_offset_map("");
assert_eq!(normalized, "");
// Only the terminal sentinel.
assert_eq!(map, vec![0]);
}
#[test]
fn offset_map_terminal_sentinel() {
let s = "hello";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "hello");
assert_eq!(map.len(), normalized.len() + 1);
assert_eq!(*map.last().unwrap(), s.len());
}
#[test]
fn offset_map_smart_quotes() {
// "\u{201C}hi\u{201D}" → "\"hi\""
// Original bytes: 0..3 = '\u{201C}' (3 bytes), 3..5 = "hi", 5..8 = '\u{201D}' (3 bytes)
// Normalized bytes: 0 = '"', 1..3 = "hi", 3 = '"'
let s = "\u{201C}hi\u{201D}";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "\"hi\"");
assert_eq!(normalized.len(), 4);
assert_eq!(map.len(), 5); // 4 bytes + sentinel
// Byte 0 of normalized ('"') maps to byte 0 of original ('\u{201C}' start)
assert_eq!(map[0], 0);
// Byte 1 of normalized ('h') maps to byte 3 of original
assert_eq!(map[1], 3);
// Byte 2 of normalized ('i') maps to byte 4 of original
assert_eq!(map[2], 4);
// Byte 3 of normalized ('"') maps to byte 5 of original ('\u{201D}' start)
assert_eq!(map[3], 5);
// Terminal sentinel
assert_eq!(map[4], 8);
}
#[test]
fn offset_map_em_dash() {
// "a\u{2014}b" → "a--b"
// Original: 0='a', 1..4='\u{2014}' (3 bytes), 4='b'
// Normalized: 0='a', 1..3="--", 3='b'
let s = "a\u{2014}b";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "a--b");
assert_eq!(map.len(), 5); // 4 bytes + sentinel
assert_eq!(map[0], 0); // 'a' → 'a'
assert_eq!(map[1], 1); // first '-' → start of em-dash
assert_eq!(map[2], 1); // second '-' → start of em-dash
assert_eq!(map[3], 4); // 'b' → 'b'
assert_eq!(map[4], 5); // sentinel
}
#[test]
fn offset_map_ellipsis() {
// "\u{2026}" → "..."
// Original: 0..3 = '\u{2026}' (3 bytes)
// Normalized: 0..3 = "..." (3 bytes)
let s = "\u{2026}";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "...");
assert_eq!(map.len(), 4); // 3 bytes + sentinel
// All three dots map to the start of the original ellipsis character.
assert_eq!(map[0], 0);
assert_eq!(map[1], 0);
assert_eq!(map[2], 0);
assert_eq!(map[3], 3); // sentinel
}
#[test]
fn offset_map_nbsp() {
// "a\u{00A0}b" → "a b"
// Original: 0='a', 1..3='\u{00A0}' (2 bytes in UTF-8), 3='b'
// Normalized: 0='a', 1=' ', 2='b'
let s = "a\u{00A0}b";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "a b");
assert_eq!(map.len(), 4); // 3 bytes + sentinel
assert_eq!(map[0], 0); // 'a'
assert_eq!(map[1], 1); // ' ' maps to NBSP start
assert_eq!(map[2], 3); // 'b'
assert_eq!(map[3], 4); // sentinel
}
#[test]
fn offset_map_non_confusable_multibyte() {
// Emoji passes through with per-byte identity mapping.
// '🌍' is U+1F30D, 4 bytes in UTF-8.
let s = "a🌍b";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, s); // no confusables → identical
// 'a'=1 byte, '🌍'=4 bytes, 'b'=1 byte → 6 bytes + sentinel
assert_eq!(map.len(), 7);
assert_eq!(map[0], 0); // 'a'
assert_eq!(map[1], 1); // '🌍' byte 0
assert_eq!(map[2], 2); // '🌍' byte 1
assert_eq!(map[3], 3); // '🌍' byte 2
assert_eq!(map[4], 4); // '🌍' byte 3
assert_eq!(map[5], 5); // 'b'
assert_eq!(map[6], 6); // sentinel
}
#[test]
fn offset_map_mixed_confusables_and_ascii() {
// "He said \u{201C}yes\u{201D} \u{2014} no\u{2026}"
let s = "He said \u{201C}yes\u{201D} \u{2014} no\u{2026}";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "He said \"yes\" -- no...");
// Verify invariants.
assert_eq!(map.len(), normalized.len() + 1);
assert_eq!(map[0], 0);
assert_eq!(*map.last().unwrap(), s.len());
// Monotonically non-decreasing.
for window in map.windows(2) {
assert!(
window[0] <= window[1],
"offset_map not monotonic: {} > {}",
window[0],
window[1]
);
}
}
#[test]
fn offset_map_en_dash_single_char_replacement() {
// "\u{2013}" is 3 bytes in UTF-8, maps to "-" (1 byte).
let s = "\u{2013}";
let (normalized, map) = build_offset_map(s);
assert_eq!(normalized, "-");
assert_eq!(map.len(), 2); // 1 byte + sentinel
assert_eq!(map[0], 0); // '-' maps to start of en-dash
assert_eq!(map[1], 3); // sentinel = original len
}
// ── Compound / integration scenarios ────────────────────────────────
#[test]
fn normalize_multiple_confusables_one_line() {
let s = "\u{201C}hello\u{201D}\u{2014}world\u{2026}";
assert_eq!(normalize_confusables(s), "\"hello\"--world...");
}
#[test]
fn detect_then_normalize_roundtrip() {
let original = "She said \u{201C}go\u{201D}";
let hits = detect_confusables(original);
assert_eq!(hits.len(), 2);
let normalized = normalize_confusables(original);
assert_eq!(normalized, "She said \"go\"");
assert!(!has_confusables(&normalized));
}
#[test]
fn build_offset_map_agrees_with_normalize() {
let s = "a\u{201C}b\u{2014}c\u{00A0}d";
let (from_map, _) = build_offset_map(s);
let from_normalize = normalize_confusables(s);
assert_eq!(from_map, from_normalize);
}
// ── Consumer-contract tests ─────────────────────────────────────────
//
// These tests simulate the exact pattern that the normalized-fallback
// matcher will use: find a substring in the normalized text,
// remap the span back to the original via offset_map, and verify that
// normalizing the extracted original slice produces the matched text.
/// Helper: find `pattern` in `normalized`, remap to original via
/// `offset_map`, and return the original slice. Panics if not found.
fn remap_first_match<'a>(
original: &'a str,
normalized: &str,
offset_map: &[usize],
pattern: &str,
) -> &'a str {
let norm_start = normalized
.find(pattern)
.expect("pattern not found in normalized text");
let norm_end = norm_start + pattern.len();
let orig_start = offset_map[norm_start];
let orig_end = offset_map[norm_end];
&original[orig_start..orig_end]
}
#[test]
fn remap_smart_quotes_roundtrip() {
// File content with smart quotes; model searches for ASCII quotes.
let original = "She said \u{201C}stream through\u{201D} clearly";
let (normalized, map) = build_offset_map(original);
let pattern = "\"stream through\"";
let orig_slice = remap_first_match(original, &normalized, &map, pattern);
// The original slice should contain the smart-quoted region.
assert_eq!(orig_slice, "\u{201C}stream through\u{201D}");
// Normalizing it back must equal the pattern we searched for.
assert_eq!(normalize_confusables(orig_slice), pattern);
}
#[test]
fn remap_mixed_confusables_roundtrip() {
// A realistic line with em-dash, smart quotes, and NBSP.
let original = "use \u{201C}flag\u{201D}\u{00A0}\u{2014}\u{00A0}see docs";
let (normalized, map) = build_offset_map(original);
// Model searches for the ASCII equivalent of the whole middle section.
let pattern = "\"flag\" -- see";
let orig_slice = remap_first_match(original, &normalized, &map, pattern);
assert_eq!(
orig_slice,
"\u{201C}flag\u{201D}\u{00A0}\u{2014}\u{00A0}see"
);
assert_eq!(normalize_confusables(orig_slice), pattern);
}
#[test]
fn remap_match_at_end_of_string() {
// Match that extends to the very end of the string (exercises
// the terminal sentinel).
let original = "wait\u{2026}";
let (normalized, map) = build_offset_map(original);
let pattern = "wait...";
let orig_slice = remap_first_match(original, &normalized, &map, pattern);
assert_eq!(orig_slice, original);
assert_eq!(normalize_confusables(orig_slice), pattern);
}
}