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    crate::libpq::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 is_sensitive_keyword_dsn_key(key: &str) -> bool {
135    matches!(
136        key.to_ascii_lowercase().as_str(),
137        "password" | "passfile" | "sslpassword"
138    )
139}
140
141/// Why a modality capability (transcription, translation, vision, or a daemon
142/// capability probe) degraded.
143///
144/// The serialized snake_case names double as the marker strings written into
145/// vault frontmatter (`transcription_degradation`, `vision_degradation`,
146/// `media_degradation`) and daemon capability reports, so variants must keep
147/// their names stable; use [`Self::as_str`] when embedding a marker in text.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ModalityDegradationReason {
151    /// Routing turned the capability off.
152    Disabled,
153    /// No endpoint is configured for the capability.
154    MissingEndpoint,
155    /// The endpoint rejected the configured credentials.
156    Unauthorized,
157    /// The endpoint could not be reached.
158    Unreachable,
159    /// The endpoint answered with an unexpected HTTP status.
160    UnexpectedStatus,
161    /// The capability depends on something that was already degraded upstream.
162    Unavailable,
163    /// Transcription was attempted and failed.
164    TranscriptionError,
165    /// Translation was attempted and failed.
166    TranslationError,
167    /// Translation support is not compiled in or configured.
168    TranslationUnavailable,
169    /// Vision extraction was attempted and failed.
170    VisionError,
171}
172
173impl ModalityDegradationReason {
174    /// The stable marker string for this reason (matches the serde name).
175    pub fn as_str(self) -> &'static str {
176        match self {
177            Self::Disabled => "disabled",
178            Self::MissingEndpoint => "missing_endpoint",
179            Self::Unauthorized => "unauthorized",
180            Self::Unreachable => "unreachable",
181            Self::UnexpectedStatus => "unexpected_status",
182            Self::Unavailable => "unavailable",
183            Self::TranscriptionError => "transcription_error",
184            Self::TranslationError => "translation_error",
185            Self::TranslationUnavailable => "translation_unavailable",
186            Self::VisionError => "vision_error",
187        }
188    }
189}
190
191impl std::fmt::Display for ModalityDegradationReason {
192    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193        f.write_str(self.as_str())
194    }
195}
196
197/// Degradation states for partial results.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub enum DegradationKind {
200    /// A configured service was unavailable during this operation.
201    ServiceUnavailable {
202        /// Optional service name.
203        service: String,
204        /// Availability state observed by the caller.
205        state: ServiceState,
206    },
207    /// Search completed with fewer sources than requested.
208    PartialSearch {
209        /// Source names that contributed results.
210        available: Vec<String>,
211        /// Source names that could not contribute results.
212        unavailable: Vec<String>,
213    },
214    /// Operation completed with capped or otherwise incomplete data.
215    PartialData {
216        /// Component whose data was incomplete.
217        component: String,
218        /// Human-readable detail about the partial data.
219        message: String,
220    },
221    /// Index data may be stale because of content drift or age thresholds.
222    StaleIndex {
223        /// Paths whose indexed data may be stale.
224        paths: Vec<String>,
225    },
226    /// Some artifacts were skipped during indexing.
227    SkippedArtifacts {
228        /// Number of skipped artifacts.
229        count: usize,
230        /// Human-readable reason the artifacts were skipped.
231        reason: String,
232    },
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn modality_reason_markers_match_serde_names() {
241        for reason in [
242            ModalityDegradationReason::Disabled,
243            ModalityDegradationReason::MissingEndpoint,
244            ModalityDegradationReason::Unauthorized,
245            ModalityDegradationReason::Unreachable,
246            ModalityDegradationReason::UnexpectedStatus,
247            ModalityDegradationReason::Unavailable,
248            ModalityDegradationReason::TranscriptionError,
249            ModalityDegradationReason::TranslationError,
250            ModalityDegradationReason::TranslationUnavailable,
251            ModalityDegradationReason::VisionError,
252        ] {
253            let serialized = serde_json::to_value(reason).expect("serialize reason");
254            assert_eq!(
255                serialized,
256                serde_json::Value::String(reason.as_str().to_string()),
257                "as_str and serde marker drifted for {reason:?}"
258            );
259            assert_eq!(reason.to_string(), reason.as_str());
260        }
261    }
262
263    #[test]
264    fn service_unavailable_degradation_is_not_fatal() {
265        let unconfigured = ServiceState::NotConfigured;
266        let unreachable = ServiceState::Unreachable {
267            message: "connection refused".to_string(),
268        };
269
270        assert!(!unconfigured.is_available());
271        assert!(!unreachable.is_available());
272
273        let degradation = DegradationKind::ServiceUnavailable {
274            service: "qdrant".to_string(),
275            state: unconfigured,
276        };
277        let fatal = CoreError::RequiredServiceUnavailable {
278            service: "postgres".to_string(),
279            message: "hub is required for writes".to_string(),
280        };
281
282        assert!(matches!(
283            degradation,
284            DegradationKind::ServiceUnavailable {
285                service,
286                state: ServiceState::NotConfigured
287            } if service == "qdrant"
288        ));
289        assert_eq!(
290            fatal.to_string(),
291            "required service unavailable: postgres — hub is required for writes"
292        );
293    }
294
295    #[test]
296    fn guidance_is_structured() {
297        let guidance = Guidance {
298            problem: "BM25 index missing".to_string(),
299            action: "run attached setup validation".to_string(),
300            command_hint: Some("gobby setup validate".to_string()),
301        };
302
303        assert_eq!(guidance.problem, "BM25 index missing");
304        assert_eq!(guidance.action, "run attached setup validation");
305        assert_eq!(
306            guidance.command_hint.as_deref(),
307            Some("gobby setup validate")
308        );
309    }
310
311    #[test]
312    fn core_error_serialization_roundtrip() {
313        let invalid_config = CoreError::InvalidConfig("missing project id".to_string());
314        let encoded = serde_json::to_string(&invalid_config).expect("serialize invalid config");
315        let decoded: CoreError =
316            serde_json::from_str(&encoded).expect("deserialize invalid config");
317        assert!(matches!(
318            decoded,
319            CoreError::InvalidConfig(message) if message == "missing project id"
320        ));
321
322        let unavailable = CoreError::RequiredServiceUnavailable {
323            service: "postgres".to_string(),
324            message: "connection refused".to_string(),
325        };
326        let encoded = serde_json::to_string(&unavailable).expect("serialize unavailable");
327        let decoded: CoreError = serde_json::from_str(&encoded).expect("deserialize unavailable");
328        assert!(matches!(
329            decoded,
330            CoreError::RequiredServiceUnavailable { service, message }
331                if service == "postgres" && message == "connection refused"
332        ));
333
334        let hub_conflict = CoreError::HubConflict {
335            existing_database_url: "postgres://existing".to_string(),
336            existing_identity: "existing-cluster/existing-db".to_string(),
337            daemon_database_url: "postgres://daemon".to_string(),
338            daemon_identity: "daemon-cluster/daemon-db".to_string(),
339        };
340        let encoded = serde_json::to_string(&hub_conflict).expect("serialize hub conflict");
341        let decoded: CoreError = serde_json::from_str(&encoded).expect("deserialize hub conflict");
342        assert!(matches!(
343            decoded,
344            CoreError::HubConflict {
345                existing_database_url,
346                existing_identity,
347                daemon_database_url,
348                daemon_identity,
349            } if existing_database_url == "postgres://existing"
350                && existing_identity == "existing-cluster/existing-db"
351                && daemon_database_url == "postgres://daemon"
352                && daemon_identity == "daemon-cluster/daemon-db"
353        ));
354    }
355
356    #[test]
357    fn hub_conflict_display_and_json_redact_database_urls() {
358        let conflict = CoreError::HubConflict {
359            existing_database_url: "postgresql://user:secret@standalone/gobby?sslmode=require#frag"
360                .to_string(),
361            existing_identity: "cluster-a/gobby".to_string(),
362            daemon_database_url: "postgresql://daemon:secret@daemon/gobby?application_name=gobby"
363                .to_string(),
364            daemon_identity: "cluster-b/gobby".to_string(),
365        };
366
367        let message = conflict.to_string();
368        assert!(message.contains("cluster-a/gobby"));
369        assert!(message.contains("cluster-b/gobby"));
370        assert!(!message.contains("postgresql://"));
371        assert!(!message.contains("secret"));
372        assert!(!message.contains("sslmode"));
373        assert!(!message.contains("application_name"));
374
375        let encoded = serde_json::to_string(&conflict).expect("serialize hub conflict");
376        assert!(encoded.contains("postgresql://standalone/gobby"));
377        assert!(encoded.contains("postgresql://daemon/gobby"));
378        assert!(!encoded.contains("secret"));
379        assert!(!encoded.contains("sslmode"));
380        assert!(!encoded.contains("application_name"));
381        assert!(!encoded.contains("frag"));
382    }
383
384    #[test]
385    fn keyword_database_url_redacts_sensitive_values_case_insensitively() {
386        let redacted = redact_database_url(
387            "host=localhost user=app PASSWORD='secret value' dbname=gobby sslpassword=topsecret",
388        );
389
390        assert!(redacted.contains("host=localhost"));
391        assert!(redacted.contains("user=app"));
392        assert!(redacted.contains("dbname=gobby"));
393        assert!(redacted.contains("PASSWORD=<redacted>"));
394        assert!(redacted.contains("sslpassword=<redacted>"));
395        assert!(!redacted.contains("secret value"));
396        assert!(!redacted.contains("topsecret"));
397    }
398
399    #[test]
400    fn hub_conflict_json_redacts_keyword_database_urls() {
401        let conflict = CoreError::HubConflict {
402            existing_database_url: "host=standalone user=app password=secret dbname=gobby"
403                .to_string(),
404            existing_identity: "cluster-a/gobby".to_string(),
405            daemon_database_url: "HOST=daemon USER=daemon PASSFILE='/tmp/pgpass' dbname=gobby"
406                .to_string(),
407            daemon_identity: "cluster-b/gobby".to_string(),
408        };
409
410        let encoded = serde_json::to_string(&conflict).expect("serialize hub conflict");
411
412        assert!(encoded.contains("host=standalone"));
413        assert!(encoded.contains("password=<redacted>"));
414        assert!(encoded.contains("PASSFILE=<redacted>"));
415        assert!(!encoded.contains("secret"));
416        assert!(!encoded.contains("/tmp/pgpass"));
417    }
418}