use saya_types::ProfileIdentity;
use std::sync::Mutex;
const MAX_OBSERVATIONS: usize = 32;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ToolObservation {
pub(crate) tool: String,
pub(crate) outcome: ObservationOutcome,
pub(crate) profile: Option<ProfileIdentity>,
pub(crate) objects: Vec<Vec<String>>,
pub(crate) columns: Vec<String>,
pub(crate) row_count: Option<usize>,
pub(crate) truncated: Option<bool>,
pub(crate) references_partial: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub(crate) enum ObservationOutcome {
Succeeded,
Failed,
Denied,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[allow(dead_code)]
pub(crate) struct DrainedObservations {
pub(crate) observations: Vec<ToolObservation>,
pub(crate) truncated: bool,
}
pub(crate) struct ObservationLog {
observations: Mutex<Vec<ToolObservation>>,
}
impl ObservationLog {
pub(crate) fn new() -> Self {
Self {
observations: Mutex::new(Vec::new()),
}
}
pub(crate) fn record(&self, observation: ToolObservation) {
let mut guard = self
.observations
.lock()
.expect("observation log not poisoned");
if guard.len() < MAX_OBSERVATIONS {
guard.push(observation);
}
}
#[allow(dead_code)]
pub(crate) fn drain(&self) -> DrainedObservations {
let mut guard = self
.observations
.lock()
.expect("observation log not poisoned");
let observations = std::mem::take(&mut *guard);
DrainedObservations {
truncated: observations.len() == MAX_OBSERVATIONS,
observations,
}
}
#[allow(dead_code)]
pub(crate) fn touched(&self, catalog: &str, schema: &str, object: &str) -> bool {
let guard = self
.observations
.lock()
.expect("observation log not poisoned");
let proposed = [catalog, schema, object];
guard.iter().any(|obs| {
obs.outcome == ObservationOutcome::Succeeded
&& obs.objects.iter().any(|path| path_matches(path, &proposed))
})
}
}
fn path_matches(path: &[String], proposed: &[&str; 3]) -> bool {
let n = path.len().min(3);
if n == 0 {
return false;
}
path.iter()
.skip(path.len() - n)
.zip(proposed.iter().skip(3 - n))
.all(|(obs, want)| obs.eq_ignore_ascii_case(want))
}
impl Default for ObservationLog {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn obs(tool: &str) -> ToolObservation {
ToolObservation {
tool: tool.into(),
outcome: ObservationOutcome::Succeeded,
profile: None,
objects: Vec::new(),
columns: Vec::new(),
row_count: None,
truncated: None,
references_partial: false,
}
}
fn obs_with_objects(tool: &str, objects: &[&[&str]]) -> ToolObservation {
ToolObservation {
objects: objects
.iter()
.map(|parts| parts.iter().map(|s| s.to_string()).collect())
.collect(),
..obs(tool)
}
}
#[test]
fn record_then_drain_returns_in_order_and_empties() {
let log = ObservationLog::new();
log.record(obs("a"));
log.record(obs("b"));
let drained = log.drain();
assert_eq!(
drained
.observations
.iter()
.map(|o| &o.tool)
.collect::<Vec<_>>(),
&["a", "b"]
);
assert!(!drained.truncated);
let again = log.drain();
assert!(again.observations.is_empty());
assert!(!again.truncated);
}
#[test]
fn cap_holds_and_drain_reports_truncation() {
let log = ObservationLog::new();
for i in 0..40 {
log.record(obs(&format!("t{i}")));
}
let drained = log.drain();
assert_eq!(drained.observations.len(), MAX_OBSERVATIONS);
assert_eq!(drained.observations.last().unwrap().tool, "t31");
assert!(drained.truncated);
}
#[test]
fn touched_matches_underspecified_observations_anchored_at_trailing_parts() {
let log = ObservationLog::new();
log.record(obs_with_objects(
"bounded_sql_query",
&[&["analytics", "public", "orders"]],
));
assert!(log.touched("analytics", "public", "orders"));
assert!(!log.touched("cat", "public", "orders"));
let log = ObservationLog::new();
log.record(obs_with_objects(
"bounded_sql_query",
&[&["public", "orders"]],
));
assert!(log.touched("analytics", "public", "orders"));
assert!(log.touched("cat", "public", "orders"));
let log = ObservationLog::new();
log.record(obs_with_objects("bounded_sql_query", &[&["orders"]]));
assert!(log.touched("analytics", "public", "orders"));
assert!(log.touched("cat", "schema", "orders"));
}
#[test]
fn touched_misses_a_different_object() {
let log = ObservationLog::new();
log.record(obs_with_objects(
"bounded_sql_query",
&[&["public", "orders"]],
));
assert!(!log.touched("analytics", "public", "lineitems"));
assert!(!log.touched("analytics", "staging", "orders"));
}
#[test]
fn touched_ignores_failed_and_denied_observations() {
let log = ObservationLog::new();
let mut failed = obs_with_objects("bounded_sql_query", &[&["public", "orders"]]);
failed.outcome = ObservationOutcome::Failed;
log.record(failed);
let mut denied = obs_with_objects("bounded_sql_query", &[&["public", "orders"]]);
denied.outcome = ObservationOutcome::Denied;
log.record(denied);
assert!(
!log.touched("analytics", "public", "orders"),
"a failed or denied query touched nothing a proposal can lean on"
);
}
#[test]
fn touched_is_non_consuming() {
let log = ObservationLog::new();
log.record(obs_with_objects(
"bounded_sql_query",
&[&["public", "orders"]],
));
assert!(log.touched("cat", "public", "orders"));
assert!(log.touched("cat", "public", "orders"));
assert_eq!(log.drain().observations.len(), 1);
}
}