use std::path::{Path, PathBuf};
use serde::Deserialize;
pub(crate) const LOCAL_FILE_NAME: &str = "lean-host-mcp.toml";
#[derive(Debug, Default, Deserialize)]
pub struct ConfigFile {
pub primary_project: Option<PathBuf>,
#[serde(default)]
pub runtime: RuntimeFileConfig,
#[serde(default)]
pub broker: BrokerFileConfig,
#[serde(default)]
pub server: ServerFileConfig,
}
#[derive(Debug, Default, Deserialize)]
pub struct RuntimeFileConfig {
pub worker_rss_post_job_restart_kib: Option<u64>,
pub worker_rss_hard_kill_kib: Option<u64>,
pub worker_rss_sample_millis: Option<u64>,
pub import_switch_rss_soft_kib: Option<u64>,
pub module_cache_rss_guard_kib: Option<u64>,
pub module_cache_max_bytes: Option<u64>,
pub project_mailbox_capacity: Option<usize>,
pub worker_restart_limit: Option<usize>,
pub worker_restart_window_secs: Option<u64>,
}
#[derive(Debug, Default, Deserialize)]
pub struct BrokerFileConfig {
pub max_projects: Option<usize>,
pub idle_timeout_secs: Option<u64>,
pub semantic_permits: Option<usize>,
pub semantic_waiters: Option<usize>,
pub semantic_admission_timeout_millis: Option<u64>,
}
#[derive(Debug, Default, Deserialize)]
pub struct ServerFileConfig {
pub bind: Option<String>,
pub http_path: Option<String>,
}
impl ConfigFile {
#[must_use]
pub fn load(cwd: &Path) -> Self {
let mut merged = toml::Value::Table(toml::Table::new());
if let Some(home) = home_config_path()
&& let Some(value) = read_toml(&home)
{
merge_toml(&mut merged, value);
}
if let Some(local) = walk_up_for(cwd, LOCAL_FILE_NAME)
&& let Some(value) = read_toml(&local)
{
merge_toml(&mut merged, value);
}
match merged.try_into() {
Ok(config) => config,
Err(err) => {
tracing::warn!(error = %err, "merged config did not match the schema; ignoring it");
Self::default()
}
}
}
}
pub(crate) fn home_config_path() -> Option<PathBuf> {
let base = std::env::var_os("LEAN_HOST_MCP_CONFIG_DIR")
.map(PathBuf::from)
.or_else(dirs::config_dir)?;
Some(base.join("lean-host-mcp").join("config.toml"))
}
fn walk_up_for(start: &Path, filename: &str) -> Option<PathBuf> {
let mut cur: Option<&Path> = Some(start);
while let Some(dir) = cur {
let candidate = dir.join(filename);
if candidate.is_file() {
return Some(candidate);
}
cur = dir.parent();
}
None
}
fn read_toml(path: &Path) -> Option<toml::Value> {
let contents = std::fs::read_to_string(path).ok()?;
match toml::from_str::<toml::Value>(&contents) {
Ok(value) => Some(value),
Err(err) => {
tracing::warn!(path = %path.display(), error = %err, "config file parse failed; ignoring");
None
}
}
}
fn merge_toml(base: &mut toml::Value, overlay: toml::Value) {
match (base, overlay) {
(toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
for (key, value) in overlay_table {
match base_table.get_mut(&key) {
Some(existing) => merge_toml(existing, value),
None => {
base_table.insert(key, value);
}
}
}
}
(slot, value) => *slot = value,
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::panic,
reason = "tests assert the branch under test directly"
)]
mod tests {
use super::*;
#[test]
fn merge_overlays_local_over_home_per_key() {
let mut base = toml::from_str::<toml::Value>(
"primary_project = \"/home/proj\"\n[runtime]\nworker_rss_post_job_restart_kib = 5\n[broker]\nmax_projects = 8\n",
)
.unwrap();
let overlay = toml::from_str::<toml::Value>(
"[runtime]\nworker_rss_post_job_restart_kib = 8\nworker_rss_hard_kill_kib = 16\n",
)
.unwrap();
merge_toml(&mut base, overlay);
let config: ConfigFile = base.try_into().unwrap();
assert_eq!(config.runtime.worker_rss_post_job_restart_kib, Some(8));
assert_eq!(config.runtime.worker_rss_hard_kill_kib, Some(16));
assert_eq!(config.broker.max_projects, Some(8));
assert_eq!(config.primary_project.as_deref(), Some(Path::new("/home/proj")));
}
#[test]
fn full_example_parses() {
let config: ConfigFile = toml::from_str::<toml::Value>(
"[runtime]\nworker_rss_post_job_restart_kib = 8388608\nworker_restart_window_secs = 60\n\
[broker]\nmax_projects = 4\nsemantic_permits = 1\n\
[server]\nbind = \"127.0.0.1:8765\"\nhttp_path = \"/mcp\"\n",
)
.unwrap()
.try_into()
.unwrap();
assert_eq!(config.runtime.worker_rss_post_job_restart_kib, Some(8_388_608));
assert_eq!(config.broker.max_projects, Some(4));
assert_eq!(config.server.bind.as_deref(), Some("127.0.0.1:8765"));
assert_eq!(config.server.http_path.as_deref(), Some("/mcp"));
}
#[test]
fn empty_config_is_all_none() {
let config = ConfigFile::default();
assert!(config.primary_project.is_none());
assert!(config.runtime.worker_rss_post_job_restart_kib.is_none());
assert!(config.broker.max_projects.is_none());
assert!(config.server.bind.is_none());
}
#[test]
fn walk_up_finds_nearest_then_ancestor() {
let tmp = std::env::temp_dir().join(format!("lhm-cfg-walk-{}", std::process::id()));
let nested = tmp.join("a").join("b");
std::fs::create_dir_all(&nested).unwrap();
std::fs::write(tmp.join(LOCAL_FILE_NAME), "max_projects_unused = 1\n").unwrap();
let found = walk_up_for(&nested, LOCAL_FILE_NAME).unwrap();
assert_eq!(found, tmp.join(LOCAL_FILE_NAME));
std::fs::remove_dir_all(&tmp).ok();
}
}