1use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
19pub struct Config {
20 #[serde(default)]
22 pub server: ServerConfig,
23 #[serde(default)]
25 pub models: ModelsConfig,
26 #[serde(default)]
28 pub rerank: RerankConfig,
29 #[serde(default)]
31 pub telemetry: TelemetryConfig,
32 #[serde(default)]
34 pub cli: CliConfig,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ServerConfig {
40 pub url: String,
42}
43
44impl Default for ServerConfig {
45 fn default() -> Self {
46 Self {
47 url: "https://midnight-manual.midnightntwrk.expert".into(),
48 }
49 }
50}
51
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct ModelsConfig {
55 pub embedding: String,
57 #[serde(default = "default_code_embedding")]
59 pub code_embedding: String,
60 #[serde(default, skip_serializing_if = "Option::is_none")]
63 pub cache_dir: Option<PathBuf>,
64 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub voyage_api_key: Option<String>,
68 #[serde(default = "default_voyage_dim")]
70 pub voyage_output_dimension: u32,
71 #[serde(default = "default_voyage_dtype")]
73 pub voyage_output_dtype: String,
74 #[serde(default = "default_voyage_timeout_secs")]
78 pub voyage_timeout_secs: u64,
79}
80
81const fn default_voyage_dim() -> u32 {
82 1024
83}
84
85const fn default_voyage_timeout_secs() -> u64 {
86 120
87}
88
89fn default_voyage_dtype() -> String {
90 "float".to_owned()
91}
92
93fn default_code_embedding() -> String {
94 "voyage-code-3".to_owned()
95}
96
97impl Default for ModelsConfig {
98 fn default() -> Self {
99 Self {
100 embedding: "voyage-context-3".into(),
101 code_embedding: default_code_embedding(),
102 cache_dir: None,
103 voyage_api_key: None,
104 voyage_output_dimension: default_voyage_dim(),
105 voyage_output_dtype: default_voyage_dtype(),
106 voyage_timeout_secs: default_voyage_timeout_secs(),
107 }
108 }
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct TelemetryConfig {
114 pub enabled: bool,
117}
118
119impl Default for TelemetryConfig {
120 fn default() -> Self {
121 Self { enabled: true }
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
127pub struct CliConfig {
128 #[serde(default)]
131 pub show_admin_cmds: bool,
132}
133
134impl Config {
135 pub fn discover(
146 explicit_path: Option<&Path>,
147 env: &impl ConfigEnv,
148 ) -> Result<(Self, Option<PathBuf>), ConfigError> {
149 let path = explicit_path.map(Path::to_path_buf).or_else(|| {
150 env.var("MIDNIGHT_MANUAL_CONFIG")
151 .map(PathBuf::from)
152 .or_else(|| xdg_config_path(env))
153 });
154
155 match path {
156 Some(p) if p.exists() => {
157 let body = std::fs::read_to_string(&p).map_err(|e| ConfigError::Read {
158 path: p.clone(),
159 message: e.to_string(),
160 })?;
161 let cfg: Self = toml::from_str(&body).map_err(|e| ConfigError::Parse {
162 path: p.clone(),
163 message: e.to_string(),
164 })?;
165 Ok((cfg, Some(p)))
166 }
167 Some(_) | None => Ok((Self::default(), None)),
168 }
169 }
170}
171
172pub trait ConfigEnv {
175 fn var(&self, name: &str) -> Option<String>;
177}
178
179#[derive(Debug, Clone, Copy, Default)]
181pub struct StdEnv;
182
183impl ConfigEnv for StdEnv {
184 fn var(&self, name: &str) -> Option<String> {
185 std::env::var(name).ok()
186 }
187}
188
189fn xdg_config_path(env: &impl ConfigEnv) -> Option<PathBuf> {
190 if let Some(xdg) = env.var("XDG_CONFIG_HOME") {
191 return Some(
192 PathBuf::from(xdg)
193 .join("midnight-manual")
194 .join("config.toml"),
195 );
196 }
197 if let Some(home) = env.var("HOME") {
198 return Some(
199 PathBuf::from(home)
200 .join(".config")
201 .join("midnight-manual")
202 .join("config.toml"),
203 );
204 }
205 None
206}
207
208pub fn resolve_voyage_api_key(
213 flag: Option<&str>,
214 cfg: &ModelsConfig,
215 env: &impl ConfigEnv,
216) -> Option<String> {
217 flag.map(str::to_owned)
218 .filter(|s| !s.is_empty())
219 .or_else(|| env.var("VOYAGE_API_KEY").filter(|s| !s.is_empty()))
220 .or_else(|| cfg.voyage_api_key.clone().filter(|s| !s.is_empty()))
221}
222
223pub fn resolve_voyage_timeout_secs(
231 flag: Option<u64>,
232 cfg: &ModelsConfig,
233 env: &impl ConfigEnv,
234) -> u64 {
235 flag.filter(|&n| n > 0)
236 .or_else(|| {
237 env.var("VOYAGE_TIMEOUT_SECS")
238 .and_then(|s| s.parse::<u64>().ok())
239 .filter(|&n| n > 0)
240 })
241 .or_else(|| (cfg.voyage_timeout_secs > 0).then_some(cfg.voyage_timeout_secs))
242 .unwrap_or_else(default_voyage_timeout_secs)
243}
244
245#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
247#[serde(default)]
248pub struct RerankConfig {
249 pub location: Option<String>,
251 pub model: Option<String>,
253}
254
255#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum RerankPlacement {
258 Local,
260 Server,
262 Off,
264}
265
266impl RerankPlacement {
267 #[must_use]
272 pub const fn wire(self) -> &'static str {
273 match self {
274 Self::Local => "local",
275 Self::Server => "server",
276 Self::Off => "off",
277 }
278 }
279}
280
281#[must_use]
290pub fn resolve_rerank_placement(
291 flag: Option<&str>,
292 cfg: &RerankConfig,
293 env: &impl ConfigEnv,
294 has_voyage_key: bool,
295) -> RerankPlacement {
296 let explicit = |s: &str| match s {
297 "local" => Some(RerankPlacement::Local),
298 "server" => Some(RerankPlacement::Server),
299 "off" => Some(RerankPlacement::Off),
300 _ => None, };
302 flag.filter(|s| !s.is_empty())
303 .and_then(explicit)
304 .or_else(|| {
305 env.var("MIDNIGHT_MANUAL_RERANK")
306 .filter(|s| !s.is_empty())
307 .as_deref()
308 .and_then(explicit)
309 })
310 .or_else(|| cfg.location.as_deref().and_then(explicit))
311 .unwrap_or(if has_voyage_key {
312 RerankPlacement::Local
313 } else {
314 RerankPlacement::Server
315 })
316}
317
318#[must_use]
327pub fn resolve_rerank_model(
328 flag: Option<&str>,
329 cfg: &RerankConfig,
330 env: &impl ConfigEnv,
331) -> crate::rerank::RerankParam {
332 use crate::rerank::RerankParam;
333 let parse = |s: &str| match s {
334 "rerank-2.5" => Some(RerankParam::Rerank25),
335 "rerank-2.5-lite" => Some(RerankParam::Rerank25Lite),
336 _ => None,
337 };
338 flag.filter(|s| !s.is_empty())
339 .and_then(parse)
340 .or_else(|| {
341 env.var("MIDNIGHT_MANUAL_RERANK_MODEL")
342 .filter(|s| !s.is_empty())
343 .as_deref()
344 .and_then(parse)
345 })
346 .or_else(|| cfg.model.as_deref().and_then(parse))
347 .unwrap_or(RerankParam::Rerank25)
348}
349
350#[derive(Debug, Error)]
352pub enum ConfigError {
353 #[error("failed to read config file `{}`: {message}", path.display())]
355 Read {
356 path: PathBuf,
358 message: String,
360 },
361 #[error("failed to parse config file `{}`: {message}", path.display())]
363 Parse {
364 path: PathBuf,
366 message: String,
368 },
369}
370
371#[cfg(test)]
372mod tests {
373 use std::collections::HashMap;
374
375 use super::*;
376
377 #[derive(Default)]
378 struct FakeEnv(HashMap<String, String>);
379
380 impl FakeEnv {
381 fn set(mut self, k: &str, v: &str) -> Self {
382 self.0.insert(k.into(), v.into());
383 self
384 }
385 }
386
387 impl ConfigEnv for FakeEnv {
388 fn var(&self, name: &str) -> Option<String> {
389 self.0.get(name).cloned()
390 }
391 }
392
393 #[test]
394 fn default_when_nothing_present() {
395 let env = FakeEnv::default();
396 let (cfg, path) = Config::discover(None, &env).unwrap();
397 assert!(path.is_none());
398 assert_eq!(cfg, Config::default());
399 }
400
401 #[test]
402 fn explicit_path_beats_env_and_xdg() {
403 let tmp = tempdir();
404 let cfg_path = tmp.path().join("explicit.toml");
405 std::fs::write(&cfg_path, "[server]\nurl = \"https://explicit.example\"\n").unwrap();
406
407 let env = FakeEnv::default().set(
408 "MIDNIGHT_MANUAL_CONFIG",
409 tmp.path()
410 .join("env.toml")
411 .to_str()
412 .expect("temp path utf-8"),
413 );
414 let (cfg, resolved) = Config::discover(Some(&cfg_path), &env).unwrap();
415 assert_eq!(resolved.as_deref(), Some(cfg_path.as_path()));
416 assert_eq!(cfg.server.url, "https://explicit.example");
417 }
418
419 #[test]
420 fn env_var_beats_xdg() {
421 let tmp = tempdir();
422 let env_target = tmp.path().join("from-env.toml");
423 std::fs::write(&env_target, "[server]\nurl = \"https://env.example\"\n").unwrap();
424
425 let env = FakeEnv::default()
426 .set("MIDNIGHT_MANUAL_CONFIG", env_target.to_str().unwrap())
427 .set("XDG_CONFIG_HOME", tmp.path().to_str().unwrap());
428 let (cfg, _) = Config::discover(None, &env).unwrap();
429 assert_eq!(cfg.server.url, "https://env.example");
430 }
431
432 #[test]
433 fn malformed_toml_returns_parse_error() {
434 let tmp = tempdir();
435 let path = tmp.path().join("broken.toml");
436 std::fs::write(&path, "this is not = valid = toml\n").unwrap();
437
438 let env = FakeEnv::default();
439 let err = Config::discover(Some(&path), &env).unwrap_err();
440 assert!(matches!(err, ConfigError::Parse { .. }));
441 }
442
443 fn tempdir() -> tempfile::TempDir {
444 tempfile::tempdir().expect("create tempdir")
445 }
446
447 #[test]
448 fn server_url_default_is_production_host() {
449 let cfg = Config::default();
450 assert_eq!(cfg.server.url, "https://midnight-manual.midnightntwrk.expert");
451 }
452
453 #[test]
454 fn models_config_defaults_to_dual_voyage_models() {
455 let m = ModelsConfig::default();
456 assert_eq!(m.embedding, "voyage-context-3");
457 assert_eq!(m.code_embedding, "voyage-code-3");
458 assert_eq!(m.voyage_output_dimension, 1024);
459 assert_eq!(m.voyage_output_dtype, "float");
460 assert_eq!(m.voyage_timeout_secs, 120);
461 assert!(m.voyage_api_key.is_none());
462 }
463
464 #[test]
465 fn models_config_roundtrips_through_toml() {
466 let toml_src = r#"
467embedding = "voyage-code-3"
468voyage_output_dimension = 1024
469voyage_output_dtype = "float"
470"#;
471 let m: ModelsConfig = toml::from_str(toml_src).unwrap();
472 assert_eq!(m.embedding, "voyage-code-3");
473 assert_eq!(m.code_embedding, "voyage-code-3"); assert_eq!(m.voyage_output_dimension, 1024);
475 assert_eq!(m.voyage_output_dtype, "float"); assert_eq!(m.voyage_timeout_secs, 120); assert!(m.voyage_api_key.is_none()); assert!(m.cache_dir.is_none()); }
480
481 #[test]
482 fn resolve_voyage_key_prefers_flag_then_env_then_config() {
483 let cfg = ModelsConfig {
484 voyage_api_key: Some("from-config".into()),
485 ..Default::default()
486 };
487 let env = FakeEnv::default().set("VOYAGE_API_KEY", "from-env");
488
489 assert_eq!(
490 resolve_voyage_api_key(Some("from-flag"), &cfg, &env).as_deref(),
491 Some("from-flag")
492 );
493 assert_eq!(resolve_voyage_api_key(None, &cfg, &env).as_deref(), Some("from-env"));
494
495 let empty = FakeEnv::default();
496 assert_eq!(resolve_voyage_api_key(None, &cfg, &empty).as_deref(), Some("from-config"));
497
498 assert_eq!(resolve_voyage_api_key(Some(""), &cfg, &env).as_deref(), Some("from-env"));
500 let env_empty = FakeEnv::default().set("VOYAGE_API_KEY", "");
501 assert_eq!(resolve_voyage_api_key(None, &cfg, &env_empty).as_deref(), Some("from-config"));
502 let cfg_none = ModelsConfig::default();
504 assert_eq!(resolve_voyage_api_key(Some(""), &cfg_none, &env_empty), None);
505 }
506
507 #[test]
508 fn resolve_voyage_timeout_prefers_flag_then_env_then_config() {
509 let cfg = ModelsConfig {
510 voyage_timeout_secs: 90,
511 ..Default::default()
512 };
513 let env = FakeEnv::default().set("VOYAGE_TIMEOUT_SECS", "60");
514
515 assert_eq!(resolve_voyage_timeout_secs(Some(45), &cfg, &env), 45);
516 assert_eq!(resolve_voyage_timeout_secs(None, &cfg, &env), 60);
517
518 let empty = FakeEnv::default();
519 assert_eq!(resolve_voyage_timeout_secs(None, &cfg, &empty), 90);
520
521 let env_garbage = FakeEnv::default().set("VOYAGE_TIMEOUT_SECS", "not-a-number");
523 assert_eq!(resolve_voyage_timeout_secs(None, &cfg, &env_garbage), 90);
524 let env_empty = FakeEnv::default().set("VOYAGE_TIMEOUT_SECS", "");
525 assert_eq!(resolve_voyage_timeout_secs(None, &cfg, &env_empty), 90);
526
527 let zero_cfg = ModelsConfig {
530 voyage_timeout_secs: 0,
531 ..Default::default()
532 };
533 assert_eq!(resolve_voyage_timeout_secs(Some(0), &zero_cfg, &empty), 120);
534 assert_eq!(resolve_voyage_timeout_secs(Some(0), &cfg, &empty), 90);
535 let env_zero = FakeEnv::default().set("VOYAGE_TIMEOUT_SECS", "0");
536 assert_eq!(resolve_voyage_timeout_secs(None, &zero_cfg, &env_zero), 120);
537 }
538
539 #[test]
540 fn rerank_config_parses_from_toml() {
541 let toml = r#"
542[rerank]
543location = "server"
544model = "rerank-2.5-lite"
545"#;
546 let cfg: Config = toml::from_str(toml).unwrap();
547 assert_eq!(cfg.rerank.location.as_deref(), Some("server"));
548 assert_eq!(cfg.rerank.model.as_deref(), Some("rerank-2.5-lite"));
549 let cfg: Config = toml::from_str("").unwrap();
551 assert!(cfg.rerank.location.is_none() && cfg.rerank.model.is_none());
552 }
553
554 #[test]
555 fn resolve_rerank_placement_precedence_and_auto() {
556 let cfg = RerankConfig {
557 location: Some("off".into()),
558 model: None,
559 };
560 let env = FakeEnv::default().set("MIDNIGHT_MANUAL_RERANK", "server");
561 assert_eq!(
563 resolve_rerank_placement(Some("local"), &cfg, &env, false),
564 RerankPlacement::Local
565 );
566 assert_eq!(resolve_rerank_placement(None, &cfg, &env, true), RerankPlacement::Server);
567 let no_env = FakeEnv::default();
568 assert_eq!(resolve_rerank_placement(None, &cfg, &no_env, true), RerankPlacement::Off);
569 let empty = RerankConfig::default();
571 assert_eq!(resolve_rerank_placement(None, &empty, &no_env, true), RerankPlacement::Local);
572 assert_eq!(resolve_rerank_placement(None, &empty, &no_env, false), RerankPlacement::Server);
573 assert_eq!(
575 resolve_rerank_placement(Some("auto"), &empty, &no_env, false),
576 RerankPlacement::Server
577 );
578 assert_eq!(
580 resolve_rerank_placement(Some("bogus"), &empty, &no_env, true),
581 RerankPlacement::Local
582 );
583 }
584
585 #[test]
586 fn rerank_placement_wire_strings() {
587 assert_eq!(RerankPlacement::Local.wire(), "local");
591 assert_eq!(RerankPlacement::Server.wire(), "server");
592 assert_eq!(RerankPlacement::Off.wire(), "off");
593 }
594
595 #[test]
596 fn resolve_rerank_model_precedence_and_default() {
597 use crate::rerank::RerankParam;
598 let cfg = RerankConfig {
599 location: None,
600 model: Some("rerank-2.5-lite".into()),
601 };
602 let env = FakeEnv::default().set("MIDNIGHT_MANUAL_RERANK_MODEL", "rerank-2.5");
603 assert_eq!(
604 resolve_rerank_model(Some("rerank-2.5-lite"), &cfg, &env),
605 RerankParam::Rerank25Lite
606 );
607 assert_eq!(resolve_rerank_model(None, &cfg, &env), RerankParam::Rerank25);
608 let no_env = FakeEnv::default();
609 assert_eq!(resolve_rerank_model(None, &cfg, &no_env), RerankParam::Rerank25Lite);
610 assert_eq!(
612 resolve_rerank_model(None, &RerankConfig::default(), &no_env),
613 RerankParam::Rerank25
614 );
615 assert_eq!(
616 resolve_rerank_model(Some("bogus"), &RerankConfig::default(), &no_env),
617 RerankParam::Rerank25
618 );
619 }
620}