use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error: {0}")]
Parse(#[from] toml::de::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct RetryConfig {
pub max_retries: u32,
pub initial_backoff_ms: u64,
pub timeout_secs: u64,
}
impl Default for RetryConfig {
fn default() -> Self {
Self {
max_retries: 3,
initial_backoff_ms: 500,
timeout_secs: 600,
}
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LlmConfig {
pub agent_a_provider: String,
pub agent_a_model: String,
pub agent_a_api_key: String,
pub agent_a_base_url: String,
pub agent_a_max_tokens: u32,
pub agent_b_provider: String,
pub agent_b_model: String,
pub agent_b_api_key: String,
pub agent_b_base_url: String,
pub agent_b_max_tokens: u32,
pub retry: RetryConfig,
}
impl Default for LlmConfig {
fn default() -> Self {
Self {
agent_a_provider: "anthropic".to_string(),
agent_a_model: "claude-sonnet-4-6".to_string(),
agent_a_api_key: String::new(),
agent_a_base_url: String::new(),
agent_a_max_tokens: 16384,
agent_b_provider: "anthropic".to_string(),
agent_b_model: "claude-sonnet-4-6".to_string(),
agent_b_api_key: String::new(),
agent_b_base_url: String::new(),
agent_b_max_tokens: 65536,
retry: RetryConfig::default(),
}
}
}
impl std::fmt::Debug for LlmConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LlmConfig")
.field("agent_a_provider", &self.agent_a_provider)
.field("agent_a_model", &self.agent_a_model)
.field(
"agent_a_api_key",
&if self.agent_a_api_key.is_empty() {
"(empty)"
} else {
"***"
},
)
.field("agent_a_base_url", &self.agent_a_base_url)
.field("agent_a_max_tokens", &self.agent_a_max_tokens)
.field("agent_b_provider", &self.agent_b_provider)
.field("agent_b_model", &self.agent_b_model)
.field(
"agent_b_api_key",
&if self.agent_b_api_key.is_empty() {
"(empty)"
} else {
"***"
},
)
.field("agent_b_base_url", &self.agent_b_base_url)
.field("agent_b_max_tokens", &self.agent_b_max_tokens)
.field("retry", &self.retry)
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct IsolationConfig {
pub mode: String,
pub docker_image: String,
pub memory_limit: String,
pub cpu_limit: String,
pub timeout_secs: u64,
pub network_mode: String,
pub pids_limit: u32,
}
impl Default for IsolationConfig {
fn default() -> Self {
Self {
mode: "context".to_string(),
docker_image: "alpine:3".to_string(),
memory_limit: "256m".to_string(),
cpu_limit: "1.0".to_string(),
timeout_secs: 60,
network_mode: "none".to_string(),
pids_limit: 64,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct LimitsConfig {
pub max_packages_per_job: u32,
pub max_package_size_mb: u32,
pub concurrency: u32,
}
impl Default for LimitsConfig {
fn default() -> Self {
Self {
max_packages_per_job: 50,
max_package_size_mb: 10,
concurrency: 3,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ValidationConfig {
pub similarity_threshold: f64,
pub run_tests: bool,
pub syntax_check: bool,
}
impl Default for ValidationConfig {
fn default() -> Self {
Self {
similarity_threshold: 0.70,
run_tests: true,
syntax_check: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct OutputConfig {
pub default_license: String,
pub output_dir: String,
pub include_csp: bool,
pub include_audit: bool,
}
impl Default for OutputConfig {
fn default() -> Self {
Self {
default_license: "mit".to_string(),
output_dir: "./phalus-output".to_string(),
include_csp: true,
include_audit: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct WebConfig {
pub enabled: bool,
pub host: String,
pub port: u16,
}
impl Default for WebConfig {
fn default() -> Self {
Self {
enabled: false,
host: "127.0.0.1".to_string(),
port: 3000,
}
}
}
#[derive(Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct DocFetcherConfig {
pub max_readme_size_kb: u32,
pub max_type_def_size_kb: u32,
pub max_code_example_lines: u32,
pub github_token: String,
}
impl Default for DocFetcherConfig {
fn default() -> Self {
Self {
max_readme_size_kb: 500,
max_type_def_size_kb: 200,
max_code_example_lines: 10,
github_token: String::new(),
}
}
}
impl std::fmt::Debug for DocFetcherConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DocFetcherConfig")
.field("max_readme_size_kb", &self.max_readme_size_kb)
.field("max_type_def_size_kb", &self.max_type_def_size_kb)
.field("max_code_example_lines", &self.max_code_example_lines)
.field(
"github_token",
&if self.github_token.is_empty() {
"(empty)"
} else {
"***"
},
)
.finish()
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct PhalusConfig {
pub llm: LlmConfig,
pub isolation: IsolationConfig,
pub limits: LimitsConfig,
pub validation: ValidationConfig,
pub output: OutputConfig,
pub web: WebConfig,
pub doc_fetcher: DocFetcherConfig,
}
impl PhalusConfig {
pub fn default_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".phalus")
.join("config.toml")
}
pub fn load() -> Result<Self, ConfigError> {
let path = Self::default_path();
if path.exists() {
Self::load_from_file(&path)
} else {
Ok(Self::default())
}
}
pub fn load_from_file(path: &Path) -> Result<Self, ConfigError> {
let contents = std::fs::read_to_string(path)?;
let config: Self = toml::from_str(&contents)?;
Ok(config)
}
pub fn with_env_overrides(mut config: Self) -> Self {
for (key, value) in std::env::vars() {
let Some(rest) = key.strip_prefix("PHALUS_") else {
continue;
};
let parts: Vec<&str> = rest.splitn(2, "__").collect();
if parts.len() != 2 {
continue;
}
let section = parts[0].to_ascii_lowercase();
let field = parts[1].to_ascii_lowercase();
match section.as_str() {
"llm" => apply_llm_override(&mut config.llm, &field, &value),
"isolation" => apply_isolation_override(&mut config.isolation, &field, &value),
"limits" => apply_limits_override(&mut config.limits, &field, &value),
"validation" => apply_validation_override(&mut config.validation, &field, &value),
"output" => apply_output_override(&mut config.output, &field, &value),
"web" => apply_web_override(&mut config.web, &field, &value),
"doc_fetcher" => {
apply_doc_fetcher_override(&mut config.doc_fetcher, &field, &value)
}
_ => {}
}
}
config
}
}
fn apply_llm_override(cfg: &mut LlmConfig, field: &str, value: &str) {
match field {
"agent_a_provider" => cfg.agent_a_provider = value.to_string(),
"agent_a_model" => cfg.agent_a_model = value.to_string(),
"agent_a_api_key" => cfg.agent_a_api_key = value.to_string(),
"agent_a_base_url" => cfg.agent_a_base_url = value.to_string(),
"agent_a_max_tokens" => {
if let Ok(v) = value.parse() {
cfg.agent_a_max_tokens = v;
}
}
"agent_b_provider" => cfg.agent_b_provider = value.to_string(),
"agent_b_model" => cfg.agent_b_model = value.to_string(),
"agent_b_api_key" => cfg.agent_b_api_key = value.to_string(),
"agent_b_base_url" => cfg.agent_b_base_url = value.to_string(),
"agent_b_max_tokens" => {
if let Ok(v) = value.parse() {
cfg.agent_b_max_tokens = v;
}
}
"retry_max_retries" => {
if let Ok(v) = value.parse() {
cfg.retry.max_retries = v;
}
}
"retry_initial_backoff_ms" => {
if let Ok(v) = value.parse() {
cfg.retry.initial_backoff_ms = v;
}
}
"retry_timeout_secs" => {
if let Ok(v) = value.parse() {
cfg.retry.timeout_secs = v;
}
}
_ => {}
}
}
fn apply_isolation_override(cfg: &mut IsolationConfig, field: &str, value: &str) {
match field {
"mode" => cfg.mode = value.to_string(),
"docker_image" => cfg.docker_image = value.to_string(),
"memory_limit" => cfg.memory_limit = value.to_string(),
"cpu_limit" => cfg.cpu_limit = value.to_string(),
"timeout_secs" => {
if let Ok(v) = value.parse() {
cfg.timeout_secs = v;
}
}
"network_mode" => cfg.network_mode = value.to_string(),
"pids_limit" => {
if let Ok(v) = value.parse() {
cfg.pids_limit = v;
}
}
_ => {}
}
}
fn apply_limits_override(cfg: &mut LimitsConfig, field: &str, value: &str) {
match field {
"max_packages_per_job" => {
if let Ok(v) = value.parse() {
cfg.max_packages_per_job = v;
}
}
"max_package_size_mb" => {
if let Ok(v) = value.parse() {
cfg.max_package_size_mb = v;
}
}
"concurrency" => {
if let Ok(v) = value.parse() {
cfg.concurrency = v;
}
}
_ => {}
}
}
fn apply_validation_override(cfg: &mut ValidationConfig, field: &str, value: &str) {
match field {
"similarity_threshold" => {
if let Ok(v) = value.parse() {
cfg.similarity_threshold = v;
}
}
"run_tests" => {
if let Ok(v) = value.parse() {
cfg.run_tests = v;
}
}
"syntax_check" => {
if let Ok(v) = value.parse() {
cfg.syntax_check = v;
}
}
_ => {}
}
}
fn apply_output_override(cfg: &mut OutputConfig, field: &str, value: &str) {
match field {
"default_license" => cfg.default_license = value.to_string(),
"output_dir" => cfg.output_dir = value.to_string(),
"include_csp" => {
if let Ok(v) = value.parse() {
cfg.include_csp = v;
}
}
"include_audit" => {
if let Ok(v) = value.parse() {
cfg.include_audit = v;
}
}
_ => {}
}
}
fn apply_web_override(cfg: &mut WebConfig, field: &str, value: &str) {
match field {
"enabled" => {
if let Ok(v) = value.parse() {
cfg.enabled = v;
}
}
"host" => cfg.host = value.to_string(),
"port" => {
if let Ok(v) = value.parse() {
cfg.port = v;
}
}
_ => {}
}
}
fn apply_doc_fetcher_override(cfg: &mut DocFetcherConfig, field: &str, value: &str) {
match field {
"max_readme_size_kb" => {
if let Ok(v) = value.parse() {
cfg.max_readme_size_kb = v;
}
}
"max_type_def_size_kb" => {
if let Ok(v) = value.parse() {
cfg.max_type_def_size_kb = v;
}
}
"max_code_example_lines" => {
if let Ok(v) = value.parse() {
cfg.max_code_example_lines = v;
}
}
"github_token" => cfg.github_token = value.to_string(),
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn test_default_config() {
let config = PhalusConfig::default();
assert_eq!(config.llm.agent_a_model, "claude-sonnet-4-6");
assert_eq!(config.limits.max_packages_per_job, 50);
assert_eq!(config.validation.similarity_threshold, 0.70);
assert_eq!(config.output.default_license, "mit");
}
#[test]
fn test_load_from_toml() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("config.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(
f,
r#"
[llm]
agent_a_model = "gpt-4"
[limits]
max_packages_per_job = 10
"#
)
.unwrap();
let config = PhalusConfig::load_from_file(&path).unwrap();
assert_eq!(config.llm.agent_a_model, "gpt-4");
assert_eq!(config.limits.max_packages_per_job, 10);
assert_eq!(config.validation.similarity_threshold, 0.70);
}
#[test]
fn test_env_override() {
unsafe {
std::env::set_var("PHALUS_LLM__AGENT_A_MODEL", "test-model");
}
let config = PhalusConfig::with_env_overrides(PhalusConfig::default());
assert_eq!(config.llm.agent_a_model, "test-model");
unsafe {
std::env::remove_var("PHALUS_LLM__AGENT_A_MODEL");
}
}
}