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