1pub use crate::degradation::{Guidance, SetupIssue};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum StoreKind {
12 Postgres,
14 FalkorDB,
16 Qdrant,
18}
19
20pub struct ValidationContext<'a> {
27 #[cfg(feature = "postgres")]
29 pub pg: Option<&'a mut postgres::Client>,
30 pub falkor_config: Option<&'a crate::config::FalkorConfig>,
32 pub qdrant_config: Option<&'a crate::config::QdrantConfig>,
34}
35
36#[derive(Debug, Default)]
38pub struct ValidationReport {
39 pub present: Vec<String>,
41 pub missing: Vec<(String, SetupIssue)>,
43}
44
45impl ValidationReport {
46 pub fn is_healthy(&self) -> bool {
48 self.missing.is_empty()
49 }
50}
51
52pub type RequiredValidator =
54 dyn for<'ctx> FnMut(&mut ValidationContext<'ctx>) -> Result<(), SetupIssue>;
55
56pub struct RequiredObject {
58 pub name: String,
60 pub store: StoreKind,
62 pub validator: Box<RequiredValidator>,
64}
65
66pub trait AttachedValidator {
70 fn required_objects(&self) -> Vec<RequiredObject>;
72
73 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
86pub struct SetupContext<'a> {
91 #[cfg(feature = "postgres")]
93 pub pg: Option<&'a mut dyn SetupPostgresExecutor>,
94 pub falkor_config: Option<&'a crate::config::FalkorConfig>,
96 pub qdrant_config: Option<&'a crate::config::QdrantConfig>,
98 pub non_interactive: bool,
100}
101
102#[cfg(feature = "postgres")]
104pub trait SetupPostgresExecutor {
105 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#[derive(Debug, Default)]
125pub struct SetupReport {
126 pub created: Vec<String>,
128 pub skipped: Vec<String>,
130 pub failed: Vec<(String, String)>,
132}
133
134#[derive(Debug, thiserror::Error)]
136pub enum SetupError {
137 #[error("connection failed for {store}: {message}")]
139 ConnectionFailed {
140 store: String,
142 message: String,
144 },
145 #[error("creation failed for {object}: {message}")]
147 CreationFailed {
148 object: String,
150 message: String,
152 },
153 #[error("setup refused in attached mode — use standalone setup")]
155 AttachedModeRefused,
156}
157
158pub type OwnedCreator = dyn for<'ctx> FnMut(&mut SetupContext<'ctx>) -> Result<(), SetupError>;
160
161pub struct OwnedObject {
163 pub name: String,
165 pub store: StoreKind,
167 pub creator: Box<OwnedCreator>,
169}
170
171pub trait StandaloneSetup {
173 fn namespace(&self) -> &str;
175
176 fn owned_objects(&self) -> Result<Vec<OwnedObject>, SetupError>;
178
179 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}