diff --git a/server/src/agent_log/format.rs b/server/src/agent_log/format.rs index e38a46e4..1629b469 100644 --- a/server/src/agent_log/format.rs +++ b/server/src/agent_log/format.rs @@ -1,5 +1,7 @@ //! Human-readable formatting of raw agent log entries. +use crate::chat::util::truncate_at_char_boundary; + /// Format a single log entry as a human-readable text line. /// /// `timestamp` is an ISO 8601 string; `event` is the flattened `AgentEvent` @@ -7,6 +9,7 @@ /// /// Returns `None` for entries that should be skipped (raw streaming noise, /// trivial status changes, empty output, etc.). +#[allow(clippy::string_slice)] // timestamp[11..19]: ISO 8601 is ASCII-only, these byte offsets are always valid pub fn format_log_entry_as_text(timestamp: &str, event: &serde_json::Value) -> Option { let agent_name = event .get("agent_name") @@ -75,7 +78,7 @@ pub fn format_log_entry_as_text(timestamp: &str, event: &serde_json::Value) -> O .map(|v| serde_json::to_string(v).unwrap_or_default()) .unwrap_or_default(); let display = if input.len() > 200 { - format!("{}...", &input[..200]) + format!("{}...", truncate_at_char_boundary(&input, 200)) } else { input }; diff --git a/server/src/agent_mode/claim.rs b/server/src/agent_mode/claim.rs index 3d50129b..c765f2a2 100644 --- a/server/src/agent_mode/claim.rs +++ b/server/src/agent_mode/claim.rs @@ -76,6 +76,7 @@ mod tests { /// AC: seed a stale claim older than the TTL, attempt a new claim from a /// different agent, assert the new claim succeeds and displacement is logged. #[test] + #[allow(clippy::string_slice)] // stale_holder is a hex/ASCII string literal; [..12] always valid fn stale_claim_displaced_and_logged() { use crate::crdt_state::{init_for_test, our_node_id, read_item, write_claim, write_item}; diff --git a/server/src/agent_mode/mod.rs b/server/src/agent_mode/mod.rs index e0e005da..b8a52f06 100644 --- a/server/src/agent_mode/mod.rs +++ b/server/src/agent_mode/mod.rs @@ -48,6 +48,7 @@ use loop_ops::{ /// /// If `join_token` and `gateway_url` are both provided the agent will register /// itself with the gateway on startup using the one-time token. +#[allow(clippy::string_slice)] // node_id is a UUID (ASCII); min(8) clamps within its length pub async fn run( project_root: Option, rendezvous_url: String, diff --git a/server/src/agents/pool/pipeline/advance/mod.rs b/server/src/agents/pool/pipeline/advance/mod.rs index 68765758..0368fccf 100644 --- a/server/src/agents/pool/pipeline/advance/mod.rs +++ b/server/src/agents/pool/pipeline/advance/mod.rs @@ -20,6 +20,7 @@ const MAX_GATE_OUTPUT_BYTES: usize = 8_000; /// Truncate gate output to [`MAX_GATE_OUTPUT_BYTES`], keeping the **tail** /// (where compiler errors and test failures are reported). +#[allow(clippy::string_slice)] // adjusted is walked forward to a char boundary before slicing fn truncate_gate_output(output: &str) -> &str { if output.len() <= MAX_GATE_OUTPUT_BYTES { return output; diff --git a/server/src/agents/pool/start/spawn.rs b/server/src/agents/pool/start/spawn.rs index 91e17eb7..e53c683d 100644 --- a/server/src/agents/pool/start/spawn.rs +++ b/server/src/agents/pool/start/spawn.rs @@ -31,6 +31,7 @@ use super::super::types::StoryAgent; const GATE_OUTPUT_PROMPT_BYTES: usize = 3_000; /// Truncate `output` to at most [`GATE_OUTPUT_PROMPT_BYTES`], keeping the tail. +#[allow(clippy::string_slice)] // adjusted is walked forward to a char boundary before slicing fn truncate_for_system_prompt(output: &str) -> &str { if output.len() <= GATE_OUTPUT_PROMPT_BYTES { return output; diff --git a/server/src/chat/commands/cost.rs b/server/src/chat/commands/cost.rs index b0416393..1e251efc 100644 --- a/server/src/chat/commands/cost.rs +++ b/server/src/chat/commands/cost.rs @@ -86,6 +86,7 @@ pub(super) fn handle_cost(ctx: &CommandContext) -> Option { /// /// Agent names like "coder-1", "qa-2", "mergemaster" map to types "coder", /// "qa", "mergemaster". If the name ends with `-`, strip the suffix. +#[allow(clippy::string_slice)] // pos comes from rfind('-'), so pos+1 is after an ASCII '-' → valid boundary pub(super) fn extract_agent_type(agent_name: &str) -> String { if let Some(pos) = agent_name.rfind('-') { let suffix = &agent_name[pos + 1..]; diff --git a/server/src/chat/commands/coverage.rs b/server/src/chat/commands/coverage.rs index 93501e8b..3dc1cd4c 100644 --- a/server/src/chat/commands/coverage.rs +++ b/server/src/chat/commands/coverage.rs @@ -240,6 +240,7 @@ fn parse_coverage_output(output: &str, passed: bool) -> String { } /// Extract a value from lines like `"Rust line coverage: 62.5%"`. +#[allow(clippy::string_slice)] // starts_with(prefix) guarantees prefix.len() is a char boundary fn extract_line_value(output: &str, prefix: &str) -> Option { output .lines() @@ -248,6 +249,7 @@ fn extract_line_value(output: &str, prefix: &str) -> Option { } /// Extract a value from the summary block: `" Overall: 62.5%"`. +#[allow(clippy::string_slice)] // starts_with(label) guarantees label.len() is a char boundary fn extract_summary_field(output: &str, label: &str) -> Option { output .lines() diff --git a/server/src/chat/commands/diff.rs b/server/src/chat/commands/diff.rs index e354023a..83009000 100644 --- a/server/src/chat/commands/diff.rs +++ b/server/src/chat/commands/diff.rs @@ -4,6 +4,7 @@ //! HEAD, formatted for readability in chat. use super::CommandContext; +use crate::chat::util::truncate_at_char_boundary; use std::path::Path; use std::process::Command; @@ -125,18 +126,6 @@ fn run_git(dir: &Path, args: &[&str]) -> String { .unwrap_or_default() } -/// Truncate `s` to at most `max_bytes` bytes without splitting a UTF-8 character. -fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str { - if s.len() <= max_bytes { - return s; - } - let mut boundary = max_bytes; - while !s.is_char_boundary(boundary) { - boundary -= 1; - } - &s[..boundary] -} - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- diff --git a/server/src/chat/commands/git.rs b/server/src/chat/commands/git.rs index 0d527a87..d5f5a03b 100644 --- a/server/src/chat/commands/git.rs +++ b/server/src/chat/commands/git.rs @@ -3,6 +3,7 @@ use super::CommandContext; /// Show compact git status: branch, uncommitted files, ahead/behind remote. +#[allow(clippy::string_slice)] // line[..2] and line[3..]: git porcelain XY status codes are always ASCII pub(super) fn handle_git(ctx: &CommandContext) -> Option { use std::process::Command; diff --git a/server/src/chat/commands/overview.rs b/server/src/chat/commands/overview.rs index b673eaee..790cf80d 100644 --- a/server/src/chat/commands/overview.rs +++ b/server/src/chat/commands/overview.rs @@ -8,6 +8,7 @@ use super::CommandContext; /// git diff --stat (files changed with line counts), and extracts key /// function/struct/type names added or modified in the implementation. /// Returns a friendly message when no merge commit is found. +#[allow(clippy::string_slice)] // commit_hash is hex (ASCII), min(8) always within bounds pub(super) fn handle_overview(ctx: &CommandContext) -> Option { let num_str = ctx.args.trim(); if num_str.is_empty() { @@ -129,6 +130,7 @@ fn get_commit_stat(root: &std::path::Path, hash: &str) -> String { /// /// Scans added lines (`+`) for Rust `fn`, `struct`, `enum`, `type`, `trait`, /// and `impl` declarations and returns them formatted as `` `Name` (kind) ``. +#[allow(clippy::string_slice)] // line starts with '+' (ASCII), so &line[1..] is always a valid boundary fn extract_diff_symbols(root: &std::path::Path, hash: &str) -> Vec { use std::process::Command; let output = Command::new("git") diff --git a/server/src/chat/commands/run_tests.rs b/server/src/chat/commands/run_tests.rs index f9f22b8a..3d7606ce 100644 --- a/server/src/chat/commands/run_tests.rs +++ b/server/src/chat/commands/run_tests.rs @@ -123,6 +123,7 @@ fn parse_test_counts(output: &str) -> (u64, u64) { (total_passed, total_failed) } +#[allow(clippy::string_slice)] // pos from line.find(label) → always a char boundary fn extract_count(line: &str, label: &str) -> Option { let pos = line.find(label)?; let before = line[..pos].trim_end(); diff --git a/server/src/chat/commands/show.rs b/server/src/chat/commands/show.rs index 6c5d55ca..05b6d8b3 100644 --- a/server/src/chat/commands/show.rs +++ b/server/src/chat/commands/show.rs @@ -3,6 +3,7 @@ use super::CommandContext; /// Strip YAML front matter and return a summary of useful fields + the remaining body. +#[allow(clippy::string_slice)] // indices from find("\n---") on ASCII delimiter; "---" and "\n---" are ASCII-only fn strip_front_matter(text: &str) -> (String, String) { let trimmed = text.trim_start(); if !trimmed.starts_with("---") { diff --git a/server/src/chat/commands/status/tests.rs b/server/src/chat/commands/status/tests.rs index ca7b28e3..366ca9c2 100644 --- a/server/src/chat/commands/status/tests.rs +++ b/server/src/chat/commands/status/tests.rs @@ -462,7 +462,7 @@ fn status_shows_crdt_done_story_in_done_not_backlog() { ); // Verify it's not in Backlog section specifically. - let backlog_section = &output[backlog_pos..done_pos]; + let backlog_section = output.get(backlog_pos..done_pos).unwrap_or(""); assert!( !backlog_section.contains("503"), "503 must not appear in Backlog section: {backlog_section}" @@ -573,10 +573,16 @@ fn merge_item_failure_snippet_truncated_at_120_chars() { ); // The snippet should not exceed 120 chars plus the ellipsis character. let snippet_start = output.find("\u{26D4}").expect("stop sign must be present"); - let line = output[snippet_start..].lines().next().unwrap_or(""); + let line = output + .get(snippet_start..) + .unwrap_or("") + .lines() + .next() + .unwrap_or(""); // Find the last " — " separator (before the snippet) and take what follows. if let Some(sep_pos) = line.rfind(" \u{2014} ") { - let snippet = &line[sep_pos + 5..]; // " — " is 5 bytes (space + 3-byte em dash + space) + // " — " is 5 bytes (space + 3-byte em dash + space) + let snippet = line.get(sep_pos + 5..).unwrap_or(""); assert!( snippet.chars().count() <= 122, // 120 chars + "…" (1 char) + possible trailing "snippet should be at most ~121 chars: {snippet}" diff --git a/server/src/chat/transport/discord/meta.rs b/server/src/chat/transport/discord/meta.rs index fe0cd78d..db5a497d 100644 --- a/server/src/chat/transport/discord/meta.rs +++ b/server/src/chat/transport/discord/meta.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use serde::Deserialize; -use crate::chat::{ChatTransport, MessageId}; +use crate::chat::{ChatTransport, MessageId, util::truncate_at_char_boundary}; use crate::slog; // ── Discord API base URL (overridable for tests) ────────────────────── @@ -71,7 +71,7 @@ impl ChatTransport for DiscordTransport { // Discord messages have a 2000-char limit. Truncate if needed. let content = if plain.len() > 2000 { - format!("{}…", &plain[..1999]) + format!("{}…", truncate_at_char_boundary(plain, 1999)) } else { plain.to_string() }; @@ -118,7 +118,7 @@ impl ChatTransport for DiscordTransport { ); let content = if plain.len() > 2000 { - format!("{}…", &plain[..1999]) + format!("{}…", truncate_at_char_boundary(plain, 1999)) } else { plain.to_string() }; diff --git a/server/src/chat/transport/matrix/bot/mentions.rs b/server/src/chat/transport/matrix/bot/mentions.rs index 83839b88..21b217a7 100644 --- a/server/src/chat/transport/matrix/bot/mentions.rs +++ b/server/src/chat/transport/matrix/bot/mentions.rs @@ -37,6 +37,7 @@ pub fn mentions_bot(body: &str, formatted_body: Option<&str>, bot_user_id: &Owne } /// Returns `true` if `haystack` contains `needle` at a word boundary. +#[allow(clippy::string_slice)] // all indices from find() or abs+needle.len() → always char boundaries pub(super) fn contains_word(haystack: &str, needle: &str) -> bool { let mut start = 0; while let Some(rel) = haystack[start..].find(needle) { @@ -87,6 +88,7 @@ pub(super) async fn is_reply_to_bot( /// /// Used in ambient mode to suppress responses when a message is clearly /// directed at a different participant (e.g. another bot in the same room). +#[allow(clippy::string_slice)] // word_end and colon_pos from find() → always char boundaries pub fn is_addressed_to_other(body: &str, bot_user_id: &OwnedUserId, bot_name: &str) -> bool { let trimmed = body.trim_start(); let lower = trimmed.to_lowercase(); diff --git a/server/src/chat/transport/matrix/htop.rs b/server/src/chat/transport/matrix/htop.rs index 1382bb3b..ef7ef89d 100644 --- a/server/src/chat/transport/matrix/htop.rs +++ b/server/src/chat/transport/matrix/htop.rs @@ -101,6 +101,7 @@ fn parse_duration(s: &str) -> Option { /// /// Returns a short string like `"load average: 1.23, 0.98, 0.75"` on success, /// or `"load: unknown"` on failure. +#[allow(clippy::string_slice)] // idx comes from output.find("load average") → always a char boundary fn get_load_average() -> String { let output = std::process::Command::new("uptime") .output() diff --git a/server/src/chat/transport/whatsapp/format.rs b/server/src/chat/transport/whatsapp/format.rs index d62a83f5..6c220674 100644 --- a/server/src/chat/transport/whatsapp/format.rs +++ b/server/src/chat/transport/whatsapp/format.rs @@ -1,4 +1,5 @@ //! WhatsApp message formatting — Markdown-to-WhatsApp conversion and message chunking. +use crate::chat::util::truncate_at_char_boundary; use regex::Regex; use std::sync::LazyLock; @@ -24,13 +25,13 @@ pub fn chunk_for_whatsapp(text: &str) -> Vec { } // Find the best split point within the limit. - let window = &remaining[..WHATSAPP_MAX_MESSAGE_LEN]; + let window = truncate_at_char_boundary(remaining, WHATSAPP_MAX_MESSAGE_LEN); // Prefer paragraph boundary. let split_pos = window .rfind("\n\n") .or_else(|| window.rfind('\n')) - .unwrap_or(WHATSAPP_MAX_MESSAGE_LEN); + .unwrap_or(window.len()); let (chunk, rest) = remaining.split_at(split_pos); let chunk = chunk.trim(); diff --git a/server/src/chat/util.rs b/server/src/chat/util.rs index 3d1ff245..73f5dd84 100644 --- a/server/src/chat/util.rs +++ b/server/src/chat/util.rs @@ -3,6 +3,23 @@ //! These functions are transport-agnostic helpers for processing chat messages: //! prefix stripping, bot-mention handling, and paragraph buffering. +/// Truncate `s` to at most `max_bytes` bytes without splitting a UTF-8 codepoint. +/// +/// If `s.len() <= max_bytes` the original slice is returned unchanged. +/// Otherwise the returned slice ends at the largest char boundary ≤ `max_bytes`, +/// preventing any panic that would result from slicing mid-codepoint. +pub fn truncate_at_char_boundary(s: &str, max_bytes: usize) -> &str { + if s.len() <= max_bytes { + return s; + } + let mut boundary = max_bytes; + while !s.is_char_boundary(boundary) { + boundary -= 1; + } + #[allow(clippy::string_slice)] // boundary is guaranteed to be a char boundary by the loop above + &s[..boundary] +} + /// Returns `true` if the message body is an affirmative permission response. /// /// Recognised affirmative tokens (case-insensitive): `yes`, `y`, `approve`, @@ -26,6 +43,7 @@ pub fn is_permission_approval(body: &str) -> bool { /// Case-insensitive prefix strip that also requires the match to end at a /// word boundary (whitespace, punctuation, or end-of-string). +#[allow(clippy::string_slice)] // prefix.len() is safe: `get(..prefix.len())` already validated the boundary pub fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { let candidate = text.get(..prefix.len())?; if !candidate.eq_ignore_ascii_case(prefix) { @@ -50,6 +68,7 @@ pub fn strip_prefix_ci<'a>(text: &'a str, prefix: &str) -> Option<&'a str> { /// - `DisplayName, rest` → `rest` (Element tab-completion may insert a comma) /// - `DisplayName ⚡️: rest` → `rest` (display name with emoji) /// - `[DisplayName](https://matrix.to/#/@user:server) rest` → `rest` (Element mention pill) +#[allow(clippy::string_slice)] // all indices come from str::find / str::find_map → always char boundaries pub fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str) -> &'a str { let trimmed = message.trim(); @@ -98,6 +117,7 @@ pub fn strip_bot_mention<'a>(message: &'a str, bot_name: &str, bot_user_id: &str /// emoji from display names (e.g. `Timmy ⚡️`), and Element tab-completion /// separators (`:` or `,`). This function skips all of that and returns a /// slice starting at the first ASCII alphanumeric character (the command). +#[allow(clippy::string_slice)] // byte_skip comes from char_indices → guaranteed char boundary fn strip_mention_separator(rest: &str) -> &str { let byte_skip = rest .char_indices() @@ -132,6 +152,7 @@ fn is_inside_code_fence(text: &str) -> bool { /// block (delimited by ` ``` ` lines) is **not** treated as a paragraph /// boundary. This prevents a blank line inside a code block from splitting /// the fence across multiple messages, which would corrupt the rendering. +#[allow(clippy::string_slice)] // abs_pos comes from str::find → always a char boundary pub fn drain_complete_paragraphs(buffer: &mut String) -> Vec { let mut paragraphs = Vec::new(); let mut search_from = 0; @@ -259,6 +280,47 @@ pub fn normalize_line_breaks(text: &str) -> String { mod tests { use super::*; + // -- truncate_at_char_boundary ------------------------------------------ + + #[test] + fn truncate_ascii_within_limit() { + assert_eq!(truncate_at_char_boundary("hello", 10), "hello"); + } + + #[test] + fn truncate_ascii_at_exact_limit() { + assert_eq!(truncate_at_char_boundary("hello", 5), "hello"); + } + + #[test] + fn truncate_ascii_over_limit() { + assert_eq!(truncate_at_char_boundary("hello world", 5), "hello"); + } + + #[test] + fn truncate_multibyte_mid_codepoint_snaps_back() { + // "héllo": 'é' is U+00E9, 2 bytes. max_bytes=2 lands inside it → snap to 1. + let s = "héllo"; + assert_eq!(truncate_at_char_boundary(s, 2), "h"); + } + + #[test] + fn truncate_multibyte_on_char_boundary() { + // "héllo": h(1) + é(2) = 3 bytes. max_bytes=3 is a valid boundary. + let s = "héllo"; + assert_eq!(truncate_at_char_boundary(s, 3), "hé"); + } + + #[test] + fn truncate_max_bytes_zero_returns_empty() { + assert_eq!(truncate_at_char_boundary("hello", 0), ""); + } + + #[test] + fn truncate_max_bytes_greater_than_len() { + assert_eq!(truncate_at_char_boundary("hi", 100), "hi"); + } + // -- is_permission_approval --------------------------------------------- #[test] diff --git a/server/src/cli.rs b/server/src/cli.rs index 05d44e5e..2ee8604a 100644 --- a/server/src/cli.rs +++ b/server/src/cli.rs @@ -24,6 +24,7 @@ pub(crate) struct CliArgs { } /// Parse CLI arguments into `CliArgs`, or exit early for `--help` / `--version`. +#[allow(clippy::string_slice)] // all slices use ASCII prefix lengths (e.g. "--port=".len()), always valid pub(crate) fn parse_cli_args(args: &[String]) -> Result { let mut port: Option = None; let mut path: Option = None; diff --git a/server/src/crdt_state/state/init.rs b/server/src/crdt_state/state/init.rs index 87430306..c91cfcfa 100644 --- a/server/src/crdt_state/state/init.rs +++ b/server/src/crdt_state/state/init.rs @@ -34,6 +34,7 @@ use crate::slog; /// Opens the SQLite database, loads or creates a node keypair, replays any /// persisted ops to reconstruct state, and spawns a background persistence /// task. Safe to call only once; subsequent calls are no-ops. +#[allow(clippy::string_slice)] // op_id is hex::encode output (ASCII-only), &op_id[..12] is always valid pub async fn init(db_path: &Path) -> Result<(), sqlx::Error> { if CRDT_STATE.get().is_some() { return Ok(()); diff --git a/server/src/crdt_state/write/migrations.rs b/server/src/crdt_state/write/migrations.rs index 6c4d9b2c..7bcd6f14 100644 --- a/server/src/crdt_state/write/migrations.rs +++ b/server/src/crdt_state/write/migrations.rs @@ -49,6 +49,7 @@ pub fn name_from_story_id(story_id: &str) -> String { /// /// Returns `Some("664")` for `"664_story_my_feature"`, and `None` for IDs /// that are already numeric-only (`"664"`) or have no valid numeric prefix. +#[allow(clippy::string_slice)] // idx comes from find('_') → always a char boundary pub(super) fn numeric_id_from_slug(story_id: &str) -> Option { // Already numeric-only — no migration needed. if story_id.chars().all(|c: char| c.is_ascii_digit()) { diff --git a/server/src/http/gateway/mcp.rs b/server/src/http/gateway/mcp.rs index a6f154d0..dce5b9b7 100644 --- a/server/src/http/gateway/mcp.rs +++ b/server/src/http/gateway/mcp.rs @@ -225,6 +225,7 @@ async fn proxy_and_respond(state: &GatewayState, bytes: &[u8], id: Option /// /// On sled disconnect mid-stream a JSON-RPC error event is emitted so the /// client does not hang forever. +#[allow(clippy::string_slice)] // pos from buf.find('\n'); '\n' is ASCII so pos and pos+1 are valid boundaries async fn proxy_and_respond_sse(state: &GatewayState, bytes: &[u8], id: Option) -> Response { let url = match state.active_url().await { Ok(u) => u, diff --git a/server/src/http/workflow/bug_ops/bug.rs b/server/src/http/workflow/bug_ops/bug.rs index a3000d1d..d7179e67 100644 --- a/server/src/http/workflow/bug_ops/bug.rs +++ b/server/src/http/workflow/bug_ops/bug.rs @@ -90,6 +90,7 @@ pub(super) fn is_bug_item(stem: &str) -> bool { } /// Extract bug name from content (heading or front matter). +#[allow(clippy::string_slice)] // colon_pos from find(": "); +2 skips ASCII ": " → valid boundary pub(super) fn extract_bug_name_from_content(content: &str) -> Option { // Try front matter first. if let Ok(meta) = parse_front_matter(content) diff --git a/server/src/http/workflow/story_ops/criterion.rs b/server/src/http/workflow/story_ops/criterion.rs index 8a93cd29..d0c6b787 100644 --- a/server/src/http/workflow/story_ops/criterion.rs +++ b/server/src/http/workflow/story_ops/criterion.rs @@ -9,6 +9,7 @@ use super::super::{ }; /// Toggle an acceptance criterion checkbox (`- [ ]` → `- [x]`) by its 0-based index among unchecked items. +#[allow(clippy::string_slice)] // indent_len = line.len() - trimmed.len(); trim() returns a sub-slice → valid boundary pub fn check_criterion_in_file( project_root: &Path, story_id: &str, @@ -107,6 +108,7 @@ pub fn remove_criterion_from_file( /// /// Finds the criterion at `criterion_index` (0-based, counting all criteria regardless /// of checked state) and replaces its text with `new_text`. +#[allow(clippy::string_slice)] // indent_len = line.len() - trimmed.len(); trim() returns a sub-slice → valid boundary pub fn edit_criterion_in_file( project_root: &Path, story_id: &str, diff --git a/server/src/http/workflow/test_results.rs b/server/src/http/workflow/test_results.rs index 2c874a7f..2fc88b7d 100644 --- a/server/src/http/workflow/test_results.rs +++ b/server/src/http/workflow/test_results.rs @@ -124,6 +124,7 @@ fn format_test_line(t: &TestCaseResult) -> String { } /// Parse `StoryTestResults` from the JSON embedded in the `## Test Results` section. +#[allow(clippy::string_slice)] // json_end from rfind("-->") → always a char boundary fn parse_test_results_from_contents(contents: &str) -> Option { for line in contents.lines() { let trimmed = line.trim(); diff --git a/server/src/llm/oauth.rs b/server/src/llm/oauth.rs index 76ec4ee5..370ff9f2 100644 --- a/server/src/llm/oauth.rs +++ b/server/src/llm/oauth.rs @@ -409,6 +409,7 @@ pub async fn swap_to_next_available_account( /// /// Returns the URL portion when the error indicates missing or expired credentials, /// `None` otherwise. +#[allow(clippy::string_slice)] // start + marker.len() is after the ASCII marker found by find() → valid boundary pub fn extract_login_url_from_error(err: &str) -> Option<&str> { let marker = "Please log in: "; let start = err.find(marker)?; diff --git a/server/src/llm/providers/claude_code/mod.rs b/server/src/llm/providers/claude_code/mod.rs index 598edae9..e3af11d9 100644 --- a/server/src/llm/providers/claude_code/mod.rs +++ b/server/src/llm/providers/claude_code/mod.rs @@ -182,6 +182,7 @@ impl ClaudeCodeProvider { /// via `--permission-prompt-tool`. Claude Code calls the MCP tool when it /// needs user approval, and the server bridges the request to the frontend. #[allow(clippy::too_many_arguments)] +#[allow(clippy::string_slice)] // end is walked to a char boundary before slicing &trimmed[..end] fn run_pty_session( user_message: &str, cwd: &str, diff --git a/server/src/llm/providers/ollama.rs b/server/src/llm/providers/ollama.rs index 77715a82..a363a8f5 100644 --- a/server/src/llm/providers/ollama.rs +++ b/server/src/llm/providers/ollama.rs @@ -43,6 +43,7 @@ impl OllamaProvider { } /// Streaming chat that calls `on_token` for each token chunk. + #[allow(clippy::string_slice)] // newline_pos from find('\n'); '\n' is ASCII so pos and pos+1 are valid pub async fn chat_stream( &self, model: &str, diff --git a/server/src/main.rs b/server/src/main.rs index 5c4f2c75..20f7e11d 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -3,6 +3,8 @@ // matrix-sdk-crypto's deeply nested types require a higher recursion limit // when the `e2e-encryption` feature is enabled. #![recursion_limit = "256"] +// Prevent panics from direct &str indexing that could split multi-byte UTF-8 codepoints. +#![deny(clippy::string_slice)] mod agent_log; mod agent_mode; diff --git a/server/src/node_identity.rs b/server/src/node_identity.rs index 4868f086..e81f6c9e 100644 --- a/server/src/node_identity.rs +++ b/server/src/node_identity.rs @@ -248,6 +248,7 @@ fn hex_encode(bytes: &[u8]) -> String { bytes.iter().map(|b| format!("{b:02x}")).collect() } +#[allow(clippy::string_slice)] // s is hex (ASCII-only); i advances in steps of 2, always within bounds fn hex_decode(s: &str) -> Option> { if !s.len().is_multiple_of(2) { return None; @@ -330,6 +331,7 @@ mod tests { } #[test] + #[allow(clippy::string_slice)] // sig is hex (ASCII-only); subtracting 4 stays within bounds for any valid sig fn verify_rejects_truncated_signature() { let kp = make_keypair(); let pubkey = public_key_hex(&kp); diff --git a/server/src/service/git_ops/porcelain.rs b/server/src/service/git_ops/porcelain.rs index 3038481b..7c544147 100644 --- a/server/src/service/git_ops/porcelain.rs +++ b/server/src/service/git_ops/porcelain.rs @@ -7,6 +7,7 @@ /// /// Returns `(staged, unstaged, untracked)` where each entry is the file path /// string from the porcelain line. +#[allow(clippy::string_slice)] // line[3..]: git porcelain format has 2 ASCII status chars + ASCII space pub fn parse_git_status_porcelain(stdout: &str) -> (Vec, Vec, Vec) { let mut staged: Vec = Vec::new(); let mut unstaged: Vec = Vec::new(); diff --git a/server/src/service/oauth/io.rs b/server/src/service/oauth/io.rs index 745083ab..26268fb6 100644 --- a/server/src/service/oauth/io.rs +++ b/server/src/service/oauth/io.rs @@ -109,10 +109,11 @@ pub(super) fn save_credentials( // Build the pool key: prefer email, fall back to token prefix. let key = if email.is_empty() { - format!( - "account-{}", - &token.access_token[..token.access_token.len().min(16)] - ) + let prefix = token + .access_token + .get(..token.access_token.len().min(16)) + .unwrap_or(&token.access_token); + format!("account-{prefix}") } else { email.to_string() }; diff --git a/server/src/service/shell/path_guard.rs b/server/src/service/shell/path_guard.rs index 33cdc596..af1912c3 100644 --- a/server/src/service/shell/path_guard.rs +++ b/server/src/service/shell/path_guard.rs @@ -88,6 +88,7 @@ pub fn parse_test_counts(output: &str) -> (u64, u64) { /// /// For example, `extract_count("5 passed; 0 failed", "passed")` returns /// `Some(5)`. Returns `None` if no digit sequence precedes `label`. +#[allow(clippy::string_slice)] // pos from line.find(label) → always a char boundary pub fn extract_count(line: &str, label: &str) -> Option { let pos = line.find(label)?; let before = line[..pos].trim_end();