Skip to main content

gobby_core/
setup.rs

1//! Shared setup-mode boundary.
2//!
3//! Attached and standalone setup contracts belong here. Runtime callers should
4//! validate externally managed state explicitly and avoid implicit schema or
5//! service creation.
6
7pub use crate::degradation::{Guidance, SetupIssue};
8
9/// Datastore kind for setup object classification.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum StoreKind {
12    /// PostgreSQL hub datastore.
13    Postgres,
14    /// FalkorDB graph datastore.
15    FalkorDB,
16    /// Qdrant vector datastore.
17    Qdrant,
18}
19
20/// Context supplied to validation callbacks.
21///
22/// Contains nullable mutable connections to each datastore. Consumers use
23/// whichever connection their validator needs; `None` represents diagnostic or
24/// explicitly degraded paths where a handle was not supplied. PostgreSQL is
25/// feature-gated because `postgres::Client::query` requires `&mut self`.
26pub struct ValidationContext<'a> {
27    /// PostgreSQL connection supplied by the caller when the `postgres` feature is enabled.
28    #[cfg(feature = "postgres")]
29    pub pg: Option<&'a mut postgres::Client>,
30    /// FalkorDB connection configuration, when configured.
31    pub falkor_config: Option<&'a crate::config::FalkorConfig>,
32    /// Qdrant connection configuration, when configured.
33    pub qdrant_config: Option<&'a crate::config::QdrantConfig>,
34}
35
36/// Result of running all attached-mode validators.
37#[derive(Debug, Default)]
38pub struct ValidationReport {
39    /// Names of objects that passed validation.
40    pub present: Vec<String>,
41    /// Objects that failed validation, with structured issue details.
42    pub missing: Vec<(String, SetupIssue)>,
43}
44
45impl ValidationReport {
46    /// Returns true when every required object passed validation.
47    pub fn is_healthy(&self) -> bool {
48        self.missing.is_empty()
49    }
50}
51
52/// Consumer-supplied validation callback for a required object.
53pub type RequiredValidator =
54    dyn for<'ctx> FnMut(&mut ValidationContext<'ctx>) -> Result<(), SetupIssue>;
55
56/// Required object that a consumer crate declares for setup validation.
57pub struct RequiredObject {
58    /// Human-readable name, such as `symbols table` or `wiki_docs table`.
59    pub name: String,
60    /// Store kind that owns the object.
61    pub store: StoreKind,
62    /// Consumer-supplied check function.
63    pub validator: Box<RequiredValidator>,
64}
65
66/// Attached-mode validation: check that externally managed resources exist.
67///
68/// Attached validation must never create, alter, or drop datastore schema.
69pub trait AttachedValidator {
70    /// Declare the objects this consumer requires.
71    fn required_objects(&self) -> Vec<RequiredObject>;
72
73    /// Run all validators and return a report of present and missing objects.
74    fn validate(&self, ctx: &mut ValidationContext<'_>) -> ValidationReport {
75        let mut report = ValidationReport::default();
76        for mut obj in self.required_objects() {
77            match (obj.validator)(ctx) {
78                Ok(()) => report.present.push(obj.name),
79                Err(issue) => report.missing.push((obj.name, issue)),
80            }
81        }
82        report
83    }
84}
85
86/// Context supplied to standalone setup creation callbacks.
87///
88/// PostgreSQL is feature-gated because `postgres::Client::execute` requires
89/// `&mut self` for DDL and DML operations.
90pub struct SetupContext<'a> {
91    /// PostgreSQL connection supplied by the caller when the `postgres` feature is enabled.
92    #[cfg(feature = "postgres")]
93    pub pg: Option<&'a mut dyn SetupPostgresExecutor>,
94    /// FalkorDB connection configuration, when configured.
95    pub falkor_config: Option<&'a crate::config::FalkorConfig>,
96    /// Qdrant connection configuration, when configured.
97    pub qdrant_config: Option<&'a crate::config::QdrantConfig>,
98    /// If true, skip prompts and apply defaults.
99    pub non_interactive: bool,
100}
101
102/// Object-safe PostgreSQL executor supplied to setup creation callbacks.
103#[cfg(feature = "postgres")]
104pub trait SetupPostgresExecutor {
105    /// Execute SQL against the underlying PostgreSQL setup connection.
106    fn batch_execute(&mut self, sql: &str) -> Result<(), postgres::Error>;
107}
108
109#[cfg(feature = "postgres")]
110impl SetupPostgresExecutor for postgres::Client {
111    fn batch_execute(&mut self, sql: &str) -> Result<(), postgres::Error> {
112        postgres::Client::batch_execute(self, sql)
113    }
114}
115
116#[cfg(feature = "postgres")]
117impl SetupPostgresExecutor for postgres::Transaction<'_> {
118    fn batch_execute(&mut self, sql: &str) -> Result<(), postgres::Error> {
119        postgres::Transaction::batch_execute(self, sql)
120    }
121}
122
123/// Report from a standalone setup creation run.
124#[derive(Debug, Default)]
125pub struct SetupReport {
126    /// Objects successfully created.
127    pub created: Vec<String>,
128    /// Objects that already existed and were skipped.
129    pub skipped: Vec<String>,
130    /// Objects that failed creation, with error detail.
131    pub failed: Vec<(String, String)>,
132}
133
134/// Error from standalone setup creation.
135#[derive(Debug, thiserror::Error)]
136pub enum SetupError {
137    /// Connection setup failed for a datastore.
138    #[error("connection failed for {store}: {message}")]
139    ConnectionFailed {
140        /// Store name.
141        store: String,
142        /// Diagnostic message.
143        message: String,
144    },
145    /// Object creation failed.
146    #[error("creation failed for {object}: {message}")]
147    CreationFailed {
148        /// Object name.
149        object: String,
150        /// Diagnostic message.
151        message: String,
152    },
153    /// Creation was attempted in attached mode.
154    #[error("setup refused in attached mode — use standalone setup")]
155    AttachedModeRefused,
156}
157
158/// Consumer-supplied creation callback for an owned object.
159pub type OwnedCreator = dyn for<'ctx> FnMut(&mut SetupContext<'ctx>) -> Result<(), SetupError>;
160
161/// An object that a consumer crate owns and can create in standalone mode.
162pub struct OwnedObject {
163    /// Human-readable name, such as `gcode_symbols table`.
164    pub name: String,
165    /// Store kind that owns the object.
166    pub store: StoreKind,
167    /// Consumer-supplied creation function.
168    pub creator: Box<OwnedCreator>,
169}
170
171/// Standalone-mode setup: explicit opt-in creation of consumer-owned resources.
172pub trait StandaloneSetup {
173    /// Namespace prefix for this consumer's owned resources, such as `gcode` or `gwiki`.
174    fn namespace(&self) -> &str;
175
176    /// Declare what this consumer owns and can create.
177    fn owned_objects(&self) -> Result<Vec<OwnedObject>, SetupError>;
178
179    /// Create consumer-owned resources. Called only on an explicit setup command.
180    fn create(&self, ctx: &mut SetupContext<'_>) -> Result<SetupReport, SetupError>;
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use std::cell::{Cell, RefCell};
187    use std::rc::Rc;
188
189    #[test]
190    fn runtime_validation_reports_setup_guidance() {
191        struct RuntimeValidator;
192
193        impl AttachedValidator for RuntimeValidator {
194            fn required_objects(&self) -> Vec<RequiredObject> {
195                vec![
196                    RequiredObject {
197                        name: "symbols table".to_string(),
198                        store: StoreKind::Postgres,
199                        validator: Box::new(|_| Ok(())),
200                    },
201                    RequiredObject {
202                        name: "BM25 index".to_string(),
203                        store: StoreKind::Postgres,
204                        validator: Box::new(|_| {
205                            Err(SetupIssue {
206                                object_name: "BM25 index".to_string(),
207                                store: "postgres".to_string(),
208                                guidance: Guidance {
209                                    problem: "BM25 index is missing".to_string(),
210                                    action: "run the standalone setup command".to_string(),
211                                    command_hint: Some("gobby setup standalone".to_string()),
212                                },
213                            })
214                        }),
215                    },
216                ]
217            }
218        }
219
220        let falkor_config = crate::config::FalkorConfig {
221            host: "localhost".to_string(),
222            port: 16379,
223            password: None,
224        };
225        let mut ctx = ValidationContext {
226            #[cfg(feature = "postgres")]
227            pg: None,
228            falkor_config: Some(&falkor_config),
229            qdrant_config: None,
230        };
231
232        let report = RuntimeValidator.validate(&mut ctx);
233
234        assert!(!report.is_healthy());
235        assert_eq!(report.present, vec!["symbols table"]);
236        assert_eq!(report.missing.len(), 1);
237        let (object, issue) = &report.missing[0];
238        assert_eq!(object, "BM25 index");
239        assert_eq!(issue.object_name, "BM25 index");
240        assert_eq!(issue.guidance.problem, "BM25 index is missing");
241        assert_eq!(
242            issue.guidance.command_hint.as_deref(),
243            Some("gobby setup standalone")
244        );
245    }
246
247    #[test]
248    fn validator_can_query_through_mutable_context() {
249        let falkor_config = crate::config::FalkorConfig {
250            host: "graph.local".to_string(),
251            port: 16379,
252            password: None,
253        };
254        let mut ctx = ValidationContext {
255            #[cfg(feature = "postgres")]
256            pg: None,
257            falkor_config: Some(&falkor_config),
258            qdrant_config: None,
259        };
260        let observed_port = Rc::new(Cell::new(None));
261        let captured_port = Rc::clone(&observed_port);
262        let mut validator = RequiredObject {
263            name: "graph config".to_string(),
264            store: StoreKind::FalkorDB,
265            validator: Box::new(move |ctx| {
266                captured_port.set(ctx.falkor_config.map(|config| config.port));
267                Ok(())
268            }),
269        };
270
271        (validator.validator)(&mut ctx).expect("validator can read mutable context");
272
273        assert_eq!(observed_port.get(), Some(16379));
274    }
275
276    #[test]
277    fn creator_executes_without_moving_ownership() {
278        let mut ctx = SetupContext {
279            #[cfg(feature = "postgres")]
280            pg: None,
281            falkor_config: None,
282            qdrant_config: None,
283            non_interactive: true,
284        };
285        let calls = Rc::new(RefCell::new(Vec::new()));
286        let first_calls = Rc::clone(&calls);
287        let second_calls = Rc::clone(&calls);
288        let mut creators = vec![
289            OwnedObject {
290                name: "first table".to_string(),
291                store: StoreKind::Postgres,
292                creator: Box::new(move |ctx| {
293                    assert!(ctx.non_interactive);
294                    first_calls.borrow_mut().push("first");
295                    Ok(())
296                }),
297            },
298            OwnedObject {
299                name: "second table".to_string(),
300                store: StoreKind::Postgres,
301                creator: Box::new(move |ctx| {
302                    assert!(ctx.non_interactive);
303                    second_calls.borrow_mut().push("second");
304                    Ok(())
305                }),
306            },
307        ];
308
309        for creator in &mut creators {
310            (creator.creator)(&mut ctx).expect("creator can execute through mutable context");
311        }
312
313        assert!(ctx.non_interactive);
314        assert_eq!(*calls.borrow(), vec!["first", "second"]);
315    }
316}