use std::path::Path;
use serde::Deserialize;
#[derive(Debug, Default, Deserialize)]
pub struct Config {
#[serde(default)]
pub provider: ProviderCfg,
#[serde(default)]
pub context: ContextCfg,
#[serde(default)]
pub behavior: BehaviorCfg,
#[serde(default)]
pub retention: RetentionCfg,
#[serde(default)]
pub network: NetworkCfg,
#[serde(default)]
pub permissions: PermissionsCfg,
#[serde(default)]
pub skills: SkillsCfg,
#[serde(skip)]
raw: Option<toml::Value>,
}
#[derive(Debug, Default, Deserialize)]
pub struct SkillsCfg {
pub claude: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
pub struct PermissionsCfg {
pub mode: Option<String>,
}
impl PermissionsCfg {
pub fn resolve(
&self,
env: Option<&str>,
) -> (hotl_tools::rules::PermissionMode, Option<String>) {
use hotl_tools::rules::PermissionMode;
let source = env.or(self.mode.as_deref());
match source {
None | Some("auto") => (PermissionMode::Auto, None),
Some("ask") => (PermissionMode::Ask, None),
Some(other) => (
PermissionMode::Ask,
Some(format!(
"[permissions].mode = \"{other}\" is not a mode (auto | ask) — failing closed to \"ask\""
)),
),
}
}
}
#[derive(Debug, Default, Deserialize)]
pub struct ProviderCfg {
pub model: Option<String>,
pub base_url: Option<String>,
pub fast_model: Option<String>,
pub api_key_helper: Option<String>,
pub api_key_helper_ttl_secs: Option<u64>,
}
#[derive(Debug, Default, Deserialize)]
pub struct ContextCfg {
pub window: Option<u64>,
pub compaction_reset: Option<bool>,
pub show_used_pct: Option<bool>,
pub evict_tokens: Option<u64>,
}
#[derive(Debug, Default, Deserialize)]
pub struct BehaviorCfg {
pub sandbox: Option<bool>,
pub vim_mode: Option<bool>,
}
#[derive(Debug, Default, Deserialize)]
pub struct RetentionCfg {
pub max_age_days: Option<u64>,
pub max_sessions: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
pub struct NetworkCfg {
pub egress: Option<String>,
#[serde(default)]
pub allow: Vec<String>,
}
impl NetworkCfg {
pub fn egress_policy(&self) -> (hotl_tools::net::EgressPolicy, Option<String>) {
use hotl_tools::net::EgressPolicy;
match self.egress.as_deref() {
None | Some("open") => (EgressPolicy::Open, None),
Some("off") => (EgressPolicy::Off, None),
Some("allowlist") => (EgressPolicy::Allowlist(self.allow.clone()), None),
Some(other) => (
EgressPolicy::Off,
Some(format!(
"config.toml [network].egress = \"{other}\" is not a mode \
(open | off | allowlist) — failing closed to \"off\""
)),
),
}
}
}
impl Config {
pub fn load(config_dir: &Path) -> Self {
let path = config_dir.join("config.toml");
let Ok(text) = std::fs::read_to_string(&path) else {
return Self::default();
};
match text.parse::<toml::Value>() {
Ok(raw) => {
let mut cfg: Config = toml::from_str(&text).unwrap_or_default();
cfg.raw = Some(raw);
cfg
}
Err(e) => {
eprintln!("hotl: config.toml ignored (parse error): {e}");
Self::default()
}
}
}
pub fn allow_toml(&self) -> Option<String> {
self.section_as_toml("allow")
}
pub fn mcp_toml(&self) -> Option<String> {
let servers = self.raw.as_ref()?.get("mcp")?;
toml::to_string(&toml::toml! { server = (servers.clone()) }).ok()
}
pub fn hooks_toml(&self) -> Option<String> {
let raw = self.raw.as_ref()?;
let hooks = raw.get("hook");
let diags = raw.get("diagnostics");
if hooks.is_none() && diags.is_none() {
return None;
}
let mut doc = toml::map::Map::new();
if let Some(h) = hooks {
doc.insert("hook".into(), h.clone());
}
if let Some(d) = diags {
doc.insert("diagnostics".into(), d.clone());
}
toml::to_string(&toml::Value::Table(doc)).ok()
}
fn section_as_toml(&self, key: &str) -> Option<String> {
let value = self.raw.as_ref()?.get(key)?;
let mut doc = toml::map::Map::new();
doc.insert(key.into(), value.clone());
toml::to_string(&toml::Value::Table(doc)).ok()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg_with(toml: &str) -> Config {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("config.toml"), toml).unwrap();
Config::load(dir.path())
}
#[test]
fn parses_settings_and_domain_sections() {
let cfg = cfg_with(
r#"
[provider]
model = "openai/gpt-5"
base_url = "http://localhost:11434/v1"
[context]
window = 128000
evict_tokens = 5000
[behavior]
vim_mode = false
[retention]
max_age_days = 30
max_sessions = 100
[[allow]]
tool = "bash"
prefix = "cargo "
[[mcp]]
name = "docs"
command = "/bin/docs"
description = "d"
[[hook]]
event = "pre_tool"
command = "/bin/guard"
[diagnostics]
rs = "cargo check"
"#,
);
assert_eq!(cfg.provider.model.as_deref(), Some("openai/gpt-5"));
assert_eq!(cfg.context.window, Some(128_000));
assert_eq!(cfg.behavior.vim_mode, Some(false));
assert_eq!(cfg.retention.max_age_days, Some(30));
assert_eq!(cfg.retention.max_sessions, Some(100));
assert!(cfg.allow_toml().unwrap().contains("prefix = \"cargo \""));
assert!(
cfg.mcp_toml().unwrap().contains("[[server]]")
&& cfg.mcp_toml().unwrap().contains("docs")
);
let hooks = cfg.hooks_toml().unwrap();
assert!(hooks.contains("pre_tool") && hooks.contains("cargo check"));
}
#[test]
fn provider_helper_keys_parse() {
let cfg = cfg_with(
"[provider]\napi_key_helper = \"mint-key --gw\"\napi_key_helper_ttl_secs = 300\n",
);
assert_eq!(
cfg.provider.api_key_helper.as_deref(),
Some("mint-key --gw")
);
assert_eq!(cfg.provider.api_key_helper_ttl_secs, Some(300));
}
#[test]
fn network_egress_parses_and_unknown_fails_closed() {
use hotl_tools::net::EgressPolicy;
let (policy, warning) = cfg_with("").network.egress_policy();
assert_eq!(policy, EgressPolicy::Open);
assert!(warning.is_none());
let (policy, warning) = cfg_with("[network]\negress = \"off\"\n")
.network
.egress_policy();
assert_eq!(policy, EgressPolicy::Off);
assert!(warning.is_none());
let cfg = cfg_with(
"[network]\negress = \"allowlist\"\nallow = [\"github.com\", \"*.crates.io\"]\n",
);
let (policy, warning) = cfg.network.egress_policy();
assert_eq!(
policy,
EgressPolicy::Allowlist(vec!["github.com".into(), "*.crates.io".into()])
);
assert!(warning.is_none());
let (policy, warning) = cfg_with("[network]\negress = \"opne\"\n")
.network
.egress_policy();
assert_eq!(policy, EgressPolicy::Off);
assert!(warning.unwrap().contains("opne"));
}
#[test]
fn permissions_mode_resolves_with_env_and_fails_closed() {
use hotl_tools::rules::PermissionMode;
let (m, w) = cfg_with("").permissions.resolve(None);
assert_eq!(m, PermissionMode::Auto);
assert!(w.is_none());
let (m, _) = cfg_with("[permissions]\nmode = \"ask\"\n")
.permissions
.resolve(None);
assert_eq!(m, PermissionMode::Ask);
let (m, _) = cfg_with("[permissions]\nmode = \"ask\"\n")
.permissions
.resolve(Some("auto"));
assert_eq!(m, PermissionMode::Auto);
let (m, w) = cfg_with("[permissions]\nmode = \"atuo\"\n")
.permissions
.resolve(None);
assert_eq!(m, PermissionMode::Ask);
assert!(w.unwrap().contains("atuo"));
}
#[test]
fn vim_mode_parses_and_defaults() {
let cfg = cfg_with("[behavior]\nvim_mode = false\n");
assert_eq!(cfg.behavior.vim_mode, Some(false));
assert_eq!(cfg_with("").behavior.vim_mode, None);
}
#[test]
fn empty_or_absent_config_is_defaults_and_none_sections() {
let cfg = Config::load(std::path::Path::new("/no/such/dir"));
assert!(cfg.provider.model.is_none());
assert!(
cfg.allow_toml().is_none() && cfg.mcp_toml().is_none() && cfg.hooks_toml().is_none()
);
assert!(cfg.retention.max_age_days.is_none());
}
}