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 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#[derive(Debug, Clone, Serialize, Deserialize)]
183pub enum DegradationKind {
184 ServiceUnavailable {
186 service: String,
188 state: ServiceState,
190 },
191 PartialSearch {
193 available: Vec<String>,
195 unavailable: Vec<String>,
197 },
198 PartialData {
200 component: String,
202 message: String,
204 },
205 StaleIndex {
207 paths: Vec<String>,
209 },
210 SkippedArtifacts {
212 count: usize,
214 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}