Skip to main content

gobby_core/
degradation.rs

1//! Shared degradation vocabulary boundary.
2//!
3//! Degradation types describe partial availability without forcing every
4//! command to treat configured-service outages or explicitly degraded paths as
5//! fatal. Detailed contracts live here so lightweight consumers can share the
6//! same vocabulary.
7
8use serde::{Deserialize, Serialize};
9
10/// Service availability state, returned alongside results from adapters.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub enum ServiceState {
13    /// Service is connected and responding.
14    Available,
15    /// Service is not configured because no config was found from any source.
16    NotConfigured,
17    /// Service is configured but could not be reached.
18    Unreachable {
19        /// Adapter-provided diagnostic message for the failed connection.
20        message: String,
21    },
22}
23
24impl ServiceState {
25    /// Returns true when the backing service is connected and responding.
26    pub fn is_available(&self) -> bool {
27        matches!(self, Self::Available)
28    }
29}
30
31/// Setup validation issue with actionable guidance.
32#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SetupIssue {
34    /// Name of the missing, invalid, or degraded resource.
35    pub object_name: String,
36    /// Store or service that owns the resource.
37    pub store: String,
38    /// Structured remediation guidance for callers to render.
39    pub guidance: Guidance,
40}
41
42/// Structured guidance text for setup issues.
43///
44/// Callers render these fields; `gobby-core` does not format CLI output.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Guidance {
47    /// What is missing or wrong.
48    pub problem: String,
49    /// What the user should do.
50    pub action: String,
51    /// Optional command suggestion.
52    pub command_hint: Option<String>,
53}
54
55/// Fatal errors that prevent a command from completing.
56#[derive(Debug, Serialize, Deserialize, thiserror::Error)]
57pub enum CoreError {
58    /// Configuration was present but invalid for the requested operation.
59    #[error("invalid configuration: {0}")]
60    InvalidConfig(String),
61    /// Two reachable hub DSNs point at different PostgreSQL clusters or databases.
62    #[error(
63        "conflicting Gobby PostgreSQL hubs: existing recorded hub identifies {existing_identity}; daemon hub identifies {daemon_identity}"
64    )]
65    HubConflict {
66        /// DSN from the existing standalone/subset configuration.
67        #[serde(serialize_with = "serialize_redacted_database_url")]
68        existing_database_url: String,
69        /// Cluster/database identity observed for the existing DSN.
70        existing_identity: String,
71        /// DSN reported by the daemon bootstrap/broker.
72        #[serde(serialize_with = "serialize_redacted_database_url")]
73        daemon_database_url: String,
74        /// Cluster/database identity observed for the daemon DSN.
75        daemon_identity: String,
76    },
77    /// A service required by this command could not be used.
78    #[error("required service unavailable: {service} — {message}")]
79    RequiredServiceUnavailable {
80        /// Required service name.
81        service: String,
82        /// Diagnostic message explaining the unavailability.
83        message: String,
84    },
85    /// A write operation failed after validation began.
86    #[error("write failed: {0}")]
87    WriteFailed(String),
88    /// Input could not be parsed or failed integrity checks.
89    #[error("corrupted input: {0}")]
90    CorruptedInput(String),
91}
92
93fn serialize_redacted_database_url<S>(database_url: &str, serializer: S) -> Result<S::Ok, S::Error>
94where
95    S: serde::Serializer,
96{
97    serializer.serialize_str(&redact_database_url(database_url))
98}
99
100pub fn redact_database_url(database_url: &str) -> String {
101    let without_fragment = database_url
102        .split_once('#')
103        .map_or(database_url, |(head, _)| head);
104    let without_query = without_fragment
105        .split_once('?')
106        .map_or(without_fragment, |(head, _)| head);
107    if let Some((scheme, rest)) = without_query.split_once("://") {
108        let host_and_path = rest
109            .rsplit_once('@')
110            .map_or(rest, |(_, host_and_path)| host_and_path);
111        format!("{scheme}://{host_and_path}")
112    } else {
113        redact_keyword_database_url(without_query)
114    }
115}
116
117fn redact_keyword_database_url(database_url: &str) -> String {
118    split_keyword_dsn_tokens(database_url)
119        .into_iter()
120        .map(|token| {
121            let Some((key, _value)) = token.split_once('=') else {
122                return token.to_string();
123            };
124            if is_sensitive_keyword_dsn_key(key) {
125                format!("{key}=<redacted>")
126            } else {
127                token.to_string()
128            }
129        })
130        .collect::<Vec<_>>()
131        .join(" ")
132}
133
134fn split_keyword_dsn_tokens(database_url: &str) -> Vec<&str> {
135    let mut tokens = Vec::new();
136    let mut start = None;
137    let mut in_single_quote = false;
138    let mut escaped = false;
139
140    for (index, ch) in database_url.char_indices() {
141        if start.is_none() {
142            if ch.is_whitespace() {
143                continue;
144            }
145            start = Some(index);
146        }
147
148        if escaped {
149            escaped = false;
150            continue;
151        }
152        if ch == '\\' {
153            escaped = true;
154            continue;
155        }
156        if ch == '\'' {
157            in_single_quote = !in_single_quote;
158            continue;
159        }
160        if ch.is_whitespace()
161            && !in_single_quote
162            && let Some(token_start) = start.take()
163        {
164            tokens.push(&database_url[token_start..index]);
165        }
166    }
167
168    if let Some(token_start) = start {
169        tokens.push(&database_url[token_start..]);
170    }
171    tokens
172}
173
174fn is_sensitive_keyword_dsn_key(key: &str) -> bool {
175    matches!(
176        key.to_ascii_lowercase().as_str(),
177        "password" | "passfile" | "sslpassword"
178    )
179}
180
181/// Degradation states for partial results.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub enum DegradationKind {
184    /// A configured service was unavailable during this operation.
185    ServiceUnavailable {
186        /// Optional service name.
187        service: String,
188        /// Availability state observed by the caller.
189        state: ServiceState,
190    },
191    /// Search completed with fewer sources than requested.
192    PartialSearch {
193        /// Source names that contributed results.
194        available: Vec<String>,
195        /// Source names that could not contribute results.
196        unavailable: Vec<String>,
197    },
198    /// Operation completed with capped or otherwise incomplete data.
199    PartialData {
200        /// Component whose data was incomplete.
201        component: String,
202        /// Human-readable detail about the partial data.
203        message: String,
204    },
205    /// Index data may be stale because of content drift or age thresholds.
206    StaleIndex {
207        /// Paths whose indexed data may be stale.
208        paths: Vec<String>,
209    },
210    /// Some artifacts were skipped during indexing.
211    SkippedArtifacts {
212        /// Number of skipped artifacts.
213        count: usize,
214        /// Human-readable reason the artifacts were skipped.
215        reason: String,
216    },
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222
223    #[test]
224    fn service_unavailable_degradation_is_not_fatal() {
225        let unconfigured = ServiceState::NotConfigured;
226        let unreachable = ServiceState::Unreachable {
227            message: "connection refused".to_string(),
228        };
229
230        assert!(!unconfigured.is_available());
231        assert!(!unreachable.is_available());
232
233        let degradation = DegradationKind::ServiceUnavailable {
234            service: "qdrant".to_string(),
235            state: unconfigured,
236        };
237        let fatal = CoreError::RequiredServiceUnavailable {
238            service: "postgres".to_string(),
239            message: "hub is required for writes".to_string(),
240        };
241
242        assert!(matches!(
243            degradation,
244            DegradationKind::ServiceUnavailable {
245                service,
246                state: ServiceState::NotConfigured
247            } if service == "qdrant"
248        ));
249        assert_eq!(
250            fatal.to_string(),
251            "required service unavailable: postgres — hub is required for writes"
252        );
253    }
254
255    #[test]
256    fn guidance_is_structured() {
257        let guidance = Guidance {
258            problem: "BM25 index missing".to_string(),
259            action: "run attached setup validation".to_string(),
260            command_hint: Some("gobby setup validate".to_string()),
261        };
262
263        assert_eq!(guidance.problem, "BM25 index missing");
264        assert_eq!(guidance.action, "run attached setup validation");
265        assert_eq!(
266            guidance.command_hint.as_deref(),
267            Some("gobby setup validate")
268        );
269    }
270
271    #[test]
272    fn core_error_serialization_roundtrip() {
273        let invalid_config = CoreError::InvalidConfig("missing project id".to_string());
274        let encoded = serde_json::to_string(&invalid_config).expect("serialize invalid config");
275        let decoded: CoreError =
276            serde_json::from_str(&encoded).expect("deserialize invalid config");
277        assert!(matches!(
278            decoded,
279            CoreError::InvalidConfig(message) if message == "missing project id"
280        ));
281
282        let unavailable = CoreError::RequiredServiceUnavailable {
283            service: "postgres".to_string(),
284            message: "connection refused".to_string(),
285        };
286        let encoded = serde_json::to_string(&unavailable).expect("serialize unavailable");
287        let decoded: CoreError = serde_json::from_str(&encoded).expect("deserialize unavailable");
288        assert!(matches!(
289            decoded,
290            CoreError::RequiredServiceUnavailable { service, message }
291                if service == "postgres" && message == "connection refused"
292        ));
293
294        let hub_conflict = CoreError::HubConflict {
295            existing_database_url: "postgres://existing".to_string(),
296            existing_identity: "existing-cluster/existing-db".to_string(),
297            daemon_database_url: "postgres://daemon".to_string(),
298            daemon_identity: "daemon-cluster/daemon-db".to_string(),
299        };
300        let encoded = serde_json::to_string(&hub_conflict).expect("serialize hub conflict");
301        let decoded: CoreError = serde_json::from_str(&encoded).expect("deserialize hub conflict");
302        assert!(matches!(
303            decoded,
304            CoreError::HubConflict {
305                existing_database_url,
306                existing_identity,
307                daemon_database_url,
308                daemon_identity,
309            } if existing_database_url == "postgres://existing"
310                && existing_identity == "existing-cluster/existing-db"
311                && daemon_database_url == "postgres://daemon"
312                && daemon_identity == "daemon-cluster/daemon-db"
313        ));
314    }
315
316    #[test]
317    fn hub_conflict_display_and_json_redact_database_urls() {
318        let conflict = CoreError::HubConflict {
319            existing_database_url: "postgresql://user:secret@standalone/gobby?sslmode=require#frag"
320                .to_string(),
321            existing_identity: "cluster-a/gobby".to_string(),
322            daemon_database_url: "postgresql://daemon:secret@daemon/gobby?application_name=gobby"
323                .to_string(),
324            daemon_identity: "cluster-b/gobby".to_string(),
325        };
326
327        let message = conflict.to_string();
328        assert!(message.contains("cluster-a/gobby"));
329        assert!(message.contains("cluster-b/gobby"));
330        assert!(!message.contains("postgresql://"));
331        assert!(!message.contains("secret"));
332        assert!(!message.contains("sslmode"));
333        assert!(!message.contains("application_name"));
334
335        let encoded = serde_json::to_string(&conflict).expect("serialize hub conflict");
336        assert!(encoded.contains("postgresql://standalone/gobby"));
337        assert!(encoded.contains("postgresql://daemon/gobby"));
338        assert!(!encoded.contains("secret"));
339        assert!(!encoded.contains("sslmode"));
340        assert!(!encoded.contains("application_name"));
341        assert!(!encoded.contains("frag"));
342    }
343
344    #[test]
345    fn keyword_database_url_redacts_sensitive_values_case_insensitively() {
346        let redacted = redact_database_url(
347            "host=localhost user=app PASSWORD='secret value' dbname=gobby sslpassword=topsecret",
348        );
349
350        assert!(redacted.contains("host=localhost"));
351        assert!(redacted.contains("user=app"));
352        assert!(redacted.contains("dbname=gobby"));
353        assert!(redacted.contains("PASSWORD=<redacted>"));
354        assert!(redacted.contains("sslpassword=<redacted>"));
355        assert!(!redacted.contains("secret value"));
356        assert!(!redacted.contains("topsecret"));
357    }
358
359    #[test]
360    fn hub_conflict_json_redacts_keyword_database_urls() {
361        let conflict = CoreError::HubConflict {
362            existing_database_url: "host=standalone user=app password=secret dbname=gobby"
363                .to_string(),
364            existing_identity: "cluster-a/gobby".to_string(),
365            daemon_database_url: "HOST=daemon USER=daemon PASSFILE='/tmp/pgpass' dbname=gobby"
366                .to_string(),
367            daemon_identity: "cluster-b/gobby".to_string(),
368        };
369
370        let encoded = serde_json::to_string(&conflict).expect("serialize hub conflict");
371
372        assert!(encoded.contains("host=standalone"));
373        assert!(encoded.contains("password=<redacted>"));
374        assert!(encoded.contains("PASSFILE=<redacted>"));
375        assert!(!encoded.contains("secret"));
376        assert!(!encoded.contains("/tmp/pgpass"));
377    }
378}