Skip to main content

agent_diva_core/
error_context.rs

1//! Error context utilities for debugging
2//!
3//! Provides utilities to capture and log problematic content when errors occur.
4
5use std::collections::HashMap;
6
7/// Maximum length of content to include in error context
8const MAX_CONTEXT_LENGTH: usize = 500;
9
10/// Maximum length for individual problematic token/character
11const MAX_TOKEN_LENGTH: usize = 100;
12
13/// Context information captured when an error occurs
14#[derive(Debug, Clone)]
15pub struct ErrorContext {
16    /// The operation that failed
17    pub operation: String,
18    /// Error message
19    pub error_message: String,
20    /// The problematic content that caused the error
21    pub problematic_content: Option<String>,
22    /// Additional metadata
23    pub metadata: HashMap<String, String>,
24}
25
26impl ErrorContext {
27    /// Create a new error context
28    pub fn new(operation: impl Into<String>, error_message: impl Into<String>) -> Self {
29        Self {
30            operation: operation.into(),
31            error_message: error_message.into(),
32            problematic_content: None,
33            metadata: HashMap::new(),
34        }
35    }
36
37    /// Add problematic content
38    pub fn with_content(mut self, content: impl Into<String>) -> Self {
39        self.problematic_content = Some(truncate_content(&content.into(), MAX_CONTEXT_LENGTH));
40        self
41    }
42
43    /// Add metadata
44    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
45        self.metadata.insert(key.into(), value.into());
46        self
47    }
48
49    /// Format the context as a human-readable string
50    pub fn to_detailed_string(&self) -> String {
51        let mut parts = vec![format!("[{}] {}", self.operation, self.error_message)];
52
53        if let Some(ref content) = self.problematic_content {
54            parts.push(format!("Problematic content: {}", content));
55        }
56
57        for (key, value) in &self.metadata {
58            parts.push(format!("{}: {}", key, value));
59        }
60
61        parts.join("\n  ")
62    }
63}
64
65/// Truncate content to a maximum length while preserving UTF-8 boundaries
66fn truncate_content(s: &str, max_len: usize) -> String {
67    if s.len() <= max_len {
68        s.to_string()
69    } else {
70        let mut end = max_len.saturating_sub(3);
71        while !s.is_char_boundary(end) && end > 0 {
72            end -= 1;
73        }
74        format!("{}...", &s[..end])
75    }
76}
77
78/// Find and highlight problematic characters in content
79/// Returns a description of problematic characters found
80pub fn find_problematic_chars(content: &str) -> Vec<String> {
81    let mut problems = Vec::new();
82
83    for (i, c) in content.char_indices() {
84        let cp = c as u32;
85
86        // Check for control characters (except allowed whitespace)
87        if cp < 0x20 && cp != 0x09 && cp != 0x0A && cp != 0x0D {
88            let context = get_char_context(content, i, MAX_TOKEN_LENGTH);
89            problems.push(format!(
90                "Control char U+{:04X} at position {}: '{}'",
91                cp,
92                i,
93                escape_for_display(&context)
94            ));
95        }
96
97        // Check for DEL character
98        if cp == 0x7F {
99            let context = get_char_context(content, i, MAX_TOKEN_LENGTH);
100            problems.push(format!(
101                "DEL char (U+007F) at position {}: '{}'",
102                i,
103                escape_for_display(&context)
104            ));
105        }
106
107        // Check for invalid UTF-8 sequences (though Rust handles this)
108        if c == '\u{FFFD}' {
109            let context = get_char_context(content, i, MAX_TOKEN_LENGTH);
110            problems.push(format!(
111                "Replacement char (U+FFFD) at position {}: '{}'",
112                i,
113                escape_for_display(&context)
114            ));
115        }
116    }
117
118    problems
119}
120
121/// Get context around a character position
122fn get_char_context(content: &str, pos: usize, max_len: usize) -> String {
123    let start = pos.saturating_sub(max_len / 2);
124    let end = (pos + max_len / 2).min(content.len());
125
126    // Ensure valid UTF-8 boundaries
127    let start = find_char_boundary(content, start);
128    let end = find_char_boundary_back(content, end);
129
130    content[start..end].to_string()
131}
132
133/// Find the nearest valid UTF-8 char boundary at or after the given position
134fn find_char_boundary(s: &str, pos: usize) -> usize {
135    if pos >= s.len() {
136        return s.len();
137    }
138    let mut p = pos;
139    while p < s.len() && !s.is_char_boundary(p) {
140        p += 1;
141    }
142    p
143}
144
145/// Find the nearest valid UTF-8 char boundary at or before the given position
146fn find_char_boundary_back(s: &str, pos: usize) -> usize {
147    if pos == 0 {
148        return 0;
149    }
150    let mut p = pos;
151    while p > 0 && !s.is_char_boundary(p) {
152        p -= 1;
153    }
154    p
155}
156
157/// Escape a string for display in logs
158fn escape_for_display(s: &str) -> String {
159    s.chars()
160        .map(|c| match c {
161            '\n' => "\\n".to_string(),
162            '\r' => "\\r".to_string(),
163            '\t' => "\\t".to_string(),
164            c if c.is_control() => format!("\\u{:04X}", c as u32),
165            c => c.to_string(),
166        })
167        .collect()
168}
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    #[test]
175    fn test_error_context_basic() {
176        let ctx = ErrorContext::new("test_op", "test error");
177        assert_eq!(ctx.operation, "test_op");
178        assert_eq!(ctx.error_message, "test error");
179        assert!(ctx.problematic_content.is_none());
180    }
181
182    #[test]
183    fn test_error_context_with_content() {
184        let ctx = ErrorContext::new("test", "error").with_content("problematic content");
185        assert_eq!(
186            ctx.problematic_content,
187            Some("problematic content".to_string())
188        );
189    }
190
191    #[test]
192    fn test_error_context_truncation() {
193        let long_content = "x".repeat(1000);
194        let ctx = ErrorContext::new("test", "error").with_content(long_content.clone());
195        assert!(ctx.problematic_content.as_ref().unwrap().len() < long_content.len());
196        assert!(ctx.problematic_content.as_ref().unwrap().ends_with("..."));
197    }
198
199    #[test]
200    fn test_find_problematic_chars_control() {
201        let content = "hello\x01world"; // Contains SOH control char
202        let problems = find_problematic_chars(content);
203        assert!(!problems.is_empty());
204        assert!(problems[0].contains("U+0001"));
205        assert!(problems[0].contains("position 5"));
206    }
207
208    #[test]
209    fn test_find_problematic_chars_clean() {
210        let content = "hello world\nthis is normal";
211        let problems = find_problematic_chars(content);
212        assert!(problems.is_empty());
213    }
214
215    #[test]
216    fn test_escape_for_display() {
217        assert_eq!(escape_for_display("hello\nworld"), "hello\\nworld");
218        assert_eq!(escape_for_display("tab\there"), "tab\\there");
219        assert_eq!(escape_for_display("\x01"), "\\u0001");
220    }
221}