use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ServiceState {
Available,
NotConfigured,
Unreachable {
message: String,
},
}
impl ServiceState {
pub fn is_available(&self) -> bool {
matches!(self, Self::Available)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetupIssue {
pub object_name: String,
pub store: String,
pub guidance: Guidance,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Guidance {
pub problem: String,
pub action: String,
pub command_hint: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, thiserror::Error)]
pub enum CoreError {
#[error("invalid configuration: {0}")]
InvalidConfig(String),
#[error(
"conflicting Gobby PostgreSQL hubs: existing recorded hub identifies {existing_identity}; daemon hub identifies {daemon_identity}"
)]
HubConflict {
#[serde(serialize_with = "serialize_redacted_database_url")]
existing_database_url: String,
existing_identity: String,
#[serde(serialize_with = "serialize_redacted_database_url")]
daemon_database_url: String,
daemon_identity: String,
},
#[error("required service unavailable: {service} — {message}")]
RequiredServiceUnavailable {
service: String,
message: String,
},
#[error("write failed: {0}")]
WriteFailed(String),
#[error("corrupted input: {0}")]
CorruptedInput(String),
}
fn serialize_redacted_database_url<S>(database_url: &str, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
serializer.serialize_str(&redact_database_url(database_url))
}
pub fn redact_database_url(database_url: &str) -> String {
let without_fragment = database_url
.split_once('#')
.map_or(database_url, |(head, _)| head);
let without_query = without_fragment
.split_once('?')
.map_or(without_fragment, |(head, _)| head);
if let Some((scheme, rest)) = without_query.split_once("://") {
let host_and_path = rest
.rsplit_once('@')
.map_or(rest, |(_, host_and_path)| host_and_path);
format!("{scheme}://{host_and_path}")
} else {
redact_keyword_database_url(without_query)
}
}
fn redact_keyword_database_url(database_url: &str) -> String {
split_keyword_dsn_tokens(database_url)
.into_iter()
.map(|token| {
let Some((key, _value)) = token.split_once('=') else {
return token.to_string();
};
if is_sensitive_keyword_dsn_key(key) {
format!("{key}=<redacted>")
} else {
token.to_string()
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn split_keyword_dsn_tokens(database_url: &str) -> Vec<&str> {
let mut tokens = Vec::new();
let mut start = None;
let mut in_single_quote = false;
let mut escaped = false;
for (index, ch) in database_url.char_indices() {
if start.is_none() {
if ch.is_whitespace() {
continue;
}
start = Some(index);
}
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if ch == '\'' {
in_single_quote = !in_single_quote;
continue;
}
if ch.is_whitespace()
&& !in_single_quote
&& let Some(token_start) = start.take()
{
tokens.push(&database_url[token_start..index]);
}
}
if let Some(token_start) = start {
tokens.push(&database_url[token_start..]);
}
tokens
}
fn is_sensitive_keyword_dsn_key(key: &str) -> bool {
matches!(
key.to_ascii_lowercase().as_str(),
"password" | "passfile" | "sslpassword"
)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DegradationKind {
ServiceUnavailable {
service: String,
state: ServiceState,
},
PartialSearch {
available: Vec<String>,
unavailable: Vec<String>,
},
PartialData {
component: String,
message: String,
},
StaleIndex {
paths: Vec<String>,
},
SkippedArtifacts {
count: usize,
reason: String,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn service_unavailable_degradation_is_not_fatal() {
let unconfigured = ServiceState::NotConfigured;
let unreachable = ServiceState::Unreachable {
message: "connection refused".to_string(),
};
assert!(!unconfigured.is_available());
assert!(!unreachable.is_available());
let degradation = DegradationKind::ServiceUnavailable {
service: "qdrant".to_string(),
state: unconfigured,
};
let fatal = CoreError::RequiredServiceUnavailable {
service: "postgres".to_string(),
message: "hub is required for writes".to_string(),
};
assert!(matches!(
degradation,
DegradationKind::ServiceUnavailable {
service,
state: ServiceState::NotConfigured
} if service == "qdrant"
));
assert_eq!(
fatal.to_string(),
"required service unavailable: postgres — hub is required for writes"
);
}
#[test]
fn guidance_is_structured() {
let guidance = Guidance {
problem: "BM25 index missing".to_string(),
action: "run attached setup validation".to_string(),
command_hint: Some("gobby setup validate".to_string()),
};
assert_eq!(guidance.problem, "BM25 index missing");
assert_eq!(guidance.action, "run attached setup validation");
assert_eq!(
guidance.command_hint.as_deref(),
Some("gobby setup validate")
);
}
#[test]
fn core_error_serialization_roundtrip() {
let invalid_config = CoreError::InvalidConfig("missing project id".to_string());
let encoded = serde_json::to_string(&invalid_config).expect("serialize invalid config");
let decoded: CoreError =
serde_json::from_str(&encoded).expect("deserialize invalid config");
assert!(matches!(
decoded,
CoreError::InvalidConfig(message) if message == "missing project id"
));
let unavailable = CoreError::RequiredServiceUnavailable {
service: "postgres".to_string(),
message: "connection refused".to_string(),
};
let encoded = serde_json::to_string(&unavailable).expect("serialize unavailable");
let decoded: CoreError = serde_json::from_str(&encoded).expect("deserialize unavailable");
assert!(matches!(
decoded,
CoreError::RequiredServiceUnavailable { service, message }
if service == "postgres" && message == "connection refused"
));
let hub_conflict = CoreError::HubConflict {
existing_database_url: "postgres://existing".to_string(),
existing_identity: "existing-cluster/existing-db".to_string(),
daemon_database_url: "postgres://daemon".to_string(),
daemon_identity: "daemon-cluster/daemon-db".to_string(),
};
let encoded = serde_json::to_string(&hub_conflict).expect("serialize hub conflict");
let decoded: CoreError = serde_json::from_str(&encoded).expect("deserialize hub conflict");
assert!(matches!(
decoded,
CoreError::HubConflict {
existing_database_url,
existing_identity,
daemon_database_url,
daemon_identity,
} if existing_database_url == "postgres://existing"
&& existing_identity == "existing-cluster/existing-db"
&& daemon_database_url == "postgres://daemon"
&& daemon_identity == "daemon-cluster/daemon-db"
));
}
#[test]
fn hub_conflict_display_and_json_redact_database_urls() {
let conflict = CoreError::HubConflict {
existing_database_url: "postgresql://user:secret@standalone/gobby?sslmode=require#frag"
.to_string(),
existing_identity: "cluster-a/gobby".to_string(),
daemon_database_url: "postgresql://daemon:secret@daemon/gobby?application_name=gobby"
.to_string(),
daemon_identity: "cluster-b/gobby".to_string(),
};
let message = conflict.to_string();
assert!(message.contains("cluster-a/gobby"));
assert!(message.contains("cluster-b/gobby"));
assert!(!message.contains("postgresql://"));
assert!(!message.contains("secret"));
assert!(!message.contains("sslmode"));
assert!(!message.contains("application_name"));
let encoded = serde_json::to_string(&conflict).expect("serialize hub conflict");
assert!(encoded.contains("postgresql://standalone/gobby"));
assert!(encoded.contains("postgresql://daemon/gobby"));
assert!(!encoded.contains("secret"));
assert!(!encoded.contains("sslmode"));
assert!(!encoded.contains("application_name"));
assert!(!encoded.contains("frag"));
}
#[test]
fn keyword_database_url_redacts_sensitive_values_case_insensitively() {
let redacted = redact_database_url(
"host=localhost user=app PASSWORD='secret value' dbname=gobby sslpassword=topsecret",
);
assert!(redacted.contains("host=localhost"));
assert!(redacted.contains("user=app"));
assert!(redacted.contains("dbname=gobby"));
assert!(redacted.contains("PASSWORD=<redacted>"));
assert!(redacted.contains("sslpassword=<redacted>"));
assert!(!redacted.contains("secret value"));
assert!(!redacted.contains("topsecret"));
}
#[test]
fn hub_conflict_json_redacts_keyword_database_urls() {
let conflict = CoreError::HubConflict {
existing_database_url: "host=standalone user=app password=secret dbname=gobby"
.to_string(),
existing_identity: "cluster-a/gobby".to_string(),
daemon_database_url: "HOST=daemon USER=daemon PASSFILE='/tmp/pgpass' dbname=gobby"
.to_string(),
daemon_identity: "cluster-b/gobby".to_string(),
};
let encoded = serde_json::to_string(&conflict).expect("serialize hub conflict");
assert!(encoded.contains("host=standalone"));
assert!(encoded.contains("password=<redacted>"));
assert!(encoded.contains("PASSFILE=<redacted>"));
assert!(!encoded.contains("secret"));
assert!(!encoded.contains("/tmp/pgpass"));
}
}