use std::path::PathBuf;
pub const DEFAULT_LLM_MODEL: &str = "claude-haiku-4-5-20251001";
pub const DEFAULT_PASTE_THRESHOLD_BYTES: u64 = 1024;
pub const DEFAULT_WEB_TIMEOUT_SECS: u64 = 10;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub paths: Paths,
pub llm: LlmConfig,
pub paste: PasteConfig,
pub web: WebConfig,
pub dotenv_path: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PasteConfig {
pub threshold_bytes: u64,
}
impl Default for PasteConfig {
fn default() -> Self {
Self {
threshold_bytes: DEFAULT_PASTE_THRESHOLD_BYTES,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WebConfig {
pub timeout_secs: u64,
}
impl Default for WebConfig {
fn default() -> Self {
Self {
timeout_secs: DEFAULT_WEB_TIMEOUT_SECS,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Paths {
pub home: PathBuf,
pub user_home: PathBuf,
}
impl Paths {
pub fn snapshots(&self) -> PathBuf {
self.home.join("snapshots")
}
pub fn scans(&self) -> PathBuf {
self.home.join("scans")
}
pub fn web_log(&self) -> PathBuf {
self.home.join("web_log")
}
pub fn paste_log(&self) -> PathBuf {
self.home.join("paste_log")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct LlmConfig {
pub api_key: Option<String>,
pub model: String,
}
impl Config {
pub fn from_env(dotenv_path: Option<PathBuf>) -> Self {
let mut cfg = Self::from_env_lookup(|k| std::env::var(k).ok());
cfg.dotenv_path = dotenv_path;
cfg
}
pub fn from_env_lookup<F>(lookup: F) -> Self
where
F: Fn(&str) -> Option<String>,
{
let user_home = lookup("HOME").unwrap_or_else(|| ".".into());
let home = lookup("AGENTSEC_HOME").map_or_else(
|| PathBuf::from(&user_home).join(".agentsec"),
PathBuf::from,
);
let user_home = PathBuf::from(user_home);
let api_key = lookup("ANTHROPIC_API_KEY").filter(|s| !s.is_empty());
let model = lookup("AGENTSEC_LLM_MODEL").unwrap_or_else(|| DEFAULT_LLM_MODEL.to_string());
let paste_threshold = lookup("AGENTSEC_PASTE_THRESHOLD")
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(DEFAULT_PASTE_THRESHOLD_BYTES);
let web_timeout = lookup("AGENTSEC_WEB_TIMEOUT")
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(DEFAULT_WEB_TIMEOUT_SECS);
Self {
paths: Paths { home, user_home },
llm: LlmConfig { api_key, model },
paste: PasteConfig {
threshold_bytes: paste_threshold,
},
web: WebConfig {
timeout_secs: web_timeout,
},
dotenv_path: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn map_lookup<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
let map: HashMap<&str, &str> = pairs.iter().copied().collect();
move |k| map.get(k).map(|s| (*s).to_string())
}
#[test]
fn empty_env_uses_all_defaults() {
let cfg = Config::from_env_lookup(|_| None);
assert_eq!(cfg.paths.user_home, PathBuf::from("."));
assert_eq!(cfg.paths.home, PathBuf::from("./.agentsec"));
assert_eq!(cfg.llm.api_key, None);
assert_eq!(cfg.llm.model, DEFAULT_LLM_MODEL);
assert_eq!(cfg.paste.threshold_bytes, DEFAULT_PASTE_THRESHOLD_BYTES);
assert_eq!(cfg.web.timeout_secs, DEFAULT_WEB_TIMEOUT_SECS);
}
#[test]
fn home_only_derives_agentsec_home() {
let cfg = Config::from_env_lookup(map_lookup(&[("HOME", "/home/alice")]));
assert_eq!(cfg.paths.user_home, PathBuf::from("/home/alice"));
assert_eq!(cfg.paths.home, PathBuf::from("/home/alice/.agentsec"));
}
#[test]
fn agentsec_home_overrides_default() {
let cfg = Config::from_env_lookup(map_lookup(&[
("HOME", "/home/alice"),
("AGENTSEC_HOME", "/var/lib/agentsec"),
]));
assert_eq!(cfg.paths.home, PathBuf::from("/var/lib/agentsec"));
assert_eq!(cfg.paths.user_home, PathBuf::from("/home/alice"));
}
#[test]
fn paths_methods_join_under_home() {
let cfg = Config::from_env_lookup(map_lookup(&[("AGENTSEC_HOME", "/tmp/x")]));
assert_eq!(cfg.paths.snapshots(), PathBuf::from("/tmp/x/snapshots"));
assert_eq!(cfg.paths.scans(), PathBuf::from("/tmp/x/scans"));
assert_eq!(cfg.paths.web_log(), PathBuf::from("/tmp/x/web_log"));
assert_eq!(cfg.paths.paste_log(), PathBuf::from("/tmp/x/paste_log"));
}
#[test]
fn paste_threshold_override_via_env() {
let cfg = Config::from_env_lookup(map_lookup(&[("AGENTSEC_PASTE_THRESHOLD", "4096")]));
assert_eq!(cfg.paste.threshold_bytes, 4096);
}
#[test]
fn paste_threshold_invalid_env_uses_default() {
let cfg =
Config::from_env_lookup(map_lookup(&[("AGENTSEC_PASTE_THRESHOLD", "not-a-number")]));
assert_eq!(cfg.paste.threshold_bytes, DEFAULT_PASTE_THRESHOLD_BYTES);
}
#[test]
fn web_timeout_override_via_env() {
let cfg = Config::from_env_lookup(map_lookup(&[("AGENTSEC_WEB_TIMEOUT", "30")]));
assert_eq!(cfg.web.timeout_secs, 30);
}
#[test]
fn web_timeout_invalid_env_uses_default() {
let cfg = Config::from_env_lookup(map_lookup(&[("AGENTSEC_WEB_TIMEOUT", "bad")]));
assert_eq!(cfg.web.timeout_secs, DEFAULT_WEB_TIMEOUT_SECS);
}
#[test]
fn empty_api_key_treated_as_absent() {
let cfg = Config::from_env_lookup(map_lookup(&[("ANTHROPIC_API_KEY", "")]));
assert_eq!(cfg.llm.api_key, None);
}
#[test]
fn api_key_and_model_are_picked_up() {
let cfg = Config::from_env_lookup(map_lookup(&[
("ANTHROPIC_API_KEY", "sk-test"),
("AGENTSEC_LLM_MODEL", "claude-sonnet-4-6"),
]));
assert_eq!(cfg.llm.api_key, Some("sk-test".to_string()));
assert_eq!(cfg.llm.model, "claude-sonnet-4-6");
}
}