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 postgres::Client>,
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#[derive(Debug, Default)]
104pub struct SetupReport {
105 pub created: Vec<String>,
107 pub skipped: Vec<String>,
109 pub failed: Vec<(String, String)>,
111}
112
113#[derive(Debug, thiserror::Error)]
115pub enum SetupError {
116 #[error("connection failed for {store}: {message}")]
118 ConnectionFailed {
119 store: String,
121 message: String,
123 },
124 #[error("creation failed for {object}: {message}")]
126 CreationFailed {
127 object: String,
129 message: String,
131 },
132 #[error("setup refused in attached mode — use standalone setup")]
134 AttachedModeRefused,
135}
136
137pub type OwnedCreator = dyn for<'ctx> FnMut(&mut SetupContext<'ctx>) -> Result<(), SetupError>;
139
140pub struct OwnedObject {
142 pub name: String,
144 pub store: StoreKind,
146 pub creator: Box<OwnedCreator>,
148}
149
150pub trait StandaloneSetup {
152 fn namespace(&self) -> &str;
154
155 fn owned_objects(&self) -> Vec<OwnedObject>;
157
158 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}