Skip to main content

talon_core/inference/
error.rs

1//! Inference client errors and PII-redacting helpers.
2//!
3//! Diagnostics propagate to the agent-facing CLI surface, so URLs and
4//! filesystem paths are scrubbed before the message leaves this crate. The
5//! redaction patterns mirror `embed/chunks-diagnostics.ts::redactForAgent`.
6
7use std::sync::OnceLock;
8
9use regex::Regex;
10use thiserror::Error;
11
12/// Maximum diagnostic message length after redaction (matches TS).
13pub const MAX_DIAGNOSTIC_CHARS: usize = 280;
14
15/// Errors returned by the inference client.
16#[derive(Debug, Error)]
17#[non_exhaustive]
18pub enum InferenceError {
19    /// `reqwest::Client` could not be constructed.
20    #[error("inference client build failed: {message}")]
21    Build {
22        /// Redacted detail.
23        message: String,
24    },
25
26    /// HTTP transport or non-2xx status from the sidecar.
27    #[error("inference HTTP error{}: {message}", .status.map(|s| format!(" ({s})")).unwrap_or_default())]
28    Http {
29        /// HTTP status code, if any (None for transport failures).
30        status: Option<u16>,
31        /// Redacted detail (URL, response body snippet).
32        message: String,
33    },
34
35    /// Response body could not be decoded into the expected JSON shape.
36    #[error("inference response decode failed: {message}")]
37    Decode {
38        /// Redacted detail.
39        message: String,
40    },
41
42    /// Configuration is invalid or incomplete.
43    #[error("inference config error: {message}")]
44    Config {
45        /// Configuration detail.
46        message: String,
47    },
48}
49
50/// Compiles a static regex literal. The pattern is hard-coded so a
51/// compile failure is a programmer bug, not a runtime condition — falling
52/// through to `unreachable!` reflects that.
53fn compile_static(pattern: &str) -> Regex {
54    match Regex::new(pattern) {
55        Ok(re) => re,
56        Err(err) => unreachable!("static regex {pattern:?} did not compile: {err}"),
57    }
58}
59
60fn url_re() -> &'static Regex {
61    static R: OnceLock<Regex> = OnceLock::new();
62    R.get_or_init(|| compile_static(r"https?://[^\s]+"))
63}
64
65fn users_path_re() -> &'static Regex {
66    static R: OnceLock<Regex> = OnceLock::new();
67    R.get_or_init(|| compile_static(r"/Users/[^\s]+"))
68}
69
70fn home_path_re() -> &'static Regex {
71    static R: OnceLock<Regex> = OnceLock::new();
72    R.get_or_init(|| compile_static(r"/home/[^\s]+"))
73}
74
75/// Strips URLs and host paths from a diagnostic string, then truncates to
76/// [`MAX_DIAGNOSTIC_CHARS`].
77///
78/// The sidecar URL and vault paths are PII-adjacent (they reveal local install
79/// layout); the agent surface gets a sanitized form so a logged failure does
80/// not leak the user's filesystem shape.
81#[must_use]
82pub fn redact(value: &str) -> String {
83    let stage1 = url_re().replace_all(value, "[sidecar]");
84    let stage2 = users_path_re().replace_all(stage1.as_ref(), "[host-path]");
85    let stage3 = home_path_re().replace_all(stage2.as_ref(), "[host-path]");
86    stage3.chars().take(MAX_DIAGNOSTIC_CHARS).collect()
87}
88
89#[cfg(test)]
90#[allow(clippy::unwrap_used, clippy::expect_used)]
91mod tests {
92    use super::*;
93
94    #[test]
95    fn redact_replaces_https_url() {
96        let input = "GET https://localhost:8080/embed failed";
97        let out = redact(input);
98        assert!(out.contains("[sidecar]"));
99        assert!(!out.contains("https://"));
100    }
101
102    #[test]
103    fn redact_replaces_http_url() {
104        let input = "POST http://example.com/api 500";
105        let out = redact(input);
106        assert!(out.contains("[sidecar]"));
107        assert!(!out.contains("example.com"));
108    }
109
110    #[test]
111    fn redact_replaces_users_path() {
112        let input = "/Users/alice/Documents/vault/note.md not found";
113        let out = redact(input);
114        assert!(out.contains("[host-path]"));
115        assert!(!out.contains("alice"));
116    }
117
118    #[test]
119    fn redact_replaces_home_path() {
120        let input = "open /home/bob/talon/idx.sqlite";
121        let out = redact(input);
122        assert!(out.contains("[host-path]"));
123        assert!(!out.contains("bob"));
124    }
125
126    #[test]
127    fn redact_truncates_to_max_chars() {
128        let long = "x".repeat(MAX_DIAGNOSTIC_CHARS + 100);
129        let out = redact(&long);
130        assert_eq!(out.chars().count(), MAX_DIAGNOSTIC_CHARS);
131    }
132
133    #[test]
134    fn redact_passes_through_short_clean_input() {
135        let input = "embedding dimension mismatch";
136        assert_eq!(redact(input), input);
137    }
138
139    #[test]
140    fn redact_handles_multiple_urls_in_one_message() {
141        let input = "fetch https://a.com/x and http://b.com/y both failed";
142        let out = redact(input);
143        assert!(!out.contains("a.com"));
144        assert!(!out.contains("b.com"));
145    }
146}