Files
zesdex/crates/zesdex-cms/src/infrastructure/persistence/app_config_repo.rs
T

167 lines
6.6 KiB
Rust
Raw Normal View History

//! JSON filebacked `AppConfigRepository` implementation.
//!
//! 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;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use zesdex_utils::write_json_atomic;
use crate::domain::app_config::{AppConfig, ModelRole, ProviderConfig};
use crate::domain::repository::AppConfigRepository;
/// 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 (zero allocation).
pub fn new() -> Self {
Self
}
}
/// 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()?;
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 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 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 {
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 {
/// 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}"))?,
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}"));
}
};
// 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);
}
// 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())
.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)
}
/// 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");
write_json_atomic(&path, config, None)
.with_context(|| "failed to save app_config")?;
tracing::debug!("app_config saved to '{}'", path.display());
Ok(())
}
}