pub mod error;
pub mod html;
pub mod http;
pub mod json;
pub mod macros;
#[cfg(test)]
pub mod test;
pub mod tree_sitter;
use directories::UserDirs;
use regex::Regex;
use regex::RegexBuilder;
use std::path::PathBuf;
use std::sync::LazyLock;
use base64::{Engine as _, engine::general_purpose::STANDARD};
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)
}
}
const MEDIA_MARKER_PATTERN: &str = r"\[(?P<kind>IMAGE|AUDIO|VIDEO):(?P<path>[^\]]+)\]";
pub(crate) static MEDIA_MARKER_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(MEDIA_MARKER_PATTERN).expect("MEDIA_MARKER_RE must compile"));
pub(crate) static TELEGRAM_MEDIA_MARKER_RE: LazyLock<Regex> = LazyLock::new(|| {
RegexBuilder::new(MEDIA_MARKER_PATTERN)
.case_insensitive(true)
.build()
.expect("TELEGRAM_MEDIA_MARKER_RE must compile")
});
#[must_use]
pub(crate) fn parse_media_marker<'h>(caps: ®ex::Captures<'h>) -> (&'h str, &'h str) {
let kind = caps
.name("kind")
.expect("parse_media_marker: expected 'kind' group")
.as_str();
let path = caps
.name("path")
.expect("parse_media_marker: expected 'path' group")
.as_str();
(kind, path)
}
#[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]
#[allow(clippy::cast_possible_truncation)]
pub(crate) fn unix_millis() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
#[must_use]
pub(crate) fn expand_tilde(path: &str) -> PathBuf {
if let Some(stripped) = path.strip_prefix('~') {
let home = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE"));
if let Ok(home) = home {
return PathBuf::from(home).join(stripped.trim_start_matches('/'));
}
}
PathBuf::from(path)
}
#[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),
}
}
#[must_use]
pub fn panic_message(payload: &(dyn std::any::Any + Send)) -> String {
if let Some(msg) = payload.downcast_ref::<&str>() {
msg.to_string()
} else if let Some(msg) = payload.downcast_ref::<String>() {
msg.clone()
} else {
"unknown panic".to_string()
}
}
#[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 truncate_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
}
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",
}
}
#[must_use]
pub(crate) fn strip_ansi_escapes(input: &str) -> String {
static RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"\x1B\[[0-9;]*[a-zA-Z]|\x1B\][0-9;]*[^\x1B]*\x1B\\|\x1B[\(\)\[\]KM]|\x1B\][0-9;]*\x07",
)
.unwrap()
});
RE.replace_all(input, "").to_string()
}
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()
}
#[must_use]
pub(crate) fn cargo_bin_dir() -> Option<PathBuf> {
if let Ok(cargo_home) = std::env::var("CARGO_HOME")
&& !cargo_home.is_empty()
{
return Some(PathBuf::from(cargo_home).join("bin"));
}
let dirs = UserDirs::new()?;
Some(dirs.home_dir().join(".cargo").join("bin"))
}
#[must_use]
pub fn unquote_c_style(raw: &str) -> Option<String> {
if let Some(inner) = raw.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
unescape_c_style(inner)
} else {
Some(raw.to_string())
}
}
fn unescape_c_style(input: &str) -> Option<String> {
let mut result = String::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i: usize = 0;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 1; if i >= bytes.len() {
tracing::warn!(
input = %input,
"unescape_c_style: dangling backslash at end of string"
);
return None;
}
match bytes[i] {
b'"' => result.push('"'),
b'\\' => result.push('\\'),
b't' => result.push('\t'),
b'n' => result.push('\n'),
b'a' => result.push('\x07'),
b'b' => result.push('\x08'),
b'f' => result.push('\x0c'),
b'r' => result.push('\r'),
b'v' => result.push('\x0b'),
b'0'..=b'3' => {
let digits_start = i;
i += 1;
let mut digit_count = 1;
while digit_count < 3 && i < bytes.len() && bytes[i].is_ascii_digit() {
if !(b'0'..=b'7').contains(&bytes[i]) {
break;
}
i += 1;
digit_count += 1;
}
let octal_str = std::str::from_utf8(&bytes[digits_start..i]).ok()?;
let Ok(byte_val) = u8::from_str_radix(octal_str, 8) else {
tracing::warn!(
input = %input, octal = %octal_str,
"unescape_c_style: invalid octal escape"
);
return None;
};
result.push_str(&String::from_utf8_lossy(&[byte_val]));
continue; }
b'4'..=b'7' => {
if i + 1 < bytes.len() && bytes[i + 1].is_ascii_digit() {
tracing::warn!(
input = %input,
ch = %(bytes[i] as char),
"unescape_c_style: invalid octal prefix \\4–\\7 followed by digit"
);
return None;
}
result.push(bytes[i] as char);
}
_ => {
tracing::warn!(
input = %input,
ch = %(bytes[i] as char),
"unescape_c_style: unrecognized escape sequence"
);
return None;
}
}
} else {
result.push(bytes[i] as char);
}
i += 1;
}
Some(result)
}
#[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 truncate_tool_output_appends_correct_label() {
let input = "abc".repeat(2_000); let result = truncate_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");
}
}
#[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}");
}
}
}
#[cfg(test)]
mod unescape_c_style_tests {
use super::unescape_c_style;
#[test]
fn test_unescape_c_style() {
let cases: &[(&str, Option<&str>)] = &[
(
r#"hello\"world\\test\nline\there"#,
Some("hello\"world\\test\nline\there"),
),
(r"\a\b\f\r\v", Some("\x07\x08\x0c\r\x0b")),
(r"\0\1", Some("\0\x01")),
(r"\12\37", Some("\n\x1f")),
(r"\101\377", Some("A\u{FFFD}")),
(r"\12x", Some("\nx")),
(r"\18", Some("\x018")),
("plain/path.rs", Some("plain/path.rs")),
("", Some("")),
(r"path\", None),
(r"\x", None),
(r"\q", None),
(r"\40", None),
(r"\77", None),
(r"\70", None),
(r"\4", Some("4")),
(r"\7x", Some("7x")),
];
for (i, (input, expected)) in cases.iter().enumerate() {
let result = unescape_c_style(input);
assert_eq!(
result.as_deref(),
*expected,
"case {i}: unescape_c_style({input:?})"
);
}
}
}
#[cfg(test)]
mod strip_ansi_escapes_tests {
use super::strip_ansi_escapes;
#[test]
fn test_ansi_escape_cases() {
let cases: &[(&str, &str)] = &[
("\x1B[31mred\x1B[0m \x1B[1mbold\x1B[22m", "red bold"),
("hello world", "hello world"),
("\x1B[32mgreen\x1B[0m", "green"),
("no escapes here", "no escapes here"),
("", ""),
];
for (input, expected) in cases {
assert_eq!(strip_ansi_escapes(input), *expected, "input: {input:?}");
}
}
}