diff --git a/src/main.rs b/src/main.rs index 93dffbe6c..4caf60e32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -579,8 +579,14 @@ fn cmd_auth(config_path: Option, auth_cmd: AuthCommand) -> a } else { eprintln!("Anthropic OAuth: valid (expires in {}m)", expires_min); } - eprintln!(" access token: {}...", &creds.access_token[..20]); - eprintln!(" refresh token: {}...", &creds.refresh_token[..20]); + eprintln!( + " access token: ({} bytes)", + creds.access_token.len() + ); + eprintln!( + " refresh token: ({} bytes)", + creds.refresh_token.len() + ); eprintln!( " credentials file: {}", spacebot::auth::credentials_path(&instance_dir).display() diff --git a/src/tools.rs b/src/tools.rs index f1e53257b..677bd3e85 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -992,4 +992,87 @@ mod tests { assert_eq!(truncate_utf8_ellipsis(text, 10), "🙂..."); assert!(truncate_output(text, 5).starts_with("🙂")); } + + #[test] + fn truncate_cyrillic_does_not_panic() { + // Cyrillic chars are 2 bytes each in UTF-8 + let text = "Привет, мир!"; // "Hello, world!" in Russian + // Cutting at byte 5 would land inside 'и' (bytes 4..5) - this must not panic + let result = truncate_utf8_ellipsis(text, 5); + assert!(!result.is_empty()); + // "П" (2 bytes) + "..." (3 bytes) = 5, fits exactly + assert_eq!(result, "П..."); + + let result = truncate_utf8_ellipsis(text, 10); + assert!(!result.is_empty()); + // Should truncate to a valid char boundary and append "..." + assert!(result.ends_with("...")); + } + + #[test] + fn truncate_cjk_does_not_panic() { + // CJK chars are 3 bytes each in UTF-8 + let text = "你好世界测试"; // "Hello world test" in Chinese + // Cutting at byte 4 would land inside '好' (bytes 3..5) + let result = truncate_utf8_ellipsis(text, 4); + assert_eq!(result, "你"); // only 3 bytes fit, no room for "..." + + let result = truncate_utf8_ellipsis(text, 10); + assert!(!result.is_empty()); + // 2 CJK chars (6 bytes) + "..." (3 bytes) = 9 bytes fits in 10 + assert_eq!(result, "你好..."); + } + + #[test] + fn truncate_emoji_does_not_panic() { + // Emoji are 4 bytes each in UTF-8 + let text = "Hello 😀🎉🚀 World"; + let result = truncate_utf8_ellipsis(text, 10); + assert!(!result.is_empty()); + // "Hello " (6 bytes) + "😀" won't fit with "..." in 10 bytes + // "Hello " (6 bytes) + "..." (3 bytes) = 9 bytes fits + assert_eq!(result, "Hello ..."); + + // Larger budget + let result = truncate_utf8_ellipsis(text, 15); + assert!(!result.is_empty()); + assert!(result.ends_with("...")); + } + + #[test] + fn truncate_mixed_multibyte_content() { + // Mix of ASCII, Cyrillic (2-byte), CJK (3-byte), and emoji (4-byte) + let text = "Hi Привет 你好 😀"; + let result = truncate_utf8_ellipsis(text, 20); + assert!(!result.is_empty()); + assert!(result.ends_with("...")); + // Verify it's valid UTF-8 by iterating chars + assert!(result.chars().count() > 0); + } + + #[test] + fn truncate_output_multibyte_does_not_panic() { + let cyrillic = "Привет, мир! Это тестовая строка для проверки."; + let result = truncate_output(cyrillic, 15); + assert!(!result.is_empty()); + + let cjk = "你好世界,这是一个测试字符串。"; + let result = truncate_output(cjk, 10); + assert!(!result.is_empty()); + + let emoji = "🎉🚀😀🌍💻🔥"; + let result = truncate_output(emoji, 6); + assert!(!result.is_empty()); + } + + #[test] + fn truncate_at_exact_char_boundary_works() { + let text = "абв"; // 3 Cyrillic chars, 6 bytes total + // Exactly at a char boundary (4 bytes = 2 chars) + let result = truncate_utf8_ellipsis(text, 6); + assert_eq!(result, "абв"); // fits entirely + + let result = truncate_utf8_ellipsis(text, 7); + assert_eq!(result, "абв"); // also fits, no truncation needed + } } diff --git a/src/tools/browser.rs b/src/tools/browser.rs index d0590846c..f5affe75f 100644 --- a/src/tools/browser.rs +++ b/src/tools/browser.rs @@ -529,11 +529,7 @@ fn render_snapshot_node(node: &SnapshotNode, depth: usize, output: &mut String) if !node.name.is_empty() { output.push_str(" \""); // Truncate very long names for context efficiency. - let display_name = if node.name.len() > 200 { - format!("{}...", &node.name[..200]) - } else { - node.name.clone() - }; + let display_name = super::truncate_utf8_ellipsis(&node.name, 200); output.push_str(&display_name.replace('"', "\\\"")); output.push('"'); } @@ -574,11 +570,7 @@ fn render_snapshot_node(node: &SnapshotNode, depth: usize, output: &mut String) // Value (e.g., text input current value) if let Some(ref value) = node.value { - let display_value = if value.len() > 100 { - format!("{}...", &value[..100]) - } else { - value.clone() - }; + let display_value = super::truncate_utf8_ellipsis(value, 100); output.push_str(&format!( " value=\"{}\"", display_value.replace('"', "\\\"") @@ -1588,11 +1580,7 @@ impl Tool for BrowserTypeTool { args.secret.as_deref().unwrap_or("unknown") ) } else { - let display_text = if text_value.len() > 50 { - format!("{}...", &text_value[..50]) - } else { - text_value - }; + let display_text = super::truncate_utf8_ellipsis(&text_value, 50); format!("Typed '{display_text}' into element at {label}") };