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)

This commit is contained in:
Hunter Bown
2026-02-24 22:36:47 -06:00
parent a94f29b47b
commit 7397fb0076
8 changed files with 95 additions and 46 deletions
+2 -1
View File
@@ -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
+40
View File
@@ -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(
+16 -1
View File
@@ -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
+2 -10
View File
@@ -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<String>,
@@ -188,15 +188,7 @@ pub struct Usage {
#[must_use]
pub fn context_window_for_model(model: &str) -> Option<u32> {
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);
}
+1 -1
View File
@@ -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 {
+1 -14
View File
@@ -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<f64> {
input.parse::<f64>().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
}
+1 -14
View File
@@ -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
}
+32 -5
View File
@@ -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.