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(default)]
pub sandbox: SandboxCfg,
#[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\""
)),
),
}
}
}
#[derive(Debug, Default, Deserialize)]
pub struct SandboxCfg {
#[serde(default)]
pub writable: Vec<String>,
pub file_tools: Option<String>,
}
impl SandboxCfg {
pub fn resolve(
&self,
config_dir: &Path,
data_dir: &Path,
) -> (hotl_tools::sandbox::SandboxExtras, Vec<String>) {
use hotl_tools::sandbox::FileToolsMode;
let mut warnings = Vec::new();
let mut writable: Vec<std::path::PathBuf> = Vec::new();
let protected = [canon_or(config_dir), canon_or(data_dir)];
let refusal = |entry: &str| {
format!(
"[sandbox].writable `{entry}` would expose hotl's own config/state \
(allow-rules, hooks, session logs) to sandboxed commands — entry refused"
)
};
for entry in &self.writable {
let expanded = expand_home(entry);
if !expanded.is_absolute() {
warnings.push(format!(
"[sandbox].writable `{entry}` is not an absolute path — entry ignored"
));
continue;
}
if protected.iter().any(|p| overlaps(&expanded, p))
|| overlaps(&expanded, config_dir)
|| overlaps(&expanded, data_dir)
{
warnings.push(refusal(entry));
continue;
}
if !expanded.exists() {
if let Err(e) = std::fs::create_dir_all(&expanded) {
warnings.push(format!(
"[sandbox].writable `{entry}` cannot be created: {e} — entry ignored"
));
continue;
}
}
let resolved = match expanded.canonicalize() {
Ok(p) => p,
Err(e) => {
warnings.push(format!(
"[sandbox].writable `{entry}` cannot be resolved: {e} — entry ignored"
));
continue;
}
};
if !resolved.is_dir() {
warnings.push(format!(
"[sandbox].writable `{entry}` is not a directory — entry ignored"
));
continue;
}
if protected.iter().any(|p| overlaps(&resolved, p)) {
warnings.push(refusal(entry));
continue;
}
if let Some(root) = risky_root(&resolved) {
warnings.push(format!(
"[sandbox].writable `{entry}` grants writes under the system root \
{root} — honored, but binaries and configuration living there \
become writable to every sandboxed command"
));
}
if !writable.contains(&resolved) {
writable.push(resolved);
}
}
let file_tools = match self.file_tools.as_deref() {
None | Some("workspace") => FileToolsMode::Workspace,
Some("writable") => FileToolsMode::Writable,
Some(other) => {
warnings.push(format!(
"config.toml [sandbox].file_tools = \"{other}\" is not a mode \
(workspace | writable) — failing closed to \"workspace\""
));
FileToolsMode::Workspace
}
};
if file_tools == FileToolsMode::Writable && writable.is_empty() {
warnings.push(
"[sandbox].file_tools = \"writable\" has no effect: `writable` is empty \
(or every entry was refused)"
.to_string(),
);
}
(
hotl_tools::sandbox::SandboxExtras {
writable,
file_tools,
},
warnings,
)
}
}
fn canon_or(p: &Path) -> std::path::PathBuf {
p.canonicalize().unwrap_or_else(|_| p.to_path_buf())
}
fn overlaps(a: &Path, b: &Path) -> bool {
a == b || a.starts_with(b) || b.starts_with(a)
}
fn risky_root(p: &Path) -> Option<&'static str> {
const RISKY: &[&str] = &[
"/etc",
"/usr",
"/bin",
"/sbin",
"/lib",
"/boot",
"/opt",
"/System",
"/Library",
"/Applications",
];
RISKY
.iter()
.find(|root| overlaps(p, &canon_or(Path::new(root))))
.copied()
}
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"));
}
fn sandbox_dirs() -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) {
let scratch = tempfile::tempdir().unwrap();
let config_dir = scratch.path().join("config-hotl");
let data_dir = scratch.path().join("data-hotl");
std::fs::create_dir_all(&config_dir).unwrap();
std::fs::create_dir_all(&data_dir).unwrap();
(scratch, config_dir, data_dir)
}
#[test]
fn sandbox_section_parses_and_empty_resolves_to_nothing() {
use hotl_tools::sandbox::FileToolsMode;
let cfg = cfg_with("[sandbox]\nwritable = [\"/x\"]\nfile_tools = \"writable\"\n");
assert_eq!(cfg.sandbox.writable, vec!["/x".to_string()]);
assert_eq!(cfg.sandbox.file_tools.as_deref(), Some("writable"));
let (_scratch, config_dir, data_dir) = sandbox_dirs();
let (extras, warnings) = cfg_with("").sandbox.resolve(&config_dir, &data_dir);
assert!(extras.writable.is_empty());
assert_eq!(extras.file_tools, FileToolsMode::Workspace);
assert!(warnings.is_empty(), "{warnings:?}");
}
#[test]
fn sandbox_writable_creates_missing_dirs_and_canonicalizes() {
let (scratch, config_dir, data_dir) = sandbox_dirs();
let missing = scratch.path().join("bazel-cache").join("disk");
let real = scratch.path().join("real");
std::fs::create_dir_all(&real).unwrap();
let link = scratch.path().join("link");
std::os::unix::fs::symlink(&real, &link).unwrap();
let cfg = SandboxCfg {
writable: vec![
format!("{}/", missing.display()), link.join("inner").display().to_string(), ],
file_tools: None,
};
let (extras, warnings) = cfg.resolve(&config_dir, &data_dir);
assert!(warnings.is_empty(), "{warnings:?}");
assert!(missing.is_dir(), "missing entries are created at startup");
assert_eq!(extras.writable.len(), 2);
assert_eq!(extras.writable[0], missing.canonicalize().unwrap());
assert_eq!(
extras.writable[1],
real.join("inner").canonicalize().unwrap()
);
}
#[test]
fn sandbox_writable_refuses_config_and_data_dirs() {
let (scratch, config_dir, data_dir) = sandbox_dirs();
let good = scratch.path().join("cache");
let link_to_config = scratch.path().join("innocent");
std::os::unix::fs::symlink(&config_dir, &link_to_config).unwrap();
let cfg = SandboxCfg {
writable: vec![
config_dir.display().to_string(), config_dir.join("sub").display().to_string(), scratch.path().display().to_string(), data_dir.display().to_string(), link_to_config.display().to_string(), good.display().to_string(), ],
file_tools: None,
};
let (extras, warnings) = cfg.resolve(&config_dir, &data_dir);
assert_eq!(extras.writable, vec![good.canonicalize().unwrap()]);
assert_eq!(warnings.len(), 5, "{warnings:?}");
for w in &warnings {
assert!(w.contains("refused"), "{w}");
}
assert!(!config_dir.join("sub").exists());
}
#[test]
fn sandbox_writable_warns_but_honors_risky_roots() {
let (_scratch, config_dir, data_dir) = sandbox_dirs();
let cfg = SandboxCfg {
writable: vec!["/etc".to_string()],
file_tools: None,
};
let (extras, warnings) = cfg.resolve(&config_dir, &data_dir);
assert_eq!(
extras.writable,
vec![std::path::Path::new("/etc").canonicalize().unwrap()]
);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("/etc"), "{}", warnings[0]);
assert!(!warnings[0].contains("refused"), "{}", warnings[0]);
}
#[test]
fn sandbox_writable_skips_files_and_relative_entries() {
let (scratch, config_dir, data_dir) = sandbox_dirs();
let file = scratch.path().join("not-a-dir");
std::fs::write(&file, b"x").unwrap();
let cfg = SandboxCfg {
writable: vec![
file.display().to_string(),
"relative/cache".to_string(),
"~".to_string(), ],
file_tools: None,
};
let (extras, warnings) = cfg.resolve(&config_dir, &data_dir);
assert!(extras.writable.is_empty());
assert_eq!(warnings.len(), 3, "{warnings:?}");
assert!(warnings[0].contains("not a directory"), "{}", warnings[0]);
assert!(warnings[1].contains("absolute"), "{}", warnings[1]);
assert!(warnings[2].contains("absolute"), "{}", warnings[2]);
}
#[test]
fn sandbox_file_tools_parses_and_unknown_fails_closed() {
use hotl_tools::sandbox::FileToolsMode;
let (scratch, config_dir, data_dir) = sandbox_dirs();
let cache = scratch.path().join("cache");
let with_mode = |mode: Option<&str>, writable: Vec<String>| {
SandboxCfg {
writable,
file_tools: mode.map(String::from),
}
.resolve(&config_dir, &data_dir)
};
let entry = vec![cache.display().to_string()];
let (extras, w) = with_mode(Some("workspace"), entry.clone());
assert_eq!(extras.file_tools, FileToolsMode::Workspace);
assert!(w.is_empty(), "{w:?}");
let (extras, w) = with_mode(Some("writable"), entry.clone());
assert_eq!(extras.file_tools, FileToolsMode::Writable);
assert!(w.is_empty(), "{w:?}");
let (extras, w) = with_mode(Some("writeable"), entry);
assert_eq!(extras.file_tools, FileToolsMode::Workspace);
assert_eq!(w.len(), 1);
assert!(
w[0].contains("writeable") && w[0].contains("workspace | writable"),
"{}",
w[0]
);
let (extras, w) = with_mode(Some("writable"), vec![]);
assert_eq!(extras.file_tools, FileToolsMode::Writable);
assert_eq!(w.len(), 1);
assert!(w[0].contains("no effect"), "{}", w[0]);
}
#[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());
}
}