Files
zesdex/crates/zesdex-infra/src/state.rs
T
asepharyanaandClaude Opus 4.8 7d8487cefb refactor: consolidate #![allow(clippy::cast_*)] to crate roots
Add the cast-allow block to zesdex-entities/src/lib.rs and
zesdex-utils/src/lib.rs (which lacked it), then remove from
65 sub-files across all 8 crates. Build and all 223 tests
continue to pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-18 03:18:27 +07:00

355 lines
12 KiB
Rust

//! 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<Session>;
/// List all available sessions.
fn list_all(&self) -> Result<Vec<Session>>;
/// 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<Settings>;
/// 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<Conversation>;
/// Persist a conversation.
fn save_conversation(&self, conv: &Conversation) -> Result<()>;
// -- Memories --
/// List all memory slugs.
fn list_memories(&self) -> Result<Vec<String>>;
/// 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<Session> {
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<Vec<Session>> {
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<Settings> {
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<Conversation> {
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<Vec<String>> {
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<dyn IamServiceProvider>,
/// CMS service provider (settings, conversations, memories).
pub cms_service: Box<dyn CmsServiceProvider>,
/// 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<AppContext>` for use with Axum's
/// `axum::extract::State`.
pub fn into_arc(self) -> Arc<Self> {
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<AppContext> {
// -- 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<dyn IamServiceProvider> =
Box::new(DefaultIamServiceProvider::new(store.base_dir.clone()));
let cms_service: Box<dyn CmsServiceProvider> = 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);
}
}