1use serde::{Deserialize, Serialize};
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub enum ServiceState {
12 Available,
14 NotConfigured,
16 Unreachable {
18 message: String,
20 },
21}
22
23impl ServiceState {
24 pub fn is_available(&self) -> bool {
26 matches!(self, Self::Available)
27 }
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct SetupIssue {
33 pub object_name: String,
35 pub store: String,
37 pub guidance: Guidance,
39}
40
41#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct Guidance {
46 pub problem: String,
48 pub action: String,
50 pub command_hint: Option<String>,
52}
53
54#[derive(Debug, Serialize, Deserialize, thiserror::Error)]
56pub enum CoreError {
57 #[error("invalid configuration: {0}")]
59 InvalidConfig(String),
60 #[error(
62 "conflicting Gobby PostgreSQL hubs: existing recorded hub identifies {existing_identity}; daemon hub identifies {daemon_identity}"
63 )]
64 HubConflict {
65 #[serde(serialize_with = "serialize_redacted_database_url")]
67 existing_database_url: String,
68 existing_identity: String,
70 #[serde(serialize_with = "serialize_redacted_database_url")]
72 daemon_database_url: String,
73 daemon_identity: String,
75 },
76 #[error("required service unavailable: {service} — {message}")]
78 RequiredServiceUnavailable {
79 service: String,
81 message: String,
83 },
84 #[error("write failed: {0}")]
86 WriteFailed(String),
87 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
182pub enum DegradationKind {
183 ServiceUnavailable {
185 service: String,
187 state: ServiceState,
189 },
190 PartialSearch {
192 available: Vec<String>,
194 unavailable: Vec<String>,
196 },
197 PartialData {
199 component: String,
201 message: String,
203 },
204 StaleIndex {
206 paths: Vec<String>,
208 },
209 SkippedArtifacts {
211 count: usize,
213 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}