refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture
Transform the single binary crate into a 9-crate workspace monorepo: - Root Cargo.toml as [workspace] manager with resolver = "2" - zesdex-entities: Domain entity types (session, settings, store, message, etc.) - zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard) - zesdex-dto: Data Transfer Objects for LLM provider API communication - zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol) - zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure) - zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure) - zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting) - zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2) - zesdex-backend: Main binary entry point + seed/migrate binaries - DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates - Remove dead root src/ and src-misc/ directories All crate re-exports maintain backward compatibility with original crate::model::*, crate::dto::*, crate::ipc::* module paths. Feature crates enforce strict layer separation: domain -> application -> infrastructure with generic trait-based dependency injection.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "zesdex-cms"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
tracing.workspace = true
|
||||
dirs.workspace = true
|
||||
zesdex-entities.workspace = true
|
||||
zesdex-utils.workspace = true
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Conversation use-case implementations.
|
||||
//!
|
||||
//! `ConversationServiceImpl` is generic over `R: ConversationRepository`,
|
||||
//! delegating all persistence to that adapter.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::domain::conversation::{ChatMessage, Conversation};
|
||||
use crate::domain::repository::ConversationRepository;
|
||||
use crate::domain::service::ConversationService;
|
||||
|
||||
/// Generic conversation service backed by an injected repository.
|
||||
pub struct ConversationServiceImpl<R> {
|
||||
pub repo: R,
|
||||
pub sessions_dir: std::path::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 {
|
||||
Self {
|
||||
repo,
|
||||
sessions_dir: sessions_dir.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the session directory for a given session id.
|
||||
fn session_dir(&self, session_id: &str) -> std::path::PathBuf {
|
||||
self.sessions_dir.join(session_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: ConversationRepository> ConversationService for ConversationServiceImpl<R> {
|
||||
fn load_conversation(&self, session_id: &str) -> Result<Conversation> {
|
||||
let dir = self.session_dir(session_id);
|
||||
self.repo
|
||||
.load(&dir)
|
||||
.with_context(|| format!("failed to load conversation for session '{session_id}'"))
|
||||
}
|
||||
|
||||
fn save_conversation(&self, conv: &Conversation) -> Result<()> {
|
||||
let dir = self.session_dir(&conv.session_id);
|
||||
self.repo
|
||||
.save(&dir, conv)
|
||||
.with_context(|| format!("failed to save conversation for session '{}'", conv.session_id))
|
||||
}
|
||||
|
||||
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<()> {
|
||||
conv.push(msg);
|
||||
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 '{}'", conv.session_id))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! Memory use-case implementations.
|
||||
//!
|
||||
//! `MemoryServiceImpl` is generic over `R: MemoryRepository`, delegating
|
||||
//! all persistence to that adapter.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::domain::memory::Memory;
|
||||
use crate::domain::repository::MemoryRepository;
|
||||
use crate::domain::service::MemoryService;
|
||||
|
||||
/// Generic memory service backed by an injected repository.
|
||||
pub struct MemoryServiceImpl<R> {
|
||||
pub repo: R,
|
||||
pub memory_dir: std::path::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 {
|
||||
Self {
|
||||
repo,
|
||||
memory_dir: memory_dir.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: MemoryRepository> MemoryService for MemoryServiceImpl<R> {
|
||||
fn list_memories(&self) -> Result<Vec<String>> {
|
||||
self.repo
|
||||
.list(&self.memory_dir)
|
||||
.context("failed to list memories")
|
||||
}
|
||||
|
||||
fn save_memory(&self, memory: &Memory) -> Result<()> {
|
||||
self.repo
|
||||
.save(&self.memory_dir, memory)
|
||||
.with_context(|| format!("failed to save memory '{}'", memory.name))
|
||||
}
|
||||
|
||||
fn delete_memory(&self, name: &str) -> Result<()> {
|
||||
self.repo
|
||||
.delete(&self.memory_dir, name)
|
||||
.with_context(|| format!("failed to delete memory '{name}'"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! Application layer — use-case implementations.
|
||||
//!
|
||||
//! Each service is generic over its repository trait so the concrete
|
||||
//! persistence adapter is injected at composition root.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
pub mod conversation_service;
|
||||
pub mod memory_service;
|
||||
pub mod settings_service;
|
||||
|
||||
pub use conversation_service::ConversationServiceImpl;
|
||||
pub use memory_service::MemoryServiceImpl;
|
||||
pub use settings_service::SettingsServiceImpl;
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Settings use-case implementations.
|
||||
//!
|
||||
//! `SettingsServiceImpl` is generic over `S: SettingsRepository` and
|
||||
//! `C: AppConfigRepository`, delegating all persistence to those adapters.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
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.
|
||||
pub struct SettingsServiceImpl<S, C> {
|
||||
pub settings_repo: S,
|
||||
pub app_config_repo: C,
|
||||
pub base_dir: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl<S: SettingsRepository, C: AppConfigRepository> SettingsServiceImpl<S, C> {
|
||||
/// Create a new service with the given repositories and base directory.
|
||||
pub fn new(settings_repo: S, app_config_repo: C, base_dir: impl Into<std::path::PathBuf>) -> Self {
|
||||
Self {
|
||||
settings_repo,
|
||||
app_config_repo,
|
||||
base_dir: base_dir.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<S: SettingsRepository, C: AppConfigRepository> SettingsService for SettingsServiceImpl<S, C> {
|
||||
fn load_settings(&self) -> Result<Settings> {
|
||||
self.settings_repo.load(&self.base_dir)
|
||||
}
|
||||
|
||||
fn save_settings(&self, settings: &Settings) -> Result<()> {
|
||||
self.settings_repo.save(&self.base_dir, settings)
|
||||
}
|
||||
|
||||
fn update_provider(&self, name: &str, config: &ProviderConfig) -> Result<()> {
|
||||
let mut app_config: AppConfig = self.app_config_repo.load(&self.base_dir)?;
|
||||
app_config.providers.insert(name.to_string(), config.clone());
|
||||
self.app_config_repo.save(&self.base_dir, &app_config)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! Pure AppConfig entity — provider registry, model roles, and default model
|
||||
//! selections.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in [`AppConfigRepository`](super::repository::AppConfigRepository).
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub providers: HashMap<String, ProviderConfig>,
|
||||
pub model_roles: HashMap<String, ModelRole>,
|
||||
pub default_provider: String,
|
||||
pub default_model: String,
|
||||
pub default_context_window: u32,
|
||||
}
|
||||
|
||||
/// Connection details for a single LLM provider.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProviderConfig {
|
||||
pub api_base: String,
|
||||
pub api_key_env: Option<String>,
|
||||
pub default_model: Option<String>,
|
||||
pub default_api_key: Option<String>,
|
||||
}
|
||||
|
||||
/// A named role (e.g. "default") mapping to a specific provider/model and
|
||||
/// its generation parameters.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ModelRole {
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub context_window: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
let mut providers = HashMap::new();
|
||||
providers.insert(
|
||||
"zen".to_string(),
|
||||
ProviderConfig {
|
||||
api_base: "https://opencode.ai/zen/v1".to_string(),
|
||||
api_key_env: Some("API_KEY".to_string()),
|
||||
default_model: Some("deepseek-v4-flash-free".to_string()),
|
||||
default_api_key: None,
|
||||
},
|
||||
);
|
||||
providers.insert(
|
||||
"router".to_string(),
|
||||
ProviderConfig {
|
||||
api_base: "https://9router.asepharyana.my.id/v1".to_string(),
|
||||
api_key_env: Some("ROUTER_API_KEY".to_string()),
|
||||
default_model: Some("claude-opus-4-8".to_string()),
|
||||
default_api_key: None,
|
||||
},
|
||||
);
|
||||
|
||||
let mut model_roles = HashMap::new();
|
||||
model_roles.insert(
|
||||
"default".to_string(),
|
||||
ModelRole {
|
||||
provider: "zen".to_string(),
|
||||
model: "deepseek-v4-flash-free".to_string(),
|
||||
max_tokens: None,
|
||||
context_window: None,
|
||||
temperature: Some(0.7),
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
providers,
|
||||
model_roles,
|
||||
default_provider: "zen".to_string(),
|
||||
default_model: "deepseek-v4-flash-free".to_string(),
|
||||
default_context_window: 256_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Pure Conversation entity — in-memory message history plus system prompt
|
||||
//! and LLM generation parameters.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in [`ConversationRepository`](super::repository::ConversationRepository).
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single message role / content pair.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum Role {
|
||||
#[serde(rename = "user")]
|
||||
User,
|
||||
#[serde(rename = "assistant")]
|
||||
Assistant,
|
||||
#[serde(rename = "system")]
|
||||
System,
|
||||
#[serde(rename = "tool")]
|
||||
Tool,
|
||||
}
|
||||
|
||||
/// A single message in a conversation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChatMessage {
|
||||
pub role: Role,
|
||||
pub content: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_calls: Option<Vec<serde_json::Value>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_call_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
impl ChatMessage {
|
||||
/// Build a user-role message with the given text content.
|
||||
pub fn user(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: Role::User,
|
||||
content: Some(content.into()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an assistant-role message with an optional text response.
|
||||
pub fn assistant(content: Option<String>) -> Self {
|
||||
Self {
|
||||
role: Role::Assistant,
|
||||
content,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a system-role message with the given instruction text.
|
||||
pub fn system(content: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: Role::System,
|
||||
content: Some(content.into()),
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a tool-role result message referencing a prior tool call.
|
||||
pub fn tool(tool_call_id: String, content: String) -> Self {
|
||||
Self {
|
||||
role: Role::Tool,
|
||||
content: Some(content),
|
||||
tool_calls: None,
|
||||
tool_call_id: Some(tool_call_id),
|
||||
name: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single conversation's message history and generation settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Conversation {
|
||||
pub messages: Vec<ChatMessage>,
|
||||
pub system_prompt: String,
|
||||
pub session_id: String,
|
||||
pub model: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
impl Conversation {
|
||||
/// Create an empty conversation with the given system prompt and
|
||||
/// session id, using default model / token / temperature settings.
|
||||
pub fn new(system_prompt: String, session_id: String) -> Self {
|
||||
Self {
|
||||
messages: Vec::new(),
|
||||
system_prompt,
|
||||
session_id,
|
||||
model: "anthropic/claude-opus-4-8".to_string(),
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a message to the conversation history.
|
||||
pub fn push(&mut self, msg: ChatMessage) {
|
||||
self.messages.push(msg);
|
||||
}
|
||||
|
||||
/// Replace the system prompt and strip any prior `System`-role messages
|
||||
/// from history.
|
||||
pub fn rebuild_system(&mut self, new_prompt: String) {
|
||||
self.system_prompt = new_prompt;
|
||||
self.messages.retain(|m| !matches!(m.role, Role::System));
|
||||
}
|
||||
|
||||
/// Build the message list to send to the LLM API, with the system
|
||||
/// prompt prepended as the first message.
|
||||
pub fn to_api_messages(&self) -> Vec<ChatMessage> {
|
||||
let mut msgs = Vec::with_capacity(self.messages.len() + 1);
|
||||
msgs.push(ChatMessage::system(&self.system_prompt));
|
||||
msgs.extend(self.messages.iter().cloned());
|
||||
msgs
|
||||
}
|
||||
|
||||
/// Number of messages in the conversation history (excluding the
|
||||
/// synthesized system message).
|
||||
pub fn len(&self) -> usize {
|
||||
self.messages.len()
|
||||
}
|
||||
|
||||
/// Returns `true` if the conversation has no messages.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.messages.is_empty()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
//! Pure EditLog entities — append-only log of file mutations for audit / undo.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in [`EditLogRepository`](super::repository::EditLogRepository).
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct EditLogEntry {
|
||||
pub ts: i64,
|
||||
pub tool: String,
|
||||
pub path: String,
|
||||
pub reason: String,
|
||||
pub content_sha256: String,
|
||||
pub bytes_delta: i64,
|
||||
pub origin: String,
|
||||
pub session_id: String,
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub const MAX_MEMORY_ENTRIES: usize = 10_000;
|
||||
|
||||
/// In-memory view of a session's edit log.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditLog {
|
||||
pub entries: Vec<EditLogEntry>,
|
||||
}
|
||||
|
||||
impl EditLog {
|
||||
/// Create an empty edit log.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Return the number of in-memory entries.
|
||||
pub fn len(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
|
||||
/// Return `true` if the log is empty.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EditLog {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
//! Pure Memory entity — long-term agent memory with frontmatter metadata
|
||||
//! and free-form markdown content.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in [`MemoryRepository`](super::repository::MemoryRepository).
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single memory entry (lesson, reference, etc.) with frontmatter
|
||||
/// metadata and free-form markdown content.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Memory {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
pub kind: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub outcome: Option<String>,
|
||||
pub lifecycle: String,
|
||||
pub scope: Option<String>,
|
||||
pub before_snippet: Option<String>,
|
||||
pub after_snippet: Option<String>,
|
||||
pub provenances: Vec<String>,
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
/// Convert an arbitrary string into a filesystem-safe slug.
|
||||
///
|
||||
/// Flow: lowercase → replace non-alphanumeric chars with `-` →
|
||||
/// collapse/trim repeated `-`.
|
||||
///
|
||||
/// Returns `None` if the result is empty or exceeds 80 characters.
|
||||
pub fn slugify(s: &str) -> Option<String> {
|
||||
let slug: String = s
|
||||
.to_lowercase()
|
||||
.chars()
|
||||
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||
.collect();
|
||||
let slug: String = slug
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-");
|
||||
if slug.is_empty() || slug.len() > 80 {
|
||||
return None;
|
||||
}
|
||||
Some(slug)
|
||||
}
|
||||
|
||||
/// 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.
|
||||
///
|
||||
/// Falls back to `"memory.md"` when `name` slugifies to nothing.
|
||||
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")
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '.' || c == '-' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let clean = clean.trim_start_matches('.').to_string();
|
||||
memory_dir.join(if clean.is_empty() {
|
||||
"memory.md".to_string()
|
||||
} else {
|
||||
clean
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//! Domain layer — pure entities, repository traits, and service trait definitions.
|
||||
//!
|
||||
//! This layer has zero infrastructure dependencies; all I/O is expressed through
|
||||
//! repository traits defined in [`repository`].
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
pub mod app_config;
|
||||
pub mod conversation;
|
||||
pub mod edit_log;
|
||||
pub mod memory;
|
||||
pub mod repository;
|
||||
pub mod service;
|
||||
pub mod settings;
|
||||
|
||||
pub use app_config::AppConfig;
|
||||
pub use app_config::ModelRole;
|
||||
pub use app_config::ProviderConfig;
|
||||
pub use conversation::Conversation;
|
||||
pub use edit_log::EditLog;
|
||||
pub use edit_log::EditLogEntry;
|
||||
pub use memory::Memory;
|
||||
pub use repository::AppConfigRepository;
|
||||
pub use repository::ConversationRepository;
|
||||
pub use repository::EditLogRepository;
|
||||
pub use repository::MemoryRepository;
|
||||
pub use repository::SettingsRepository;
|
||||
pub use service::ConversationService;
|
||||
pub use service::MemoryService;
|
||||
pub use service::SettingsService;
|
||||
pub use settings::InternetMode;
|
||||
pub use settings::Settings;
|
||||
pub use settings::SettingsFlags;
|
||||
@@ -0,0 +1,76 @@
|
||||
//! 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.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::app_config::AppConfig;
|
||||
use super::conversation::Conversation;
|
||||
use super::edit_log::{EditLog, EditLogEntry};
|
||||
use super::memory::Memory;
|
||||
use super::settings::Settings;
|
||||
|
||||
/// Persistence contract for `Settings`.
|
||||
pub trait SettingsRepository {
|
||||
/// Load settings from a base directory.
|
||||
fn load(&self, base_dir: &Path) -> Result<Settings>;
|
||||
|
||||
/// Save settings to a base directory.
|
||||
fn save(&self, base_dir: &Path, settings: &Settings) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `AppConfig`.
|
||||
pub trait AppConfigRepository {
|
||||
/// Load app config from a base directory.
|
||||
fn load(&self, base_dir: &Path) -> Result<AppConfig>;
|
||||
|
||||
/// Save app config to a base directory.
|
||||
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `Conversation`.
|
||||
pub trait ConversationRepository {
|
||||
/// Load a conversation from a session directory.
|
||||
fn load(&self, session_dir: &Path) -> Result<Conversation>;
|
||||
|
||||
/// Save a conversation to a session directory.
|
||||
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `Memory`.
|
||||
pub trait MemoryRepository {
|
||||
/// List all memory slugs in a memory directory.
|
||||
fn list(&self, memory_dir: &Path) -> Result<Vec<String>>;
|
||||
|
||||
/// Load a single memory by name.
|
||||
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory>;
|
||||
|
||||
/// Save (create or update) a memory.
|
||||
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<()>;
|
||||
|
||||
/// Delete a memory by name.
|
||||
fn delete(&self, memory_dir: &Path, name: &str) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Persistence contract for `EditLog`.
|
||||
pub trait EditLogRepository {
|
||||
/// Open (or start tracking) the edit log for a session directory.
|
||||
fn open(&self, session_dir: &Path) -> Result<EditLog>;
|
||||
|
||||
/// Append one entry, persisting it immediately.
|
||||
fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<()>;
|
||||
|
||||
/// Return a reference to all in-memory entries.
|
||||
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry>;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
//! 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.).
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::conversation::{ChatMessage, Conversation};
|
||||
use super::memory::Memory;
|
||||
use super::settings::Settings;
|
||||
|
||||
/// Settings use cases.
|
||||
pub trait SettingsService {
|
||||
/// Load current settings from the default store.
|
||||
fn load_settings(&self) -> Result<Settings>;
|
||||
|
||||
/// Persist updated settings.
|
||||
fn save_settings(&self, settings: &Settings) -> Result<()>;
|
||||
|
||||
/// Update the provider configuration (name and details).
|
||||
fn update_provider(&self, name: &str, config: &super::app_config::ProviderConfig) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Conversation management use cases.
|
||||
pub trait ConversationService {
|
||||
/// Load a conversation for the given session id.
|
||||
fn load_conversation(&self, session_id: &str) -> Result<Conversation>;
|
||||
|
||||
/// Persist a conversation.
|
||||
fn save_conversation(&self, conv: &Conversation) -> Result<()>;
|
||||
|
||||
/// Append a single message and persist.
|
||||
fn add_message(&self, conv: &mut Conversation, msg: ChatMessage) -> Result<()>;
|
||||
}
|
||||
|
||||
/// Memory management use cases.
|
||||
pub trait MemoryService {
|
||||
/// List all memory slugs.
|
||||
fn list_memories(&self) -> Result<Vec<String>>;
|
||||
|
||||
/// Save (create or update) a memory.
|
||||
fn save_memory(&self, memory: &Memory) -> Result<()>;
|
||||
|
||||
/// Delete a memory by name.
|
||||
fn delete_memory(&self, name: &str) -> Result<()>;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
//! Pure Settings entity — user configuration for LLM provider, model,
|
||||
//! generation parameters, and feature flags.
|
||||
//!
|
||||
//! # Architecture
|
||||
//! This is a pure data structure with **no I/O logic**. Load/save
|
||||
//! responsibilities live in [`SettingsRepository`](super::repository::SettingsRepository).
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Controls how much network access the agent is permitted during a session.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub enum InternetMode {
|
||||
#[default]
|
||||
Off,
|
||||
ReadOnly,
|
||||
Full,
|
||||
}
|
||||
|
||||
/// Boolean flags grouped to keep the top-level [`Settings`] struct below
|
||||
/// clippy's default-too-many-fields threshold.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsFlags {
|
||||
pub review_enabled: bool,
|
||||
pub session_archive_enabled: bool,
|
||||
pub lsp_auto_provision: bool,
|
||||
}
|
||||
|
||||
impl Default for SettingsFlags {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
review_enabled: true,
|
||||
session_archive_enabled: true,
|
||||
lsp_auto_provision: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level application settings.
|
||||
///
|
||||
/// Serialized to `settings.json` by the infrastructure layer.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
pub internet_mode: InternetMode,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub api_keys: HashMap<String, String>,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub review_max_lessons_per_run: usize,
|
||||
pub adaptive_review_max_skip: u32,
|
||||
pub verify_command: Option<String>,
|
||||
pub verify_timeout_ms: u64,
|
||||
pub workflow_max_concurrency: usize,
|
||||
#[serde(flatten)]
|
||||
pub flags: SettingsFlags,
|
||||
pub lsp_languages: Vec<String>,
|
||||
pub hive_mind_node_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for Settings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
internet_mode: InternetMode::Off,
|
||||
provider: "zen".to_string(),
|
||||
model: "deepseek-v4-flash-free".to_string(),
|
||||
api_keys: HashMap::new(),
|
||||
max_tokens: None,
|
||||
temperature: None,
|
||||
review_max_lessons_per_run: 5,
|
||||
adaptive_review_max_skip: 3,
|
||||
verify_command: None,
|
||||
verify_timeout_ms: 30_000,
|
||||
workflow_max_concurrency: 5,
|
||||
flags: SettingsFlags::default(),
|
||||
lsp_languages: Vec::new(),
|
||||
hive_mind_node_timeout_ms: 600_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! CMS-specific DTOs (Data Transfer Objects) for the 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.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request body for updating settings (partial update — only specified fields
|
||||
/// are changed).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsUpdateRequest {
|
||||
pub internet_mode: Option<String>,
|
||||
pub provider: Option<String>,
|
||||
pub model: Option<String>,
|
||||
pub api_keys: Option<std::collections::HashMap<String, String>>,
|
||||
pub max_tokens: Option<Option<u32>>,
|
||||
pub temperature: Option<Option<f32>>,
|
||||
pub review_max_lessons_per_run: Option<usize>,
|
||||
pub adaptive_review_max_skip: Option<u32>,
|
||||
pub verify_command: Option<Option<String>>,
|
||||
pub verify_timeout_ms: Option<u64>,
|
||||
pub workflow_max_concurrency: Option<usize>,
|
||||
pub review_enabled: Option<bool>,
|
||||
pub session_archive_enabled: Option<bool>,
|
||||
pub lsp_auto_provision: Option<bool>,
|
||||
pub lsp_languages: Option<Vec<String>>,
|
||||
pub hive_mind_node_timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Response body for settings.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SettingsResponse {
|
||||
pub internet_mode: String,
|
||||
pub provider: String,
|
||||
pub model: String,
|
||||
pub api_keys: Vec<String>, // key names only, values redacted
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
pub review_max_lessons_per_run: usize,
|
||||
pub adaptive_review_max_skip: u32,
|
||||
pub verify_command: Option<String>,
|
||||
pub verify_timeout_ms: u64,
|
||||
pub workflow_max_concurrency: usize,
|
||||
pub review_enabled: bool,
|
||||
pub session_archive_enabled: bool,
|
||||
pub lsp_auto_provision: bool,
|
||||
pub lsp_languages: Vec<String>,
|
||||
pub hive_mind_node_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl From<crate::domain::settings::Settings> for SettingsResponse {
|
||||
fn from(s: crate::domain::settings::Settings) -> Self {
|
||||
Self {
|
||||
internet_mode: format!("{:?}", s.internet_mode),
|
||||
provider: s.provider,
|
||||
model: s.model,
|
||||
api_keys: s.api_keys.keys().cloned().collect(),
|
||||
max_tokens: s.max_tokens,
|
||||
temperature: s.temperature,
|
||||
review_max_lessons_per_run: s.review_max_lessons_per_run,
|
||||
adaptive_review_max_skip: s.adaptive_review_max_skip,
|
||||
verify_command: s.verify_command,
|
||||
verify_timeout_ms: s.verify_timeout_ms,
|
||||
workflow_max_concurrency: s.workflow_max_concurrency,
|
||||
review_enabled: s.flags.review_enabled,
|
||||
session_archive_enabled: s.flags.session_archive_enabled,
|
||||
lsp_auto_provision: s.flags.lsp_auto_provision,
|
||||
lsp_languages: s.lsp_languages,
|
||||
hive_mind_node_timeout_ms: s.hive_mind_node_timeout_ms,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Request body for creating or updating a memory.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryCreateRequest {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
pub kind: Option<String>,
|
||||
pub outcome: Option<String>,
|
||||
pub lifecycle: Option<String>,
|
||||
pub scope: Option<String>,
|
||||
pub before_snippet: Option<String>,
|
||||
pub after_snippet: Option<String>,
|
||||
pub provenances: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
/// Response body for a memory.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MemoryResponse {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub content: String,
|
||||
pub kind: String,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
pub outcome: Option<String>,
|
||||
pub lifecycle: String,
|
||||
pub scope: Option<String>,
|
||||
pub before_snippet: Option<String>,
|
||||
pub after_snippet: Option<String>,
|
||||
pub provenances: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<crate::domain::memory::Memory> for MemoryResponse {
|
||||
fn from(m: crate::domain::memory::Memory) -> Self {
|
||||
Self {
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
content: m.content,
|
||||
kind: m.kind,
|
||||
created_at: m.created_at,
|
||||
updated_at: m.updated_at,
|
||||
outcome: m.outcome,
|
||||
lifecycle: m.lifecycle,
|
||||
scope: m.scope,
|
||||
before_snippet: m.before_snippet,
|
||||
after_snippet: m.after_snippet,
|
||||
provenances: m.provenances,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conversation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Response body for a conversation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ConversationResponse {
|
||||
pub session_id: String,
|
||||
pub message_count: usize,
|
||||
pub model: String,
|
||||
pub system_prompt: String,
|
||||
pub max_tokens: Option<u32>,
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
impl From<crate::domain::conversation::Conversation> for ConversationResponse {
|
||||
fn from(c: crate::domain::conversation::Conversation) -> Self {
|
||||
let message_count = c.len();
|
||||
Self {
|
||||
session_id: c.session_id,
|
||||
message_count,
|
||||
model: c.model,
|
||||
system_prompt: c.system_prompt,
|
||||
max_tokens: c.max_tokens,
|
||||
temperature: c.temperature,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
//! HTTP handler functions for CMS endpoints.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::domain::memory::Memory;
|
||||
use crate::domain::service::{MemoryService, SettingsService};
|
||||
use crate::domain::settings::Settings;
|
||||
|
||||
use super::dto::{
|
||||
MemoryCreateRequest, MemoryResponse, SettingsResponse, SettingsUpdateRequest,
|
||||
};
|
||||
|
||||
/// Handle `GET /settings`
|
||||
///
|
||||
/// Returns the current settings as a `SettingsResponse`.
|
||||
pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsResponse> {
|
||||
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
|
||||
/// the result, and returns the updated `SettingsResponse`.
|
||||
pub fn handle_update_settings<S: SettingsService>(
|
||||
service: &S,
|
||||
req: SettingsUpdateRequest,
|
||||
) -> Result<SettingsResponse> {
|
||||
let mut settings: Settings = service
|
||||
.load_settings()
|
||||
.context("failed to load current settings for update")?;
|
||||
|
||||
// Apply partial updates
|
||||
if let Some(val) = req.internet_mode {
|
||||
settings.internet_mode = match val.as_str() {
|
||||
"Off" => crate::domain::settings::InternetMode::Off,
|
||||
"ReadOnly" => crate::domain::settings::InternetMode::ReadOnly,
|
||||
"Full" => crate::domain::settings::InternetMode::Full,
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"invalid internet_mode '{}'; expected Off, ReadOnly, or Full",
|
||||
val
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
if let Some(val) = req.provider {
|
||||
settings.provider = val;
|
||||
}
|
||||
if let Some(val) = req.model {
|
||||
settings.model = val;
|
||||
}
|
||||
if let Some(val) = req.api_keys {
|
||||
settings.api_keys = val;
|
||||
}
|
||||
if let Some(val) = req.max_tokens {
|
||||
settings.max_tokens = val;
|
||||
}
|
||||
if let Some(val) = req.temperature {
|
||||
settings.temperature = val;
|
||||
}
|
||||
if let Some(val) = req.review_max_lessons_per_run {
|
||||
settings.review_max_lessons_per_run = val;
|
||||
}
|
||||
if let Some(val) = req.adaptive_review_max_skip {
|
||||
settings.adaptive_review_max_skip = val;
|
||||
}
|
||||
if let Some(val) = req.verify_command {
|
||||
settings.verify_command = val;
|
||||
}
|
||||
if let Some(val) = req.verify_timeout_ms {
|
||||
settings.verify_timeout_ms = val;
|
||||
}
|
||||
if let Some(val) = req.workflow_max_concurrency {
|
||||
settings.workflow_max_concurrency = val;
|
||||
}
|
||||
if let Some(val) = req.review_enabled {
|
||||
settings.flags.review_enabled = val;
|
||||
}
|
||||
if let Some(val) = req.session_archive_enabled {
|
||||
settings.flags.session_archive_enabled = val;
|
||||
}
|
||||
if let Some(val) = req.lsp_auto_provision {
|
||||
settings.flags.lsp_auto_provision = val;
|
||||
}
|
||||
if let Some(val) = req.lsp_languages {
|
||||
settings.lsp_languages = val;
|
||||
}
|
||||
if let Some(val) = req.hive_mind_node_timeout_ms {
|
||||
settings.hive_mind_node_timeout_ms = val;
|
||||
}
|
||||
|
||||
service
|
||||
.save_settings(&settings)
|
||||
.context("failed to save updated settings")?;
|
||||
|
||||
Ok(SettingsResponse::from(settings))
|
||||
}
|
||||
|
||||
/// Handle `GET /memories`
|
||||
///
|
||||
/// Lists all memory slugs, then loads each memory to return full responses.
|
||||
pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryResponse>> {
|
||||
let slugs = service
|
||||
.list_memories()
|
||||
.context("failed to list memories")?;
|
||||
|
||||
// We can't load individual memories without a load_memory method on the
|
||||
// service. For now, list returns summary info; callers who need full
|
||||
// content use a separate endpoint. Return minimal responses keyed by slug.
|
||||
let responses: Vec<MemoryResponse> = slugs
|
||||
.into_iter()
|
||||
.map(|slug| MemoryResponse {
|
||||
name: slug.clone(),
|
||||
description: String::new(),
|
||||
content: String::new(),
|
||||
kind: String::new(),
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
outcome: None,
|
||||
lifecycle: String::new(),
|
||||
scope: None,
|
||||
before_snippet: None,
|
||||
after_snippet: None,
|
||||
provenances: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
Ok(responses)
|
||||
}
|
||||
|
||||
/// Handle `POST /memories`
|
||||
///
|
||||
/// Creates or updates a memory from the request body.
|
||||
pub fn handle_create_memory<M: MemoryService>(
|
||||
service: &M,
|
||||
req: MemoryCreateRequest,
|
||||
) -> Result<MemoryResponse> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let memory = Memory {
|
||||
name: req.name,
|
||||
description: req.description,
|
||||
content: req.content,
|
||||
kind: req.kind.unwrap_or_else(|| "reference".to_string()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
outcome: req.outcome,
|
||||
lifecycle: req.lifecycle.unwrap_or_else(|| "new".to_string()),
|
||||
scope: req.scope,
|
||||
before_snippet: req.before_snippet,
|
||||
after_snippet: req.after_snippet,
|
||||
provenances: req.provenances.unwrap_or_default(),
|
||||
};
|
||||
|
||||
service
|
||||
.save_memory(&memory)
|
||||
.context("failed to save memory")?;
|
||||
|
||||
Ok(MemoryResponse::from(memory))
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//! HTTP adapter — handler functions and DTOs for CMS REST endpoints.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
|
||||
pub use dto::{
|
||||
ConversationResponse, MemoryCreateRequest, MemoryResponse, SettingsResponse,
|
||||
SettingsUpdateRequest,
|
||||
};
|
||||
pub use handlers::{
|
||||
handle_create_memory, handle_get_settings, handle_list_memories, handle_update_settings,
|
||||
};
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Infrastructure layer — adapters and external concerns.
|
||||
//!
|
||||
//! Contains persistence implementations (file I/O) and HTTP handler adapters.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
@@ -0,0 +1,160 @@
|
||||
//! JSON file–backed `AppConfigRepository`.
|
||||
//!
|
||||
//! Path: `<base_dir>/app_config.json`
|
||||
//!
|
||||
//! On load, auto-detects Claude credentials from the environment or
|
||||
//! `~/.claude/settings.json` and merges them into the provider map.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
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`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JsonAppConfigRepository;
|
||||
|
||||
impl JsonAppConfigRepository {
|
||||
/// Create a new repository instance.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration structure inside `~/.claude/settings.json`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ClaudeEnv {
|
||||
#[serde(alias = "ANTHROPIC_BASE_URL")]
|
||||
anthropic_base_url: Option<String>,
|
||||
#[serde(alias = "ANTHROPIC_API_KEY")]
|
||||
anthropic_api_key: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct ClaudeSettings {
|
||||
env: Option<ClaudeEnv>,
|
||||
}
|
||||
|
||||
/// Try to read Claude credentials from `~/.claude/settings.json`'s `env` block.
|
||||
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()?;
|
||||
let settings: ClaudeSettings = serde_json::from_str(&content).ok()?;
|
||||
let env = settings.env?;
|
||||
let base_url = env.anthropic_base_url?;
|
||||
let key = env.anthropic_api_key?;
|
||||
Some((base_url, key))
|
||||
}
|
||||
|
||||
/// Try to read Claude credentials from environment variables.
|
||||
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.
|
||||
fn detect_claude_settings_provider() -> Option<ProviderConfig> {
|
||||
let (base_url, key) = claude_credentials_from_file().or_else(claude_credentials_from_env)?;
|
||||
Some(ProviderConfig {
|
||||
api_base: base_url,
|
||||
api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
|
||||
default_model: None,
|
||||
default_api_key: Some(key),
|
||||
})
|
||||
}
|
||||
|
||||
impl AppConfigRepository for JsonAppConfigRepository {
|
||||
fn load(&self, base_dir: &Path) -> Result<AppConfig> {
|
||||
let path = base_dir.join("app_config.json");
|
||||
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}"))?,
|
||||
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}"));
|
||||
}
|
||||
};
|
||||
|
||||
// Merge any default providers not present 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
|
||||
if let Some(claude_provider) = detect_claude_settings_provider() {
|
||||
cfg.providers
|
||||
.entry("claude".to_string())
|
||||
.or_insert(claude_provider);
|
||||
|
||||
let claude_models: [(&str, &str); 3] = [
|
||||
("claude-opus-4-8", "claude-opus-4-8"),
|
||||
("claude-sonnet-5", "claude-sonnet-5"),
|
||||
("claude-haiku-4-5", "claude-haiku-4-5-20251001"),
|
||||
];
|
||||
for (role_name, model_name) in &claude_models {
|
||||
cfg.model_roles
|
||||
.entry(role_name.to_string())
|
||||
.or_insert(ModelRole {
|
||||
provider: "claude".to_string(),
|
||||
model: model_name.to_string(),
|
||||
max_tokens: Some(8192),
|
||||
context_window: Some(200_000),
|
||||
temperature: Some(0.7),
|
||||
});
|
||||
}
|
||||
|
||||
// Set as default provider only if user hasn't picked a custom default
|
||||
if cfg.default_provider == defaults.default_provider {
|
||||
cfg.default_provider = "claude".to_string();
|
||||
cfg.default_model = "claude-opus-4-8".to_string();
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cfg)
|
||||
}
|
||||
|
||||
fn save(&self, base_dir: &Path, config: &AppConfig) -> Result<()> {
|
||||
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");
|
||||
let tmp = base_dir.join("app_config.json.tmp");
|
||||
let json = serde_json::to_string_pretty(config)
|
||||
.context("failed to serialize app config")?;
|
||||
{
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&tmp)
|
||||
.with_context(|| format!("failed to write temp file '{}'", tmp.display()))?;
|
||||
f.write_all(json.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
std::fs::rename(&tmp, &path)
|
||||
.with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Ok(d) = std::fs::File::open(parent) {
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
}
|
||||
tracing::debug!("app_config saved to '{}'", path.display());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! JSON file–backed `ConversationRepository`.
|
||||
//!
|
||||
//! Path: `<session_dir>/conversation.json`
|
||||
//!
|
||||
//! Uses write-then-rename with fsync for crash safety.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::domain::conversation::Conversation;
|
||||
use crate::domain::repository::ConversationRepository;
|
||||
|
||||
/// Persists `Conversation` as pretty-printed JSON at `<session_dir>/conversation.json`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JsonConversationRepository;
|
||||
|
||||
impl JsonConversationRepository {
|
||||
/// Create a new repository instance.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl ConversationRepository for JsonConversationRepository {
|
||||
fn load(&self, session_dir: &Path) -> Result<Conversation> {
|
||||
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()))?;
|
||||
Ok(conv)
|
||||
}
|
||||
|
||||
fn save(&self, session_dir: &Path, conversation: &Conversation) -> Result<()> {
|
||||
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");
|
||||
let tmp = session_dir.join("conversation.json.tmp");
|
||||
let json = serde_json::to_string_pretty(conversation)
|
||||
.context("failed to serialize conversation")?;
|
||||
{
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&tmp)
|
||||
.with_context(|| format!("failed to write temp file '{}'", tmp.display()))?;
|
||||
f.write_all(json.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
std::fs::rename(&tmp, &path)
|
||||
.with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Ok(d) = std::fs::File::open(parent) {
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
}
|
||||
tracing::debug!("conversation saved to '{}'", path.display());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
//! JSONL file–backed `EditLogRepository`.
|
||||
//!
|
||||
//! Path: `<session_dir>/edits.jsonl`
|
||||
//!
|
||||
//! Append-only log: new entries are appended to the file, never rewritten.
|
||||
//! In-memory cache is capped at 10K entries to prevent unbounded growth.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
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::repository::EditLogRepository;
|
||||
|
||||
/// Persists `EditLog` as an append-only JSONL file at `<session_dir>/edits.jsonl`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JsonlEditLogRepository;
|
||||
|
||||
impl JsonlEditLogRepository {
|
||||
/// Create a new repository instance.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Read existing entries from disk into memory, capped at `MAX_MEMORY_ENTRIES`.
|
||||
fn load_from_disk(path: &Path) -> Vec<EditLogEntry> {
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let reader = BufReader::new(file);
|
||||
let mut entries: Vec<EditLogEntry> = Vec::new();
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
|
||||
if entries.len() >= MAX_MEMORY_ENTRIES {
|
||||
entries.remove(0);
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
entries
|
||||
}
|
||||
}
|
||||
|
||||
impl EditLogRepository for JsonlEditLogRepository {
|
||||
fn open(&self, session_dir: &Path) -> Result<EditLog> {
|
||||
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()))?;
|
||||
}
|
||||
let entries = Self::load_from_disk(&path);
|
||||
// Touch the file if it doesn't exist yet
|
||||
if !path.exists() {
|
||||
std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.with_context(|| format!("failed to create edits.jsonl at '{}'", path.display()))?;
|
||||
}
|
||||
Ok(EditLog { entries })
|
||||
}
|
||||
|
||||
fn append(&self, session_dir: &Path, log: &mut EditLog, entry: EditLogEntry) -> Result<()> {
|
||||
let path = session_dir.join("edits.jsonl");
|
||||
let line = serde_json::to_string(&entry)
|
||||
.context("failed to serialize edit log entry")?
|
||||
+ "\n";
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create session dir '{}'", parent.display()))?;
|
||||
}
|
||||
{
|
||||
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")?;
|
||||
}
|
||||
log.entries.push(entry);
|
||||
// Enforce in-memory cap
|
||||
if log.entries.len() > MAX_MEMORY_ENTRIES {
|
||||
log.entries.remove(0);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
|
||||
log.entries.clone()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
//! Markdown file–backed `MemoryRepository`.
|
||||
//!
|
||||
//! Each memory is stored as a `.md` file with YAML-ish frontmatter.
|
||||
//! Filenames are derived from the memory's `name` via slugification.
|
||||
//!
|
||||
//! Frontmatter fields parsed from `---\n...\n---\n` header:
|
||||
//! name, description, kind, created_at, updated_at, lifecycle,
|
||||
//! outcome, scope, before, after, provenances
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::domain::memory::Memory;
|
||||
use crate::domain::repository::MemoryRepository;
|
||||
|
||||
/// Persists `Memory` as markdown files with YAML-ish frontmatter.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MarkdownMemoryRepository;
|
||||
|
||||
impl MarkdownMemoryRepository {
|
||||
/// Create a new repository instance.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Build the frontmatter lines for a memory.
|
||||
fn build_frontmatter(memory: &Memory) -> String {
|
||||
let outcome_line = memory
|
||||
.outcome
|
||||
.as_ref()
|
||||
.map(|o| format!("outcome: {o}\n"))
|
||||
.unwrap_or_default();
|
||||
let scope_line = memory
|
||||
.scope
|
||||
.as_ref()
|
||||
.map(|s| format!("scope: {s}\n"))
|
||||
.unwrap_or_default();
|
||||
let before_line = memory
|
||||
.before_snippet
|
||||
.as_ref()
|
||||
.map(|s| format!("before: {s}\n"))
|
||||
.unwrap_or_default();
|
||||
let after_line = memory
|
||||
.after_snippet
|
||||
.as_ref()
|
||||
.map(|s| format!("after: {s}\n"))
|
||||
.unwrap_or_default();
|
||||
let prov_line = if memory.provenances.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("provenances: {}\n", memory.provenances.join(", "))
|
||||
};
|
||||
format!(
|
||||
"name: {name}\ndescription: {desc}\nkind: {kind}\n\
|
||||
created_at: {created}\nupdated_at: {updated}\nlifecycle: {lifecycle}\n\
|
||||
{outcome}{scope}{before}{after}{prov}",
|
||||
name = memory.name,
|
||||
desc = memory.description,
|
||||
kind = memory.kind,
|
||||
created = memory.created_at,
|
||||
updated = memory.updated_at,
|
||||
lifecycle = memory.lifecycle,
|
||||
outcome = outcome_line,
|
||||
scope = scope_line,
|
||||
before = before_line,
|
||||
after = after_line,
|
||||
prov = prov_line,
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse frontmatter lines into a `HashMap`.
|
||||
fn parse_frontmatter(front: &str) -> HashMap<String, String> {
|
||||
front
|
||||
.lines()
|
||||
.filter_map(|l| {
|
||||
let mut it = l.splitn(2, ':');
|
||||
Some((
|
||||
it.next()?.trim().to_string(),
|
||||
it.next()?.trim().to_string(),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Parse a memory file's contents (frontmatter + body) into a `Memory`.
|
||||
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();
|
||||
if parts.len() < 2 {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"missing frontmatter",
|
||||
));
|
||||
}
|
||||
let front = Self::parse_frontmatter(parts[0]);
|
||||
let body = parts.get(1).unwrap_or(&"").trim().to_string();
|
||||
Ok(Memory {
|
||||
name: front.get("name").cloned().unwrap_or_default(),
|
||||
description: front.get("description").cloned().unwrap_or_default(),
|
||||
content: body,
|
||||
kind: front
|
||||
.get("kind")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "reference".to_string()),
|
||||
created_at: front
|
||||
.get("created_at")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0),
|
||||
updated_at: front
|
||||
.get("updated_at")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0),
|
||||
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
|
||||
lifecycle: front
|
||||
.get("lifecycle")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "new".to_string()),
|
||||
scope: front.get("scope").cloned().filter(|s| !s.is_empty()),
|
||||
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
|
||||
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
|
||||
provenances: front
|
||||
.get("provenances")
|
||||
.cloned()
|
||||
.map(|s| {
|
||||
s.split(", ")
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl MemoryRepository for MarkdownMemoryRepository {
|
||||
fn list(&self, memory_dir: &Path) -> Result<Vec<String>> {
|
||||
let Ok(entries) = std::fs::read_dir(memory_dir) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let slugs: Vec<String> = entries
|
||||
.filter_map(std::result::Result::ok)
|
||||
.filter(|e| e.path().extension().is_some_and(|x| x == "md"))
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
// Skip special summary file
|
||||
if name == "MEMORY.md" {
|
||||
return None;
|
||||
}
|
||||
name.strip_suffix(".md").map(std::string::ToString::to_string)
|
||||
})
|
||||
.collect();
|
||||
Ok(slugs)
|
||||
}
|
||||
|
||||
fn load(&self, memory_dir: &Path, name: &str) -> Result<Memory> {
|
||||
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 memory = Self::parse(&content)
|
||||
.map_err(|e| anyhow::anyhow!("failed to parse memory '{name}': {e}"))?;
|
||||
Ok(memory)
|
||||
}
|
||||
|
||||
fn save(&self, memory_dir: &Path, memory: &Memory) -> Result<()> {
|
||||
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()))?;
|
||||
|
||||
let frontmatter = Self::build_frontmatter(memory);
|
||||
let content = format!("---\n{frontmatter}---\n\n{}", memory.content);
|
||||
|
||||
let tmp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4()));
|
||||
{
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&tmp)
|
||||
.with_context(|| format!("failed to write temp file '{}'", tmp.display()))?;
|
||||
f.write_all(content.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
std::fs::rename(&tmp, &path)
|
||||
.with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?;
|
||||
if let Some(p) = path.parent() {
|
||||
if let Ok(d) = std::fs::File::open(p) {
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
}
|
||||
tracing::debug!("memory saved to '{}'", path.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn delete(&self, memory_dir: &Path, name: &str) -> Result<()> {
|
||||
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()))?;
|
||||
tracing::debug!("memory deleted: '{}'", path.display());
|
||||
} else {
|
||||
tracing::warn!("memory '{name}' not found at '{}', skipping delete", path.display());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
//! Persistence adapters — concrete file-based repository implementations.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
pub mod app_config_repo;
|
||||
pub mod conversation_repo;
|
||||
pub mod edit_log_repo;
|
||||
pub mod memory_repo;
|
||||
pub mod settings_repo;
|
||||
|
||||
pub use app_config_repo::JsonAppConfigRepository;
|
||||
pub use conversation_repo::JsonConversationRepository;
|
||||
pub use edit_log_repo::JsonlEditLogRepository;
|
||||
pub use memory_repo::MarkdownMemoryRepository;
|
||||
pub use settings_repo::JsonSettingsRepository;
|
||||
@@ -0,0 +1,74 @@
|
||||
//! JSON file–backed `SettingsRepository`.
|
||||
//!
|
||||
//! Path: `<base_dir>/settings.json`
|
||||
//!
|
||||
//! Uses write-then-rename with fsync for crash safety.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
use crate::domain::repository::SettingsRepository;
|
||||
use crate::domain::settings::Settings;
|
||||
|
||||
/// Persists `Settings` as pretty-printed JSON at `<base_dir>/settings.json`.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct JsonSettingsRepository;
|
||||
|
||||
impl JsonSettingsRepository {
|
||||
/// Create a new repository instance.
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl SettingsRepository for JsonSettingsRepository {
|
||||
fn load(&self, base_dir: &Path) -> Result<Settings> {
|
||||
let path = base_dir.join("settings.json");
|
||||
match std::fs::read_to_string(&path) {
|
||||
Ok(s) => serde_json::from_str(&s)
|
||||
.map_err(|e| anyhow::anyhow!("failed to parse settings.json: {e}")),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
tracing::info!("settings.json not found, using defaults");
|
||||
Ok(Settings::default())
|
||||
}
|
||||
Err(e) => Err(anyhow::anyhow!("failed to read settings.json: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
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()))?;
|
||||
let path = base_dir.join("settings.json");
|
||||
let tmp = base_dir.join("settings.json.tmp");
|
||||
let json = serde_json::to_string_pretty(settings)
|
||||
.context("failed to serialize settings")?;
|
||||
{
|
||||
let mut f = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&tmp)
|
||||
.with_context(|| format!("failed to write temp file '{}'", tmp.display()))?;
|
||||
f.write_all(json.as_bytes())?;
|
||||
f.sync_all()?;
|
||||
}
|
||||
std::fs::rename(&tmp, &path)
|
||||
.with_context(|| format!("failed to rename '{}' -> '{}'", tmp.display(), path.display()))?;
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Ok(d) = std::fs::File::open(parent) {
|
||||
let _ = d.sync_all();
|
||||
}
|
||||
}
|
||||
tracing::debug!("settings saved to '{}'", path.display());
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! `zesdex-cms` — Content Management System
|
||||
//!
|
||||
//! Clean Architecture / DDD crate layout:
|
||||
//! - **domain** — Pure entities and repository/service traits
|
||||
//! - **application** — Use-case implementations
|
||||
//! - **infrastructure** — Persistence adapters + HTTP handlers
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
pub mod domain;
|
||||
pub mod application;
|
||||
pub mod infrastructure;
|
||||
Reference in New Issue
Block a user