pub use crate::degradation::{Guidance, SetupIssue};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StoreKind {
Postgres,
FalkorDB,
Qdrant,
}
pub struct ValidationContext<'a> {
#[cfg(feature = "postgres")]
pub pg: Option<&'a mut postgres::Client>,
pub falkor_config: Option<&'a crate::config::FalkorConfig>,
pub qdrant_config: Option<&'a crate::config::QdrantConfig>,
}
#[derive(Debug, Default)]
pub struct ValidationReport {
pub present: Vec<String>,
pub missing: Vec<(String, SetupIssue)>,
}
impl ValidationReport {
pub fn is_healthy(&self) -> bool {
self.missing.is_empty()
}
}
pub type RequiredValidator =
dyn for<'ctx> FnMut(&mut ValidationContext<'ctx>) -> Result<(), SetupIssue>;
pub struct RequiredObject {
pub name: String,
pub store: StoreKind,
pub validator: Box<RequiredValidator>,
}
pub trait AttachedValidator {
fn required_objects(&self) -> Vec<RequiredObject>;
fn validate(&self, ctx: &mut ValidationContext<'_>) -> ValidationReport {
let mut report = ValidationReport::default();
for mut obj in self.required_objects() {
match (obj.validator)(ctx) {
Ok(()) => report.present.push(obj.name),
Err(issue) => report.missing.push((obj.name, issue)),
}
}
report
}
}
pub struct SetupContext<'a> {
#[cfg(feature = "postgres")]
pub pg: Option<&'a mut dyn SetupPostgresExecutor>,
pub falkor_config: Option<&'a crate::config::FalkorConfig>,
pub qdrant_config: Option<&'a crate::config::QdrantConfig>,
pub non_interactive: bool,
}
#[cfg(feature = "postgres")]
pub trait SetupPostgresExecutor {
fn batch_execute(&mut self, sql: &str) -> Result<(), postgres::Error>;
}
#[cfg(feature = "postgres")]
impl SetupPostgresExecutor for postgres::Client {
fn batch_execute(&mut self, sql: &str) -> Result<(), postgres::Error> {
postgres::Client::batch_execute(self, sql)
}
}
#[cfg(feature = "postgres")]
impl SetupPostgresExecutor for postgres::Transaction<'_> {
fn batch_execute(&mut self, sql: &str) -> Result<(), postgres::Error> {
postgres::Transaction::batch_execute(self, sql)
}
}
#[derive(Debug, Default)]
pub struct SetupReport {
pub created: Vec<String>,
pub skipped: Vec<String>,
pub failed: Vec<(String, String)>,
}
#[derive(Debug, thiserror::Error)]
pub enum SetupError {
#[error("connection failed for {store}: {message}")]
ConnectionFailed {
store: String,
message: String,
},
#[error("creation failed for {object}: {message}")]
CreationFailed {
object: String,
message: String,
},
#[error("setup refused in attached mode — use standalone setup")]
AttachedModeRefused,
}
pub type OwnedCreator = dyn for<'ctx> FnMut(&mut SetupContext<'ctx>) -> Result<(), SetupError>;
pub struct OwnedObject {
pub name: String,
pub store: StoreKind,
pub creator: Box<OwnedCreator>,
}
pub trait StandaloneSetup {
fn namespace(&self) -> &str;
fn owned_objects(&self) -> Result<Vec<OwnedObject>, SetupError>;
fn create(&self, ctx: &mut SetupContext<'_>) -> Result<SetupReport, SetupError>;
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::{Cell, RefCell};
use std::rc::Rc;
#[test]
fn runtime_validation_reports_setup_guidance() {
struct RuntimeValidator;
impl AttachedValidator for RuntimeValidator {
fn required_objects(&self) -> Vec<RequiredObject> {
vec![
RequiredObject {
name: "symbols table".to_string(),
store: StoreKind::Postgres,
validator: Box::new(|_| Ok(())),
},
RequiredObject {
name: "BM25 index".to_string(),
store: StoreKind::Postgres,
validator: Box::new(|_| {
Err(SetupIssue {
object_name: "BM25 index".to_string(),
store: "postgres".to_string(),
guidance: Guidance {
problem: "BM25 index is missing".to_string(),
action: "run the standalone setup command".to_string(),
command_hint: Some("gobby setup standalone".to_string()),
},
})
}),
},
]
}
}
let falkor_config = crate::config::FalkorConfig {
host: "localhost".to_string(),
port: 16379,
password: None,
};
let mut ctx = ValidationContext {
#[cfg(feature = "postgres")]
pg: None,
falkor_config: Some(&falkor_config),
qdrant_config: None,
};
let report = RuntimeValidator.validate(&mut ctx);
assert!(!report.is_healthy());
assert_eq!(report.present, vec!["symbols table"]);
assert_eq!(report.missing.len(), 1);
let (object, issue) = &report.missing[0];
assert_eq!(object, "BM25 index");
assert_eq!(issue.object_name, "BM25 index");
assert_eq!(issue.guidance.problem, "BM25 index is missing");
assert_eq!(
issue.guidance.command_hint.as_deref(),
Some("gobby setup standalone")
);
}
#[test]
fn validator_can_query_through_mutable_context() {
let falkor_config = crate::config::FalkorConfig {
host: "graph.local".to_string(),
port: 16379,
password: None,
};
let mut ctx = ValidationContext {
#[cfg(feature = "postgres")]
pg: None,
falkor_config: Some(&falkor_config),
qdrant_config: None,
};
let observed_port = Rc::new(Cell::new(None));
let captured_port = Rc::clone(&observed_port);
let mut validator = RequiredObject {
name: "graph config".to_string(),
store: StoreKind::FalkorDB,
validator: Box::new(move |ctx| {
captured_port.set(ctx.falkor_config.map(|config| config.port));
Ok(())
}),
};
(validator.validator)(&mut ctx).expect("validator can read mutable context");
assert_eq!(observed_port.get(), Some(16379));
}
#[test]
fn creator_executes_without_moving_ownership() {
let mut ctx = SetupContext {
#[cfg(feature = "postgres")]
pg: None,
falkor_config: None,
qdrant_config: None,
non_interactive: true,
};
let calls = Rc::new(RefCell::new(Vec::new()));
let first_calls = Rc::clone(&calls);
let second_calls = Rc::clone(&calls);
let mut creators = vec![
OwnedObject {
name: "first table".to_string(),
store: StoreKind::Postgres,
creator: Box::new(move |ctx| {
assert!(ctx.non_interactive);
first_calls.borrow_mut().push("first");
Ok(())
}),
},
OwnedObject {
name: "second table".to_string(),
store: StoreKind::Postgres,
creator: Box::new(move |ctx| {
assert!(ctx.non_interactive);
second_calls.borrow_mut().push("second");
Ok(())
}),
},
];
for creator in &mut creators {
(creator.creator)(&mut ctx).expect("creator can execute through mutable context");
}
assert!(ctx.non_interactive);
assert_eq!(*calls.borrow(), vec!["first", "second"]);
}
}