use anyhow::Result;
use crate::store::{AgentContext, KnowledgeStore};
#[derive(Debug, Clone)]
pub struct FieldUpdate {
pub assignment: String,
pub param: String,
pub value: UpdateValue,
}
#[derive(Debug, Clone)]
pub enum UpdateValue {
Str(String),
Int(i64),
None,
}
#[derive(Debug, Clone, Default)]
pub struct UpdateSpec {
pub fields: Vec<FieldUpdate>,
pub add_tags: Vec<String>,
}
impl UpdateSpec {
pub fn is_empty(&self) -> bool {
self.fields.is_empty() && self.add_tags.is_empty()
}
pub fn has_column_updates(&self) -> bool {
!self.fields.is_empty()
}
pub fn set_clause_columns(&self) -> String {
self.fields
.iter()
.map(|f| f.assignment.as_str())
.collect::<Vec<_>>()
.join(", ")
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct UpdateOutcome {
pub applied: bool,
pub no_op: bool,
}
impl UpdateOutcome {
pub(crate) fn no_op() -> Self {
Self {
applied: false,
no_op: true,
}
}
pub(crate) fn ran(applied: bool) -> Self {
Self {
applied,
no_op: false,
}
}
}
pub struct UpdateBuilder<'a> {
store: &'a dyn KnowledgeStore,
id: String,
spec: UpdateSpec,
}
impl<'a> UpdateBuilder<'a> {
pub fn new(store: &'a dyn KnowledgeStore, id: impl Into<String>) -> Self {
Self {
store,
id: id.into(),
spec: UpdateSpec::default(),
}
}
pub fn summary(mut self, summary: impl Into<String>) -> Self {
self.spec.fields.push(FieldUpdate {
assignment: "summary = $set_summary".to_string(),
param: "set_summary".to_string(),
value: UpdateValue::Str(summary.into()),
});
self
}
pub fn resonance(mut self, resonance: i32) -> Self {
self.spec.fields.push(FieldUpdate {
assignment: "resonance = $set_resonance".to_string(),
param: "set_resonance".to_string(),
value: UpdateValue::Int(resonance as i64),
});
self
}
pub fn activation_count(mut self, count: i32) -> Self {
self.spec.fields.push(FieldUpdate {
assignment: "activation_count = $set_activation_count".to_string(),
param: "set_activation_count".to_string(),
value: UpdateValue::Int(count as i64),
});
self
}
pub fn increment_activation_count(mut self, delta: i32) -> Self {
self.spec.fields.push(FieldUpdate {
assignment: "activation_count += $set_activation_delta".to_string(),
param: "set_activation_delta".to_string(),
value: UpdateValue::Int(delta as i64),
});
self
}
pub fn touch_last_activated(mut self) -> Self {
self.spec.fields.push(FieldUpdate {
assignment: "last_activated = time::now()".to_string(),
param: String::new(),
value: UpdateValue::None,
});
self
}
pub fn add_tag(mut self, tag: impl Into<String>) -> Self {
self.spec.add_tags.push(tag.into());
self
}
pub fn spec(&self) -> &UpdateSpec {
&self.spec
}
pub fn execute(self, ctx: &AgentContext) -> Result<UpdateOutcome> {
if self.spec.is_empty() {
return Ok(UpdateOutcome::no_op());
}
self.store.apply_update(&self.id, &self.spec, ctx)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn summary_field(s: &str) -> FieldUpdate {
FieldUpdate {
assignment: "summary = $set_summary".to_string(),
param: "set_summary".to_string(),
value: UpdateValue::Str(s.to_string()),
}
}
fn resonance_field(r: i64) -> FieldUpdate {
FieldUpdate {
assignment: "resonance = $set_resonance".to_string(),
param: "set_resonance".to_string(),
value: UpdateValue::Int(r),
}
}
#[test]
fn empty_spec_is_empty_and_has_no_columns() {
let spec = UpdateSpec::default();
assert!(spec.is_empty());
assert!(!spec.has_column_updates());
assert_eq!(spec.set_clause_columns(), "");
}
#[test]
fn single_field_sets_only_that_column() {
let spec = UpdateSpec {
fields: vec![summary_field("hello")],
add_tags: vec![],
};
let set = spec.set_clause_columns();
assert_eq!(set, "summary = $set_summary");
assert!(!set.contains("resonance"));
assert!(!set.contains("activation_count"));
assert!(!set.contains("last_activated"));
}
#[test]
fn multiple_fields_compose_in_order() {
let spec = UpdateSpec {
fields: vec![summary_field("hi"), resonance_field(8)],
add_tags: vec![],
};
assert_eq!(
spec.set_clause_columns(),
"summary = $set_summary, resonance = $set_resonance"
);
assert!(spec.has_column_updates());
assert!(!spec.is_empty());
}
#[test]
fn unset_fields_never_appear_in_set_clause() {
let spec = UpdateSpec {
fields: vec![resonance_field(3)],
add_tags: vec![],
};
let set = spec.set_clause_columns();
assert_eq!(set, "resonance = $set_resonance");
for forbidden in ["summary", "activation_count", "last_activated"] {
assert!(
!set.contains(forbidden),
"unset column `{forbidden}` leaked into SET clause: {set}"
);
}
}
#[test]
fn tag_only_spec_has_no_column_updates_but_is_not_empty() {
let spec = UpdateSpec {
fields: vec![],
add_tags: vec!["rust".to_string()],
};
assert!(!spec.is_empty(), "tag-only spec is not empty");
assert!(
!spec.has_column_updates(),
"tag-only spec must not trigger a column SET"
);
assert_eq!(spec.set_clause_columns(), "");
}
#[test]
fn no_value_field_binds_nothing() {
let spec = UpdateSpec {
fields: vec![FieldUpdate {
assignment: "last_activated = time::now()".to_string(),
param: String::new(),
value: UpdateValue::None,
}],
add_tags: vec![],
};
assert_eq!(spec.set_clause_columns(), "last_activated = time::now()");
assert!(spec.fields[0].param.is_empty());
assert!(matches!(spec.fields[0].value, UpdateValue::None));
}
}