From 721cb2b64c4e833585b866f1b0ff0deafc46eb8a Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Wed, 13 May 2026 08:27:34 +0530 Subject: [PATCH] fix(util): UTF-8-safe truncation helpers + audit unsafe byte-slice call sites (OPENHUMAN-TAURI-7G) (#1549) Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) Co-authored-by: Steven Enamakel --- src/openhuman/agent/harness/credentials.rs | 32 +++++++- src/openhuman/agent/harness/parse.rs | 2 +- src/openhuman/agent/triage/evaluator.rs | 7 +- src/openhuman/agent/triage/evaluator_tests.rs | 13 +++ src/openhuman/context/tool_result_budget.rs | 9 +-- src/openhuman/integrations/client.rs | 23 +----- src/openhuman/providers/ops.rs | 40 +++------ src/openhuman/routing/provider.rs | 9 +-- src/openhuman/security/core.rs | 9 ++- src/openhuman/skills/inject.rs | 38 ++++++++- .../subconscious/situation_report/mod.rs | 7 +- src/openhuman/tools/impl/filesystem/grep.rs | 19 ++--- src/openhuman/tools/impl/network/composio.rs | 11 +-- src/openhuman/tools/impl/network/web_fetch.rs | 16 +++- .../tools/impl/network/web_search.rs | 31 ++++--- src/openhuman/util.rs | 81 ++++++++++++++++++- 16 files changed, 228 insertions(+), 119 deletions(-) diff --git a/src/openhuman/agent/harness/credentials.rs b/src/openhuman/agent/harness/credentials.rs index de82ec09b..ab32eb176 100644 --- a/src/openhuman/agent/harness/credentials.rs +++ b/src/openhuman/agent/harness/credentials.rs @@ -21,7 +21,14 @@ pub(crate) fn scrub_credentials(input: &str) -> String { .unwrap_or(""); // Preserve first 4 chars for context, then redact - let prefix = if val.len() > 4 { &val[..4] } else { "" }; + let prefix = if val.chars().count() > 4 { + match val.char_indices().nth(4) { + Some((idx, _)) => &val[..idx], + None => val, + } + } else { + "" + }; if full_match.contains(':') { if full_match.contains('"') { @@ -41,3 +48,26 @@ pub(crate) fn scrub_credentials(input: &str) -> String { }) .to_string() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_scrub_credentials_utf8() { + // Regex requires at least 8 chars for the value + // The [a-zA-Z0-9_\-\.]{8,} part of the regex does NOT match emoji + // So we must use quotes to hit the "([^"]{8,})" part + let input = "api_key: \"🦀🦀🦀🦀🦀🦀🦀🦀\""; + let output = scrub_credentials(input); + // Should preserve 4 crabs and then redact + assert!(output.contains("🦀🦀🦀🦀*[REDACTED]")); + } + + #[test] + fn test_scrub_credentials_short_val() { + let input = "api_key: 12345678"; + let output = scrub_credentials(input); + assert!(output.contains("api_key: 1234*[REDACTED]")); + } +} diff --git a/src/openhuman/agent/harness/parse.rs b/src/openhuman/agent/harness/parse.rs index 28fe8118e..5c38a8e5a 100644 --- a/src/openhuman/agent/harness/parse.rs +++ b/src/openhuman/agent/harness/parse.rs @@ -364,7 +364,7 @@ pub(crate) fn parse_tool_calls(response: &str) -> (String, Vec) // Fall back to XML-style tool-call tag parsing. while let Some((start, open_tag)) = find_first_tag(remaining, &TOOL_CALL_OPEN_TAGS) { - // Everything before the tag is text + // Everything before the tag is text. let before = &remaining[..start]; if !before.trim().is_empty() { text_parts.push(before.trim().to_string()); diff --git a/src/openhuman/agent/triage/evaluator.rs b/src/openhuman/agent/triage/evaluator.rs index e24d052db..a96cb3d3b 100644 --- a/src/openhuman/agent/triage/evaluator.rs +++ b/src/openhuman/agent/triage/evaluator.rs @@ -321,7 +321,7 @@ async fn try_arm( )) })?; - let system_prompt = extract_inline_prompt(&definition).ok_or_else(|| { + let system_prompt = extract_inline_prompt(definition).ok_or_else(|| { ArmError::Fatal(anyhow!( "trigger_triage agent definition must ship an inline prompt body" )) @@ -549,10 +549,7 @@ fn truncate_payload(payload: &serde_json::Value, max_bytes: usize) -> String { return pretty; } let dropped = pretty.len() - max_bytes; - let mut end = max_bytes; - while end > 0 && !pretty.is_char_boundary(end) { - end -= 1; - } + let end = crate::openhuman::util::floor_char_boundary(&pretty, max_bytes); format!("{}\n[...truncated {dropped} bytes]", &pretty[..end]) } diff --git a/src/openhuman/agent/triage/evaluator_tests.rs b/src/openhuman/agent/triage/evaluator_tests.rs index e59135dde..330064a28 100644 --- a/src/openhuman/agent/triage/evaluator_tests.rs +++ b/src/openhuman/agent/triage/evaluator_tests.rs @@ -33,6 +33,19 @@ fn truncate_payload_marks_truncation_and_stays_valid_utf8() { let _ = out.as_str(); } +#[test] +fn test_truncate_payload_utf8_boundary() { + // Each "🦀" is 4 bytes. 10 of them = 40 bytes. + let payload = json!({ "msg": "🦀".repeat(10) }); + // Truncate mid-emoji + let out = truncate_payload(&payload, 25); + assert!(out.contains("[...truncated")); + // The part before truncation must be valid UTF-8 + let head = out.split('\n').next().unwrap(); + // Use a method that is stable on our toolchain + assert!(!head.is_empty()); +} + #[test] fn extract_inline_prompt_returns_body_for_trigger_triage_builtin() { let builtin = BUILTINS diff --git a/src/openhuman/context/tool_result_budget.rs b/src/openhuman/context/tool_result_budget.rs index 5e3de4648..02b4540cd 100644 --- a/src/openhuman/context/tool_result_budget.rs +++ b/src/openhuman/context/tool_result_budget.rs @@ -73,14 +73,7 @@ pub fn apply_tool_result_budget(content: String, budget_bytes: usize) -> (String // Walk char indices forward until we cross the head capacity. The // last char fully inside the head is where we cut. - let mut cut = 0usize; - for (idx, ch) in content.char_indices() { - let next = idx + ch.len_utf8(); - if next > head_capacity { - break; - } - cut = next; - } + let mut cut = crate::openhuman::util::floor_char_boundary(&content, head_capacity); // Extremely short content (single multi-byte char) — guarantee at // least one character makes it into the head so we don't emit a diff --git a/src/openhuman/integrations/client.rs b/src/openhuman/integrations/client.rs index 74892e42a..bedbf98e3 100644 --- a/src/openhuman/integrations/client.rs +++ b/src/openhuman/integrations/client.rs @@ -30,30 +30,11 @@ pub(crate) fn extract_error_detail(body: &str, max_bytes: usize) -> String { if let Some(msg) = v.get("error").and_then(|e| e.as_str()) { let trimmed = msg.trim(); if !trimmed.is_empty() { - return truncate_at_char_boundary(trimmed, max_bytes); + return crate::openhuman::util::truncate_at_byte_boundary(trimmed, max_bytes); } } } - truncate_at_char_boundary(body, max_bytes) -} - -fn truncate_at_char_boundary(s: &str, max: usize) -> String { - if s.len() <= max { - return s.to_string(); - } - // Reserve space for the trailing `…` so the returned string never - // exceeds `max` bytes. Without this, a 500-byte cap could return - // 503 bytes (500 raw + 3-byte ellipsis), breaking the hard cap that - // Sentry tag values and user-facing toasts rely on. - let ellipsis_len = '…'.len_utf8(); - if max < ellipsis_len { - return String::new(); - } - let mut end = max - ellipsis_len; - while end > 0 && !s.is_char_boundary(end) { - end -= 1; - } - format!("{}…", &s[..end]) + crate::openhuman::util::truncate_at_byte_boundary(body, max_bytes) } /// Shared client for all integration tools. Holds backend URL, auth token, diff --git a/src/openhuman/providers/ops.rs b/src/openhuman/providers/ops.rs index 64c1f6e4e..ec45b246b 100644 --- a/src/openhuman/providers/ops.rs +++ b/src/openhuman/providers/ops.rs @@ -83,17 +83,7 @@ pub fn scrub_secret_patterns(input: &str) -> String { /// Sanitize API error text by scrubbing secrets and truncating length. pub fn sanitize_api_error(input: &str) -> String { let scrubbed = scrub_secret_patterns(input); - - if scrubbed.chars().count() <= MAX_API_ERROR_CHARS { - return scrubbed; - } - - let mut end = MAX_API_ERROR_CHARS; - while end > 0 && !scrubbed.is_char_boundary(end) { - end -= 1; - } - - format!("{}...", &scrubbed[..end]) + crate::openhuman::util::truncate_with_ellipsis(&scrubbed, MAX_API_ERROR_CHARS) } const TRANSPORT_ERROR_MAX_CHARS: usize = 1200; @@ -108,14 +98,7 @@ pub fn format_error_chain(err: &dyn std::error::Error) -> String { } let joined = parts.join(" | "); let scrubbed = scrub_secret_patterns(&joined); - if scrubbed.chars().count() <= TRANSPORT_ERROR_MAX_CHARS { - return scrubbed; - } - let mut end = TRANSPORT_ERROR_MAX_CHARS; - while end > 0 && !scrubbed.is_char_boundary(end) { - end -= 1; - } - format!("{}…", &scrubbed[..end]) + crate::openhuman::util::truncate_with_suffix(&scrubbed, TRANSPORT_ERROR_MAX_CHARS, "…") } /// Cause chain from [`anyhow::Error`] (e.g. responses fallback), scrubbed and length-limited. @@ -126,14 +109,7 @@ pub fn format_anyhow_chain(err: &anyhow::Error) -> String { .collect::>() .join(" | "); let scrubbed = scrub_secret_patterns(&joined); - if scrubbed.chars().count() <= TRANSPORT_ERROR_MAX_CHARS { - return scrubbed; - } - let mut end = TRANSPORT_ERROR_MAX_CHARS; - while end > 0 && !scrubbed.is_char_boundary(end) { - end -= 1; - } - format!("{}…", &scrubbed[..end]) + crate::openhuman::util::truncate_with_suffix(&scrubbed, TRANSPORT_ERROR_MAX_CHARS, "…") } /// Provider label used by the OpenHuman backend inference path @@ -446,4 +422,14 @@ mod tests { reqwest::StatusCode::SERVICE_UNAVAILABLE )); } + + #[test] + fn test_sanitize_api_error_utf8() { + let input = "🦀".repeat(MAX_API_ERROR_CHARS + 10); + let sanitized = sanitize_api_error(&input); + assert!(sanitized.ends_with("...")); + // Should truncate at MAX_API_ERROR_CHARS crabs + let crabs_count = sanitized.chars().filter(|c| *c == '🦀').count(); + assert_eq!(crabs_count, MAX_API_ERROR_CHARS); + } } diff --git a/src/openhuman/routing/provider.rs b/src/openhuman/routing/provider.rs index de52c0623..e4c5a0f2c 100644 --- a/src/openhuman/routing/provider.rs +++ b/src/openhuman/routing/provider.rs @@ -37,14 +37,7 @@ fn stream_local_not_supported_error() -> StreamResult { } fn truncate_safe(s: &str, max_bytes: usize) -> &str { - if s.len() <= max_bytes { - return s; - } - - let mut end = max_bytes; - while end > 0 && !s.is_char_boundary(end) { - end -= 1; - } + let end = crate::openhuman::util::floor_char_boundary(s, max_bytes); &s[..end] } diff --git a/src/openhuman/security/core.rs b/src/openhuman/security/core.rs index d01afff34..d63a8f70d 100644 --- a/src/openhuman/security/core.rs +++ b/src/openhuman/security/core.rs @@ -1,10 +1,10 @@ /// Redact sensitive values for safe logging. Shows first 4 chars + "***" suffix. /// This function intentionally breaks the data-flow taint chain for static analysis. pub fn redact(value: &str) -> String { - if value.len() <= 4 { + if value.chars().count() <= 4 { "***".to_string() } else { - format!("{}***", &value[..4]) + crate::openhuman::util::truncate_with_suffix(value, 4, "***") } } @@ -40,4 +40,9 @@ mod tests { assert_eq!(redact(""), "***"); assert_eq!(redact("12345"), "1234***"); } + + #[test] + fn redact_utf8_boundary() { + assert_eq!(redact("🦀🦀🦀🦀🦀"), "🦀🦀🦀🦀***"); + } } diff --git a/src/openhuman/skills/inject.rs b/src/openhuman/skills/inject.rs index 995c22e41..b916ef8cb 100644 --- a/src/openhuman/skills/inject.rs +++ b/src/openhuman/skills/inject.rs @@ -460,10 +460,7 @@ where // footer? let max_body = remaining.saturating_sub(header_len + footer_trunc_len); // Round down to a char boundary. - let mut cut = max_body.min(body.len()); - while cut > 0 && !body.is_char_boundary(cut) { - cut -= 1; - } + let cut = crate::openhuman::util::floor_char_boundary(&body, max_body); let truncated_body = &body[..cut]; rendered.push_str(&header); @@ -691,6 +688,39 @@ mod tests { assert!(inj.decisions[0].truncated); } + #[test] + fn test_render_injection_utf8_boundary() { + let s = skill("utf8", "d"); + let skills = [s]; + let matches = match_skills(&skills, "@utf8"); + // Header: [SKILL:utf8]\n (13 bytes) + // Footer (full): \n[/SKILL]\n (10 bytes) + // Footer (trunc): \n[/SKILL:truncated]\n (20 bytes) + // Body: 🦀🦀 (8 bytes) + // Full length: 13 + 8 + 10 = 31 bytes. + // Min truncated: 13 + 20 + 1 = 34 bytes. + + // Cap at 35 bytes. Full (31) fits. + let inj = render_injection(&matches, 35, |_| Some("🦀🦀".to_string())); + assert!(!inj.truncated); + assert_eq!(inj.rendered.chars().filter(|c| *c == '🦀').count(), 2); + + // Cap at 35 bytes again, but with a body that just barely fits. + // (Just demonstrating it doesn't skip when cap >= min_truncated) + assert!(!inj.truncated); + + // To force truncation, body + full footer must exceed cap. + // Let body be 20 bytes. + // Full length: 13 + 20 + 10 = 43 bytes. + // Min truncated: 34 bytes. + // Cap at 40 bytes. + // max_body = 40 - 33 = 7 bytes. + // 7 bytes fits one 🦀 (4 bytes), but not two (8 bytes). + let inj = render_injection(&matches, 40, |_| Some("🦀".repeat(5))); + assert!(inj.truncated); + assert_eq!(inj.rendered.chars().filter(|c| *c == '🦀').count(), 1); + } + #[test] fn budget_exhausted_skips_later_candidates() { let a = skill("first", "x"); diff --git a/src/openhuman/subconscious/situation_report/mod.rs b/src/openhuman/subconscious/situation_report/mod.rs index a5276a82d..a367fe892 100644 --- a/src/openhuman/subconscious/situation_report/mod.rs +++ b/src/openhuman/subconscious/situation_report/mod.rs @@ -186,12 +186,7 @@ fn append_section(report: &mut String, remaining: &mut usize, section: &str) { *remaining -= needed; } else { let budget = *remaining; - let truncate_at = section - .char_indices() - .map(|(i, ch)| i + ch.len_utf8()) - .take_while(|end| *end <= budget) - .last() - .unwrap_or(0); + let truncate_at = crate::openhuman::util::floor_char_boundary(section, budget); report.push_str(§ion[..truncate_at]); report.push_str("\n[... truncated — token budget exceeded]\n"); *remaining = 0; diff --git a/src/openhuman/tools/impl/filesystem/grep.rs b/src/openhuman/tools/impl/filesystem/grep.rs index aafad3f54..7e71b6e97 100644 --- a/src/openhuman/tools/impl/filesystem/grep.rs +++ b/src/openhuman/tools/impl/filesystem/grep.rs @@ -193,17 +193,14 @@ fn scan_for_matches( let rel = path.strip_prefix(workspace).unwrap_or(path); for (lineno, line) in contents.lines().enumerate() { if regex.is_match(line) { - let display_line = if line.len() > MAX_LINE_BYTES { - // Walk back to a UTF-8 char boundary; slicing `&str` at a - // non-boundary byte panics at runtime. - let mut cut = MAX_LINE_BYTES; - while cut > 0 && !line.is_char_boundary(cut) { - cut -= 1; - } - format!("{}…", &line[..cut]) - } else { - line.to_string() - }; + // `MAX_LINE_BYTES` is a BYTE budget — use the byte-aware + // helper. The earlier migration to `truncate_with_suffix` + // mis-typed this as a char budget; for multi-byte text + // (CJK / emoji) the rendered line could balloon to ~3× + // the intended cap. Per CodeRabbit critical review on + // PR #1549. + let display_line = + crate::openhuman::util::truncate_at_byte_boundary(line, MAX_LINE_BYTES); matches.push(format!("{}:{}:{}", rel.display(), lineno + 1, display_line)); if matches.len() >= max_matches { truncated = true; diff --git a/src/openhuman/tools/impl/network/composio.rs b/src/openhuman/tools/impl/network/composio.rs index ef3cb75f4..642593268 100644 --- a/src/openhuman/tools/impl/network/composio.rs +++ b/src/openhuman/tools/impl/network/composio.rs @@ -647,16 +647,7 @@ fn sanitize_error_message(message: &str) -> String { sanitized = sanitized.replace(marker, "[redacted]"); } - let max_chars = 240; - if sanitized.chars().count() <= max_chars { - sanitized - } else { - let mut end = max_chars; - while end > 0 && !sanitized.is_char_boundary(end) { - end -= 1; - } - format!("{}...", &sanitized[..end]) - } + crate::openhuman::util::truncate_with_ellipsis(&sanitized, 240) } fn extract_api_error_message(body: &str) -> Option { diff --git a/src/openhuman/tools/impl/network/web_fetch.rs b/src/openhuman/tools/impl/network/web_fetch.rs index 50fd1e0d9..3ae47154e 100644 --- a/src/openhuman/tools/impl/network/web_fetch.rs +++ b/src/openhuman/tools/impl/network/web_fetch.rs @@ -154,10 +154,7 @@ impl Tool for WebFetchTool { } let (snippet, truncated) = if body.len() > max_bytes { - let mut cut = max_bytes; - while cut > 0 && !body.is_char_boundary(cut) { - cut -= 1; - } + let cut = crate::openhuman::util::floor_char_boundary(&body, max_bytes); (&body[..cut], true) } else { (body.as_str(), false) @@ -215,4 +212,15 @@ mod tests { let result = tool.execute(json!({ "url": "not-a-url" })).await.unwrap(); assert!(result.is_error); } + + #[test] + fn test_web_fetch_truncation_utf8() { + // Mock body with multi-byte char exactly at budget + let body = "Hello 🦀 World"; // 🦀 is at index 6-9 + let max_bytes = 8; + // Should truncate at index 6 + let cut = crate::openhuman::util::floor_char_boundary(body, max_bytes); + assert_eq!(cut, 6); + assert_eq!(&body[..cut], "Hello "); + } } diff --git a/src/openhuman/tools/impl/network/web_search.rs b/src/openhuman/tools/impl/network/web_search.rs index 6fdf0752c..e6c31a99f 100644 --- a/src/openhuman/tools/impl/network/web_search.rs +++ b/src/openhuman/tools/impl/network/web_search.rs @@ -61,11 +61,7 @@ impl WebSearchTool { if let Some(first) = result.excerpts.first() { let excerpt = first.trim(); if !excerpt.is_empty() { - let truncated = if let Some((idx, _)) = excerpt.char_indices().nth(500) { - format!("{}...", &excerpt[..idx]) - } else { - excerpt.to_string() - }; + let truncated = crate::openhuman::util::truncate_with_ellipsis(excerpt, 500); lines.push(format!(" {}", truncated)); } } @@ -95,11 +91,7 @@ impl WebSearchTool { if let Some(first) = r.excerpts.first() { let excerpt = first.trim(); if !excerpt.is_empty() { - let truncated = if let Some((idx, _)) = excerpt.char_indices().nth(500) { - format!("{}…", &excerpt[..idx]) - } else { - excerpt.to_string() - }; + let truncated = crate::openhuman::util::truncate_with_suffix(excerpt, 500, "…"); out.push_str(&format!("> {truncated}\n")); } } @@ -311,6 +303,25 @@ mod tests { assert!(excerpt_line.trim().len() <= 503); } + #[test] + fn test_web_search_truncation_utf8() { + let excerpt = "🦀".repeat(600); + let results = vec![SearchResultItem { + title: "T".into(), + url: "https://t.com".into(), + publish_date: None, + excerpts: vec![excerpt], + }]; + let result = tool().parse_parallel_results(&results, "q").unwrap(); + assert!(result.contains("...")); + // Should have 500 crabs + "..." + let excerpt_line = result.lines().find(|l| l.contains('🦀')).unwrap(); + assert_eq!( + excerpt_line.trim().chars().filter(|c| *c == '🦀').count(), + 500 + ); + } + #[tokio::test] async fn test_execute_missing_query() { let result = tool().execute(json!({})).await; diff --git a/src/openhuman/util.rs b/src/openhuman/util.rs index 208efd533..b11b0f656 100644 --- a/src/openhuman/util.rs +++ b/src/openhuman/util.rs @@ -33,16 +33,52 @@ /// assert_eq!(truncate_with_ellipsis("", 10), ""); /// ``` pub fn truncate_with_ellipsis(s: &str, max_chars: usize) -> String { + truncate_with_suffix(s, max_chars, "...") +} + +/// Truncate a string to at most `max_chars` characters, appending `suffix` if truncated. +pub fn truncate_with_suffix(s: &str, max_chars: usize, suffix: &str) -> String { match s.char_indices().nth(max_chars) { Some((idx, _)) => { let truncated = &s[..idx]; // Trim trailing whitespace for cleaner output - format!("{}...", truncated.trim_end()) + format!("{}{}", truncated.trim_end(), suffix) } None => s.to_string(), } } +/// Truncate a string to at most `max_bytes` bytes, appending a single-character +/// ellipsis `…` (3 bytes) if truncated. The returned string's total byte +/// length will never exceed `max_bytes`. +pub fn truncate_at_byte_boundary(s: &str, max_bytes: usize) -> String { + if s.len() <= max_bytes { + return s.to_string(); + } + let ellipsis = "…"; + let ellipsis_len = ellipsis.len(); + if max_bytes < ellipsis_len { + return String::new(); + } + let mut end = max_bytes - ellipsis_len; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + format!("{}{}", &s[..end], ellipsis) +} + +/// Round a byte index DOWN to the nearest UTF-8 character boundary. +pub fn floor_char_boundary(s: &str, index: usize) -> usize { + if index >= s.len() { + return s.len(); + } + let mut end = index; + while end > 0 && !s.is_char_boundary(end) { + end -= 1; + } + end +} + /// Utility enum for handling optional values. pub enum MaybeSet { Set(T), @@ -142,4 +178,47 @@ mod tests { // Edge case: max_chars = 0 assert_eq!(truncate_with_ellipsis("hello", 0), "..."); } + + #[test] + fn test_truncate_at_byte_boundary() { + let s = "Hello 🦀 World"; // 16 bytes total. "🦀" is 4 bytes at index 6-9. + // No truncation + assert_eq!(truncate_at_byte_boundary(s, 16), s); + assert_eq!(truncate_at_byte_boundary(s, 20), s); + + // Truncate at index 11 (the space after 🦀) + // max_bytes = 14, ellipsis = 3 bytes, target end = 11. + assert_eq!(truncate_at_byte_boundary(s, 14), "Hello 🦀 …"); + + // Truncate mid-emoji (byte 8 is mid-🦀) + // max_bytes = 9, ellipsis = 3 bytes, target end = 6. + // should back up to index 6, add "…" (3 bytes) -> 9 bytes total + let truncated = truncate_at_byte_boundary(s, 9); + assert_eq!(truncated, "Hello …"); + assert!(truncated.len() <= 9); + + // Very small budget + assert_eq!(truncate_at_byte_boundary("abc", 2), ""); + assert_eq!(truncate_at_byte_boundary("abc", 3), "abc"); + } + + #[test] + fn test_floor_char_boundary() { + let s = "A🦀C"; + assert_eq!(floor_char_boundary(s, 0), 0); + assert_eq!(floor_char_boundary(s, 1), 1); // After 'A' + assert_eq!(floor_char_boundary(s, 2), 1); // Mid-🦀 + assert_eq!(floor_char_boundary(s, 3), 1); // Mid-🦀 + assert_eq!(floor_char_boundary(s, 4), 1); // Mid-🦀 + assert_eq!(floor_char_boundary(s, 5), 5); // After '🦀' + assert_eq!(floor_char_boundary(s, 6), 6); // After 'C' + assert_eq!(floor_char_boundary(s, 100), 6); + } + + #[test] + fn test_truncate_with_suffix() { + let s = "Hello World"; + assert_eq!(truncate_with_suffix(s, 5, "!!!"), "Hello!!!"); + assert_eq!(truncate_with_suffix(s, 20, "!!!"), "Hello World"); + } }