lean_host_mcp/
config_file.rs1use std::path::{Path, PathBuf};
19
20use serde::Deserialize;
21
22pub(crate) const LOCAL_FILE_NAME: &str = "lean-host-mcp.toml";
25
26#[derive(Debug, Default, Deserialize)]
29pub struct ConfigFile {
30 pub primary_project: Option<PathBuf>,
33 #[serde(default)]
34 pub runtime: RuntimeFileConfig,
35 #[serde(default)]
36 pub broker: BrokerFileConfig,
37 #[serde(default)]
38 pub server: ServerFileConfig,
39 #[serde(default)]
40 pub telemetry: TelemetryFileConfig,
41 #[serde(default)]
42 pub output: OutputFileConfig,
43}
44
45#[derive(Debug, Default, Deserialize)]
47pub struct RuntimeFileConfig {
48 pub worker_rss_post_job_restart_kib: Option<u64>,
49 pub worker_rss_hard_kill_kib: Option<u64>,
50 pub worker_rss_sample_millis: Option<u64>,
51 pub import_switch_rss_soft_kib: Option<u64>,
52 pub module_cache_rss_guard_kib: Option<u64>,
53 pub module_cache_max_bytes: Option<u64>,
54 pub request_timeout_millis: Option<u64>,
55 pub project_mailbox_capacity: Option<usize>,
56 pub worker_restart_limit: Option<usize>,
57 pub worker_restart_window_secs: Option<u64>,
58}
59
60#[derive(Debug, Default, Deserialize)]
62pub struct BrokerFileConfig {
63 pub max_projects: Option<usize>,
64 pub idle_timeout_secs: Option<u64>,
65 pub semantic_permits: Option<usize>,
66 pub semantic_waiters: Option<usize>,
67 pub semantic_admission_timeout_millis: Option<u64>,
68 pub semantic_lock_dir: Option<PathBuf>,
69}
70
71#[derive(Debug, Default, Deserialize)]
78pub struct ServerFileConfig {
79 pub bind: Option<String>,
80 pub http_path: Option<String>,
81 pub response_carrier: Option<String>,
84}
85
86#[derive(Debug, Default, Deserialize)]
89pub struct TelemetryFileConfig {
90 pub verbosity: Option<String>,
91}
92
93#[derive(Debug, Default, Deserialize)]
99pub struct OutputFileConfig {
100 pub max_field_bytes: Option<u32>,
101 pub max_total_bytes: Option<u32>,
102 pub heartbeat_limit: Option<u64>,
103}
104
105impl ConfigFile {
106 #[must_use]
111 pub fn load(cwd: &Path) -> Self {
112 let mut merged = toml::Value::Table(toml::Table::new());
113 if let Some(home) = home_config_path()
114 && let Some(value) = read_toml(&home)
115 {
116 merge_toml(&mut merged, value);
117 }
118 if let Some(local) = walk_up_for(cwd, LOCAL_FILE_NAME)
119 && let Some(value) = read_toml(&local)
120 {
121 merge_toml(&mut merged, value);
122 }
123 match merged.try_into() {
124 Ok(config) => config,
125 Err(err) => {
126 tracing::warn!(error = %err, "merged config did not match the schema; ignoring it");
127 Self::default()
128 }
129 }
130 }
131}
132
133pub(crate) fn home_config_path() -> Option<PathBuf> {
138 let base = std::env::var_os("LEAN_HOST_MCP_CONFIG_DIR")
139 .map(PathBuf::from)
140 .or_else(dirs::config_dir)?;
141 Some(base.join("lean-host-mcp").join("config.toml"))
142}
143
144fn walk_up_for(start: &Path, filename: &str) -> Option<PathBuf> {
146 let mut cur: Option<&Path> = Some(start);
147 while let Some(dir) = cur {
148 let candidate = dir.join(filename);
149 if candidate.is_file() {
150 return Some(candidate);
151 }
152 cur = dir.parent();
153 }
154 None
155}
156
157fn read_toml(path: &Path) -> Option<toml::Value> {
160 let contents = std::fs::read_to_string(path).ok()?;
161 match toml::from_str::<toml::Value>(&contents) {
162 Ok(value) => Some(value),
163 Err(err) => {
164 tracing::warn!(path = %path.display(), error = %err, "config file parse failed; ignoring");
165 None
166 }
167 }
168}
169
170fn merge_toml(base: &mut toml::Value, overlay: toml::Value) {
174 match (base, overlay) {
175 (toml::Value::Table(base_table), toml::Value::Table(overlay_table)) => {
176 for (key, value) in overlay_table {
177 match base_table.get_mut(&key) {
178 Some(existing) => merge_toml(existing, value),
179 None => {
180 base_table.insert(key, value);
181 }
182 }
183 }
184 }
185 (slot, value) => *slot = value,
186 }
187}
188
189#[cfg(test)]
190#[allow(
191 clippy::unwrap_used,
192 clippy::panic,
193 reason = "tests assert the branch under test directly"
194)]
195mod tests {
196 use super::*;
197
198 #[test]
199 fn merge_overlays_local_over_home_per_key() {
200 let mut base = toml::from_str::<toml::Value>(
201 "primary_project = \"/home/proj\"\n[runtime]\nworker_rss_post_job_restart_kib = 5\n[broker]\nmax_projects = 8\n",
202 )
203 .unwrap();
204 let overlay = toml::from_str::<toml::Value>(
205 "[runtime]\nworker_rss_post_job_restart_kib = 8\nworker_rss_hard_kill_kib = 16\n",
206 )
207 .unwrap();
208 merge_toml(&mut base, overlay);
209 let config: ConfigFile = base.try_into().unwrap();
210
211 assert_eq!(config.runtime.worker_rss_post_job_restart_kib, Some(8));
213 assert_eq!(config.runtime.worker_rss_hard_kill_kib, Some(16));
215 assert_eq!(config.broker.max_projects, Some(8));
217 assert_eq!(config.primary_project.as_deref(), Some(Path::new("/home/proj")));
218 }
219
220 #[test]
221 fn full_example_parses() {
222 let config: ConfigFile = toml::from_str::<toml::Value>(
223 "[runtime]\nworker_rss_post_job_restart_kib = 8388608\nworker_restart_window_secs = 60\n\
224 [broker]\nmax_projects = 4\nsemantic_permits = 1\n\
225 [server]\nbind = \"127.0.0.1:8765\"\nhttp_path = \"/mcp\"\n",
226 )
227 .unwrap()
228 .try_into()
229 .unwrap();
230
231 assert_eq!(config.runtime.worker_rss_post_job_restart_kib, Some(8_388_608));
232 assert_eq!(config.broker.max_projects, Some(4));
233 assert_eq!(config.server.bind.as_deref(), Some("127.0.0.1:8765"));
234 assert_eq!(config.server.http_path.as_deref(), Some("/mcp"));
235 }
236
237 #[test]
238 fn empty_config_is_all_none() {
239 let config = ConfigFile::default();
240 assert!(config.primary_project.is_none());
241 assert!(config.runtime.worker_rss_post_job_restart_kib.is_none());
242 assert!(config.broker.max_projects.is_none());
243 assert!(config.server.bind.is_none());
244 }
245
246 #[test]
247 fn walk_up_finds_nearest_then_ancestor() {
248 let tmp = std::env::temp_dir().join(format!("lhm-cfg-walk-{}", std::process::id()));
249 let nested = tmp.join("a").join("b");
250 std::fs::create_dir_all(&nested).unwrap();
251 std::fs::write(tmp.join(LOCAL_FILE_NAME), "max_projects_unused = 1\n").unwrap();
252 let found = walk_up_for(&nested, LOCAL_FILE_NAME).unwrap();
254 assert_eq!(found, tmp.join(LOCAL_FILE_NAME));
255 std::fs::remove_dir_all(&tmp).ok();
256 }
257}