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 history: HistoryCfg,
#[serde(default)]
pub network: NetworkCfg,
#[serde(default)]
pub permissions: PermissionsCfg,
#[serde(default)]
pub skills: SkillsCfg,
#[serde(default)]
pub agents: AgentsCfg,
#[serde(default)]
pub concurrency: ConcurrencyCfg,
#[serde(skip)]
raw: Option<toml::Value>,
}
#[derive(Debug, Default, Deserialize)]
pub struct SkillsCfg {
pub claude: Option<bool>,
#[serde(default)]
pub marketplaces: std::collections::BTreeMap<String, String>,
}
impl SkillsCfg {
pub fn marketplace_roots(
&self,
config_dir: &Path,
) -> (Vec<(String, std::path::PathBuf)>, Vec<String>) {
let mut roots = Vec::new();
let mut warnings = Vec::new();
for (name, source) in &self.marketplaces {
let Some(name) = hotl_tools::skills::normalize_marketplace_name(name) else {
warnings.push(format!(
"[skills.marketplaces] `{name}` is not a valid marketplace name \
(letters, digits, `.`/`_`/`-`, alphanumeric first char, ≤ 64 chars) \
— entry skipped"
));
continue;
};
let dir = if is_git_url(source) {
config_dir.join("marketplaces").join(&name)
} else {
expand_home(source)
};
roots.push((name, dir));
}
(roots, warnings)
}
}
#[derive(Debug, Default, Deserialize)]
pub struct AgentsCfg {
pub claude: Option<bool>,
}
pub fn is_git_url(source: &str) -> bool {
source.starts_with("http://")
|| source.starts_with("https://")
|| source.starts_with("git@")
|| source.starts_with("ssh://")
|| source.ends_with(".git")
}
fn expand_home(path: &str) -> std::path::PathBuf {
if let Some(rest) = path.strip_prefix("~/") {
if let Some(home) = std::env::var_os("HOME") {
return std::path::PathBuf::from(home).join(rest);
}
}
std::path::PathBuf::from(path)
}
#[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 => (PermissionMode::Auto, None),
Some(s) => match PermissionMode::from_str(s) {
Some(m) => (m, None),
None => (
PermissionMode::Ask,
Some(format!(
"[permissions].mode = \"{s}\" is not a mode (ask | auto | plan | dontask) — failing closed to \"ask\""
)),
),
},
}
}
}
#[derive(Debug, Default, Deserialize)]
pub struct ProviderCfg {
pub model: Option<String>,
pub base_url: Option<String>,
pub auth: 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>,
}
#[allow(dead_code)]
impl ContextCfg {
pub fn resolve_window(&self, model: &str, env: Option<&str>) -> (u64, Option<String>) {
if let Some(window) = env.and_then(|v| v.parse::<u64>().ok()).or(self.window) {
return (window, None);
}
match hotl_provider::catalog::context_window(model) {
Some(window) => (window, None),
None => (
hotl_provider::catalog::FALLBACK_CONTEXT_WINDOW,
Some(format!(
"hotl: `{model}` is not in the model catalog — assuming a \
{} token context window. If that is wrong, set \
`[context] window` in config.toml (or HOTL_CONTEXT_WINDOW); \
too high overflows the model, too low compacts early.",
hotl_provider::catalog::FALLBACK_CONTEXT_WINDOW
)),
),
}
}
pub fn token_profile(&self, model: &str) -> hotl_context::TokenProfile {
match hotl_provider::catalog::lookup(model) {
Some(info) => {
hotl_context::TokenProfile::from_chars_per_token(info.ascii_chars_per_token)
}
None => hotl_context::TokenProfile::CONSERVATIVE,
}
}
}
#[derive(Debug, Default, Deserialize)]
pub struct BehaviorCfg {
pub sandbox: Option<bool>,
pub vim_mode: Option<bool>,
pub max_turns: Option<i64>,
}
impl BehaviorCfg {
pub fn vim_mode(&self) -> bool {
self.vim_mode.unwrap_or(false)
}
}
#[derive(Debug, Default, Deserialize)]
pub struct RetentionCfg {
pub max_age_days: Option<u64>,
pub max_sessions: Option<usize>,
}
#[derive(Debug, Default, Deserialize)]
pub struct HistoryCfg {
pub enabled: Option<bool>,
pub path: Option<String>,
pub max_entries: Option<usize>,
pub max_bytes: Option<u64>,
}
impl HistoryCfg {
pub fn is_enabled(&self) -> bool {
self.enabled.unwrap_or(true)
}
pub fn max_entries(&self) -> usize {
self.max_entries.unwrap_or(1000)
}
pub fn max_bytes(&self) -> u64 {
self.max_bytes.unwrap_or(2 * 1024 * 1024)
}
pub fn resolved_path(&self, data_dir: &Path) -> std::path::PathBuf {
match &self.path {
Some(p) => expand_home(p),
None => data_dir.join("history.jsonl"),
}
}
}
#[derive(Debug, Default, Deserialize)]
pub struct ConcurrencyCfg {
pub agents: Option<usize>,
pub requests: Option<usize>,
pub subprocs: Option<usize>,
pub worker_threads: Option<usize>,
pub blocking_threads: 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 retrieval_toml(&self) -> Option<String> {
let backends = self.raw.as_ref()?.get("retrieval")?;
toml::to_string(&toml::toml! { backend = (backends.clone()) }).ok()
}
pub fn web_toml(&self) -> Option<String> {
let web = self.raw.as_ref()?.get("web")?;
toml::to_string(web).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 window_resolves_from_the_catalog_when_unconfigured() {
let (window, warning) = cfg_with("").context.resolve_window("claude-opus-4-8", None);
assert_eq!(window, 1_000_000);
assert!(warning.is_none());
let (window, _) = cfg_with("")
.context
.resolve_window("claude-haiku-4-5", None);
assert_eq!(window, 200_000);
}
#[test]
fn explicit_config_and_env_still_beat_the_catalog() {
let cfg = cfg_with("[context]\nwindow = 64000\n");
assert_eq!(
cfg.context.resolve_window("claude-opus-4-8", None).0,
64_000
);
assert_eq!(
cfg.context
.resolve_window("claude-opus-4-8", Some("32000"))
.0,
32_000
);
assert_eq!(
cfg.context
.resolve_window("claude-opus-4-8", Some("banana"))
.0,
64_000
);
}
#[test]
fn an_uncatalogued_model_falls_back_loudly_not_silently() {
let (window, warning) = cfg_with("").context.resolve_window("openai/llama3", None);
assert_eq!(window, hotl_provider::catalog::FALLBACK_CONTEXT_WINDOW);
let warning = warning.expect("must warn — a silent wrong window is the bug we are fixing");
assert!(warning.contains("llama3"), "{warning}");
assert!(
warning.contains("[context] window"),
"name the knob: {warning}"
);
let cfg = cfg_with("[context]\nwindow = 8192\n");
let (window, warning) = cfg.context.resolve_window("openai/llama3", None);
assert_eq!(window, 8192);
assert!(warning.is_none());
}
#[test]
fn token_profile_follows_the_model() {
let cfg = cfg_with("");
let opus = cfg.context.token_profile("claude-opus-4-8");
assert_eq!(opus, hotl_context::TokenProfile::from_chars_per_token(3.0));
assert_eq!(
cfg.context.token_profile("llama3"),
hotl_context::TokenProfile::CONSERVATIVE
);
}
#[test]
fn default_model_matches_the_anthropic_provider() {
assert_eq!(
hotl_provider::catalog::DEFAULT_MODEL,
hotl_provider_anthropic::DEFAULT_MODEL
);
assert!(hotl_provider::catalog::lookup(hotl_provider::catalog::DEFAULT_MODEL).is_some());
}
#[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 behavior_max_turns_parses_including_the_unlimited_sentinel() {
assert_eq!(
cfg_with("[behavior]\nmax_turns = 250\n").behavior.max_turns,
Some(250)
);
assert_eq!(
cfg_with("[behavior]\nmax_turns = -1\n").behavior.max_turns,
Some(-1)
);
assert_eq!(cfg_with("").behavior.max_turns, None);
}
#[test]
fn retrieval_section_reserializes_for_the_loader() {
let cfg =
cfg_with("[[retrieval]]\nname = \"notes\"\nkind = \"mcp\"\ncommand = \"/bin/rag\"\n");
let t = cfg.retrieval_toml().unwrap();
assert!(t.contains("[[backend]]") && t.contains("notes"));
assert!(cfg_with("").retrieval_toml().is_none());
}
#[test]
fn concurrency_section_parses_and_defaults_absent() {
let cfg = cfg_with(
"[concurrency]\nagents = 2\nrequests = 3\nsubprocs = 6\nworker_threads = 4\nblocking_threads = 32\n",
);
assert_eq!(cfg.concurrency.agents, Some(2));
assert_eq!(cfg.concurrency.requests, Some(3));
assert_eq!(cfg.concurrency.subprocs, Some(6));
assert_eq!(cfg.concurrency.worker_threads, Some(4));
assert_eq!(cfg.concurrency.blocking_threads, Some(32));
let absent = cfg_with("");
assert_eq!(absent.concurrency.agents, None);
assert_eq!(absent.concurrency.requests, None);
assert_eq!(absent.concurrency.subprocs, None);
}
#[test]
fn web_section_passes_through_for_the_search_tool_loader() {
let cfg = cfg_with(
"[web]\n[web.search]\nurl = \"https://s.example/api\"\napi_key_env = \"SEARCH_KEY\"\n",
);
let t = cfg.web_toml().unwrap();
assert!(t.contains("[search]"), "was: {t}");
assert!(t.contains("https://s.example/api"));
assert!(t.contains("SEARCH_KEY"));
assert!(cfg_with("").web_toml().is_none());
}
#[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"));
let (m, w) = cfg_with("[permissions]\nmode = \"plan\"\n")
.permissions
.resolve(None);
assert_eq!(m, PermissionMode::Plan);
assert!(w.is_none());
let (m, w) = cfg_with("[permissions]\nmode = \"dontask\"\n")
.permissions
.resolve(None);
assert_eq!(m, PermissionMode::DontAsk);
assert!(w.is_none());
let (m, _) = cfg_with("").permissions.resolve(Some("plan"));
assert_eq!(m, PermissionMode::Plan);
let (m, _) = cfg_with("").permissions.resolve(Some("dontask"));
assert_eq!(m, PermissionMode::DontAsk);
}
#[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 vim_mode_resolves_off_unless_opted_in() {
assert!(!cfg_with("").behavior.vim_mode());
assert!(!cfg_with("[behavior]\nvim_mode = false\n")
.behavior
.vim_mode());
assert!(cfg_with("[behavior]\nvim_mode = true\n")
.behavior
.vim_mode());
}
#[test]
fn history_parses_and_falls_back_to_defaults() {
let cfg = cfg_with("");
assert!(cfg.history.is_enabled());
assert_eq!(cfg.history.max_entries(), 1000);
assert_eq!(cfg.history.max_bytes(), 2 * 1024 * 1024);
let cfg = cfg_with(
"[history]\nenabled = false\nmax_entries = 5\nmax_bytes = 4096\npath = \"~/h.jsonl\"\n",
);
assert!(!cfg.history.is_enabled());
assert_eq!(cfg.history.max_entries(), 5);
assert_eq!(cfg.history.max_bytes(), 4096);
}
#[test]
fn history_path_expands_home_and_defaults_under_data_dir() {
let data = std::path::Path::new("/data/hotl");
assert_eq!(
cfg_with("").history.resolved_path(data),
data.join("history.jsonl")
);
let cfg = cfg_with("[history]\npath = \"~/notes/h.jsonl\"\n");
let home = std::path::PathBuf::from(std::env::var_os("HOME").unwrap());
assert_eq!(cfg.history.resolved_path(data), home.join("notes/h.jsonl"));
}
#[test]
fn skills_marketplaces_parse_and_resolve() {
let cfg = cfg_with(
"[skills.marketplaces]\n\
acme = \"https://github.com/acme/skills.git\"\n\
team = \"/abs/team-skills\"\n\
\"bad:name\" = \"/x\"\n",
);
let dir = std::path::Path::new("/cfg");
let (roots, warnings) = cfg.skills.marketplace_roots(dir);
assert_eq!(
roots,
vec![
("acme".to_string(), dir.join("marketplaces/acme")),
(
"team".to_string(),
std::path::PathBuf::from("/abs/team-skills")
),
]
);
assert_eq!(warnings.len(), 1, "{warnings:?}");
assert!(warnings[0].contains("bad:name"), "{warnings:?}");
let cfg = cfg_with("[skills.marketplaces]\nhome = \"~/team-skills\"\n");
let (roots, _) = cfg.skills.marketplace_roots(dir);
let home = std::path::PathBuf::from(std::env::var_os("HOME").unwrap());
assert_eq!(roots, vec![("home".to_string(), home.join("team-skills"))]);
assert!(cfg_with("").skills.marketplace_roots(dir).0.is_empty());
}
#[test]
fn git_url_detection() {
for url in [
"https://github.com/a/b.git",
"http://host/repo",
"git@github.com:a/b.git",
"ssh://host/repo",
"/local/path/origin.git",
] {
assert!(is_git_url(url), "{url}");
}
for path in ["~/skills", "/abs/dir", "relative/dir"] {
assert!(!is_git_url(path), "{path}");
}
}
#[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());
}
}