pub mod http;
pub mod json;
#[cfg(test)]
pub mod test;
pub mod tree_sitter;
use regex::Regex;
use serde::de::DeserializeOwned;
use std::sync::LazyLock;
use base64::{Engine as _, engine::general_purpose::STANDARD};
use crate::Reasoning;
use serde_json::Value;
pub trait UnwrapPoison {
type Inner;
#[must_use]
fn unwrap_poison(self) -> Self::Inner;
}
impl<T> UnwrapPoison for Result<T, std::sync::PoisonError<T>> {
type Inner = T;
fn unwrap_poison(self) -> T {
self.unwrap_or_else(std::sync::PoisonError::into_inner)
}
}
pub(crate) static MEDIA_MARKER_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"\[(IMAGE|AUDIO|VIDEO):([^\]]+)\]").expect("MEDIA_MARKER_RE must compile")
});
#[must_use]
pub fn truncate(input: &str, max_chars: usize) -> String {
match input.char_indices().nth(max_chars) {
Some((idx, _)) => format!("{}…", input[..idx].trim_end()),
None => input.to_string(),
}
}
#[must_use]
pub(crate) fn unix_millis() -> u128 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
}
#[must_use]
pub fn summarize_args(args: &serde_json::Value) -> String {
match args {
serde_json::Value::Object(map) => {
let parts: Vec<String> = map
.iter()
.map(|(k, v)| {
let val = match v {
serde_json::Value::String(s) => truncate(s, 80),
other => truncate(&other.to_string(), 80),
};
format!("{k}: {val}")
})
.collect();
parts.join(", ")
}
other => truncate(&other.to_string(), 120),
}
}
pub(crate) fn parse_fenced_json<T: DeserializeOwned>(text: &str) -> anyhow::Result<T> {
let trimmed = text.trim();
let json_str = if let Some(start) = trimmed.find("```json") {
extract_fenced_content(&trimmed[start + 7..])
} else if let Some(start) = trimmed.find("```") {
extract_fenced_content(&trimmed[start + 3..])
} else {
trimmed
};
serde_json::from_str::<T>(json_str).or_else(|parse_err| {
if let Ok(repaired) = jsonrepair_rs::jsonrepair(json_str)
&& let Ok(value) = serde_json::from_str::<T>(&repaired)
{
tracing::warn!(
original_error = %parse_err,
"Repaired malformed JSON in fenced extraction"
);
return Ok(value);
}
Err(anyhow::anyhow!("Failed to parse JSON: {parse_err}"))
})
}
fn extract_fenced_content(text: &str) -> &str {
let end = text.find("```").unwrap_or(text.len());
text.get(..end).unwrap_or(text).trim()
}
#[must_use]
pub fn truncate_sandwich(s: &str, max_bytes: usize, label: &str) -> String {
if s.len() <= max_bytes {
return s.to_string();
}
let head_bytes = max_bytes * 2 / 3;
let tail_bytes = max_bytes / 3;
let head_end = s.floor_char_boundary(head_bytes);
let tail_start = s.floor_char_boundary(s.len().saturating_sub(tail_bytes));
if head_end < tail_start {
let omitted = s[head_end..tail_start].len();
format!(
"{}... ({} bytes omitted at {label} truncation)\n{}",
&s[..head_end],
omitted,
&s[tail_start..]
)
} else {
let boundary = s.floor_char_boundary(max_bytes);
let mut out = s[..boundary].to_string();
let _ = std::fmt::Write::write_fmt(
&mut out,
format_args!("\n... [{label} truncated at {max_bytes} bytes]"),
);
out
}
}
#[must_use]
pub fn format_tool_output(output: &str) -> String {
truncate_sandwich(output, 5_000, "tool output")
}
pub(crate) async fn local_image_to_data_uri(path: &std::path::Path) -> anyhow::Result<String> {
let bytes = tokio::fs::read(path).await?;
let mime = mime_for_extension(path);
Ok(format!("data:{mime};base64,{}", STANDARD.encode(&bytes)))
}
#[allow(clippy::cast_precision_loss)]
pub(crate) async fn load_reference_image(
path: &std::path::Path,
max_bytes: u64,
) -> anyhow::Result<String> {
if !path.exists() {
anyhow::bail!("Reference image not found: {}", path.display());
}
let metadata = tokio::fs::metadata(path)
.await
.map_err(|e| anyhow::anyhow!("Failed to read reference image {}: {e}", path.display()))?;
if metadata.len() > max_bytes {
let mb = max_bytes as f64 / (1024.0 * 1024.0);
anyhow::bail!(
"Reference image {} is {} bytes, exceeds {:.1} MB limit. \
Use a smaller or compressed image.",
path.display(),
metadata.len(),
mb,
);
}
local_image_to_data_uri(path).await
}
pub(crate) fn mime_for_extension(path: &std::path::Path) -> &'static str {
match path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("png") => "image/png",
Some("jpg" | "jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("webp") => "image/webp",
Some("bmp") => "image/bmp",
_ => "application/octet-stream",
}
}
fn reasoning_detail_type(item: &Value) -> Option<&str> {
item.get("type").and_then(Value::as_str)
}
fn append_reasoning_fragment(out: &mut String, fragment: &str) {
let t = fragment.trim();
if t.is_empty() {
return;
}
if !out.is_empty() {
out.push('\n');
}
out.push_str(t);
}
fn append_plaintext_from_detail_item(out: &mut String, item: &Value) {
let Some(ty) = reasoning_detail_type(item) else {
return;
};
if ty.contains("encrypted") {
return;
}
if ty.contains("summary") {
if let Some(s) = item.get("summary").and_then(Value::as_str) {
append_reasoning_fragment(out, s);
}
return;
}
if ty.contains("text")
&& let Some(s) = item.get("text").and_then(Value::as_str)
{
append_reasoning_fragment(out, s);
}
}
#[must_use]
pub(crate) fn plaintext_from_reasoning_details(details: &Value) -> String {
let mut out = String::new();
match details {
Value::Array(items) => {
for item in items {
append_plaintext_from_detail_item(&mut out, item);
}
}
Value::Object(_) => append_plaintext_from_detail_item(&mut out, details),
_ => {}
}
out
}
pub(crate) fn merged_reasoning_string(
reasoning_content: Option<String>,
reasoning: Option<String>,
) -> Option<String> {
reasoning_content
.filter(|s| !s.trim().is_empty())
.or_else(|| reasoning.filter(|s| !s.trim().is_empty()))
}
#[must_use]
pub fn plaintext_for_display(reasoning: Option<&Reasoning>) -> Option<String> {
let r = reasoning?;
merged_reasoning_string(r.reasoning_content.clone(), r.reasoning.clone()).or_else(|| {
r.reasoning_details.as_ref().and_then(|d| {
let s = plaintext_from_reasoning_details(d);
(!s.trim().is_empty()).then_some(s)
})
})
}
static SENSITIVE_KV_REGEX: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)(token|api[_-]?key|password|secret|user[_-]?key|bearer|credential)["']?\s*[:=]\s*(?:"([^"]{8,})"|'([^']{8,})'|([a-zA-Z0-9_\-\./+=]{8,}))"#).expect("hardcoded regex is valid")
});
#[must_use]
pub fn scrub_credentials(input: &str) -> String {
SENSITIVE_KV_REGEX
.replace_all(input, |caps: ®ex::Captures| {
let full_match = &caps[0];
let key = &caps[1];
let val = caps
.get(2)
.or(caps.get(3))
.or(caps.get(4))
.map_or("", |m| m.as_str());
debug_assert!(val.len() >= 8, "regex guarantees values >= 8 chars");
let prefix = val
.char_indices()
.nth(4)
.map_or(val, |(byte_idx, _)| &val[..byte_idx]);
let quote = if caps.get(2).is_some() {
Some('"')
} else if caps.get(3).is_some() {
Some('\'')
} else {
None
};
let redacted = format!("{prefix}*[REDACTED]");
if full_match.contains(':') {
match quote {
Some('"') => format!("\"{key}\": \"{redacted}\""),
Some('\'') => format!("{key}: '{redacted}'"),
_ => format!("{key}: {redacted}"),
}
} else {
match quote {
Some('"') => format!("{key}=\"{redacted}\""),
Some('\'') => format!("{key}='{redacted}'"),
_ => format!("{key}={redacted}"),
}
}
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::parse_fenced_json;
use crate::Verdict;
#[derive(serde::Deserialize, Debug, PartialEq)]
struct TestVerdict {
score: u8,
#[serde(default)]
critique: String,
}
#[test]
fn parse_fenced_json_with_json_tag() {
let text = "Based on the analysis, here's my verdict:\n\n```json\n{\"score\": 8, \"critique\": \"Looks good\"}\n```";
let result: TestVerdict = parse_fenced_json(text).unwrap();
assert_eq!(result.score, 8);
assert_eq!(result.critique, "Looks good");
}
#[test]
fn parse_fenced_json_bare_fence() {
let text = "```\n{\"score\": 7, \"critique\": \"Some issues\"}\n```";
let result: TestVerdict = parse_fenced_json(text).unwrap();
assert_eq!(result.score, 7);
assert_eq!(result.critique, "Some issues");
}
#[test]
fn parse_fenced_json_unfenced() {
#[derive(serde::Deserialize, Debug, PartialEq)]
struct TestVerdict {
score: u8,
critique: String,
issues: Vec<String>,
}
let text = r#"{"score": 10, "critique": "Perfect", "issues": []}"#;
let result: TestVerdict = parse_fenced_json(text).unwrap();
assert_eq!(result.score, 10);
assert!(result.issues.is_empty());
}
#[test]
fn parse_fenced_json_commentary_before_fence() {
let text = "I have reviewed the code.\n\n```json\n{\"score\": 6, \"critique\": \"Needs improvement\"}\n```\n\nOverall, acceptable.";
let result: TestVerdict = parse_fenced_json(text).unwrap();
assert_eq!(result.score, 6);
assert_eq!(result.critique, "Needs improvement");
}
#[test]
fn parse_fenced_json_multiple_fences_uses_first_json() {
let text = "```json\n{\"score\": 9}\n```\n\nSome text\n\n```\n{\"score\": 5}\n```";
let result: TestVerdict = parse_fenced_json(text).unwrap();
assert_eq!(result.score, 9);
}
#[test]
fn parse_fenced_json_with_issues() {
let text = r#"```json
{"score": 5, "critique": "Problems found", "issues": ["Bug in edge case", "Missing error handling"]}
```"#;
let result: Verdict = parse_fenced_json(text).unwrap();
assert_eq!(result.score, 5);
assert_eq!(result.critique.as_deref(), Some("Problems found"));
assert_eq!(result.issues_detected.len(), 2);
assert!(
result
.issues_detected
.contains(&"Bug in edge case".to_string())
);
}
#[test]
fn parse_fenced_json_invalid_json_returns_err() {
let text = "```json\n{invalid: true}\n```";
let result = parse_fenced_json::<Verdict>(text);
assert!(result.is_err());
}
#[test]
fn parse_fenced_json_no_json_at_all() {
let text = "This is just plain text with no JSON whatsoever.";
let result = parse_fenced_json::<Verdict>(text);
assert!(result.is_err());
}
}
#[cfg(test)]
mod truncate_tests {
use super::*;
#[test]
fn passthrough_under_limit() {
let input = "hello world";
let result = truncate_sandwich(input, 5_000, "test");
assert_eq!(
result, input,
"should pass through unchanged when under limit"
);
}
#[test]
fn passthrough_at_exact_limit() {
let input = "a".repeat(5_000);
assert_eq!(input.len(), 5_000);
let result = truncate_sandwich(&input, 5_000, "test");
assert_eq!(result, input, "exact limit should pass through unchanged");
}
#[test]
fn sandwich_just_over_limit() {
let input = "x".repeat(5_001);
let result = truncate_sandwich(&input, 5_000, "test");
assert!(
result.starts_with("xxx"),
"head portion should be preserved"
);
assert!(
result.contains("bytes omitted at test truncation"),
"should contain the omission marker"
);
assert!(result.ends_with('x'), "tail should contain input suffix");
}
#[test]
fn sandwich_large_input() {
let line = "hello world\n".repeat(200_000);
assert!(line.len() > 1_048_576, "input should exceed 1MB");
let result = truncate_sandwich(&line, 1_048_576, "output");
assert!(result.len() < line.len(), "should truncate");
assert!(
result.contains("bytes omitted at output truncation"),
"should contain label in omission marker"
);
assert!(
result.starts_with("hello world"),
"head should be preserved"
);
let last_line = result.lines().last().unwrap_or("");
assert_eq!(last_line, "hello world", "tail should be preserved");
}
#[test]
fn sandwich_preserves_utf8_boundaries() {
let mut input = String::new();
input.push_str(&"x".repeat(3_329));
input.push('🐱'); input.push_str(&"y".repeat(20_000));
let result = truncate_sandwich(&input, 5_000, "test");
assert!(
result.contains('🐱'),
"multibyte char at boundary should survive intact"
);
}
#[test]
fn sandwich_line_boundaries_intact() {
let line = "hello world!\n".repeat(100_000);
let result = truncate_sandwich(&line, 500_000, "test");
assert!(result.len() < line.len(), "should truncate");
for l in result.lines().filter(|l| !l.starts_with("...")) {
assert!(
!l.contains("hello world!hello"),
"lines should not be concatenated"
);
}
}
#[test]
fn custom_label_appears_in_marker() {
let input = "x".repeat(10_000);
let result = truncate_sandwich(&input, 5_000, "my custom label");
assert!(
result.contains("bytes omitted at my custom label truncation"),
"custom label should appear verbatim in marker"
);
}
#[test]
fn empty_label() {
let input = "x".repeat(10_000);
let result = truncate_sandwich(&input, 5_000, "");
assert!(
result.contains("bytes omitted at truncation"),
"empty label should still produce coherent marker"
);
}
#[test]
fn format_tool_output_delegates_correctly() {
let input = "abc".repeat(2_000); let result = format_tool_output(&input);
assert!(result.len() < input.len(), "should truncate");
assert!(
result.contains("bytes omitted at tool output truncation"),
"should use 'tool output' label"
);
assert!(result.starts_with("abcabc"), "head should be preserved");
}
#[test]
fn format_tool_output_passthrough() {
let input = "short";
let result = format_tool_output(input);
assert_eq!(result, input, "short input passes through unchanged");
}
}
#[cfg(test)]
mod scrub_tests {
use super::scrub_credentials;
#[test]
fn scrub_redacts_credentials() {
const CASES: &[(&str, &str, &str, &str)] = &[
(
"alphanumeric unquoted value",
"API_KEY=sk-1234567890abcdef",
"1234567890abcdef",
"API_KEY=sk-1",
),
(
"Base64 unquoted value with plus and slash",
"api_key=u2FsdGVkX1+h/wZ/L3Y+Q==",
"u2FsdGVkX1+h/wZ/L3Y+Q==",
"api_key=u2Fs",
),
(
"double-quoted value with colon separator",
r#"token: "abcdefgh1234567890""#,
"1234567890",
"",
),
(
"bearer colon-separated value",
"bearer: eyJhbGciOiJIUzI1NiJ9",
"eyJhbG",
"",
),
(
"hyphen-key variant",
"user-key=abcdefgh12345678",
"12345678",
"user-key=abcd",
),
];
for &(name, input, not_contains, prefix) in CASES {
let out = scrub_credentials(input);
assert!(out.contains("[REDACTED]"), "{name}: should redact: {out}");
if !not_contains.is_empty() {
assert!(
!out.contains(not_contains),
"{name}: should not leak value: {out}"
);
}
if !prefix.is_empty() {
assert!(out.starts_with(prefix), "{name}: should keep prefix: {out}");
}
}
}
#[test]
fn scrub_exact_output() {
const CASES: &[(&str, &str, &str)] = &[
(
"single-quoted value with colon separator",
"password: 's3cr3t_p@ssw0rd!!'",
"password: 's3cr*[REDACTED]'",
),
(
"single-quoted value with equals separator",
"password='mysecretvalue123'",
"password='myse*[REDACTED]'",
),
(
"double-quoted key with single-quoted value",
r#""password": 'secretvalue123'"#,
"\"password: 'secr*[REDACTED]'",
),
];
for &(name, input, expected) in CASES {
assert_eq!(scrub_credentials(input), expected, "{name}");
}
}
#[test]
fn scrub_passthrough() {
const CASES: &[(&str, &str)] = &[
("short unquoted values (under 8 chars)", "key=short"),
(
"non-secret lines with = and /",
"normal line with = equals and / slash",
),
];
for &(name, input) in CASES {
assert_eq!(scrub_credentials(input), input, "{name}");
}
}
#[test]
fn unix_millis_is_reasonable() {
let ts = super::unix_millis();
assert!(
ts > 1_577_836_800_000,
"unix_millis() seems too small: {ts}"
);
assert!(
ts < 4_102_444_800_000,
"unix_millis() seems too large: {ts}"
);
}
}