gobby-core 0.6.1

Shared foundation primitives for Gobby CLI tools
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
//! Shared degradation vocabulary boundary.
//!
//! Degradation types describe partial availability without forcing every
//! command to treat configured-service outages or explicitly degraded paths as
//! fatal. Detailed contracts live here so lightweight consumers can share the
//! same vocabulary.

use serde::{Deserialize, Serialize};

/// Service availability state, returned alongside results from adapters.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum ServiceState {
    /// Service is connected and responding.
    Available,
    /// Service is not configured because no config was found from any source.
    NotConfigured,
    /// Service is configured but could not be reached.
    Unreachable {
        /// Adapter-provided diagnostic message for the failed connection.
        message: String,
    },
}

impl ServiceState {
    /// Returns true when the backing service is connected and responding.
    pub fn is_available(&self) -> bool {
        matches!(self, Self::Available)
    }
}

/// Setup validation issue with actionable guidance.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SetupIssue {
    /// Name of the missing, invalid, or degraded resource.
    pub object_name: String,
    /// Store or service that owns the resource.
    pub store: String,
    /// Structured remediation guidance for callers to render.
    pub guidance: Guidance,
}

/// Structured guidance text for setup issues.
///
/// Callers render these fields; `gobby-core` does not format CLI output.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Guidance {
    /// What is missing or wrong.
    pub problem: String,
    /// What the user should do.
    pub action: String,
    /// Optional command suggestion.
    pub command_hint: Option<String>,
}

/// Fatal errors that prevent a command from completing.
#[derive(Debug, Serialize, Deserialize, thiserror::Error)]
pub enum CoreError {
    /// Configuration was present but invalid for the requested operation.
    #[error("invalid configuration: {0}")]
    InvalidConfig(String),
    /// Two reachable hub DSNs point at different PostgreSQL clusters or databases.
    #[error(
        "conflicting Gobby PostgreSQL hubs: existing recorded hub identifies {existing_identity}; daemon hub identifies {daemon_identity}"
    )]
    HubConflict {
        /// DSN from the existing standalone/subset configuration.
        #[serde(serialize_with = "serialize_redacted_database_url")]
        existing_database_url: String,
        /// Cluster/database identity observed for the existing DSN.
        existing_identity: String,
        /// DSN reported by the daemon bootstrap/broker.
        #[serde(serialize_with = "serialize_redacted_database_url")]
        daemon_database_url: String,
        /// Cluster/database identity observed for the daemon DSN.
        daemon_identity: String,
    },
    /// A service required by this command could not be used.
    #[error("required service unavailable: {service} — {message}")]
    RequiredServiceUnavailable {
        /// Required service name.
        service: String,
        /// Diagnostic message explaining the unavailability.
        message: String,
    },
    /// A write operation failed after validation began.
    #[error("write failed: {0}")]
    WriteFailed(String),
    /// Input could not be parsed or failed integrity checks.
    #[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 {
    crate::libpq::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 is_sensitive_keyword_dsn_key(key: &str) -> bool {
    matches!(
        key.to_ascii_lowercase().as_str(),
        "password" | "passfile" | "sslpassword"
    )
}

/// Why a modality capability (transcription, translation, vision, or a daemon
/// capability probe) degraded.
///
/// The serialized snake_case names double as the marker strings written into
/// vault frontmatter (`transcription_degradation`, `vision_degradation`,
/// `media_degradation`) and daemon capability reports, so variants must keep
/// their names stable; use [`Self::as_str`] when embedding a marker in text.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ModalityDegradationReason {
    /// Routing turned the capability off.
    Disabled,
    /// No endpoint is configured for the capability.
    MissingEndpoint,
    /// The endpoint rejected the configured credentials.
    Unauthorized,
    /// The endpoint could not be reached.
    Unreachable,
    /// The endpoint answered with an unexpected HTTP status.
    UnexpectedStatus,
    /// The capability depends on something that was already degraded upstream.
    Unavailable,
    /// Transcription was attempted and failed.
    TranscriptionError,
    /// Translation was attempted and failed.
    TranslationError,
    /// Translation support is not compiled in or configured.
    TranslationUnavailable,
    /// Vision extraction was attempted and failed.
    VisionError,
}

impl ModalityDegradationReason {
    /// The stable marker string for this reason (matches the serde name).
    pub fn as_str(self) -> &'static str {
        match self {
            Self::Disabled => "disabled",
            Self::MissingEndpoint => "missing_endpoint",
            Self::Unauthorized => "unauthorized",
            Self::Unreachable => "unreachable",
            Self::UnexpectedStatus => "unexpected_status",
            Self::Unavailable => "unavailable",
            Self::TranscriptionError => "transcription_error",
            Self::TranslationError => "translation_error",
            Self::TranslationUnavailable => "translation_unavailable",
            Self::VisionError => "vision_error",
        }
    }
}

impl std::fmt::Display for ModalityDegradationReason {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str(self.as_str())
    }
}

/// Degradation states for partial results.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum DegradationKind {
    /// A configured service was unavailable during this operation.
    ServiceUnavailable {
        /// Optional service name.
        service: String,
        /// Availability state observed by the caller.
        state: ServiceState,
    },
    /// Search completed with fewer sources than requested.
    PartialSearch {
        /// Source names that contributed results.
        available: Vec<String>,
        /// Source names that could not contribute results.
        unavailable: Vec<String>,
    },
    /// Operation completed with capped or otherwise incomplete data.
    PartialData {
        /// Component whose data was incomplete.
        component: String,
        /// Human-readable detail about the partial data.
        message: String,
    },
    /// Index data may be stale because of content drift or age thresholds.
    StaleIndex {
        /// Paths whose indexed data may be stale.
        paths: Vec<String>,
    },
    /// Some artifacts were skipped during indexing.
    SkippedArtifacts {
        /// Number of skipped artifacts.
        count: usize,
        /// Human-readable reason the artifacts were skipped.
        reason: String,
    },
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn modality_reason_markers_match_serde_names() {
        for reason in [
            ModalityDegradationReason::Disabled,
            ModalityDegradationReason::MissingEndpoint,
            ModalityDegradationReason::Unauthorized,
            ModalityDegradationReason::Unreachable,
            ModalityDegradationReason::UnexpectedStatus,
            ModalityDegradationReason::Unavailable,
            ModalityDegradationReason::TranscriptionError,
            ModalityDegradationReason::TranslationError,
            ModalityDegradationReason::TranslationUnavailable,
            ModalityDegradationReason::VisionError,
        ] {
            let serialized = serde_json::to_value(reason).expect("serialize reason");
            assert_eq!(
                serialized,
                serde_json::Value::String(reason.as_str().to_string()),
                "as_str and serde marker drifted for {reason:?}"
            );
            assert_eq!(reason.to_string(), reason.as_str());
        }
    }

    #[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"));
    }
}