1use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12pub enum ServiceState {
13 Available,
15 NotConfigured,
17 Unreachable {
19 message: String,
21 },
22}
23
24impl ServiceState {
25 pub fn is_available(&self) -> bool {
27 matches!(self, Self::Available)
28 }
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct SetupIssue {
34 pub object_name: String,
36 pub store: String,
38 pub guidance: Guidance,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Guidance {
47 pub problem: String,
49 pub action: String,
51 pub command_hint: Option<String>,
53}
54
55#[derive(Debug, Serialize, Deserialize, thiserror::Error)]
57pub enum CoreError {
58 #[error("invalid configuration: {0}")]
60 InvalidConfig(String),
61 #[error(
63 "conflicting Gobby PostgreSQL hubs: existing recorded hub identifies {existing_identity}; daemon hub identifies {daemon_identity}"
64 )]
65 HubConflict {
66 #[serde(serialize_with = "serialize_redacted_database_url")]
68 existing_database_url: String,
69 existing_identity: String,
71 #[serde(serialize_with = "serialize_redacted_database_url")]
73 daemon_database_url: String,
74 daemon_identity: String,
76 },
77 #[error("required service unavailable: {service} — {message}")]
79 RequiredServiceUnavailable {
80 service: String,
82 message: String,
84 },
85 #[error("write failed: {0}")]
87 WriteFailed(String),
88 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
149#[serde(rename_all = "snake_case")]
150pub enum ModalityDegradationReason {
151 Disabled,
153 MissingEndpoint,
155 Unauthorized,
157 Unreachable,
159 UnexpectedStatus,
161 Unavailable,
163 TranscriptionError,
165 TranslationError,
167 TranslationUnavailable,
169 VisionError,
171}
172
173impl ModalityDegradationReason {
174 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#[derive(Debug, Clone, Serialize, Deserialize)]
199pub enum DegradationKind {
200 ServiceUnavailable {
202 service: String,
204 state: ServiceState,
206 },
207 PartialSearch {
209 available: Vec<String>,
211 unavailable: Vec<String>,
213 },
214 PartialData {
216 component: String,
218 message: String,
220 },
221 StaleIndex {
223 paths: Vec<String>,
225 },
226 SkippedArtifacts {
228 count: usize,
230 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}