refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture
Transform the single binary crate into a 9-crate workspace monorepo: - Root Cargo.toml as [workspace] manager with resolver = "2" - zesdex-entities: Domain entity types (session, settings, store, message, etc.) - zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard) - zesdex-dto: Data Transfer Objects for LLM provider API communication - zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol) - zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure) - zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure) - zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting) - zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2) - zesdex-backend: Main binary entry point + seed/migrate binaries - DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates - Remove dead root src/ and src-misc/ directories All crate re-exports maintain backward compatibility with original crate::model::*, crate::dto::*, crate::ipc::* module paths. Feature crates enforce strict layer separation: domain -> application -> infrastructure with generic trait-based dependency injection.
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! SQLite database connection pool initialisation and schema migrations.
|
||||
//!
|
||||
//! Uses `r2d2` + `r2d2_sqlite` for connection pooling with the same
|
||||
//! `rusqlite` backend the rest of the project uses, avoiding native
|
||||
//! library conflicts between `rusqlite` and `sqlx`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A shared SQLite connection wrapped for thread-safe access.
|
||||
/// Uses a simple Mutex-guarded connection rather than a full pool,
|
||||
/// since the daemon is single-threaded for database operations.
|
||||
#[derive(Clone)]
|
||||
pub struct DbConn {
|
||||
conn: Arc<Mutex<rusqlite::Connection>>,
|
||||
}
|
||||
|
||||
impl DbConn {
|
||||
/// Execute a closure with a reference to the underlying connection.
|
||||
pub fn with<F, T>(&self, f: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce(&rusqlite::Connection) -> Result<T>,
|
||||
{
|
||||
let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("db lock poisoned: {e}"))?;
|
||||
f(&conn)
|
||||
}
|
||||
}
|
||||
|
||||
/// Embedded SQL schema for all zesdex tables.
|
||||
///
|
||||
/// Uses `CREATE TABLE IF NOT EXISTS` so repeated runs are idempotent.
|
||||
const SCHEMA_SQL: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
model TEXT NOT NULL DEFAULT '',
|
||||
workspace_roots TEXT NOT NULL DEFAULT '[]',
|
||||
message_count INTEGER NOT NULL DEFAULT 0,
|
||||
token_count INTEGER NOT NULL DEFAULT 0,
|
||||
archived INTEGER NOT NULL DEFAULT 0,
|
||||
summary TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
name TEXT PRIMARY KEY,
|
||||
data TEXT NOT NULL DEFAULT '{}',
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS edit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
entry TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_edit_logs_session
|
||||
ON edit_logs (session_id);
|
||||
"#;
|
||||
|
||||
/// Initialise a shared SQLite connection at the given path.
|
||||
///
|
||||
/// Opens (or creates) the database, enables WAL mode, and returns a
|
||||
/// thread-safe `DbConn` handle.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the database cannot be opened or created.
|
||||
pub fn init_db(db_path: &str) -> Result<DbConn> {
|
||||
let conn = rusqlite::Connection::open(db_path)
|
||||
.with_context(|| format!("failed to open SQLite database at '{db_path}'"))?;
|
||||
|
||||
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
|
||||
conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
|
||||
|
||||
tracing::info!("connected to SQLite database at '{db_path}'");
|
||||
Ok(DbConn {
|
||||
conn: Arc::new(Mutex::new(conn)),
|
||||
})
|
||||
}
|
||||
|
||||
/// Run embedded SQL schema migrations.
|
||||
///
|
||||
/// Executes the [`SCHEMA_SQL`] string which creates all tables using
|
||||
/// `CREATE TABLE IF NOT EXISTS`, making it safe to call on every startup.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if any SQL statement fails.
|
||||
pub fn run_migrations(db: &DbConn) -> Result<()> {
|
||||
db.with(|conn| {
|
||||
conn.execute_batch(SCHEMA_SQL)
|
||||
.context("failed to execute database schema migrations")
|
||||
})?;
|
||||
tracing::info!("database schema migrations applied");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_init_db_and_migrate() {
|
||||
let tmp = std::env::temp_dir().join(format!("zesdex-test-db-{}", uuid::Uuid::new_v4()));
|
||||
let db_path = tmp.to_str().unwrap().to_string();
|
||||
|
||||
let db = init_db(&db_path).unwrap();
|
||||
run_migrations(&db).unwrap();
|
||||
|
||||
// Verify sessions table exists
|
||||
db.with(|conn| {
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 0);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let _ = std::fs::remove_file(&db_path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! JWT token utilities for HMAC-SHA256 / HS256 signing and verification.
|
||||
//!
|
||||
//! Uses the `jsonwebtoken` crate under the hood.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Standard JWT claims with optional session binding.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct JwtClaims {
|
||||
/// Subject (usually a user or session identifier).
|
||||
pub sub: String,
|
||||
/// Expiration time (UNIX epoch seconds).
|
||||
pub exp: u64,
|
||||
/// Issued-at time (UNIX epoch seconds).
|
||||
pub iat: u64,
|
||||
/// Optional session id for binding the token to a specific session.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl JwtClaims {
|
||||
/// Create a new set of claims with the current time as `iat` and the
|
||||
/// given `exp` offset.
|
||||
///
|
||||
/// * `sub` — subject identifier.
|
||||
/// * `exp` — absolute expiry as a UNIX timestamp (seconds).
|
||||
/// * `session_id` — optional session binding.
|
||||
pub fn new(sub: String, exp: u64, session_id: Option<String>) -> Self {
|
||||
let iat = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
Self {
|
||||
sub,
|
||||
exp,
|
||||
iat,
|
||||
session_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a set of claims into a JWT string using HS256.
|
||||
///
|
||||
/// * `secret` — HMAC secret key (at least 32 bytes recommended).
|
||||
/// * `claims` — the claims to encode and sign.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if encoding or signing fails (e.g. malformed secret
|
||||
/// or serialisation error).
|
||||
pub fn create_token(secret: &str, claims: JwtClaims) -> Result<String> {
|
||||
let header = jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256);
|
||||
let key = jsonwebtoken::EncodingKey::from_secret(secret.as_bytes());
|
||||
jsonwebtoken::encode(&header, &claims, &key)
|
||||
.context("failed to encode JWT")
|
||||
}
|
||||
|
||||
/// Verify a JWT string and return its claims.
|
||||
///
|
||||
/// * `secret` — the same HMAC secret used to sign the token.
|
||||
/// * `token` — the encoded JWT string.
|
||||
///
|
||||
/// Validation includes signature verification and expiration check.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the token is malformed, expired, or has an invalid
|
||||
/// signature.
|
||||
pub fn verify_token(secret: &str, token: &str) -> Result<JwtClaims> {
|
||||
let mut validation = jsonwebtoken::Validation::new(jsonwebtoken::Algorithm::HS256);
|
||||
validation.validate_exp = true;
|
||||
validation.required_spec_claims = ["sub", "exp", "iat"]
|
||||
.iter()
|
||||
.map(|&s| s.to_string())
|
||||
.collect();
|
||||
|
||||
let key = jsonwebtoken::DecodingKey::from_secret(secret.as_bytes());
|
||||
let token_data = jsonwebtoken::decode::<JwtClaims>(token, &key, &validation)
|
||||
.context("failed to verify JWT")?;
|
||||
Ok(token_data.claims)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TEST_SECRET: &str = "this-is-a-test-secret-that-is-at-least-32-bytes-long!";
|
||||
|
||||
#[test]
|
||||
fn test_create_and_verify_token() {
|
||||
let claims = JwtClaims::new(
|
||||
"test-user".to_string(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
+ 3600,
|
||||
Some("sess-123".to_string()),
|
||||
);
|
||||
let token = create_token(TEST_SECRET, claims.clone()).unwrap();
|
||||
let verified = verify_token(TEST_SECRET, &token).unwrap();
|
||||
assert_eq!(verified.sub, claims.sub);
|
||||
assert_eq!(verified.session_id, claims.session_id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_expired_token_fails() {
|
||||
let claims = JwtClaims {
|
||||
sub: "expired-user".to_string(),
|
||||
exp: 1, // expired long ago
|
||||
iat: 1,
|
||||
session_id: None,
|
||||
};
|
||||
let token = create_token(TEST_SECRET, claims).unwrap();
|
||||
let result = verify_token(TEST_SECRET, &token);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_invalid_signature_fails() {
|
||||
let claims = JwtClaims::new(
|
||||
"test-user".to_string(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs()
|
||||
+ 3600,
|
||||
None,
|
||||
);
|
||||
let token = create_token(TEST_SECRET, claims).unwrap();
|
||||
let result = verify_token("wrong-secret", &token);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
pub mod database;
|
||||
pub mod jwt;
|
||||
pub mod password;
|
||||
pub mod state;
|
||||
@@ -0,0 +1,86 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Argon2 password hashing and verification utilities.
|
||||
//!
|
||||
//! Uses the `argon2` crate (Argon2id variant) with default parameters,
|
||||
//! which provide a good security / performance trade-off for interactive
|
||||
//! authentication.
|
||||
|
||||
use anyhow::Result;
|
||||
use argon2::{
|
||||
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
|
||||
Argon2,
|
||||
};
|
||||
use rand_core::OsRng;
|
||||
|
||||
/// Hash a plaintext password using Argon2id with a random salt.
|
||||
///
|
||||
/// The returned string is in the PHC string format
|
||||
/// (`$argon2id$v=19$...`) and can be stored directly in the database.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the argon2 library fails (extremely rare —
|
||||
/// typically indicates an OOM or system-level crypto failure).
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let hash = argon2
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|e| anyhow::anyhow!("failed to hash password: {e}"))?;
|
||||
Ok(hash.to_string())
|
||||
}
|
||||
|
||||
/// Verify a plaintext password against a previously-hashed PHC string.
|
||||
///
|
||||
/// Returns `Ok(true)` if the password matches, `Ok(false)` if it does not,
|
||||
/// and `Err` if the hash string is malformed.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the hash string is not a valid PHC string or if
|
||||
/// the argon2 library encounters an internal failure.
|
||||
pub fn verify_password(password: &str, hash: &str) -> Result<bool> {
|
||||
let parsed_hash =
|
||||
PasswordHash::new(hash).map_err(|e| anyhow::anyhow!("failed to parse password hash: {e}"))?;
|
||||
let argon2 = Argon2::default();
|
||||
Ok(argon2
|
||||
.verify_password(password.as_bytes(), &parsed_hash)
|
||||
.is_ok())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_hash_and_verify() {
|
||||
let password = "my-secure-password-123!";
|
||||
let hash = hash_password(password).unwrap();
|
||||
assert!(verify_password(password, &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_password_fails() {
|
||||
let hash = hash_password("correct-password").unwrap();
|
||||
assert!(!verify_password("wrong-password", &hash).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_hashes_are_different() {
|
||||
let h1 = hash_password("same-password").unwrap();
|
||||
let h2 = hash_password("same-password").unwrap();
|
||||
// Different salts → different hashes.
|
||||
assert_ne!(h1, h2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_invalid_hash_returns_error() {
|
||||
let result = verify_password("password", "not-a-valid-hash");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! 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::settings::Settings;
|
||||
use zesdex_cms::infrastructure::persistence::{
|
||||
JsonAppConfigRepository, JsonConversationRepository, JsonSettingsRepository,
|
||||
MarkdownMemoryRepository,
|
||||
};
|
||||
use zesdex_entities::seaorm::common::store::Store;
|
||||
use zesdex_cms::domain::repository::{
|
||||
AppConfigRepository, ConversationRepository, MemoryRepository, SettingsRepository,
|
||||
};
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user