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,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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user