Refactor error handling in IAM and CMS crates

- Introduced `RepositoryError` and `ServiceError` enums in both IAM and CMS domains for better error management.
- Updated domain traits and services to return specific error types instead of `anyhow::Result`.
- Enhanced session and OAuth repository implementations to handle errors more explicitly.
- Refactored session service methods to return `Result<T, ServiceError>` for improved error handling.
- Updated HTTP handlers to utilize the new error types.
- Modified password hashing functions to run in a blocking context using `tokio::task::spawn_blocking`.
- Added tests for new error handling mechanisms and async password functions.
This commit is contained in:
asepharyana
2026-07-20 06:14:23 +07:00
parent 5aaedbf787
commit ab1a54b72e
47 changed files with 630 additions and 347 deletions
@@ -12,10 +12,10 @@
use std::path::PathBuf;
use anyhow::{Context, Result};
use tracing;
use crate::domain::conversation::{ChatMessage, Conversation};
use crate::domain::error::ServiceError;
use crate::domain::repository::ConversationRepository;
use crate::domain::service::ConversationService;
@@ -59,25 +59,27 @@ impl<R: ConversationRepository> ConversationService for ConversationServiceImpl<
/// Load a conversation from disk for the given session.
///
/// Flow: resolve session dir → delegate to repo.load() → wrap error with context.
fn load_conversation(&self, session_id: &str) -> Result<Conversation> {
fn load_conversation(&self, session_id: &str) -> Result<Conversation, ServiceError> {
tracing::debug!("loading conversation for session {session_id}");
let dir = self.session_dir(session_id);
self.repo
.load(&dir)
.with_context(|| format!("failed to load conversation for session '{session_id}'"))
self.repo.load(&dir).map_err(|e| {
ServiceError::Other(format!(
"failed to load conversation for session '{session_id}': {e}"
))
})
}
/// Persist a conversation to disk.
///
/// Flow: resolve session dir from conv.session_id → delegate to repo.save() → wrap error.
fn save_conversation(&self, conv: &Conversation) -> Result<()> {
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError> {
tracing::debug!("saving conversation for session {}", conv.session_id);
let dir = self.session_dir(&conv.session_id);
self.repo.save(&dir, conv).with_context(|| {
format!(
"failed to save conversation for session '{}'",
self.repo.save(&dir, conv).map_err(|e| {
ServiceError::Other(format!(
"failed to save conversation for session '{}': {e}",
conv.session_id
)
))
})
}
@@ -88,15 +90,15 @@ impl<R: ConversationRepository> ConversationService for ConversationServiceImpl<
/// ## Note
/// This is a write-through operation: the message is appended to the
/// in-memory `Conversation` and then the full conversation is persisted.
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<()> {
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<(), ServiceError> {
tracing::debug!("adding message to session {}", conv.session_id);
conv.push(msg); // append message to in-memory conversation
let dir = self.session_dir(&conv.session_id);
self.repo.save(&dir, conv).with_context(|| {
format!(
"failed to persist conversation after adding message for session '{}'",
self.repo.save(&dir, conv).map_err(|e| {
ServiceError::Other(format!(
"failed to persist conversation after adding message for session '{}': {e}",
conv.session_id
)
))
})
}
}
@@ -12,9 +12,9 @@
use std::path::PathBuf;
use anyhow::{Context, Result};
use tracing;
use crate::domain::error::ServiceError;
use crate::domain::memory::Memory;
use crate::domain::repository::MemoryRepository;
use crate::domain::service::MemoryService;
@@ -52,30 +52,30 @@ impl<R: MemoryRepository> MemoryService for MemoryServiceImpl<R> {
/// List all stored memory names.
///
/// Flow: delegate to repo.list() → wrap error with context.
fn list_memories(&self) -> Result<Vec<String>> {
fn list_memories(&self) -> Result<Vec<String>, ServiceError> {
tracing::debug!("listing memories from {:?}", self.memory_dir);
self.repo
.list(&self.memory_dir)
.context("failed to list memories")
self.repo.list(&self.memory_dir).map_err(|e| {
ServiceError::Other(format!("failed to list memories: {e}"))
})
}
/// Persist a memory to disk.
///
/// Flow: delegate to repo.save() → wrap error with memory name context.
fn save_memory(&self, memory: &Memory) -> Result<()> {
fn save_memory(&self, memory: &Memory) -> Result<(), ServiceError> {
tracing::debug!("saving memory '{}'", memory.name);
self.repo
.save(&self.memory_dir, memory)
.with_context(|| format!("failed to save memory '{}'", memory.name))
self.repo.save(&self.memory_dir, memory).map_err(|e| {
ServiceError::Other(format!("failed to save memory '{}': {e}", memory.name))
})
}
/// Delete a memory by name.
///
/// Flow: delegate to repo.delete() → wrap error with memory name context.
fn delete_memory(&self, name: &str) -> Result<()> {
fn delete_memory(&self, name: &str) -> Result<(), ServiceError> {
tracing::debug!("deleting memory '{name}'");
self.repo
.delete(&self.memory_dir, name)
.with_context(|| format!("failed to delete memory '{name}'"))
self.repo.delete(&self.memory_dir, name).map_err(|e| {
ServiceError::Other(format!("failed to delete memory '{name}': {e}"))
})
}
}
@@ -14,10 +14,10 @@
use std::path::PathBuf;
use anyhow::Result;
use tracing;
use crate::domain::app_config::{AppConfig, ProviderConfig};
use crate::domain::error::ServiceError;
use crate::domain::repository::{AppConfigRepository, SettingsRepository};
use crate::domain::service::SettingsService;
use crate::domain::settings::Settings;
@@ -62,17 +62,21 @@ impl<S: SettingsRepository, C: AppConfigRepository> SettingsService for Settings
/// Load application settings from disk.
///
/// Flow: delegate to settings_repo.load() at base_dir.
fn load_settings(&self) -> Result<Settings> {
fn load_settings(&self) -> Result<Settings, ServiceError> {
tracing::debug!("loading settings");
self.settings_repo.load(&self.base_dir)
self.settings_repo.load(&self.base_dir).map_err(|e| {
ServiceError::Other(format!("failed to load settings: {e}"))
})
}
/// Save application settings to disk.
///
/// Flow: delegate to settings_repo.save() at base_dir.
fn save_settings(&self, settings: &Settings) -> Result<()> {
fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError> {
tracing::debug!("saving settings");
self.settings_repo.save(&self.base_dir, settings)
self.settings_repo.save(&self.base_dir, settings).map_err(|e| {
ServiceError::Other(format!("failed to save settings: {e}"))
})
}
/// Update (or insert) a provider configuration in the app config.
@@ -83,7 +87,7 @@ impl<S: SettingsRepository, C: AppConfigRepository> SettingsService for Settings
/// ## Parameters
/// - `name` — provider name (key in the providers map)
/// - `config` — the provider configuration to store
fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<()> {
fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<(), ServiceError> {
tracing::debug!("updating provider '{name}'");
// Load current app config from disk
let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?;
@@ -92,6 +96,8 @@ impl<S: SettingsRepository, C: AppConfigRepository> SettingsService for Settings
.providers
.insert(name.to_string(), config.clone());
// Persist the modified app config
self.app_config_repo.save(&self.base_dir, &app_config)
self.app_config_repo.save(&self.base_dir, &app_config).map_err(|e| {
ServiceError::Other(format!("failed to update provider '{name}': {e}"))
})
}
}
+116
View File
@@ -0,0 +1,116 @@
//! Domain error types for the CMS crate.
//!
//! Typed error enums for repository and service operations.
//! `From` impls connect `std::io::Error` and `serde_json::Error` into
//! `RepositoryError`, and `RepositoryError` into `ServiceError`.
//! Anyhow's blanket `From<E: StdError + Send + Sync + 'static>`
//! covers conversion to `anyhow::Error` for downstream code.
use std::fmt;
// ---------------------------------------------------------------------------
// RepositoryError
// ---------------------------------------------------------------------------
/// Errors from repository / persistence operations in the CMS domain.
#[derive(Debug)]
pub enum RepositoryError {
/// The requested entity does not exist.
NotFound(String),
/// A conflict occurred (e.g. duplicate entry).
Conflict(String),
/// An I/O error occurred during persistence.
Io(std::io::Error),
/// A serialisation / deserialisation error occurred.
Serialization(serde_json::Error),
/// The supplied identifier is invalid (e.g. path traversal attempt).
InvalidId(String),
/// An error that could not be downcast to a specific variant.
Other(String),
}
impl RepositoryError {
/// Convert an `anyhow::Error` to `RepositoryError` by attempting
/// downcast to known inner types.
pub fn from_anyhow(e: anyhow::Error) -> Self {
if let Some(ioe) = e.downcast_ref::<std::io::Error>() {
return RepositoryError::Io(std::io::Error::new(ioe.kind(), ioe.to_string()));
}
RepositoryError::Other(e.to_string())
}
}
impl fmt::Display for RepositoryError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RepositoryError::NotFound(msg) => write!(f, "not found: {msg}"),
RepositoryError::Conflict(msg) => write!(f, "conflict: {msg}"),
RepositoryError::Io(e) => write!(f, "I/O error: {e}"),
RepositoryError::Serialization(e) => write!(f, "serialization error: {e}"),
RepositoryError::InvalidId(msg) => write!(f, "invalid id: {msg}"),
RepositoryError::Other(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for RepositoryError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
RepositoryError::Io(e) => Some(e),
RepositoryError::Serialization(e) => Some(e),
_ => None,
}
}
}
impl From<std::io::Error> for RepositoryError {
fn from(e: std::io::Error) -> Self {
RepositoryError::Io(e)
}
}
impl From<serde_json::Error> for RepositoryError {
fn from(e: serde_json::Error) -> Self {
RepositoryError::Serialization(e)
}
}
// ---------------------------------------------------------------------------
// ServiceError
// ---------------------------------------------------------------------------
/// Errors from service / use-case operations in the CMS domain.
#[derive(Debug)]
pub enum ServiceError {
/// A repository operation failed.
Repository(RepositoryError),
/// The provided input is invalid.
InvalidInput(String),
/// A generic error with a message.
Other(String),
}
impl fmt::Display for ServiceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ServiceError::Repository(e) => write!(f, "repository error: {e}"),
ServiceError::InvalidInput(msg) => write!(f, "invalid input: {msg}"),
ServiceError::Other(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for ServiceError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ServiceError::Repository(e) => Some(e),
_ => None,
}
}
}
impl From<RepositoryError> for ServiceError {
fn from(e: RepositoryError) -> Self {
ServiceError::Repository(e)
}
}
+1
View File
@@ -21,6 +21,7 @@
pub mod app_config;
pub mod conversation;
pub mod edit_log;
pub mod error;
pub mod memory;
pub mod repository;
pub mod service;
+16 -39
View File
@@ -19,11 +19,10 @@
use std::path::Path;
use anyhow::Result;
use super::app_config::AppConfig;
use super::conversation::Conversation;
use super::edit_log::{EditLog, EditLogEntry};
use super::error::RepositoryError;
use super::memory::Memory;
use super::settings::Settings;
@@ -32,14 +31,10 @@ use super::settings::Settings;
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
pub trait SettingsRepository {
/// Load `Settings` from the given base directory.
///
/// Flow: read and deserialize `settings.json` from `base_dir`.
fn load(&self, base_dir: &Path) -> Result<Settings>;
fn load(&self, base_dir: &Path) -> Result<Settings, RepositoryError>;
/// Persist `Settings` to the given base directory.
///
/// Flow: serialize and write `settings.json` to `base_dir`.
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<()>;
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<(), RepositoryError>;
}
/// Persistence contract for `AppConfig` (provider and model configuration).
@@ -47,14 +42,10 @@ pub trait SettingsRepository {
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
pub trait AppConfigRepository {
/// Load `AppConfig` from the given base directory.
///
/// Flow: read and deserialize `app_config.json` from `base_dir`.
fn load(&self, base_dir: &Path) -> Result<AppConfig>;
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError>;
/// Persist `AppConfig` to the given base directory.
///
/// Flow: serialize and write `app_config.json` to `base_dir`.
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<()>;
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError>;
}
/// Persistence contract for `Conversation` (session conversation data).
@@ -62,14 +53,10 @@ pub trait AppConfigRepository {
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
pub trait ConversationRepository {
/// Load a `Conversation` from the given session directory.
///
/// Flow: read and deserialize `conversation.json` from `session_dir`.
fn load(&self, session_dir: &Path) -> Result<Conversation>;
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError>;
/// Persist a `Conversation` to the given session directory.
///
/// Flow: serialize and write `conversation.json` to `session_dir`.
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<()>;
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<(), RepositoryError>;
}
/// Persistence contract for `Memory` (long-term agent memory entries).
@@ -77,16 +64,16 @@ pub trait ConversationRepository {
/// Implementors provide the actual I/O logic (e.g. per-memory markdown files).
pub trait MemoryRepository {
/// List all memory slugs (filenames without extension) in the memory directory.
fn list(&self, memory_dir: &Path) -> Result<Vec<String>>;
fn list(&self, memory_dir: &Path) -> Result<Vec<String>, RepositoryError>;
/// Load a single `Memory` by name from the memory directory.
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory>;
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory, RepositoryError>;
/// Save (create or overwrite) a `Memory` in the memory directory.
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<()>;
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError>;
/// Delete a `Memory` by name from the memory directory.
fn delete(&self, memory_dir: &Path, name: &str) -> Result<()>;
fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError>;
}
/// Persistence contract for rewind-snapshot binary blobs.
@@ -95,25 +82,19 @@ pub trait MemoryRepository {
/// within a session. They capture file snapshots for the "rewind" feature.
pub trait RewindBlobRepository {
/// Store (or overwrite) a binary blob under `blob_key` for this session.
///
/// ## Parameters
/// - `session_dir` — the session directory to store the blob in
/// - `blob_key` — arbitrary caller-supplied key (e.g. tool-call ID)
/// - `data` — raw byte content of the blob
/// - `mime_type` — optional MIME type hint
fn store_blob(
&self,
session_dir: &Path,
blob_key: &str,
data: &[u8],
mime_type: Option<&str>,
) -> anyhow::Result<()>;
) -> Result<(), RepositoryError>;
/// Retrieve a blob's raw bytes by key, or `None` if not found.
fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> anyhow::Result<Option<Vec<u8>>>;
fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> Result<Option<Vec<u8>>, RepositoryError>;
/// List all blob keys for this session, ordered oldest-first.
fn list_blob_keys(&self, session_dir: &Path) -> anyhow::Result<Vec<String>>;
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>, RepositoryError>;
}
/// Persistence contract for `EditLog` (append-only file mutation log).
@@ -122,14 +103,10 @@ pub trait RewindBlobRepository {
/// typically persisted to a file for audit and potential undo.
pub trait EditLogRepository {
/// Open (or initialise) the edit log for a session directory.
///
/// Flow: load existing log file if present, or create an empty log.
fn open(&self, session_dir: &Path) -> Result<EditLog>;
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError>;
/// Append one entry to the log and persist immediately (write-through).
///
/// Flow: push entry to in-memory log → append to disk file.
fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<()>;
fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<(), RepositoryError>;
/// Return a cloned copy of all in-memory entries for inspection.
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry>;
+10 -16
View File
@@ -14,49 +14,43 @@
//! type parameters. Infrastructure adapters depend only on these service
//! traits, never on concrete implementations.
use anyhow::Result;
use super::conversation::{ChatMessage, Conversation};
use super::error::ServiceError;
use super::memory::Memory;
use super::settings::Settings;
/// Use-cases for application settings.
pub trait SettingsService {
/// Load the current `Settings` from the default store location.
fn load_settings(&self) -> Result<Settings>;
fn load_settings(&self) -> Result<Settings, ServiceError>;
/// Persist updated `Settings` to the default store location.
fn save_settings(&self, settings: &Settings) -> Result<()>;
fn save_settings(&self, settings: &Settings) -> Result<(), ServiceError>;
/// Update (or insert) a provider configuration entry.
///
/// Flow: load current AppConfig → mutate provider map → save.
fn update_provider(&self, name: &str, config: &super::app_config::ProviderConfig)
-> Result<()>;
fn update_provider(&self, name: &str, config: &super::app_config::ProviderConfig) -> Result<(), ServiceError>;
}
/// Use-cases for conversation (session message) management.
pub trait ConversationService {
/// Load a `Conversation` for the given session ID.
fn load_conversation(&self, session_id: &str) -> Result<Conversation>;
fn load_conversation(&self, session_id: &str) -> Result<Conversation, ServiceError>;
/// Persist a `Conversation` to its session storage.
fn save_conversation(&self, conv: &Conversation) -> Result<()>;
fn save_conversation(&self, conv: &Conversation) -> Result<(), ServiceError>;
/// Append a single `ChatMessage` to the conversation and persist.
///
/// Flow: push message to in-memory conv → persist full conversation.
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<()>;
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<(), ServiceError>;
}
/// Use-cases for long-term memory management.
pub trait MemoryService {
/// List all memory slugs (filenames without extension).
fn list_memories(&self) -> Result<Vec<String>>;
fn list_memories(&self) -> Result<Vec<String>, ServiceError>;
/// Save (create or overwrite) a `Memory`.
fn save_memory(&self, memory: &Memory) -> Result<()>;
fn save_memory(&self, memory: &Memory) -> Result<(), ServiceError>;
/// Delete a `Memory` by its slug/name.
fn delete_memory(&self, name: &str) -> Result<()>;
fn delete_memory(&self, name: &str) -> Result<(), ServiceError>;
}
@@ -18,7 +18,7 @@
//! response and setting HTTP status codes.
use anyhow::{Context, Result};
use tracing;
use tracing::instrument;
use crate::domain::memory::Memory;
use crate::domain::service::{MemoryService, SettingsService};
@@ -31,6 +31,7 @@ use super::dto::{MemoryCreateRequest, MemoryResponse, SettingsResponse, Settings
/// Returns the current settings as a `SettingsResponse`.
///
/// Flow: load settings from service → convert to DTO → return.
#[instrument(skip(service))]
pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsResponse> {
tracing::debug!("handling GET /settings");
let settings = service.load_settings().context("failed to load settings")?;
@@ -46,6 +47,7 @@ pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsRe
///
/// ## Validation
/// - `internet_mode` must be "Off", "ReadOnly", or "Full" (case-sensitive).
#[instrument(skip(service))]
pub fn handle_update_settings<S: SettingsService>(
service: &S,
req: SettingsUpdateRequest,
@@ -130,6 +132,7 @@ pub fn handle_update_settings<S: SettingsService>(
/// use a dedicated endpoint.
///
/// Flow: list slugs from service → map each to minimal MemoryResponse → return.
#[instrument(skip(service))]
pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryResponse>> {
tracing::debug!("handling GET /memories");
let slugs = service.list_memories().context("failed to list memories")?;
@@ -166,6 +169,7 @@ pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryR
/// ## Defaults
/// - `kind` defaults to "reference" if not specified
/// - `lifecycle` defaults to "new" if not specified
#[instrument(skip(service), fields(name = %req.name))]
pub fn handle_create_memory<M: MemoryService>(
service: &M,
req: MemoryCreateRequest,
@@ -15,11 +15,11 @@
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use zesdex_utils::write_json_atomic;
use crate::domain::app_config::{AppConfig, ModelRole, ProviderConfig};
use crate::domain::error::RepositoryError;
use crate::domain::repository::AppConfigRepository;
/// File-based `AppConfigRepository` that reads/writes `app_config.json`.
@@ -95,19 +95,18 @@ impl AppConfigRepository for JsonAppConfigRepository {
/// Flow: read file → parse JSON → merge default providers → auto-detect Claude → return.
///
/// If the file is missing, returns `AppConfig::default()`.
fn load(&self, base_dir: &Path) -> Result<AppConfig> {
fn load(&self, base_dir: &Path) -> Result<AppConfig, RepositoryError> {
tracing::debug!("loading app_config from {base_dir:?}");
let path = base_dir.join("app_config.json");
// Try to read and parse the config file
let mut cfg: AppConfig = match std::fs::read_to_string(&path) {
Ok(s) => serde_json::from_str(&s)
.map_err(|e| anyhow::anyhow!("failed to parse app_config.json: {e}"))?,
Ok(s) => serde_json::from_str(&s)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
tracing::info!("app_config.json not found, using defaults");
AppConfig::default()
}
Err(e) => {
return Err(anyhow::anyhow!("failed to read app_config.json: {e}"));
return Err(RepositoryError::Io(e));
}
};
@@ -153,13 +152,12 @@ impl AppConfigRepository for JsonAppConfigRepository {
/// Persist `AppConfig` to `<base_dir>/app_config.json`.
///
/// Flow: create base dir → atomic JSON write → log success.
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<()> {
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<(), RepositoryError> {
tracing::debug!("saving app_config to {base_dir:?}");
std::fs::create_dir_all(base_dir)
.with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?;
std::fs::create_dir_all(base_dir)?;
let path = base_dir.join("app_config.json");
write_json_atomic(&path, config, None)
.with_context(|| "failed to save app_config")?;
.map_err(RepositoryError::from_anyhow)?;
tracing::debug!("app_config saved to '{}'", path.display());
Ok(())
}
@@ -9,10 +9,10 @@
use std::path::Path;
use anyhow::{Context, Result};
use zesdex_utils::write_json_atomic;
use crate::domain::conversation::Conversation;
use crate::domain::error::RepositoryError;
use crate::domain::repository::ConversationRepository;
/// File-based `ConversationRepository` that reads/writes `conversation.json`.
@@ -32,25 +32,22 @@ impl ConversationRepository for JsonConversationRepository {
/// Load a `Conversation` from `<session_dir>/conversation.json`.
///
/// Flow: read file → parse JSON → return Conversation.
fn load(&self, session_dir: &Path) -> Result<Conversation> {
fn load(&self, session_dir: &Path) -> Result<Conversation, RepositoryError> {
let path = session_dir.join("conversation.json");
let data = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read conversation at '{}'", path.display()))?;
let conv: Conversation = serde_json::from_str(&data)
.with_context(|| format!("failed to parse conversation at '{}'", path.display()))?;
let data = std::fs::read_to_string(&path)?;
let conv: Conversation = serde_json::from_str(&data)?;
Ok(conv)
}
/// Persist a `Conversation` to `<session_dir>/conversation.json`.
///
/// Flow: create session dir → atomic JSON write → log success.
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<()> {
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<(), RepositoryError> {
tracing::debug!("saving conversation to {session_dir:?}");
std::fs::create_dir_all(session_dir)
.with_context(|| format!("failed to create session dir '{}'", session_dir.display()))?;
std::fs::create_dir_all(session_dir)?;
let path = session_dir.join("conversation.json");
write_json_atomic(&path, conversation, None)
.with_context(|| "failed to save conversation")?;
.map_err(RepositoryError::from_anyhow)?;
tracing::debug!("conversation saved to '{}'", path.display());
Ok(())
}
@@ -16,9 +16,8 @@
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
use anyhow::{Context, Result};
use crate::domain::edit_log::{EditLog, EditLogEntry, MAX_MEMORY_ENTRIES};
use crate::domain::error::RepositoryError;
use crate::domain::repository::EditLogRepository;
/// File-based `EditLogRepository` that reads/writes `edits.jsonl`.
@@ -63,13 +62,12 @@ impl EditLogRepository for JsonlEditLogRepository {
///
/// Flow: ensure parent dir exists → load existing entries from disk →
/// touch file if absent → return in-memory EditLog.
fn open(&self, session_dir: &Path) -> Result<EditLog> {
fn open(&self, session_dir: &Path) -> Result<EditLog, RepositoryError> {
tracing::debug!("opening edit log for {session_dir:?}");
let path = session_dir.join("edits.jsonl");
// Ensure parent dir exists
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create session dir '{}'", parent.display()))?;
std::fs::create_dir_all(parent)?;
}
let entries = Self::load_from_disk(&path);
// Touch the file if it doesn't exist yet
@@ -77,8 +75,7 @@ impl EditLogRepository for JsonlEditLogRepository {
std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("failed to create edits.jsonl at '{}'", path.display()))?;
.open(&path)?;
}
Ok(EditLog { entries })
}
@@ -87,24 +84,20 @@ impl EditLogRepository for JsonlEditLogRepository {
///
/// Flow: serialize entry → open file (append mode) → write line → fsync →
/// push to in-memory Vec → evict oldest if over cap.
fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<()> {
fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<(), RepositoryError> {
tracing::debug!("appending edit log entry for {session_dir:?}");
let path = session_dir.join("edits.jsonl");
let line =
serde_json::to_string(&entry).context("failed to serialize edit log entry")? + "\n";
let line = serde_json::to_string(&entry)? + "\n";
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create session dir '{}'", parent.display()))?;
std::fs::create_dir_all(parent)?;
}
{
let mut file = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.with_context(|| format!("failed to open edits.jsonl at '{}'", path.display()))?;
file.write_all(line.as_bytes())
.context("failed to write edit log entry")?;
file.sync_all().context("failed to fsync edit log")?;
.open(&path)?;
file.write_all(line.as_bytes())?;
file.sync_all()?;
}
log.entries.push(entry);
// Enforce in-memory cap
@@ -35,8 +35,7 @@ use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use crate::domain::error::RepositoryError;
use crate::domain::memory::Memory;
use crate::domain::repository::MemoryRepository;
@@ -173,7 +172,7 @@ impl MemoryRepository for MarkdownMemoryRepository {
///
/// Flow: read directory entries → filter `*.md` → strip extension → exclude MEMORY.md.
/// Returns empty Vec if the directory doesn't exist.
fn list(&self, memory_dir: &Path) -> Result<Vec<String>> {
fn list(&self, memory_dir: &Path) -> Result<Vec<String>, RepositoryError> {
let Ok(entries) = std::fs::read_dir(memory_dir) else {
return Ok(Vec::new());
};
@@ -196,21 +195,19 @@ impl MemoryRepository for MarkdownMemoryRepository {
/// Load a single `Memory` by name from `memory_dir`.
///
/// Flow: resolve file path → read file → parse frontmatter + body → return Memory.
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory> {
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory, RepositoryError> {
tracing::debug!("loading memory '{name}'");
let path = Memory::path(memory_dir, name);
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read memory '{name}' at '{}'", path.display()))?;
let content = std::fs::read_to_string(&path)?;
let memory = Self::parse(&content)
.map_err(|e| anyhow::anyhow!("failed to parse memory '{name}': {e}"))?;
.map_err(|e| RepositoryError::Other(format!("failed to parse memory '{name}': {e}")))?;
Ok(memory)
}
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<()> {
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<(), RepositoryError> {
let path = Memory::path(memory_dir, &memory.name);
let parent = path.parent().unwrap();
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create memory dir '{}'", parent.display()))?;
std::fs::create_dir_all(parent)?;
let frontmatter = Self::build_frontmatter(memory);
let content = format!("---\n{frontmatter}---\n\n{}", memory.content);
@@ -221,18 +218,11 @@ impl MemoryRepository for MarkdownMemoryRepository {
.create(true)
.truncate(true)
.write(true)
.open(&tmp)
.with_context(|| format!("failed to write temp file '{}'", tmp.display()))?;
.open(&tmp)?;
f.write_all(content.as_bytes())?;
f.sync_all()?;
}
std::fs::rename(&tmp, &path).with_context(|| {
format!(
"failed to rename '{}' -> '{}'",
tmp.display(),
path.display()
)
})?;
std::fs::rename(&tmp, &path)?;
if let Some(p) = path.parent() {
if let Ok(d) = std::fs::File::open(p) {
let _ = d.sync_all();
@@ -242,12 +232,10 @@ impl MemoryRepository for MarkdownMemoryRepository {
Ok(())
}
fn delete(&self, memory_dir: &Path, name: &str) -> Result<()> {
fn delete(&self, memory_dir: &Path, name: &str) -> Result<(), RepositoryError> {
let path = Memory::path(memory_dir, name);
if path.exists() {
std::fs::remove_file(&path).with_context(|| {
format!("failed to delete memory '{name}' at '{}'", path.display())
})?;
std::fs::remove_file(&path)?;
tracing::debug!("memory deleted: '{}'", path.display());
} else {
tracing::warn!(
@@ -16,10 +16,10 @@
use std::io::Write;
use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tracing;
use crate::domain::error::RepositoryError;
use crate::domain::repository::RewindBlobRepository;
/// A single entry in the append-only blob index (`index.jsonl`).
@@ -81,10 +81,9 @@ impl RewindBlobRepository for FileRewindBlobRepository {
blob_key: &str,
data: &[u8],
mime_type: Option<&str>,
) -> Result<()> {
) -> Result<(), RepositoryError> {
let blobs_dir = Self::blobs_dir(session_dir);
std::fs::create_dir_all(&blobs_dir)
.with_context(|| format!("failed to create blobs dir '{}'", blobs_dir.display()))?;
std::fs::create_dir_all(&blobs_dir)?;
// Write blob data atomically: temp → fsync → rename
let path = Self::blob_file_path(session_dir, blob_key);
@@ -104,8 +103,7 @@ impl RewindBlobRepository for FileRewindBlobRepository {
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&index_path)
.with_context(|| format!("failed to open blob index '{}'", index_path.display()))?;
.open(&index_path)?;
writeln!(f, "{}", serde_json::to_string(&entry)?)?;
f.sync_all()?;
@@ -122,7 +120,7 @@ impl RewindBlobRepository for FileRewindBlobRepository {
///
/// Returns `None` when no blob file exists for `blob_key` (i.e. the
/// blob was never stored or the session directory does not exist).
fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> Result<Option<Vec<u8>>> {
fn retrieve_blob(&self, session_dir: &Path, blob_key: &str) -> Result<Option<Vec<u8>>, RepositoryError> {
let path = Self::blob_file_path(session_dir, blob_key);
if !path.exists() {
tracing::debug!("blob key={} not found (path does not exist)", blob_key);
@@ -141,7 +139,7 @@ impl RewindBlobRepository for FileRewindBlobRepository {
/// When a key has been overwritten, it appears exactly once in the output
/// (pointing to the latest stored data). Returns an empty vec if the
/// index file does not exist yet.
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>> {
fn list_blob_keys(&self, session_dir: &Path) -> Result<Vec<String>, RepositoryError> {
let index_path = Self::index_path(session_dir);
let Ok(content) = std::fs::read_to_string(&index_path) else {
tracing::debug!("no blob index file yet at '{}'", index_path.display());
@@ -6,10 +6,10 @@
use std::path::Path;
use anyhow::{Context, Result};
use tracing;
use zesdex_utils::write_json_atomic;
use crate::domain::error::RepositoryError;
use crate::domain::repository::SettingsRepository;
use crate::domain::settings::Settings;
@@ -32,7 +32,7 @@ impl SettingsRepository for JsonSettingsRepository {
/// Graceful degradation: returns `Settings::default()` when the file is
/// missing (first run) *or* when it exists but fails to parse (e.g. a
/// newer field was added after the file was written).
fn load(&self, base_dir: &Path) -> Result<Settings> {
fn load(&self, base_dir: &Path) -> Result<Settings, RepositoryError> {
let path = base_dir.join("settings.json");
match std::fs::read_to_string(&path) {
Ok(s) => match serde_json::from_str(&s) {
@@ -54,7 +54,7 @@ impl SettingsRepository for JsonSettingsRepository {
tracing::info!("settings.json not found, using defaults");
Ok(Settings::default())
}
Err(e) => Err(anyhow::anyhow!("failed to read settings.json: {e}")),
Err(e) => Err(RepositoryError::Io(e)),
}
}
@@ -62,12 +62,11 @@ impl SettingsRepository for JsonSettingsRepository {
///
/// Flow: create base dir (if missing) → atomic JSON write via
/// `write_json_atomic` (write to temp → fsync → rename).
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<()> {
std::fs::create_dir_all(base_dir)
.with_context(|| format!("failed to create base dir '{}'", base_dir.display()))?;
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<(), RepositoryError> {
std::fs::create_dir_all(base_dir)?;
let path = base_dir.join("settings.json");
write_json_atomic(&path, settings, None)
.with_context(|| "failed to save settings")?;
.map_err(RepositoryError::from_anyhow)?;
tracing::debug!("settings saved to '{}'", path.display());
Ok(())
}
-7
View File
@@ -20,13 +20,6 @@
//! - Domain types are plain Rust structs with `serde` serialisation,
//! stored as JSON files on disk.
#![allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
clippy::cast_possible_wrap
)]
pub mod application;
pub mod domain;
pub mod infrastructure;