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:
@@ -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  after");
|
||||
let result = extract_base64_images(input);
|
||||
assert_eq!(
|
||||
result.text,
|
||||
"Before  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!("");
|
||||
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(");
|
||||
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`.
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 (a–z).
|
||||
///
|
||||
/// 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, ¶ms(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, ¶ms(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, ¶ms(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, ¶ms(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, ¶ms(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, ¶ms(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");
|
||||
}
|
||||
}
|
||||
@@ -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`"]);
|
||||
}
|
||||
}
|
||||
@@ -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, ¶m_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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user