//! Application state initialisation and wiring. //! //! This module acts as the composition root for the zesdex daemon (and //! any other binary that needs a full set of services). It: //! //! 1. Defines [`IamServiceProvider`] and [`CmsServiceProvider`] trait //! objects so callers depend on interfaces, not generics. //! 2. Provides default implementations that wire together the //! infrastructure/repository adapters with the domain service traits. //! 3. Exposes [`initialize_app_context`] as a one-call entry point. use std::path::PathBuf; use std::sync::Arc; use anyhow::{Context, Result}; use uuid::Uuid; use zesdex_cms::domain::app_config::ProviderConfig; use zesdex_cms::domain::conversation::Conversation; use zesdex_cms::domain::memory::Memory; use zesdex_cms::domain::repository::{ AppConfigRepository, ConversationRepository, MemoryRepository, SettingsRepository, }; use zesdex_cms::domain::settings::Settings; use zesdex_cms::infrastructure::persistence::{ JsonAppConfigRepository, JsonConversationRepository, JsonSettingsRepository, MarkdownMemoryRepository, }; use zesdex_entities::domain::common::store::Store; use zesdex_iam::domain::repository::SessionRepository; use zesdex_iam::domain::session::Session; use zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository; use crate::database; // --------------------------------------------------------------------------- // Trait definitions // --------------------------------------------------------------------------- /// Session-management service provider. /// /// Abstracts session CRUD behind a trait object so the HTTP / CLI layers /// do not depend on concrete repository generics. pub trait IamServiceProvider: Send + Sync { /// Create a new session with a generated UUID and default fields. fn create_session(&self) -> Result; /// List all available sessions. fn list_all(&self) -> Result>; /// Archive a session by id (sets `archived = true`). fn archive_session(&self, id: &str) -> Result<()>; } /// CMS (content-management) service provider. /// /// Combines settings, conversation, and memory operations behind a single /// trait object. pub trait CmsServiceProvider: Send + Sync { // -- Settings -- /// Load current settings from the default store. fn load_settings(&self) -> Result; /// Persist updated settings. fn save_settings(&self, settings: &Settings) -> Result<()>; /// Update the provider configuration (name and details). fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<()>; // -- Conversations -- /// Load a conversation for the given session id. fn load_conversation(&self, session_id: &str) -> Result; /// Persist a conversation. fn save_conversation(&self, conv: &Conversation) -> Result<()>; // -- Memories -- /// List all memory slugs. fn list_memories(&self) -> Result>; /// Save (create or update) a memory. fn save_memory(&self, memory: &Memory) -> Result<()>; /// Delete a memory by name. fn delete_memory(&self, name: &str) -> Result<()>; } // --------------------------------------------------------------------------- // Default IAM provider // --------------------------------------------------------------------------- /// Default [`IamServiceProvider`] backed by the filesystem session /// repository. pub struct DefaultIamServiceProvider { session_repo: FileSystemSessionRepository, base_dir: PathBuf, } impl DefaultIamServiceProvider { /// Create a new provider using the given data directory. pub fn new(base_dir: PathBuf) -> Self { Self { session_repo: FileSystemSessionRepository::new(), base_dir, } } } impl IamServiceProvider for DefaultIamServiceProvider { fn create_session(&self) -> Result { let id = Uuid::new_v4().to_string(); let session = Session::new(id, "New Session".to_string()); self.session_repo .save_session(&self.base_dir, &session) .context("failed to persist new session")?; Ok(session) } fn list_all(&self) -> Result> { self.session_repo .list_sessions(&self.base_dir) .context("failed to list sessions") } fn archive_session(&self, id: &str) -> Result<()> { let mut session = self .session_repo .load_session(&self.base_dir, id) .with_context(|| format!("session not found: {id}"))?; session.archived = true; session.updated_at = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as i64; self.session_repo .save_session(&self.base_dir, &session) .context("failed to save archived session") } } // --------------------------------------------------------------------------- // Default CMS provider // --------------------------------------------------------------------------- /// Default [`CmsServiceProvider`] backed by filesystem repositories. pub struct DefaultCmsServiceProvider { settings_repo: JsonSettingsRepository, app_config_repo: JsonAppConfigRepository, conversation_repo: JsonConversationRepository, memory_repo: MarkdownMemoryRepository, base_dir: PathBuf, memory_dir: PathBuf, } impl DefaultCmsServiceProvider { /// Create a new provider. pub fn new(store: &Store) -> Self { Self { settings_repo: JsonSettingsRepository::new(), app_config_repo: JsonAppConfigRepository::new(), conversation_repo: JsonConversationRepository::new(), memory_repo: MarkdownMemoryRepository::new(), base_dir: store.base_dir.clone(), memory_dir: store.memory_dir.clone(), } } /// Compute the session directory for a given session id. fn session_dir(&self, session_id: &str) -> PathBuf { self.base_dir.join("sessions").join(session_id) } } impl CmsServiceProvider for DefaultCmsServiceProvider { // -- Settings -- fn load_settings(&self) -> Result { self.settings_repo .load(&self.base_dir) .context("failed to load settings") } fn save_settings(&self, settings: &Settings) -> Result<()> { self.settings_repo .save(&self.base_dir, settings) .context("failed to save settings") } fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<()> { let mut app_config = self .app_config_repo .load(&self.base_dir) .context("failed to load app config")?; app_config .providers .insert(name.to_string(), config.clone()); self.app_config_repo .save(&self.base_dir, &app_config) .context("failed to save app config after provider update") } // -- Conversations -- fn load_conversation(&self, session_id: &str) -> Result { let dir = self.session_dir(session_id); self.conversation_repo .load(&dir) .with_context(|| format!("failed to load conversation for session '{session_id}'")) } fn save_conversation(&self, conv: &Conversation) -> Result<()> { let dir = self.session_dir(&conv.session_id); self.conversation_repo.save(&dir, conv).with_context(|| { format!( "failed to save conversation for session '{}'", conv.session_id ) }) } // -- Memories -- fn list_memories(&self) -> Result> { self.memory_repo .list(&self.memory_dir) .context("failed to list memories") } fn save_memory(&self, memory: &Memory) -> Result<()> { self.memory_repo .save(&self.memory_dir, memory) .with_context(|| format!("failed to save memory '{}'", memory.name)) } fn delete_memory(&self, name: &str) -> Result<()> { self.memory_repo .delete(&self.memory_dir, name) .with_context(|| format!("failed to delete memory '{name}'")) } } // --------------------------------------------------------------------------- // AppContext // --------------------------------------------------------------------------- /// Aggregated shared state for the zesdex daemon (or any binary using the /// full service stack). pub struct AppContext { /// Filesystem store (resolved paths for all data directories). pub store: Store, /// IAM service provider (session management). pub iam_service: Box, /// CMS service provider (settings, conversations, memories). pub cms_service: Box, /// SQLite database connection. pub db: database::DbConn, /// JWT HMAC secret used to sign / verify tokens. pub jwt_secret: String, } impl AppContext { /// Return a shared `Arc` for use with Axum's /// `axum::extract::State`. pub fn into_arc(self) -> Arc { Arc::new(self) } } // --------------------------------------------------------------------------- // Initialisation // --------------------------------------------------------------------------- /// Wire together the full application stack and return an [`AppContext`]. /// /// Steps: /// 1. Initialise [`Store`] and create all data directories. /// 2. Connect to the SQLite database and run migrations. /// 3. Instantiate the IAM and CMS service providers. /// 4. Determine the JWT secret (env var `ZESDEX_JWT_SECRET` or a default). /// /// # Errors /// /// Returns an error if any step fails (directory creation, DB connection, /// migration execution, etc.). pub fn initialize_app_context() -> Result { // -- Store -- let store = Store::new(); store .ensure_dirs() .context("failed to create store directories")?; // -- Database -- let db_path = store.base_dir.join("zesdex.db"); let db_path_str = db_path .to_str() .ok_or_else(|| anyhow::anyhow!("invalid db path: {}", db_path.display()))?; let db = database::init_db(db_path_str).context("failed to initialise database")?; database::run_migrations(&db).context("failed to run database migrations")?; // -- Services -- let iam_service: Box = Box::new(DefaultIamServiceProvider::new(store.base_dir.clone())); let cms_service: Box = Box::new(DefaultCmsServiceProvider::new(&store)); // -- JWT secret -- let jwt_secret = std::env::var("ZESDEX_JWT_SECRET") .unwrap_or_else(|_| "zesdex-dev-secret-do-not-use-in-production".to_string()); Ok(AppContext { store, iam_service, cms_service, db, jwt_secret, }) } #[cfg(test)] mod tests { use super::*; #[test] fn test_default_iam_provider_list_all_empty() { let tmp = std::env::temp_dir().join(format!("zesdex-test-iam-{}", Uuid::new_v4())); std::fs::create_dir_all(&tmp).unwrap(); let provider = DefaultIamServiceProvider::new(tmp.clone()); let sessions = provider.list_all().unwrap(); assert!(sessions.is_empty()); let _ = std::fs::remove_dir_all(&tmp); } #[test] fn test_default_iam_provider_create_and_list() { let tmp = std::env::temp_dir().join(format!("zesdex-test-iam-{}", Uuid::new_v4())); std::fs::create_dir_all(&tmp).unwrap(); let provider = DefaultIamServiceProvider::new(tmp.clone()); let session = provider.create_session().unwrap(); assert!(!session.id.is_empty()); let sessions = provider.list_all().unwrap(); assert_eq!(sessions.len(), 1); let _ = std::fs::remove_dir_all(&tmp); } #[test] fn test_default_cms_provider_default_settings() { let tmp = std::env::temp_dir().join(format!("zesdex-test-cms-{}", Uuid::new_v4())); std::fs::create_dir_all(&tmp).unwrap(); let mut store = Store::new(); store.base_dir = tmp.clone(); store.memory_dir = tmp.join("memory"); let provider = DefaultCmsServiceProvider::new(&store); let settings = provider.load_settings().unwrap(); assert_eq!(settings.provider, "zen"); let _ = std::fs::remove_dir_all(&tmp); } }