From 7397fb00766adb4259995120f1804567ab05d7cf Mon Sep 17 00:00:00 2001 From: Hunter Bown Date: Tue, 24 Feb 2026 22:36:47 -0600 Subject: [PATCH] fix: UTF-8 safe truncation, deduplicate url_encode, add Display impls, fix pricing display, simplify model matching, fix changelog links, add PartialEq for Tool (#3) --- CHANGELOG.md | 3 ++- src/error_taxonomy.rs | 40 ++++++++++++++++++++++++++++++++++++++++ src/features.rs | 17 ++++++++++++++++- src/models.rs | 12 ++---------- src/pricing.rs | 2 +- src/tools/finance.rs | 15 +-------------- src/tools/weather.rs | 15 +-------------- src/utils.rs | 37 ++++++++++++++++++++++++++++++++----- 8 files changed, 95 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 24e71ae0..bc6bfc67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -324,7 +324,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Hooks system and config profiles - Example skills and launch assets -[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.22...HEAD +[Unreleased]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.23...HEAD +[0.3.23]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.22...v0.3.23 [0.3.22]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.21...v0.3.22 [0.3.21]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.17...v0.3.21 [0.3.17]: https://github.com/Hmbown/DeepSeek-TUI/compare/v0.3.16...v0.3.17 diff --git a/src/error_taxonomy.rs b/src/error_taxonomy.rs index ed1f006b..7e99ee08 100644 --- a/src/error_taxonomy.rs +++ b/src/error_taxonomy.rs @@ -3,6 +3,8 @@ //! Not yet wired into consumers; will be adopted incrementally. #![allow(dead_code)] +use std::fmt; + use crate::llm_client::LlmError; use crate::tools::spec::ToolError; @@ -42,6 +44,44 @@ pub struct ErrorEnvelope { pub message: String, } +impl fmt::Display for ErrorCategory { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self { + Self::Network => "network", + Self::Authentication => "authentication", + Self::Authorization => "authorization", + Self::RateLimit => "rate_limit", + Self::Timeout => "timeout", + Self::InvalidInput => "invalid_input", + Self::Parse => "parse", + Self::Tool => "tool", + Self::State => "state", + Self::Internal => "internal", + }; + f.write_str(label) + } +} + +impl fmt::Display for ErrorSeverity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self { + Self::Info => "info", + Self::Warning => "warning", + Self::Error => "error", + Self::Critical => "critical", + }; + f.write_str(label) + } +} + +impl fmt::Display for ErrorEnvelope { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "[{}] {}: {}", self.severity, self.code, self.message) + } +} + +impl std::error::Error for ErrorEnvelope {} + impl ErrorEnvelope { #[must_use] pub fn new( diff --git a/src/features.rs b/src/features.rs index c5e22f90..0c304279 100644 --- a/src/features.rs +++ b/src/features.rs @@ -2,8 +2,10 @@ #![allow(dead_code)] -use serde::{Deserialize, Serialize}; use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +use serde::{Deserialize, Serialize}; /// Lifecycle stage for a feature flag. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -32,6 +34,19 @@ pub enum Feature { ExecPolicy, } +impl fmt::Display for Stage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let label = match self { + Self::Experimental => "experimental", + Self::Beta => "beta", + Self::Stable => "stable", + Self::Deprecated => "deprecated", + Self::Removed => "removed", + }; + f.write_str(label) + } +} + impl Feature { pub fn key(self) -> &'static str { self.info().key diff --git a/src/models.rs b/src/models.rs index 5787c2fd..e29c4608 100644 --- a/src/models.rs +++ b/src/models.rs @@ -124,7 +124,7 @@ pub struct ToolCaller { } /// Tool definition exposed to the model. -#[derive(Debug, Serialize, Deserialize, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] pub struct Tool { #[serde(rename = "type", skip_serializing_if = "Option::is_none")] pub tool_type: Option, @@ -188,15 +188,7 @@ pub struct Usage { #[must_use] pub fn context_window_for_model(model: &str) -> Option { let lower = model.to_lowercase(); - if lower.contains("deepseek-v3.2") { - return Some(DEFAULT_CONTEXT_WINDOW_TOKENS); - } - if lower.contains("deepseek-chat") - || lower.contains("deepseek-reasoner") - || lower.contains("deepseek-r1") - { - return Some(DEFAULT_CONTEXT_WINDOW_TOKENS); - } + // All DeepSeek models currently share the same 128k context window. if lower.contains("deepseek") { return Some(DEFAULT_CONTEXT_WINDOW_TOKENS); } diff --git a/src/pricing.rs b/src/pricing.rs index 563a9ba9..f7d6c8f7 100644 --- a/src/pricing.rs +++ b/src/pricing.rs @@ -101,7 +101,7 @@ pub fn calculate_turn_cost(model: &str, input_tokens: u32, output_tokens: u32) - #[must_use] pub fn format_cost(cost: f64) -> String { if cost < 0.0001 { - "<$0.01".to_string() + "<$0.0001".to_string() } else if cost < 0.01 { format!("${:.4}", cost) } else if cost < 1.0 { diff --git a/src/tools/finance.rs b/src/tools/finance.rs index bb49d78f..9c56cb83 100644 --- a/src/tools/finance.rs +++ b/src/tools/finance.rs @@ -3,6 +3,7 @@ use super::spec::{ ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str, }; +use crate::utils::url_encode; use async_trait::async_trait; use serde::Serialize; use serde_json::{Value, json}; @@ -334,17 +335,3 @@ fn normalize_stooq_symbol(ticker: &str, market: &str) -> String { fn parse_f64(input: &str) -> Option { input.parse::().ok() } - -fn url_encode(input: &str) -> String { - let mut encoded = String::new(); - for ch in input.bytes() { - match ch { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - encoded.push(ch as char) - } - b' ' => encoded.push('+'), - _ => encoded.push_str(&format!("%{ch:02X}")), - } - } - encoded -} diff --git a/src/tools/weather.rs b/src/tools/weather.rs index 6ca438ff..58f35114 100644 --- a/src/tools/weather.rs +++ b/src/tools/weather.rs @@ -4,6 +4,7 @@ use super::spec::{ ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, optional_str, optional_u64, required_str, }; +use crate::utils::url_encode; use async_trait::async_trait; use chrono::{NaiveDate, Utc}; use serde::Serialize; @@ -320,17 +321,3 @@ async fn fetch_forecast( fn c_to_f(c: f64) -> f64 { c * 9.0 / 5.0 + 32.0 } - -fn url_encode(input: &str) -> String { - let mut encoded = String::new(); - for ch in input.bytes() { - match ch { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - encoded.push(ch as char) - } - b' ' => encoded.push('+'), - _ => encoded.push_str(&format!("%{ch:02X}")), - } - } - encoded -} diff --git a/src/utils.rs b/src/utils.rs index 37cd305f..904e7bab 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -187,15 +187,42 @@ pub fn output_path(output_dir: &Path, filename: &str) -> PathBuf { output_dir.join(filename) } -/// Truncate a string to a maximum length, adding an ellipsis if truncated +/// Truncate a string to a maximum length, adding an ellipsis if truncated. +/// +/// Uses char boundaries to avoid panicking on multi-byte UTF-8 characters. #[must_use] pub fn truncate_with_ellipsis(s: &str, max_len: usize, ellipsis: &str) -> String { if s.len() <= max_len { - s.to_string() - } else { - let truncate_at = max_len.saturating_sub(ellipsis.len()); - format!("{}{}", &s[..truncate_at], ellipsis) + return s.to_string(); } + let budget = max_len.saturating_sub(ellipsis.len()); + // Find the last char boundary that fits within the byte budget. + let safe_end = s + .char_indices() + .map(|(i, _)| i) + .take_while(|&i| i <= budget) + .last() + .unwrap_or(0); + format!("{}{}", &s[..safe_end], ellipsis) +} + +/// Percent-encode a string for use in URL query parameters. +/// +/// Encodes all characters except unreserved characters (A-Z, a-z, 0-9, `-`, `_`, `.`, `~`). +/// Spaces are encoded as `+`. +#[must_use] +pub fn url_encode(input: &str) -> String { + let mut encoded = String::new(); + for ch in input.bytes() { + match ch { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { + encoded.push(ch as char) + } + b' ' => encoded.push('+'), + _ => encoded.push_str(&format!("%{ch:02X}")), + } + } + encoded } /// Estimate the total character count across message content blocks.