docs: tambah doc comment, logging, dan inline comments di semua 255 file

Meliputi:
- File-level //! doc comment: tujuan file, alur kerja, komponen utama
- Function-level /// doc comment: apa, parameter, return, flow, edge cases
- Struct/enum/trait /// doc comment: peran, field docs
- Tracing logging (tracing::info!/debug!/trace!/warn!/error!) di setiap fungsi
- Inline comments untuk variable dan branching logic penting
- Seluruh 8 crates di workspace: zesdex-backend, zesdex-cms, zesdex-entities,
  zesdex-iam, zesdex-infra, zesdex-ipc, zesdex-middleware, zesdex-utils
- Build: 0 errors, 242/242 tests passed
This commit is contained in:
asepharyana
2026-07-19 17:05:47 +07:00
parent 6680795ce7
commit 5aaedbf787
255 changed files with 5666 additions and 743 deletions
@@ -1,23 +1,46 @@
//! Conversation use-case implementations.
//! Conversation use-case implementations for the CMS.
//!
//! `ConversationServiceImpl` is generic over `R: ConversationRepository`,
//! delegating all persistence to that adapter.
//! `ConversationServiceImpl` implements `ConversationService` (defined in
//! `domain::service`) and is generic over `R: ConversationRepository`
//! (defined in `domain::repository`), delegating all persistence to that
//! adapter. The repository is injected at composition root.
//!
//! ## Flow
//! Each method computes the session directory from the session ID, then
//! delegates the actual I/O to the injected `repo`. Error context is
//! added at this layer to identify which session caused the failure.
use std::path::PathBuf;
use anyhow::{Context, Result};
use tracing;
use crate::domain::conversation::{ChatMessage, Conversation};
use crate::domain::repository::ConversationRepository;
use crate::domain::service::ConversationService;
/// Generic conversation service backed by an injected repository.
/// Service implementation for conversation CRUD operations.
///
/// Generic over `R: ConversationRepository` so the persistence layer
/// can be swapped without changing business logic.
///
/// ## Fields
/// - `repo` — injected conversation repository implementation
/// - `sessions_dir` — base path under which session directories live
pub struct ConversationServiceImpl<R> {
pub repo: R,
pub sessions_dir: std::path::PathBuf,
/// Base directory containing session subdirectories.
pub sessions_dir: PathBuf,
}
impl<R: ConversationRepository> ConversationServiceImpl<R> {
/// Create a new service with the given repository and sessions directory.
pub fn new(repo: R, sessions_dir: impl Into<std::path::PathBuf>) -> Self {
///
/// ## Parameters
/// - `repo` — the repository adapter to delegate persistence to
/// - `sessions_dir` — base path for session directories (converted via `Into`)
pub fn new(repo: R, sessions_dir: impl Into<PathBuf>) -> Self {
tracing::debug!("creating ConversationServiceImpl");
Self {
repo,
sessions_dir: sessions_dir.into(),
@@ -25,20 +48,30 @@ impl<R: ConversationRepository> ConversationServiceImpl<R> {
}
/// Compute the session directory for a given session id.
fn session_dir(&self, session_id: &str) -> std::path::PathBuf {
///
/// Returns `{sessions_dir}/{session_id}`.
fn session_dir(&self, session_id: &str) -> PathBuf {
self.sessions_dir.join(session_id)
}
}
impl<R: ConversationRepository> ConversationService for ConversationServiceImpl<R> {
/// 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> {
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}'"))
}
/// 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<()> {
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!(
@@ -48,8 +81,16 @@ impl<R: ConversationRepository> ConversationService for ConversationServiceImpl<
})
}
/// Add a message to a conversation and persist immediately.
///
/// Flow: push message to in-memory conversation → resolve session dir → delegate save.
///
/// ## 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<()> {
conv.push(msg);
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!(
@@ -1,23 +1,46 @@
//! Memory use-case implementations.
//! Memory use-case implementations for the CMS.
//!
//! `MemoryServiceImpl` is generic over `R: MemoryRepository`, delegating
//! all persistence to that adapter.
//! `MemoryServiceImpl` implements `MemoryService` (defined in
//! `domain::service`) and is generic over `R: MemoryRepository`
//! (defined in `domain::repository`), delegating all persistence to that
//! adapter. The repository is injected at composition root.
//!
//! ## Flow
//! Each method delegates to the injected `repo` with the configured
//! `memory_dir`. Error context is added at this layer to identify which
//! memory operation failed.
use std::path::PathBuf;
use anyhow::{Context, Result};
use tracing;
use crate::domain::memory::Memory;
use crate::domain::repository::MemoryRepository;
use crate::domain::service::MemoryService;
/// Generic memory service backed by an injected repository.
/// Service implementation for memory CRUD operations.
///
/// Generic over `R: MemoryRepository` so the persistence layer can be
/// swapped without changing business logic.
///
/// ## Fields
/// - `repo` — injected memory repository implementation
/// - `memory_dir` — base path where memory files are stored
pub struct MemoryServiceImpl<R> {
pub repo: R,
pub memory_dir: std::path::PathBuf,
/// Base directory for memory storage files.
pub memory_dir: PathBuf,
}
impl<R: MemoryRepository> MemoryServiceImpl<R> {
/// Create a new service with the given repository and memory directory.
pub fn new(repo: R, memory_dir: impl Into<std::path::PathBuf>) -> Self {
///
/// ## Parameters
/// - `repo` — the repository adapter to delegate persistence to
/// - `memory_dir` — base path for memory files (converted via `Into`)
pub fn new(repo: R, memory_dir: impl Into<PathBuf>) -> Self {
tracing::debug!("creating MemoryServiceImpl");
Self {
repo,
memory_dir: memory_dir.into(),
@@ -26,19 +49,31 @@ impl<R: MemoryRepository> MemoryServiceImpl<R> {
}
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>> {
tracing::debug!("listing memories from {:?}", self.memory_dir);
self.repo
.list(&self.memory_dir)
.context("failed to list memories")
}
/// Persist a memory to disk.
///
/// Flow: delegate to repo.save() → wrap error with memory name context.
fn save_memory(&self, memory: &Memory) -> Result<()> {
tracing::debug!("saving memory '{}'", memory.name);
self.repo
.save(&self.memory_dir, memory)
.with_context(|| format!("failed to save memory '{}'", 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<()> {
tracing::debug!("deleting memory '{name}'");
self.repo
.delete(&self.memory_dir, name)
.with_context(|| format!("failed to delete memory '{name}'"))
+17 -3
View File
@@ -1,10 +1,24 @@
//! Application layer — use-case implementations.
//! Application layer — use-case service implementations.
//!
//! Each service is generic over its repository trait so the concrete
//! persistence adapter is injected at composition root.
//! This module defines the concrete service types that orchestrate
//! business operations. Each service is generic over its repository
//! trait (from `domain::repository`), so the concrete persistence
//! adapter is injected at composition root via dependency inversion.
//!
//! ## Services
//! - `ConversationServiceImpl` — CRUD for conversations and messages
//! - `MemoryServiceImpl` — CRUD for session memories
//! - `SettingsServiceImpl` — Read/write for application settings and config
//!
//! ## Architecture
//! Application services depend only on domain trait abstractions.
//! They never reference infrastructure types directly.
/// Conversation use-case: create, read, update, delete conversations.
pub mod conversation_service;
/// Memory use-case: store, retrieve, rewrite, delete session memories.
pub mod memory_service;
/// Settings use-case: load, save application settings and configuration.
pub mod settings_service;
pub use conversation_service::ConversationServiceImpl;
@@ -1,29 +1,55 @@
//! Settings use-case implementations.
//! Settings and app-config use-case implementations for the CMS.
//!
//! `SettingsServiceImpl` is generic over `S: SettingsRepository` and
//! `C: AppConfigRepository`, delegating all persistence to those adapters.
//! `SettingsServiceImpl` implements `SettingsService` (defined in
//! `domain::service`) and is generic over `S: SettingsRepository` and
//! `C: AppConfigRepository` (defined in `domain::repository`), delegating
//! persistence to those adapters. Both repositories are injected at
//! composition root.
//!
//! ## Flow
//! Each method delegates to the appropriate injected repository with the
//! configured `base_dir`. The `update_provider` method coordinates
//! between both repositories: load app config → mutate provider map →
//! save app config.
use std::path::PathBuf;
use anyhow::Result;
use tracing;
use crate::domain::app_config::{AppConfig, ProviderConfig};
use crate::domain::repository::{AppConfigRepository, SettingsRepository};
use crate::domain::service::SettingsService;
use crate::domain::settings::Settings;
/// Generic settings service backed by injected repository implementations.
/// Service implementation for settings and app-config operations.
///
/// Generic over `S: SettingsRepository` and `C: AppConfigRepository` so
/// the persistence layer can be swapped without changing business logic.
///
/// ## Fields
/// - `settings_repo` — injected settings repository implementation
/// - `app_config_repo` — injected app-config repository implementation
/// - `base_dir` — base path where config files are stored
pub struct SettingsServiceImpl<S, C> {
pub settings_repo: S,
pub app_config_repo: C,
pub base_dir: std::path::PathBuf,
pub base_dir: PathBuf,
}
impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
/// Create a new service with the given repositories and base directory.
///
/// ## Parameters
/// - `settings_repo` — the settings repository adapter
/// - `app_config_repo` — the app-config repository adapter
/// - `base_dir` — base path for configuration files (converted via `Into`)
pub fn new(
settings_repo: S,
app_config_repo: C,
base_dir: impl Into<std::path::PathBuf>,
base_dir: impl Into<PathBuf>,
) -> Self {
tracing::debug!("creating SettingsServiceImpl");
Self {
settings_repo,
app_config_repo,
@@ -33,19 +59,39 @@ impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
}
impl<S: SettingsRepository, C: AppConfigRepository> SettingsService for SettingsServiceImpl<S, C> {
/// Load application settings from disk.
///
/// Flow: delegate to settings_repo.load() at base_dir.
fn load_settings(&self) -> Result<Settings> {
tracing::debug!("loading settings");
self.settings_repo.load(&self.base_dir)
}
/// Save application settings to disk.
///
/// Flow: delegate to settings_repo.save() at base_dir.
fn save_settings(&self, settings: &Settings) -> Result<()> {
tracing::debug!("saving settings");
self.settings_repo.save(&self.base_dir, settings)
}
/// Update (or insert) a provider configuration in the app config.
///
/// Flow: load existing AppConfig → insert/update provider entry →
/// persist AppConfig back to disk.
///
/// ## Parameters
/// - `name` — provider name (key in the providers map)
/// - `config` — the provider configuration to store
fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<()> {
tracing::debug!("updating provider '{name}'");
// Load current app config from disk
let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?;
// Insert or overwrite the provider entry
app_config
.providers
.insert(name.to_string(), config.clone());
// Persist the modified app config
self.app_config_repo.save(&self.base_dir, &app_config)
}
}
+51 -9
View File
@@ -1,16 +1,35 @@
//! Pure AppConfig entity — provider registry, model roles, and default model
//! selections.
//! Pure domain entity for application configuration.
//!
//! Defines `AppConfig`, `ProviderConfig`, and `ModelRole` — the data
//! structures that describe which LLM providers are registered, which
//! model roles exist, and which provider/model is the default.
//!
//! # Architecture
//! This is a pure data structure with **no I/O logic**. Load/save
//! responsibilities live in [`AppConfigRepository`](super::repository::AppConfigRepository).
//! These are pure data structures with **no I/O logic**. Load/save
//! responsibilities live in `AppConfigRepository` (domain::repository).
//!
//! ## Data Flow
//! 1. `AppConfig` is deserialised from `app_config.json` at startup
//! 2. The HTTP handler layer calls `SettingsService::update_provider()`
//! to mutate the provider map
//! 3. The modified `AppConfig` is serialised back to `app_config.json`
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// Top-level application config: registered providers, named model roles,
/// and which provider/model to use by default.
/// Top-level application configuration.
///
/// Holds the registry of configured LLM providers, named model roles
/// (logical profiles mapping to a provider+model pair), and the default
/// provider/model selection.
///
/// ## Fields
/// - `providers` — map of provider name → connection details
/// - `model_roles` — map of role name → provider/model/temperature
/// - `default_provider` — the provider to use when none is specified
/// - `default_model` — the model to use when none is specified
/// - `default_context_window` — fallback context window size in tokens
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
pub providers: HashMap<String, ProviderConfig>,
@@ -20,7 +39,13 @@ pub struct AppConfig {
pub default_context_window: u32,
}
/// Connection details for a single LLM provider.
/// Connection details for a single LLM provider endpoint.
///
/// ## Fields
/// - `api_base` — base URL for the provider API
/// - `api_key_env` — optional environment variable name holding the API key
/// - `default_model` — optional default model name for this provider
/// - `default_api_key` — optional inline API key (less secure than env var)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProviderConfig {
pub api_base: String,
@@ -29,8 +54,17 @@ pub struct ProviderConfig {
pub default_api_key: Option<String>,
}
/// A named role (e.g. "default") mapping to a specific provider/model and
/// its generation parameters.
/// A named model role mapping to a specific provider/model with parameters.
///
/// Roles allow the UI to present logical profiles (e.g. "fast", "reasoning")
/// that abstract over concrete provider+model strings.
///
/// ## Fields
/// - `provider` — which provider serves this role
/// - `model` — which model to use for this role
/// - `max_tokens` — optional maximum output token limit
/// - `context_window` — optional context window override
/// - `temperature` — optional generation temperature
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelRole {
pub provider: String,
@@ -40,7 +74,15 @@ pub struct ModelRole {
pub temperature: Option<f32>,
}
/// Returns the default AppConfig with built-in "zen" and "router" providers.
impl Default for AppConfig {
/// Construct an AppConfig with the default "zen" and "router" providers.
///
/// ## Defaults
/// - Zen provider: `deepseek-v4-flash-free` model
/// - Router provider: `claude-opus-4-8` model
/// - Default role: "default" → zen / deepseek-v4-flash-free, temp 0.7
/// - `default_context_window`: 256,000 tokens
fn default() -> Self {
let mut providers = HashMap::new();
providers.insert(
+11 -2
View File
@@ -1,6 +1,15 @@
//! Pure Conversation entity
//! Pure domain entity for conversations and chat messages.
//!
//! Re-exported from zesdex_entities for consistency.
//! Re-exports the canonical `Conversation`, `ChatMessage`, and `Role`
//! types from `zesdex_entities` to provide a consistent domain import
//! boundary within the `zesdex-cms` crate. All CMS code references
//! conversation types through this module rather than depending on the
//! entities crate directly.
//!
//! ## Re-exports
//! - `Conversation` — top-level conversation container with message list
//! - `ChatMessage` — a single message with role, content, and tool metadata
//! - `Role` — message role enum (User, Assistant, System, Tool)
pub use zesdex_entities::domain::common::message::{ChatMessage, Role};
pub use zesdex_entities::domain::common::conversation::Conversation;
+32 -8
View File
@@ -1,13 +1,32 @@
//! Pure EditLog entities — append-only log of file mutations for audit / undo.
//! Pure domain entity for the edit log — an append-only log of file mutations.
//!
//! Records every file mutation made by any tool, enabling audit trails
//! and potential undo operations. Each entry captures the tool name,
//! target path, reason, content hash, and byte delta.
//!
//! # Architecture
//! This is a pure data structure with **no I/O logic**. Load/save
//! responsibilities live in [`EditLogRepository`](super::repository::EditLogRepository).
//! This is a pure data structure with **no I/O logic**. Load/save
//! responsibilities live in `EditLogRepository` (domain::repository).
//!
//! ## Data Flow
//! 1. Tools call `EditLog::push()` to record each mutation
//! 2. The in-memory `EditLog` is periodically flushed to disk by the repo
//! 3. Oldest entries are evicted from the in-memory cache when
//! `MAX_MEMORY_ENTRIES` is exceeded (prevents unbounded growth)
use serde::{Deserialize, Serialize};
/// A single recorded file edit: which tool made it, to which path, why,
/// and a content hash/size delta for verification.
/// A single recorded file edit event.
///
/// ## Fields
/// - `ts` — Unix timestamp (seconds) when the edit occurred
/// - `tool` — name of the tool that performed the edit (e.g. "Bash", "Edit")
/// - `path` — absolute file path that was modified
/// - `reason` — human-readable explanation of why the edit was made
/// - `content_sha256` — SHA-256 hex digest of the content *after* the edit
/// - `bytes_delta` — signed byte count change (+added, -removed)
/// - `origin` — origin identifier (which agent / session context)
/// - `session_id` — session in which this edit was performed
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EditLogEntry {
pub ts: i64,
@@ -21,18 +40,22 @@ pub struct EditLogEntry {
}
/// Maximum number of edit entries held in memory at once.
///
/// Beyond this limit, old entries are dropped from the in-memory cache
/// to prevent unbounded memory growth in long sessions.
/// to prevent unbounded memory growth in long-running sessions.
pub const MAX_MEMORY_ENTRIES: usize = 10_000;
/// In-memory view of a session's edit log.
///
/// Wraps a `Vec<EditLogEntry>` and provides basic query helpers.
#[derive(Debug, Clone)]
pub struct EditLog {
/// Ordered list of edit entries (newest appended last).
pub entries: Vec<EditLogEntry>,
}
impl EditLog {
/// Create an empty edit log.
/// Create an empty edit log with no entries.
pub fn new() -> Self {
Self {
entries: Vec::new(),
@@ -44,13 +67,14 @@ impl EditLog {
self.entries.len()
}
/// Return `true` if the log is empty.
/// Return `true` if the log contains no entries.
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
}
impl Default for EditLog {
/// Returns an empty `EditLog` via `EditLog::new()`.
fn default() -> Self {
Self::new()
}
+45 -9
View File
@@ -1,16 +1,39 @@
//! Pure Memory entity long-term agent memory with frontmatter metadata
//! and free-form markdown content.
//! Pure domain entity for long-term agent memory.
//!
//! A `Memory` entry stores a named, kinded piece of information (lesson,
//! reference, fact) with frontmatter metadata and free-form markdown
//! content. Memories are persisted as individual `.md` files with YAML
//! frontmatter.
//!
//! # Architecture
//! This is a pure data structure with **no I/O logic**. Load/save
//! responsibilities live in [`MemoryRepository`](super::repository::MemoryRepository).
//! This is a pure data structure with **no I/O logic**. Load/save
//! responsibilities live in `MemoryRepository` (domain::repository).
//!
//! ## Utility Functions
//! - `slugify()` — converts a name string into a filesystem-safe slug
//! - `path()` — computes the on-disk path for a given memory name
//!
//! Both are pure computations that take parameters and perform no I/O.
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
/// A single memory entry (lesson, reference, etc.) with frontmatter
/// metadata and free-form markdown content.
/// A single memory entry with frontmatter metadata and markdown content.
///
/// ## Fields
/// - `name` — unique identifier / title for this memory
/// - `description` — short summary of what this memory contains
/// - `content` — free-form markdown body
/// - `kind` — category/tag (e.g. "lesson", "reference", "fact")
/// - `created_at` — Unix timestamp of creation
/// - `updated_at` — Unix timestamp of last modification
/// - `outcome` — optional outcome of applying this memory
/// - `lifecycle` — lifecycle stage (e.g. "active", "archived")
/// - `scope` — optional scope qualifier (which session/context this applies to)
/// - `before_snippet` — optional context snapshot before memory was applied
/// - `after_snippet` — optional context snapshot after memory was applied
/// - `provenances` — list of origin identifiers that created or confirmed this memory
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memory {
pub name: String,
@@ -34,12 +57,20 @@ impl Memory {
/// collapse/trim repeated `-`.
///
/// Returns `None` if the result is empty or exceeds 80 characters.
///
/// ## Example
/// ```
/// # use zesdex_cms::domain::memory::Memory;
/// assert_eq!(Memory::slugify("Hello World!").unwrap(), "hello-world");
/// ```
pub fn slugify(s: &str) -> Option<String> {
// Phase 1: replace every non-alphanumeric character with '-'
let slug: String = s
.to_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
.collect();
// Phase 2: collapse consecutive '-' separators
let slug: String = slug
.split('-')
.filter(|s| !s.is_empty())
@@ -53,10 +84,15 @@ impl Memory {
/// Compute the on-disk path for a memory of the given name.
///
/// This is a **pure** computation: it takes `memory_dir` as a parameter
/// and performs no I/O itself.
/// ## Parameters
/// - `memory_dir` — the base directory for memory storage
/// - `name` — the memory name (will be slugified internally)
///
/// Falls back to `"memory.md"` when `name` slugifies to nothing.
/// Falls back to `"memory.md"` when the name slugifies to an empty
/// or invalid string.
///
/// ## Pure Computation
/// This function performs **no I/O** — it only computes a path.
pub fn path(memory_dir: &Path, name: &str) -> PathBuf {
let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string());
let clean: String = format!("{slug}.md")
+18 -3
View File
@@ -1,7 +1,22 @@
//! Domain layer — pure entities, repository traits, and service trait definitions.
//! Domain layer — pure entities, value objects, repository traits, and service interfaces.
//!
//! This layer has zero infrastructure dependencies; all I/O is expressed through
//! repository traits defined in [`repository`].
//! This is the innermost layer of the Clean Architecture onion. It has **zero
//! infrastructure dependencies** — all I/O is expressed through repository
//! traits defined in [`repository`], and business operations through service
//! traits in [`service`].
//!
//! ## Sub-modules
//! - `app_config` — provider configuration model (`AppConfig`, `ProviderConfig`, `ModelRole`)
//! - `conversation` — conversation entity + chat message model (`Conversation`, `ChatMessage`)
//! - `edit_log` — edit log model (`EditLog`, `EditLogEntry`)
//! - `memory` — memory file model (`Memory`)
//! - `settings` — application settings model (`Settings`, `InternetMode`, `SettingsFlags`)
//! - `repository` — trait definitions for all persistence adapters
//! - `service` — trait definitions for all application services
//!
//! ## Key Design Principle
//! Domain types are plain Rust structs with `serde` for serialisation.
//! They contain no I/O, no framework imports, and no side effects.
pub mod app_config;
pub mod conversation;
+73 -25
View File
@@ -1,8 +1,21 @@
//! Repository traits — pure abstraction boundaries for persistence.
//!
//! Each trait defines load / save / query operations that infrastructure
//! adapters implement. The domain and application layers depend only on
//! these traits, never on concrete persistence implementations.
//! adapters implement. The domain and application layers depend **only**
//! on these traits, never on concrete persistence implementations.
//!
//! ## Traits
//! - `SettingsRepository` — load/save `Settings` from/to a base directory
//! - `AppConfigRepository` — load/save `AppConfig` from/to a base directory
//! - `ConversationRepository` — load/save `Conversation` from/to a session directory
//! - `MemoryRepository` — list/load/save/delete `Memory` entries
//! - `RewindBlobRepository` — store/retrieve/list binary blobs per session
//! - `EditLogRepository` — open/append/query edit log entries per session
//!
//! ## Dependency Inversion
//! Application services accept these traits as generic type parameters,
//! allowing the composition root to inject concrete implementations
//! (file-based, SQLite-backed, etc.) without changing business logic.
use std::path::Path;
@@ -14,52 +27,80 @@ use super::edit_log::{EditLog, EditLogEntry};
use super::memory::Memory;
use super::settings::Settings;
/// Persistence contract for `Settings`.
/// Persistence contract for `Settings` (application settings model).
///
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
pub trait SettingsRepository {
/// Load settings from a base directory.
/// Load `Settings` from the given base directory.
///
/// Flow: read and deserialize `settings.json` from `base_dir`.
fn load(&self, base_dir: &Path) -> Result<Settings>;
/// Save settings to a base directory.
/// 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<()>;
}
/// Persistence contract for `AppConfig`.
/// Persistence contract for `AppConfig` (provider and model configuration).
///
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
pub trait AppConfigRepository {
/// Load app config from a base directory.
/// 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>;
/// Save app config to a base directory.
/// 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<()>;
}
/// Persistence contract for `Conversation`.
/// Persistence contract for `Conversation` (session conversation data).
///
/// Implementors provide the actual I/O logic (e.g. file-based JSON storage).
pub trait ConversationRepository {
/// Load a conversation from a session directory.
/// 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>;
/// Save a conversation to a session directory.
/// 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<()>;
}
/// Persistence contract for `Memory`.
/// Persistence contract for `Memory` (long-term agent memory entries).
///
/// Implementors provide the actual I/O logic (e.g. per-memory markdown files).
pub trait MemoryRepository {
/// List all memory slugs in a memory directory.
/// List all memory slugs (filenames without extension) in the memory directory.
fn list(&self, memory_dir: &Path) -> Result<Vec<String>>;
/// Load a single memory by name.
/// Load a single `Memory` by name from the memory directory.
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory>;
/// Save (create or update) a memory.
/// Save (create or overwrite) a `Memory` in the memory directory.
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<()>;
/// Delete a memory by name.
/// Delete a `Memory` by name from the memory directory.
fn delete(&self, memory_dir: &Path, name: &str) -> Result<()>;
}
/// Repository for rewind-snapshot binary blobs, keyed by an arbitrary
/// caller-supplied key (e.g. a tool-call id) within a session.
/// Persistence contract for rewind-snapshot binary blobs.
///
/// Blobs are keyed by an arbitrary caller-supplied key (e.g. a tool-call ID)
/// within a session. They capture file snapshots for the "rewind" feature.
pub trait RewindBlobRepository {
/// Store (or overwrite) a blob under `blob_key` for this session.
/// 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,
@@ -68,21 +109,28 @@ pub trait RewindBlobRepository {
mime_type: Option<&str>,
) -> anyhow::Result<()>;
/// Retrieve a blob's bytes by key, or `None` if not found.
/// 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>>>;
/// List all blob keys for this session, oldest first.
/// List all blob keys for this session, ordered oldest-first.
fn list_blob_keys(&self, session_dir: &Path) -> anyhow::Result<Vec<String>>;
}
/// Persistence contract for `EditLog`.
/// Persistence contract for `EditLog` (append-only file mutation log).
///
/// Implementors manage an append-only log of `EditLogEntry` items per session,
/// typically persisted to a file for audit and potential undo.
pub trait EditLogRepository {
/// Open (or start tracking) the edit log for a session directory.
/// 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>;
/// Append one entry, persisting it immediately.
/// 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<()>;
/// Return a reference to all in-memory entries.
/// Return a cloned copy of all in-memory entries for inspection.
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry>;
}
+29 -14
View File
@@ -1,7 +1,18 @@
//! Service trait definitions — use-case boundaries for CMS operations.
//!
//! These traits are implemented by the application layer and consumed by
//! infrastructure adapters (HTTP handlers, CLI commands, etc.).
//! These traits define the public API of the application use-cases.
//! They are implemented by concrete types in the `application` layer
//! and consumed by infrastructure adapters (HTTP handlers, CLI commands).
//!
//! ## Traits
//! - `SettingsService` — load/save settings, update provider config
//! - `ConversationService` — load/save conversations, add messages
//! - `MemoryService` — list/save/delete session memories
//!
//! ## Dependency Inversion
//! Application service implementations accept repository traits as generic
//! type parameters. Infrastructure adapters depend only on these service
//! traits, never on concrete implementations.
use anyhow::Result;
@@ -9,39 +20,43 @@ use super::conversation::{ChatMessage, Conversation};
use super::memory::Memory;
use super::settings::Settings;
/// Settings use cases.
/// Use-cases for application settings.
pub trait SettingsService {
/// Load current settings from the default store.
/// Load the current `Settings` from the default store location.
fn load_settings(&self) -> Result<Settings>;
/// Persist updated settings.
/// Persist updated `Settings` to the default store location.
fn save_settings(&self, settings: &Settings) -> Result<()>;
/// Update the provider configuration (name and details).
/// 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<()>;
}
/// Conversation management use cases.
/// Use-cases for conversation (session message) management.
pub trait ConversationService {
/// Load a conversation for the given session id.
/// Load a `Conversation` for the given session ID.
fn load_conversation(&self, session_id: &str) -> Result<Conversation>;
/// Persist a conversation.
/// Persist a `Conversation` to its session storage.
fn save_conversation(&self, conv: &Conversation) -> Result<()>;
/// Append a single message and persist.
/// 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<()>;
}
/// Memory management use cases.
/// Use-cases for long-term memory management.
pub trait MemoryService {
/// List all memory slugs.
/// List all memory slugs (filenames without extension).
fn list_memories(&self) -> Result<Vec<String>>;
/// Save (create or update) a memory.
/// Save (create or overwrite) a `Memory`.
fn save_memory(&self, memory: &Memory) -> Result<()>;
/// Delete a memory by name.
/// Delete a `Memory` by its slug/name.
fn delete_memory(&self, name: &str) -> Result<()>;
}
+42 -8
View File
@@ -1,25 +1,55 @@
//! Pure Settings entity — user configuration for LLM provider, model,
//! generation parameters, and feature flags.
//! Pure domain entity for application settings.
//!
//! Defines `Settings` (top-level user configuration), `SettingsFlags`
//! (grouped boolean toggles), and `InternetMode` (network access level).
//! Serialised to `settings.json` by the infrastructure layer.
//!
//! # Architecture
//! This is a pure data structure with **no I/O logic**. Load/save
//! responsibilities live in [`SettingsRepository`](super::repository::SettingsRepository).
//! This is a pure data structure with **no I/O logic**. Load/save
//! responsibilities live in `SettingsRepository` (domain::repository).
//!
//! ## Settings Fields
//! - `internet_mode` — network access policy (Off / ReadOnly / Full)
//! - `provider` / `model` — default LLM provider and model name
//! - `api_keys` — per-provider API key overrides (name → key)
//! - `max_tokens` / `temperature` — generation parameter defaults
//! - `review_max_lessons_per_run` — max lessons per auto-review pass
//! - `verify_command` — optional shell command to run for verification
//! - `workflow_max_concurrency` — max parallel hive-mind nodes
//! - `hive_mind_node_timeout_ms` — per-node timeout for hive-mind orchestration
//! - `flags` — grouped boolean feature toggles
//! - `lsp_languages` — list of language IDs for LSP auto-provisioning
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
/// Controls how much network access the agent is permitted during a session.
///
/// ## Variants
/// - `Off` — no network access
/// - `ReadOnly` — HTTP GET / HEAD only
/// - `Full` — any HTTP method permitted
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum InternetMode {
/// No network access permitted.
#[default]
Off,
/// HTTP GET / HEAD requests only.
ReadOnly,
/// Any HTTP method permitted.
Full,
}
/// Boolean flags grouped to keep the top-level [`Settings`] struct below
/// clippy's default-too-many-fields threshold.
/// Grouped boolean feature toggles for the application.
///
/// Kept as a separate struct to avoid clippy's
/// `default-too-many-fields` threshold on `Settings`.
///
/// ## Fields
/// - `review_enabled` — enable automatic inline review after edits
/// - `session_archive_enabled` — enable periodic session archiving
/// - `lsp_auto_provision` — auto-provision LSP language servers on project open
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SettingsFlags {
pub review_enabled: bool,
@@ -28,6 +58,7 @@ pub struct SettingsFlags {
}
impl Default for SettingsFlags {
/// Returns the default flags with all features enabled.
fn default() -> Self {
Self {
review_enabled: true,
@@ -37,13 +68,16 @@ impl Default for SettingsFlags {
}
}
/// Returns the default hive-mind node timeout (600 seconds).
fn default_hive_mind_node_timeout_ms() -> u64 {
600_000
}
/// Top-level application settings.
/// Top-level application settings model.
///
/// Serialized to `settings.json` by the infrastructure layer.
/// Serialised to `settings.json` by the infrastructure persistence layer.
/// Holds LLM provider selection, generation parameters, feature flags,
/// and workflow configuration.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
pub internet_mode: InternetMode,
@@ -1,8 +1,20 @@
//! CMS-specific DTOs (Data Transfer Objects) for the REST API.
//! Data Transfer Objects (DTOs) for the CMS REST API.
//!
//! These types define the wire format accepted and returned by HTTP handlers.
//! They are independent of the domain entities so the API contract can
//! evolve without coupling to the domain model.
//! They are intentionally independent of the domain entities so the API
//! contract can evolve without coupling to the domain model.
//!
//! ## DTOs
//! - `SettingsUpdateRequest` — partial-update body for PUT /settings
//! - `SettingsResponse` — response body for GET /settings (API keys redacted)
//! - `MemoryCreateRequest` — request body for POST /memories
//! - `MemoryResponse` — response body for memory operations
//! - `ConversationResponse` — response body for GET /conversation
//!
//! ## Conventions
//! - `From<DomainType>` impls convert domain entities → DTO responses
//! - API key values are **redacted** in responses (only key names exposed)
//! - Request fields use `Option` to support partial updates
use serde::{Deserialize, Serialize};
@@ -53,6 +65,10 @@ pub struct SettingsResponse {
pub hive_mind_node_timeout_ms: u64,
}
/// Convert a domain `Settings` entity into its API response representation.
///
/// ## Side-effects
/// - API key **values are redacted** — only key names are exposed.
impl From<crate::domain::settings::Settings> for SettingsResponse {
fn from(s: crate::domain::settings::Settings) -> Self {
Self {
@@ -112,6 +128,7 @@ pub struct MemoryResponse {
pub provenances: Vec<String>,
}
/// Convert a domain `Memory` entity into its API response representation.
impl From<crate::domain::memory::Memory> for MemoryResponse {
fn from(m: crate::domain::memory::Memory) -> Self {
Self {
@@ -146,6 +163,11 @@ pub struct ConversationResponse {
pub temperature: Option<f32>,
}
/// Convert a domain `Conversation` entity into its API response summary.
///
/// ## Note
/// Only metadata is included (message count, model, system prompt);
/// individual messages are not returned in this response.
impl From<crate::domain::conversation::Conversation> for ConversationResponse {
fn from(c: crate::domain::conversation::Conversation) -> Self {
let message_count = c.len();
@@ -1,11 +1,24 @@
//! HTTP handler functions for CMS endpoints.
//! HTTP handler functions for the CMS REST API.
//!
//! Each handler takes a service trait (via generics or trait objects) and
//! returns domain-level results. These functions are agnostic about the
//! HTTP framework — callers (e.g. Axum routes) are responsible for mapping
//! `Result` into HTTP responses.
//! returns domain-level results. These functions are agnostic about the
//! HTTP framework — callers (e.g. hyper/Axum routes) are responsible for
//! mapping `Result` into HTTP responses with appropriate status codes.
//!
//! ## Handlers
//! - `handle_get_settings` — GET /settings → full settings response
//! - `handle_update_settings` — PUT /settings → partial update + full response
//! - `handle_list_memories` — GET /memories → list of memory summaries
//! - `handle_create_memory` — POST /memories → create/update memory response
//!
//! ## Design
//! Handlers are pure Rust functions with no dependency on the HTTP framework.
//! They receive service trait objects (`&S` or `&M`) and return `Result<T>`.
//! The caller (e.g. a hyper `Service`) is responsible for serialising the
//! response and setting HTTP status codes.
use anyhow::{Context, Result};
use tracing;
use crate::domain::memory::Memory;
use crate::domain::service::{MemoryService, SettingsService};
@@ -16,24 +29,34 @@ use super::dto::{MemoryCreateRequest, MemoryResponse, SettingsResponse, Settings
/// Handle `GET /settings`
///
/// Returns the current settings as a `SettingsResponse`.
///
/// Flow: load settings from service → convert to DTO → return.
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")?;
Ok(SettingsResponse::from(settings))
}
/// Handle `PUT /settings`
///
/// Applies the partial update from `req` to the current settings, persists
/// Applies a partial update from `req` to the current settings, persists
/// the result, and returns the updated `SettingsResponse`.
///
/// Flow: load current settings → apply each optional field → save → return DTO.
///
/// ## Validation
/// - `internet_mode` must be "Off", "ReadOnly", or "Full" (case-sensitive).
pub fn handle_update_settings<S: SettingsService>(
service: &S,
req: SettingsUpdateRequest,
) -> Result<SettingsResponse> {
tracing::debug!("handling PUT /settings");
// Load current settings as baseline for partial update
let mut settings: Settings = service
.load_settings()
.context("failed to load current settings for update")?;
// Apply partial updates
// Apply each optional field from the request (None = skip, Some = overwrite)
if let Some(val) = req.internet_mode {
settings.internet_mode = match val.as_str() {
"Off" => crate::domain::settings::InternetMode::Off,
@@ -102,8 +125,13 @@ pub fn handle_update_settings<S: SettingsService>(
/// Handle `GET /memories`
///
/// Lists all memory slugs, then loads each memory to return full responses.
/// Lists all memory slugs, returning summary responses for each.
/// Full content is not loaded — callers who need full content should
/// use a dedicated endpoint.
///
/// Flow: list slugs from service → map each to minimal MemoryResponse → return.
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")?;
// We can't load individual memories without a load_memory method on the
@@ -132,10 +160,17 @@ pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryR
/// Handle `POST /memories`
///
/// Creates or updates a memory from the request body.
///
/// Flow: build Memory from request DTO → save via service → return MemoryResponse.
///
/// ## Defaults
/// - `kind` defaults to "reference" if not specified
/// - `lifecycle` defaults to "new" if not specified
pub fn handle_create_memory<M: MemoryService>(
service: &M,
req: MemoryCreateRequest,
) -> Result<MemoryResponse> {
tracing::debug!("handling POST /memories for '{}'", req.name);
let now = chrono::Utc::now().timestamp();
let memory = Memory {
name: req.name,
@@ -1,4 +1,20 @@
//! HTTP adapter — handler functions and DTOs for CMS REST endpoints.
//! HTTP adapter — handler functions and DTOs for the CMS REST API.
//!
//! Provides hyper-based request handlers and serialisation types for
//! the CMS HTTP endpoints. Handlers receive domain service trait objects
//! via dependency injection (Arc-wrapped trait objects) and translate
//! between HTTP request/response formats and domain types.
//!
//! ## Sub-modules
//! - `dto` — request/response DTO types (JSON serialisation)
//! - `handlers` — hyper request handler functions
//!
//! ## Endpoints
//! - `GET /settings` — load current application settings
//! - `PUT /settings` — update application settings
//! - `GET /memories` — list all memory slugs
//! - `POST /memories` — create a new memory entry
//! - `POST /memories/{name}` — (future) update memory
pub mod dto;
pub mod handlers;
+8 -2
View File
@@ -1,6 +1,12 @@
//! Infrastructure layer — adapters and external concerns.
//! Infrastructure layer — concrete adapters and external-concern implementations.
//!
//! Contains persistence implementations (file I/O) and HTTP handler adapters.
//! This layer implements the traits defined in `domain::repository` and
//! provides HTTP handler adapters that consume `domain::service` traits.
//! It is the outermost ring of the Clean Architecture onion.
//!
//! ## Sub-modules
//! - `persistence` — file-based repository implementations (JSON, markdown, SQLite)
//! - `http` — hyper-based HTTP API handlers and DTO types
pub mod http;
pub mod persistence;
@@ -1,9 +1,17 @@
//! JSON filebacked `AppConfigRepository`.
//! JSON filebacked `AppConfigRepository` implementation.
//!
//! Path: `<base_dir>/app_config.json`
//!
//! On load, auto-detects Claude credentials from the environment or
//! Stores `AppConfig` as pretty-printed JSON at `<base_dir>/app_config.json`.
//! On load, auto-detects Claude credentials from the environment or from
//! `~/.claude/settings.json` and merges them into the provider map.
//!
//! ## Auto-Detection Flow
//! 1. Load `app_config.json` from disk (or use defaults if absent)
//! 2. Merge any default providers not present in the loaded config
//! 3. Detect Claude credentials from `~/.claude/settings.json` or env vars
//! 4. If Claude detected, add "claude" provider + model roles, set as default
//!
//! ## Atomicity
//! Writes use `write_json_atomic` (temp file + rename) to prevent corruption.
use std::path::Path;
@@ -14,32 +22,41 @@ use zesdex_utils::write_json_atomic;
use crate::domain::app_config::{AppConfig, ModelRole, ProviderConfig};
use crate::domain::repository::AppConfigRepository;
/// Persists `AppConfig` as pretty-printed JSON at `<base_dir>/app_config.json`.
/// File-based `AppConfigRepository` that reads/writes `app_config.json`.
///
/// On load, auto-detects Claude credentials and merges them into the
/// provider map (see module docs for the full flow).
#[derive(Debug, Clone, Default)]
pub struct JsonAppConfigRepository;
impl JsonAppConfigRepository {
/// Create a new repository instance.
/// Create a new repository instance (zero allocation).
pub fn new() -> Self {
Self
}
}
/// Configuration structure inside `~/.claude/settings.json`.
/// Internal helper: the `env` block inside `~/.claude/settings.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaudeEnv {
/// Override URL for the Anthropic API.
#[serde(alias = "ANTHROPIC_BASE_URL")]
anthropic_base_url: Option<String>,
/// Override API key for the Anthropic API.
#[serde(alias = "ANTHROPIC_API_KEY")]
anthropic_api_key: Option<String>,
}
/// Internal helper: top-level structure of `~/.claude/settings.json`.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ClaudeSettings {
/// Environment variable overrides block.
env: Option<ClaudeEnv>,
}
/// Try to read Claude credentials from `~/.claude/settings.json`'s `env` block.
///
/// Returns `(base_url, api_key)` if both are present, or `None`.
fn claude_credentials_from_file() -> Option<(String, String)> {
let path = dirs::home_dir()?.join(".claude").join("settings.json");
let content = std::fs::read_to_string(&path).ok()?;
@@ -50,15 +67,18 @@ fn claude_credentials_from_file() -> Option<(String, String)> {
Some((base_url, key))
}
/// Try to read Claude credentials from environment variables.
/// Try to read Claude credentials from the process environment variables.
///
/// Returns `(ANTHROPIC_BASE_URL, ANTHROPIC_API_KEY)` if both are set, or `None`.
fn claude_credentials_from_env() -> Option<(String, String)> {
let base_url = std::env::var("ANTHROPIC_BASE_URL").ok()?;
let key = std::env::var("ANTHROPIC_API_KEY").ok()?;
Some((base_url, key))
}
/// Return a `ProviderConfig` for the Claude provider, checking both
/// `~/.claude/settings.json` and the process environment.
/// Return a `ProviderConfig` for the Claude provider, checking both sources.
///
/// Flow: try ~/.claude/settings.json → fall back to env vars → return None if neither found.
fn detect_claude_settings_provider() -> Option<ProviderConfig> {
let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?;
Some(ProviderConfig {
@@ -70,8 +90,15 @@ fn detect_claude_settings_provider() -> Option<ProviderConfig> {
}
impl AppConfigRepository for JsonAppConfigRepository {
/// Load `AppConfig` from `<base_dir>/app_config.json`.
///
/// 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> {
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}"))?,
@@ -84,13 +111,13 @@ impl AppConfigRepository for JsonAppConfigRepository {
}
};
// Merge any default providers not present in the loaded config
// Phase 1: merge default providers that are not yet in the loaded config
let defaults = AppConfig::default();
for (name, provider) in defaults.providers {
cfg.providers.entry(name).or_insert(provider);
}
// Auto-detect Claude provider
// Phase 2: auto-detect Claude provider from file or environment
if let Some(claude_provider) = detect_claude_settings_provider() {
cfg.providers
.entry("claude".to_string())
@@ -123,7 +150,11 @@ impl AppConfigRepository for JsonAppConfigRepository {
Ok(cfg)
}
/// 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<()> {
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()))?;
let path = base_dir.join("app_config.json");
@@ -1,8 +1,11 @@
//! JSON filebacked `ConversationRepository`.
//! JSON filebacked `ConversationRepository` implementation.
//!
//! Path: `<session_dir>/conversation.json`
//! Stores `Conversation` as pretty-printed JSON at `<session_dir>/conversation.json`.
//! Uses atomic write (temp file + rename + fsync) for crash safety.
//!
//! Uses write-then-rename with fsync for crash safety.
//! ## Data Flow
//! - `load()`: read file → deserialize JSON → return Conversation
//! - `save()`: serialize Conversation → atomic write to conversation.json
use std::path::Path;
@@ -12,7 +15,9 @@ use zesdex_utils::write_json_atomic;
use crate::domain::conversation::Conversation;
use crate::domain::repository::ConversationRepository;
/// Persists `Conversation` as pretty-printed JSON at `<session_dir>/conversation.json`.
/// File-based `ConversationRepository` that reads/writes `conversation.json`.
///
/// Zero-allocation: the struct is a unit type marker.
#[derive(Debug, Clone, Default)]
pub struct JsonConversationRepository;
@@ -24,6 +29,9 @@ impl JsonConversationRepository {
}
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> {
let path = session_dir.join("conversation.json");
let data = std::fs::read_to_string(&path)
@@ -33,7 +41,11 @@ impl ConversationRepository for JsonConversationRepository {
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<()> {
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()))?;
let path = session_dir.join("conversation.json");
@@ -1,9 +1,17 @@
//! JSONL filebacked `EditLogRepository`.
//! JSONL filebacked `EditLogRepository` implementation.
//!
//! Path: `<session_dir>/edits.jsonl`
//! Stores `EditLog` as an append-only newline-delimited JSON file at
//! `<session_dir>/edits.jsonl`. New entries are appended to the file,
//! never rewritten, making this a durable write-ahead log.
//!
//! Append-only log: new entries are appended to the file, never rewritten.
//! In-memory cache is capped at 10K entries to prevent unbounded growth.
//! ## Data Flow
//! - `open()`: read existing JSONL lines from disk → parse into in-memory Vec
//! - `append()`: serialize entry as JSON line → fsync to disk → push to memory
//!
//! ## Memory Management
//! The in-memory cache is capped at `MAX_MEMORY_ENTRIES` (10K) to prevent
//! unbounded growth in long-running sessions. Old entries are evicted
//! from memory but remain on disk.
use std::io::{BufRead, BufReader, Write};
use std::path::Path;
@@ -13,7 +21,9 @@ use anyhow::{Context, Result};
use crate::domain::edit_log::{EditLog, EditLogEntry, MAX_MEMORY_ENTRIES};
use crate::domain::repository::EditLogRepository;
/// Persists `EditLog` as an append-only JSONL file at `<session_dir>/edits.jsonl`.
/// File-based `EditLogRepository` that reads/writes `edits.jsonl`.
///
/// Append-only JSONL format: each entry is one JSON line.
#[derive(Debug, Clone, Default)]
pub struct JsonlEditLogRepository;
@@ -24,6 +34,9 @@ impl JsonlEditLogRepository {
}
/// Read existing entries from disk into memory, capped at `MAX_MEMORY_ENTRIES`.
///
/// Flow: open file → read lines → parse JSON → cap at MAX_MEMORY_ENTRIES → return.
/// Silently skips malformed lines.
fn load_from_disk(path: &Path) -> Vec<EditLogEntry> {
let Ok(file) = std::fs::File::open(path) else {
return Vec::new();
@@ -46,7 +59,12 @@ impl JsonlEditLogRepository {
}
impl EditLogRepository for JsonlEditLogRepository {
/// Open (or initialise) the edit log for a session directory.
///
/// 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> {
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() {
@@ -65,7 +83,12 @@ impl EditLogRepository for JsonlEditLogRepository {
Ok(EditLog { entries })
}
/// Append one entry to the edit log and persist immediately (write-through).
///
/// 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<()> {
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";
@@ -91,6 +114,7 @@ impl EditLogRepository for JsonlEditLogRepository {
Ok(())
}
/// Return a cloned copy of all in-memory entries for inspection.
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
log.entries.clone()
}
@@ -1,11 +1,35 @@
//! Markdown filebacked `MemoryRepository`.
//! Markdown filebacked `MemoryRepository` implementation.
//!
//! Each memory is stored as a `.md` file with YAML-ish frontmatter.
//! Filenames are derived from the memory's `name` via slugification.
//! Filenames are derived from the memory's `name` via slugification
//! (see `Memory::slugify`).
//!
//! Frontmatter fields parsed from `---\n...\n---\n` header:
//! name, description, kind, created_at, updated_at, lifecycle,
//! outcome, scope, before, after, provenances
//! ## File Format
//! ```text
//! ---
//! name: my-memory
//! description: A useful lesson
//! kind: lesson
//! created_at: 1700000000
//! updated_at: 1700000000
//! lifecycle: active
//! outcome: success
//! scope: global
//! before: old content
//! after: new content
//! provenances: tool1, tool2
//! ---
//! Free-form markdown content body...
//! ```
//!
//! ## Data Flow
//! - `list()`: scan `*.md` files (excluding `MEMORY.md`), return slugs
//! - `load()`: read file → strip `---\n...\n---\n` frontmatter → parse fields
//! - `save()`: build frontmatter → write to temp file → rename atomically
//! - `delete()`: remove file from disk
//!
//! ## Atomicity
//! Writes use temp-file + rename + parent-directory fsync for crash safety.
use std::collections::HashMap;
use std::io::Write;
@@ -16,7 +40,10 @@ use anyhow::{Context, Result};
use crate::domain::memory::Memory;
use crate::domain::repository::MemoryRepository;
/// Persists `Memory` as markdown files with YAML-ish frontmatter.
/// File-based `MemoryRepository` that stores memories as `.md` files with frontmatter.
///
/// Each file has a YAML-ish `---\n...\n---\n` header followed by free-form
/// markdown content. Filenames are derived from `Memory.name` via slugification.
#[derive(Debug, Clone, Default)]
pub struct MarkdownMemoryRepository;
@@ -26,7 +53,9 @@ impl MarkdownMemoryRepository {
Self
}
/// Build the frontmatter lines for a memory.
/// Build the YAML-ish frontmatter string for a memory.
///
/// Only non-empty optional fields are included in the output.
fn build_frontmatter(memory: &Memory) -> String {
let outcome_line = memory
.outcome
@@ -71,7 +100,10 @@ impl MarkdownMemoryRepository {
)
}
/// Parse frontmatter lines into a `HashMap`.
/// Parse frontmatter lines into a `HashMap<String, String>`.
///
/// Flow: split lines → for each non-empty line, split on first ':' → insert.
/// Malformed lines (no ':') are silently skipped.
fn parse_frontmatter(front: &str) -> HashMap<String, String> {
front
.lines()
@@ -82,7 +114,12 @@ impl MarkdownMemoryRepository {
.collect()
}
/// Parse a memory file's contents (frontmatter + body) into a `Memory`.
/// Parse a memory file's full contents (frontmatter + body) into a `Memory`.
///
/// Flow: strip `---\n` prefix → split on `\n---\n` → parse front half with
/// `parse_frontmatter()` → use back half as content body → build Memory.
///
/// Returns `InvalidData` error if the frontmatter delimiter is missing.
fn parse(content: &str) -> std::io::Result<Memory> {
let content = content.strip_prefix("---\n").unwrap_or(content);
let parts: Vec<&str> = content.splitn(2, "\n---\n").collect();
@@ -132,6 +169,10 @@ impl MarkdownMemoryRepository {
}
impl MemoryRepository for MarkdownMemoryRepository {
/// List all memory slugs in `memory_dir` by scanning `*.md` files.
///
/// 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>> {
let Ok(entries) = std::fs::read_dir(memory_dir) else {
return Ok(Vec::new());
@@ -152,7 +193,11 @@ impl MemoryRepository for MarkdownMemoryRepository {
Ok(slugs)
}
/// 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> {
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()))?;
@@ -1,4 +1,20 @@
//! Persistence adapters — concrete file-based repository implementations.
//!
//! Each module implements one of the repository traits from `domain::repository`
//! using file-based storage (JSON, markdown, or newline-delimited JSON).
//! These are the outermost adapters that perform actual filesystem I/O.
//!
//! ## Repository Implementations
//! - `JsonSettingsRepository` — reads/writes `settings.json` (JSON)
//! - `JsonAppConfigRepository` — reads/writes `app_config.json` (JSON)
//! - `JsonConversationRepository` — reads/writes `conversation.json` (JSON)
//! - `MarkdownMemoryRepository` — reads/writes `{slug}.md` files (markdown + YAML frontmatter)
//! - `JsonlEditLogRepository` — appends to `edit_log.jsonl` (newline-delimited JSON)
//! - `FileRewindBlobRepository` — stores blobs as files in a `blobs/` subdirectory
//!
//! ## Atomicity
//! JSON writes use a temp-file + rename pattern to prevent partial writes
//! from corrupting configuration files during crashes.
pub mod app_config_repo;
pub mod conversation_repo;
@@ -18,13 +18,22 @@ use std::path::Path;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use tracing;
use crate::domain::repository::RewindBlobRepository;
/// A single entry in the append-only blob index (`index.jsonl`).
///
/// Each `store_blob` call appends one line; later entries with the same key
/// shadow earlier ones during `list_blob_keys` (keeping the last `created_at`
/// for ordering).
#[derive(Debug, Clone, Serialize, Deserialize)]
struct BlobIndexEntry {
/// The blob key (unique logical identifier).
key: String,
/// Optional MIME type hint (e.g. `"text/plain"`, `"image/png"`).
mime_type: Option<String>,
/// Epoch timestamp in milliseconds when the blob was stored.
created_at: i64,
}
@@ -35,23 +44,37 @@ pub struct FileRewindBlobRepository;
impl FileRewindBlobRepository {
/// Create a new filesystem rewind-blob repository.
pub fn new() -> Self {
tracing::debug!("FileRewindBlobRepository created");
Self
}
/// Return the blob storage directory for a given session directory.
fn blobs_dir(session_dir: &Path) -> std::path::PathBuf {
session_dir.join("blobs")
}
/// Return the on-disk path for a single blob file.
///
/// The key is hex-encoded before being used as the filename to avoid
/// path-traversal or invalid-filename-character issues.
fn blob_file_path(session_dir: &Path, blob_key: &str) -> std::path::PathBuf {
Self::blobs_dir(session_dir).join(format!("{}.bin", hex::encode(blob_key.as_bytes())))
}
/// Return the path to the append-only blob index file.
fn index_path(session_dir: &Path) -> std::path::PathBuf {
Self::blobs_dir(session_dir).join("index.jsonl")
}
}
impl RewindBlobRepository for FileRewindBlobRepository {
/// Persist `data` under `blob_key` in the session's blob directory.
///
/// Flow: write to a `.bin.tmp` temp file → fsync → rename to `.bin` →
/// append a JSON line to `index.jsonl` → fsync index.
///
/// This write-then-rename pattern ensures the blob file is never
/// observed in a partially-written state.
fn store_blob(
&self,
session_dir: &Path,
@@ -63,6 +86,7 @@ impl RewindBlobRepository for FileRewindBlobRepository {
std::fs::create_dir_all(&blobs_dir)
.with_context(|| format!("failed to create blobs dir '{}'", blobs_dir.display()))?;
// Write blob data atomically: temp → fsync → rename
let path = Self::blob_file_path(session_dir, blob_key);
let tmp = path.with_extension("bin.tmp");
std::fs::write(&tmp, data)?;
@@ -70,6 +94,7 @@ impl RewindBlobRepository for FileRewindBlobRepository {
f.sync_all()?;
std::fs::rename(&tmp, &path)?;
// Append index entry (JSONL line)
let entry = BlobIndexEntry {
key: blob_key.to_string(),
mime_type: mime_type.map(String::from),
@@ -84,22 +109,45 @@ impl RewindBlobRepository for FileRewindBlobRepository {
writeln!(f, "{}", serde_json::to_string(&entry)?)?;
f.sync_all()?;
tracing::debug!(
"stored blob key={} size={} mime={:?}",
blob_key,
data.len(),
mime_type
);
Ok(())
}
/// Read back a previously stored blob by key.
///
/// 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>>> {
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);
return Ok(None);
}
Ok(Some(std::fs::read(&path)?))
let data = std::fs::read(&path)?;
tracing::debug!("retrieved blob key={} size={}", blob_key, data.len());
Ok(Some(data))
}
/// List all unique blob keys in first-seen (oldest-first) order.
///
/// Flow: read `index.jsonl` → parse each line → de-duplicate by keeping
/// the *last* occurrence of each key → sort by `created_at` ASC.
///
/// 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>> {
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());
return Ok(Vec::new());
};
// Keep only the last occurrence of each key (later overwrites win),
// but remember first-seen order for the final ascending sort.
let mut first_seen_order: Vec<String> = Vec::new();
@@ -107,6 +155,8 @@ impl RewindBlobRepository for FileRewindBlobRepository {
std::collections::HashMap::new();
for line in content.lines() {
let Ok(entry) = serde_json::from_str::<BlobIndexEntry>(line) else {
// Silently skip malformed lines — they may come from an
// interrupted write in a previous session.
continue;
};
if !latest_by_key.contains_key(&entry.key) {
@@ -114,12 +164,17 @@ impl RewindBlobRepository for FileRewindBlobRepository {
}
latest_by_key.insert(entry.key.clone(), entry);
}
// Sort entries by creation timestamp ascending (oldest first).
let mut entries: Vec<BlobIndexEntry> = first_seen_order
.into_iter()
.filter_map(|k| latest_by_key.get(&k).cloned())
.collect();
entries.sort_by_key(|e| e.created_at);
Ok(entries.into_iter().map(|e| e.key).collect())
let keys: Vec<String> = entries.into_iter().map(|e| e.key).collect();
tracing::debug!("listed {} blob keys from index", keys.len());
Ok(keys)
}
}
@@ -7,6 +7,7 @@
use std::path::Path;
use anyhow::{Context, Result};
use tracing;
use zesdex_utils::write_json_atomic;
use crate::domain::repository::SettingsRepository;
@@ -24,12 +25,24 @@ impl JsonSettingsRepository {
}
impl SettingsRepository for JsonSettingsRepository {
/// Load settings from `<base_dir>/settings.json`.
///
/// Flow: read JSON file → deserialise → return `Settings`.
///
/// 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> {
let path = base_dir.join("settings.json");
match std::fs::read_to_string(&path) {
Ok(s) => match serde_json::from_str(&s) {
Ok(settings) => Ok(settings),
Ok(settings) => {
tracing::debug!("settings loaded from '{}'", path.display());
Ok(settings)
}
Err(e) => {
// Parse failure: a new field was added since the file
// was written → fall back to defaults gracefully.
tracing::warn!(
"settings.json at '{}' failed to parse ({e}); falling back to defaults",
path.display()
@@ -45,6 +58,10 @@ impl SettingsRepository for JsonSettingsRepository {
}
}
/// Persist `settings` as pretty-printed JSON at `<base_dir>/settings.json`.
///
/// 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()))?;
+20 -5
View File
@@ -1,9 +1,24 @@
//! `zesdex-cms` — Content Management System
//! `zesdex-cms` — Content Management System crate.
//!
//! Clean Architecture / DDD crate layout:
//! - **domain** — Pure entities and repository/service traits
//! - **application** — Use-case implementations
//! - **infrastructure** — Persistence adapters + HTTP handlers
//! Provides the domain model, application services, and infrastructure
//! adapters for managing conversations, memories, settings, and app
//! configuration in the zesdex platform.
//!
//! ## Architecture (Clean Architecture / DDD)
//! - **`domain`** — Pure entities, value objects, repository traits, and
//! service interfaces. Zero external framework dependencies.
//! - **`application`** — Use-case orchestration (conversation, memory,
//! settings services) that depend only on domain traits.
//! - **`infrastructure`** — Concrete adapters: file-based persistence
//! repositories and an HTTP API layer (hyper-based handlers + DTOs).
//!
//! ## Key Design Decisions
//! - All repositories in `infrastructure::persistence` operate on the
//! filesystem via the `Store` base path — no database server needed.
//! - HTTP handlers in `infrastructure::http` are thin — they delegate to
//! application services which hold the business logic.
//! - Domain types are plain Rust structs with `serde` serialisation,
//! stored as JSON files on disk.
#![allow(
clippy::cast_possible_truncation,