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 optional mutable connections to each datastore. Consumers use
23/// whichever connection their validator needs; `None` means the service is not
24/// configured. PostgreSQL is feature-gated because `postgres::Client::query`
25/// 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 postgres::Client>,
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/// Report from a standalone setup creation run.
103#[derive(Debug, Default)]
104pub struct SetupReport {
105    /// Objects successfully created.
106    pub created: Vec<String>,
107    /// Objects that already existed and were skipped.
108    pub skipped: Vec<String>,
109    /// Objects that failed creation, with error detail.
110    pub failed: Vec<(String, String)>,
111}
112
113/// Error from standalone setup creation.
114#[derive(Debug, thiserror::Error)]
115pub enum SetupError {
116    /// Connection setup failed for a datastore.
117    #[error("connection failed for {store}: {message}")]
118    ConnectionFailed {
119        /// Store name.
120        store: String,
121        /// Diagnostic message.
122        message: String,
123    },
124    /// Object creation failed.
125    #[error("creation failed for {object}: {message}")]
126    CreationFailed {
127        /// Object name.
128        object: String,
129        /// Diagnostic message.
130        message: String,
131    },
132    /// Creation was attempted in attached mode.
133    #[error("setup refused in attached mode — use standalone setup")]
134    AttachedModeRefused,
135}
136
137/// Consumer-supplied creation callback for an owned object.
138pub type OwnedCreator = dyn for<'ctx> FnMut(&mut SetupContext<'ctx>) -> Result<(), SetupError>;
139
140/// An object that a consumer crate owns and can create in standalone mode.
141pub struct OwnedObject {
142    /// Human-readable name, such as `gcode_symbols table`.
143    pub name: String,
144    /// Store kind that owns the object.
145    pub store: StoreKind,
146    /// Consumer-supplied creation function.
147    pub creator: Box<OwnedCreator>,
148}
149
150/// Standalone-mode setup: explicit opt-in creation of consumer-owned resources.
151pub trait StandaloneSetup {
152    /// Namespace prefix for this consumer's owned resources, such as `gcode` or `gwiki`.
153    fn namespace(&self) -> &str;
154
155    /// Declare what this consumer owns and can create.
156    fn owned_objects(&self) -> Vec<OwnedObject>;
157
158    /// Create consumer-owned resources. Called only on an explicit setup command.
159    fn create(&self, ctx: &mut SetupContext<'_>) -> Result<SetupReport, SetupError>;
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165    use std::cell::{Cell, RefCell};
166    use std::rc::Rc;
167
168    #[test]
169    fn runtime_validation_reports_setup_guidance() {
170        struct RuntimeValidator;
171
172        impl AttachedValidator for RuntimeValidator {
173            fn required_objects(&self) -> Vec<RequiredObject> {
174                vec![
175                    RequiredObject {
176                        name: "symbols table".to_string(),
177                        store: StoreKind::Postgres,
178                        validator: Box::new(|_| Ok(())),
179                    },
180                    RequiredObject {
181                        name: "BM25 index".to_string(),
182                        store: StoreKind::Postgres,
183                        validator: Box::new(|_| {
184                            Err(SetupIssue {
185                                object_name: "BM25 index".to_string(),
186                                store: "postgres".to_string(),
187                                guidance: Guidance {
188                                    problem: "BM25 index is missing".to_string(),
189                                    action: "run the standalone setup command".to_string(),
190                                    command_hint: Some("gobby setup standalone".to_string()),
191                                },
192                            })
193                        }),
194                    },
195                ]
196            }
197        }
198
199        let falkor_config = crate::config::FalkorConfig {
200            host: "localhost".to_string(),
201            port: 16379,
202            password: None,
203        };
204        let mut ctx = ValidationContext {
205            #[cfg(feature = "postgres")]
206            pg: None,
207            falkor_config: Some(&falkor_config),
208            qdrant_config: None,
209        };
210
211        let report = RuntimeValidator.validate(&mut ctx);
212
213        assert!(!report.is_healthy());
214        assert_eq!(report.present, vec!["symbols table"]);
215        assert_eq!(report.missing.len(), 1);
216        let (object, issue) = &report.missing[0];
217        assert_eq!(object, "BM25 index");
218        assert_eq!(issue.object_name, "BM25 index");
219        assert_eq!(issue.guidance.problem, "BM25 index is missing");
220        assert_eq!(
221            issue.guidance.command_hint.as_deref(),
222            Some("gobby setup standalone")
223        );
224    }
225
226    #[test]
227    fn validator_can_query_through_mutable_context() {
228        let falkor_config = crate::config::FalkorConfig {
229            host: "graph.local".to_string(),
230            port: 16379,
231            password: None,
232        };
233        let mut ctx = ValidationContext {
234            #[cfg(feature = "postgres")]
235            pg: None,
236            falkor_config: Some(&falkor_config),
237            qdrant_config: None,
238        };
239        let observed_port = Rc::new(Cell::new(None));
240        let captured_port = Rc::clone(&observed_port);
241        let mut validator = RequiredObject {
242            name: "graph config".to_string(),
243            store: StoreKind::FalkorDB,
244            validator: Box::new(move |ctx| {
245                captured_port.set(ctx.falkor_config.map(|config| config.port));
246                Ok(())
247            }),
248        };
249
250        (validator.validator)(&mut ctx).expect("validator can read mutable context");
251
252        assert_eq!(observed_port.get(), Some(16379));
253    }
254
255    #[test]
256    fn creator_executes_without_moving_ownership() {
257        let mut ctx = SetupContext {
258            #[cfg(feature = "postgres")]
259            pg: None,
260            falkor_config: None,
261            qdrant_config: None,
262            non_interactive: true,
263        };
264        let calls = Rc::new(RefCell::new(Vec::new()));
265        let first_calls = Rc::clone(&calls);
266        let second_calls = Rc::clone(&calls);
267        let mut creators = vec![
268            OwnedObject {
269                name: "first table".to_string(),
270                store: StoreKind::Postgres,
271                creator: Box::new(move |ctx| {
272                    assert!(ctx.non_interactive);
273                    first_calls.borrow_mut().push("first");
274                    Ok(())
275                }),
276            },
277            OwnedObject {
278                name: "second table".to_string(),
279                store: StoreKind::Postgres,
280                creator: Box::new(move |ctx| {
281                    assert!(ctx.non_interactive);
282                    second_calls.borrow_mut().push("second");
283                    Ok(())
284                }),
285            },
286        ];
287
288        for creator in &mut creators {
289            (creator.creator)(&mut ctx).expect("creator can execute through mutable context");
290        }
291
292        assert!(ctx.non_interactive);
293        assert_eq!(*calls.borrow(), vec!["first", "second"]);
294    }
295}