//! Shared test fixtures for the knowledge crate: an in-memory //! `IKnowledgeRepository`, a no-op event broadcaster, and a service factory. use std::path::Path; use std::sync::atomic::{AtomicI64, Ordering}; use std::sync::{Arc, Mutex}; use nomifun_common::TimestampMs; use nomifun_db::models::{CreateKnowledgeTagParams, KnowledgeBaseRow, KnowledgeBindingRow, KnowledgeTagRow, UpdateKnowledgeTagParams}; use nomifun_db::{DbError, IKnowledgeRepository}; use crate::events::KnowledgeEventEmitter; use crate::service::KnowledgeService; #[derive(Default)] pub(crate) struct MemRepo { pub bases: Mutex>, /// Each binding row plus its ordered `kb_id` list (mirrors the /// `knowledge_binding_bases` junction). pub bindings: Mutex)>>, next_binding_id: AtomicI64, pub tags: Mutex>, } /// Build a binding row with the `target_id` written to the column selected by /// `target_kind` (the in-memory analogue of the CHECK-constrained columns). fn binding_row( binding_id: i64, kind: &str, target_id: &str, enabled: bool, writeback: bool, writeback_mode: &str, writeback_eagerness: &str, channel_write_enabled: bool, updated_at: TimestampMs, ) -> KnowledgeBindingRow { let mut row = KnowledgeBindingRow { binding_id, target_kind: kind.to_owned(), target_workpath: None, target_conv_id: None, target_term_id: None, target_companion_id: None, enabled, writeback, writeback_mode: writeback_mode.to_owned(), writeback_eagerness: writeback_eagerness.to_owned(), channel_write_enabled, updated_at, }; // `target_conv_id` / `target_term_id` are integer FKs now; `target_workpath` // / `target_companion_id` stay string keys. Route the test id to the right column // with the right type. match kind { "workpath" => row.target_workpath = Some(target_id.to_owned()), "conversation" => row.target_conv_id = target_id.parse::().ok(), "terminal" => row.target_term_id = target_id.parse::().ok(), "companion" => row.target_companion_id = Some(target_id.to_owned()), _ => {} } row } #[async_trait::async_trait] impl IKnowledgeRepository for MemRepo { async fn insert_base(&self, row: &KnowledgeBaseRow) -> Result<(), DbError> { self.bases.lock().unwrap().push(row.clone()); Ok(()) } async fn update_base(&self, row: &KnowledgeBaseRow) -> Result<(), DbError> { let mut bases = self.bases.lock().unwrap(); match bases.iter_mut().find(|r| r.id == row.id) { Some(r) => { *r = row.clone(); Ok(()) } None => Err(DbError::NotFound(row.id.clone())), } } async fn delete_base(&self, id: &str) -> Result<(), DbError> { let mut bases = self.bases.lock().unwrap(); let before = bases.len(); bases.retain(|r| r.id != id); if bases.len() == before { Err(DbError::NotFound(id.to_owned())) } else { Ok(()) } } async fn get_base(&self, id: &str) -> Result, DbError> { Ok(self.bases.lock().unwrap().iter().find(|r| r.id == id).cloned()) } async fn list_bases(&self) -> Result, DbError> { Ok(self.bases.lock().unwrap().clone()) } async fn get_binding( &self, kind: &str, id: &str, ) -> Result)>, DbError> { Ok(self .bindings .lock() .unwrap() .iter() .find(|(b, _)| b.target_kind == kind && b.target_id().as_deref() == Some(id)) .cloned()) } async fn set_binding( &self, kind: &str, id: &str, kb_ids: &[String], enabled: bool, writeback: bool, writeback_mode: &str, writeback_eagerness: &str, channel_write_enabled: bool, updated_at: TimestampMs, ) -> Result { let mut bindings = self.bindings.lock().unwrap(); // Reuse the existing binding_id on upsert; allocate a fresh one // otherwise (the surrogate-key analogue of the real table). let binding_id = bindings .iter() .find(|(b, _)| b.target_kind == kind && b.target_id().as_deref() == Some(id)) .map(|(b, _)| b.binding_id) .unwrap_or_else(|| self.next_binding_id.fetch_add(1, Ordering::SeqCst) + 1); bindings.retain(|(b, _)| !(b.target_kind == kind && b.target_id().as_deref() == Some(id))); let row = binding_row(binding_id, kind, id, enabled, writeback, writeback_mode, writeback_eagerness, channel_write_enabled, updated_at); bindings.push((row, kb_ids.to_vec())); Ok(binding_id) } async fn delete_binding(&self, kind: &str, id: &str) -> Result<(), DbError> { self.bindings .lock() .unwrap() .retain(|(b, _)| !(b.target_kind == kind && b.target_id().as_deref() == Some(id))); Ok(()) } async fn list_bindings_using_kb(&self, kb_id: &str) -> Result, DbError> { let mut rows: Vec = self .bindings .lock() .unwrap() .iter() .filter(|(_, kb_ids)| kb_ids.iter().any(|k| k == kb_id)) .map(|(b, _)| b.clone()) .collect(); rows.sort_by(|a, b| a.target_kind.cmp(&b.target_kind).then(a.binding_id.cmp(&b.binding_id))); Ok(rows) } async fn list_knowledge_tags(&self) -> Result, DbError> { let mut tags = self.tags.lock().unwrap().clone(); tags.sort_by(|a, b| a.sort_order.cmp(&b.sort_order).then(a.key.cmp(&b.key))); Ok(tags) } async fn create_knowledge_tag(&self, params: CreateKnowledgeTagParams) -> Result<(), DbError> { self.tags.lock().unwrap().push(KnowledgeTagRow { key: params.key, label: params.label, color: params.color, sort_order: params.sort_order, created_at: params.created_at, }); Ok(()) } async fn update_knowledge_tag(&self, key: &str, params: UpdateKnowledgeTagParams) -> Result<(), DbError> { let mut tags = self.tags.lock().unwrap(); match tags.iter_mut().find(|t| t.key == key) { Some(t) => { if let Some(label) = params.label { t.label = label; } if let Some(color) = params.color { t.color = color; } if let Some(sort_order) = params.sort_order { t.sort_order = sort_order; } Ok(()) } None => Err(DbError::NotFound(format!("knowledge tag {key}"))), } } async fn delete_knowledge_tag(&self, key: &str) -> Result<(), DbError> { let mut tags = self.tags.lock().unwrap(); let before = tags.len(); tags.retain(|t| t.key != key); if tags.len() == before { Err(DbError::NotFound(format!("knowledge tag {key}"))) } else { Ok(()) } } } pub(crate) struct NoopBroadcaster; impl nomifun_realtime::EventBroadcaster for NoopBroadcaster { fn broadcast(&self, _event: nomifun_api_types::WebSocketMessage) {} } pub(crate) fn make_service(data_dir: &Path) -> KnowledgeService { KnowledgeService::new( Arc::new(MemRepo::default()), data_dir, KnowledgeEventEmitter::new(Arc::new(NoopBroadcaster)), ) }