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    /// A service required by this command could not be used.
61    #[error("required service unavailable: {service} — {message}")]
62    RequiredServiceUnavailable {
63        /// Required service name.
64        service: String,
65        /// Diagnostic message explaining the unavailability.
66        message: String,
67    },
68    /// A write operation failed after validation began.
69    #[error("write failed: {0}")]
70    WriteFailed(String),
71    /// Input could not be parsed or failed integrity checks.
72    #[error("corrupted input: {0}")]
73    CorruptedInput(String),
74}
75
76/// Degradation states for partial results.
77#[derive(Debug, Clone, Serialize, Deserialize)]
78pub enum DegradationKind {
79    /// An optional service was unavailable during this operation.
80    ServiceUnavailable {
81        /// Optional service name.
82        service: String,
83        /// Availability state observed by the caller.
84        state: ServiceState,
85    },
86    /// Search completed with fewer sources than requested.
87    PartialSearch {
88        /// Source names that contributed results.
89        available: Vec<String>,
90        /// Source names that could not contribute results.
91        unavailable: Vec<String>,
92    },
93    /// Index data may be stale because of content drift or age thresholds.
94    StaleIndex {
95        /// Paths whose indexed data may be stale.
96        paths: Vec<String>,
97    },
98    /// Some artifacts were skipped during indexing.
99    SkippedArtifacts {
100        /// Number of skipped artifacts.
101        count: usize,
102        /// Human-readable reason the artifacts were skipped.
103        reason: String,
104    },
105}
106
107#[cfg(test)]
108mod tests {
109    use super::*;
110
111    #[test]
112    fn optional_service_degradation_is_not_fatal() {
113        let unconfigured = ServiceState::NotConfigured;
114        let unreachable = ServiceState::Unreachable {
115            message: "connection refused".to_string(),
116        };
117
118        assert!(!unconfigured.is_available());
119        assert!(!unreachable.is_available());
120
121        let degradation = DegradationKind::ServiceUnavailable {
122            service: "qdrant".to_string(),
123            state: unconfigured,
124        };
125        let fatal = CoreError::RequiredServiceUnavailable {
126            service: "postgres".to_string(),
127            message: "hub is required for writes".to_string(),
128        };
129
130        assert!(matches!(
131            degradation,
132            DegradationKind::ServiceUnavailable {
133                service,
134                state: ServiceState::NotConfigured
135            } if service == "qdrant"
136        ));
137        assert_eq!(
138            fatal.to_string(),
139            "required service unavailable: postgres — hub is required for writes"
140        );
141    }
142
143    #[test]
144    fn guidance_is_structured() {
145        let guidance = Guidance {
146            problem: "BM25 index missing".to_string(),
147            action: "run attached setup validation".to_string(),
148            command_hint: Some("gobby setup validate".to_string()),
149        };
150
151        assert_eq!(guidance.problem, "BM25 index missing");
152        assert_eq!(guidance.action, "run attached setup validation");
153        assert_eq!(
154            guidance.command_hint.as_deref(),
155            Some("gobby setup validate")
156        );
157    }
158
159    #[test]
160    fn core_error_serialization_roundtrip() {
161        let invalid_config = CoreError::InvalidConfig("missing project id".to_string());
162        let encoded = serde_json::to_string(&invalid_config).expect("serialize invalid config");
163        let decoded: CoreError =
164            serde_json::from_str(&encoded).expect("deserialize invalid config");
165        assert!(matches!(
166            decoded,
167            CoreError::InvalidConfig(message) if message == "missing project id"
168        ));
169
170        let unavailable = CoreError::RequiredServiceUnavailable {
171            service: "postgres".to_string(),
172            message: "connection refused".to_string(),
173        };
174        let encoded = serde_json::to_string(&unavailable).expect("serialize unavailable");
175        let decoded: CoreError = serde_json::from_str(&encoded).expect("deserialize unavailable");
176        assert!(matches!(
177            decoded,
178            CoreError::RequiredServiceUnavailable { service, message }
179                if service == "postgres" && message == "connection refused"
180        ));
181    }
182}