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