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 #[serde(default)]
37 pub security: SecurityConfig,
38}
39
40pub const DEFAULT_SERVER_URL: &str = "https://midnight-manual.midnightntwrk.expert";
44
45#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct ServerConfig {
48 pub url: String,
50}
51
52impl Default for ServerConfig {
53 fn default() -> Self {
54 Self { url: DEFAULT_SERVER_URL.into() }
55 }
56}
57
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct ModelsConfig {
61 pub embedding: String,
63 #[serde(default = "default_code_embedding")]
65 pub code_embedding: String,
66 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub cache_dir: Option<PathBuf>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub voyage_api_key: Option<String>,
74 #[serde(default = "default_voyage_dim")]
76 pub voyage_output_dimension: u32,
77 #[serde(default = "default_voyage_dtype")]
79 pub voyage_output_dtype: String,
80 #[serde(default = "default_voyage_timeout_secs")]
84 pub voyage_timeout_secs: u64,
85}
86
87const fn default_voyage_dim() -> u32 {
88 1024
89}
90
91const fn default_voyage_timeout_secs() -> u64 {
92 120
93}
94
95fn default_voyage_dtype() -> String {
96 "float".to_owned()
97}
98
99fn default_code_embedding() -> String {
100 "voyage-code-3".to_owned()
101}
102
103impl Default for ModelsConfig {
104 fn default() -> Self {
105 Self {
106 embedding: "voyage-context-3".into(),
107 code_embedding: default_code_embedding(),
108 cache_dir: None,
109 voyage_api_key: None,
110 voyage_output_dimension: default_voyage_dim(),
111 voyage_output_dtype: default_voyage_dtype(),
112 voyage_timeout_secs: default_voyage_timeout_secs(),
113 }
114 }
115}
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct TelemetryConfig {
120 pub enabled: bool,
123}
124
125impl Default for TelemetryConfig {
126 fn default() -> Self {
127 Self { enabled: true }
128 }
129}
130
131#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
133pub struct CliConfig {
134 #[serde(default)]
137 pub show_admin_cmds: bool,
138}
139
140#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
142#[serde(default)]
143pub struct SecurityConfig {
144 pub level: Option<String>,
147}
148
149impl Config {
150 pub fn discover(
161 explicit_path: Option<&Path>,
162 env: &impl ConfigEnv,
163 ) -> Result<(Self, Option<PathBuf>), ConfigError> {
164 let path = explicit_path.map(Path::to_path_buf).or_else(|| {
165 env.var("MIDNIGHT_MANUAL_CONFIG")
166 .map(PathBuf::from)
167 .or_else(|| xdg_config_path(env))
168 });
169
170 match path {
171 Some(p) if p.exists() => {
172 let body = std::fs::read_to_string(&p).map_err(|e| ConfigError::Read {
173 path: p.clone(),
174 message: e.to_string(),
175 })?;
176 let cfg: Self = toml::from_str(&body).map_err(|e| ConfigError::Parse {
177 path: p.clone(),
178 message: e.to_string(),
179 })?;
180 Ok((cfg, Some(p)))
181 }
182 Some(_) | None => Ok((Self::default(), None)),
183 }
184 }
185}
186
187pub trait ConfigEnv {
190 fn var(&self, name: &str) -> Option<String>;
192}
193
194#[derive(Debug, Clone, Copy, Default)]
196pub struct StdEnv;
197
198impl ConfigEnv for StdEnv {
199 fn var(&self, name: &str) -> Option<String> {
200 std::env::var(name).ok()
201 }
202}
203
204fn xdg_config_path(env: &impl ConfigEnv) -> Option<PathBuf> {
205 if let Some(xdg) = env.var("XDG_CONFIG_HOME") {
206 return Some(
207 PathBuf::from(xdg)
208 .join("midnight-manual")
209 .join("config.toml"),
210 );
211 }
212 if let Some(home) = env.var("HOME") {
213 return Some(
214 PathBuf::from(home)
215 .join(".config")
216 .join("midnight-manual")
217 .join("config.toml"),
218 );
219 }
220 None
221}
222
223pub fn resolve_voyage_api_key(
228 flag: Option<&str>,
229 cfg: &ModelsConfig,
230 env: &impl ConfigEnv,
231) -> Option<String> {
232 flag.map(str::to_owned)
233 .filter(|s| !s.is_empty())
234 .or_else(|| env.var("VOYAGE_API_KEY").filter(|s| !s.is_empty()))
235 .or_else(|| cfg.voyage_api_key.clone().filter(|s| !s.is_empty()))
236}
237
238pub fn resolve_voyage_timeout_secs(
246 flag: Option<u64>,
247 cfg: &ModelsConfig,
248 env: &impl ConfigEnv,
249) -> u64 {
250 flag.filter(|&n| n > 0)
251 .or_else(|| {
252 env.var("VOYAGE_TIMEOUT_SECS")
253 .and_then(|s| s.parse::<u64>().ok())
254 .filter(|&n| n > 0)
255 })
256 .or_else(|| (cfg.voyage_timeout_secs > 0).then_some(cfg.voyage_timeout_secs))
257 .unwrap_or_else(default_voyage_timeout_secs)
258}
259
260#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
262#[serde(default)]
263pub struct RerankConfig {
264 pub location: Option<String>,
266 pub model: Option<String>,
268}
269
270#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub enum RerankPlacement {
273 Local,
275 Server,
277 Off,
279}
280
281impl RerankPlacement {
282 #[must_use]
287 pub const fn wire(self) -> &'static str {
288 match self {
289 Self::Local => "local",
290 Self::Server => "server",
291 Self::Off => "off",
292 }
293 }
294}
295
296#[must_use]
305pub fn resolve_rerank_placement(
306 flag: Option<&str>,
307 cfg: &RerankConfig,
308 env: &impl ConfigEnv,
309 has_voyage_key: bool,
310) -> RerankPlacement {
311 let explicit = |s: &str| match s {
312 "local" => Some(RerankPlacement::Local),
313 "server" => Some(RerankPlacement::Server),
314 "off" => Some(RerankPlacement::Off),
315 _ => None, };
317 flag.filter(|s| !s.is_empty())
318 .and_then(explicit)
319 .or_else(|| {
320 env.var("MIDNIGHT_MANUAL_RERANK")
321 .filter(|s| !s.is_empty())
322 .as_deref()
323 .and_then(explicit)
324 })
325 .or_else(|| cfg.location.as_deref().and_then(explicit))
326 .unwrap_or(if has_voyage_key {
327 RerankPlacement::Local
328 } else {
329 RerankPlacement::Server
330 })
331}
332
333#[must_use]
342pub fn resolve_rerank_model(
343 flag: Option<&str>,
344 cfg: &RerankConfig,
345 env: &impl ConfigEnv,
346) -> crate::rerank::RerankParam {
347 use crate::rerank::RerankParam;
348 let parse = |s: &str| match s {
349 "rerank-2.5" => Some(RerankParam::Rerank25),
350 "rerank-2.5-lite" => Some(RerankParam::Rerank25Lite),
351 _ => None,
352 };
353 flag.filter(|s| !s.is_empty())
354 .and_then(parse)
355 .or_else(|| {
356 env.var("MIDNIGHT_MANUAL_RERANK_MODEL")
357 .filter(|s| !s.is_empty())
358 .as_deref()
359 .and_then(parse)
360 })
361 .or_else(|| cfg.model.as_deref().and_then(parse))
362 .unwrap_or(RerankParam::Rerank25)
363}
364
365#[must_use]
372pub fn resolve_security_level(
373 flag: Option<&str>,
374 cfg: &SecurityConfig,
375 env: &impl ConfigEnv,
376) -> crate::injection::SecurityLevel {
377 use std::str::FromStr as _;
378
379 use crate::injection::SecurityLevel;
380
381 let parse = |s: &str| SecurityLevel::from_str(s).ok();
382 flag.filter(|s| !s.is_empty())
383 .and_then(parse)
384 .or_else(|| {
385 env.var("MIDNIGHT_MANUAL_SECURITY")
386 .filter(|s| !s.is_empty())
387 .as_deref()
388 .and_then(parse)
389 })
390 .or_else(|| cfg.level.as_deref().and_then(parse))
391 .unwrap_or_default()
392}
393
394#[derive(Debug, Error)]
396pub enum ConfigError {
397 #[error("failed to read config file `{}`: {message}", path.display())]
399 Read {
400 path: PathBuf,
402 message: String,
404 },
405 #[error("failed to parse config file `{}`: {message}", path.display())]
407 Parse {
408 path: PathBuf,
410 message: String,
412 },
413}
414
415#[cfg(test)]
416mod tests {
417 use std::collections::HashMap;
418
419 use super::*;
420
421 #[derive(Default)]
422 struct FakeEnv(HashMap<String, String>);
423
424 impl FakeEnv {
425 fn set(mut self, k: &str, v: &str) -> Self {
426 self.0.insert(k.into(), v.into());
427 self
428 }
429 }
430
431 impl ConfigEnv for FakeEnv {
432 fn var(&self, name: &str) -> Option<String> {
433 self.0.get(name).cloned()
434 }
435 }
436
437 #[test]
438 fn default_when_nothing_present() {
439 let env = FakeEnv::default();
440 let (cfg, path) = Config::discover(None, &env).unwrap();
441 assert!(path.is_none());
442 assert_eq!(cfg, Config::default());
443 }
444
445 #[test]
446 fn explicit_path_beats_env_and_xdg() {
447 let tmp = tempdir();
448 let cfg_path = tmp.path().join("explicit.toml");
449 std::fs::write(&cfg_path, "[server]\nurl = \"https://explicit.example\"\n").unwrap();
450
451 let env = FakeEnv::default().set(
452 "MIDNIGHT_MANUAL_CONFIG",
453 tmp.path()
454 .join("env.toml")
455 .to_str()
456 .expect("temp path utf-8"),
457 );
458 let (cfg, resolved) = Config::discover(Some(&cfg_path), &env).unwrap();
459 assert_eq!(resolved.as_deref(), Some(cfg_path.as_path()));
460 assert_eq!(cfg.server.url, "https://explicit.example");
461 }
462
463 #[test]
464 fn env_var_beats_xdg() {
465 let tmp = tempdir();
466 let env_target = tmp.path().join("from-env.toml");
467 std::fs::write(&env_target, "[server]\nurl = \"https://env.example\"\n").unwrap();
468
469 let env = FakeEnv::default()
470 .set("MIDNIGHT_MANUAL_CONFIG", env_target.to_str().unwrap())
471 .set("XDG_CONFIG_HOME", tmp.path().to_str().unwrap());
472 let (cfg, _) = Config::discover(None, &env).unwrap();
473 assert_eq!(cfg.server.url, "https://env.example");
474 }
475
476 #[test]
477 fn malformed_toml_returns_parse_error() {
478 let tmp = tempdir();
479 let path = tmp.path().join("broken.toml");
480 std::fs::write(&path, "this is not = valid = toml\n").unwrap();
481
482 let env = FakeEnv::default();
483 let err = Config::discover(Some(&path), &env).unwrap_err();
484 assert!(matches!(err, ConfigError::Parse { .. }));
485 }
486
487 fn tempdir() -> tempfile::TempDir {
488 tempfile::tempdir().expect("create tempdir")
489 }
490
491 #[test]
492 fn server_url_default_is_production_host() {
493 let cfg = Config::default();
494 assert_eq!(cfg.server.url, "https://midnight-manual.midnightntwrk.expert");
495 }
496
497 #[test]
498 fn models_config_defaults_to_dual_voyage_models() {
499 let m = ModelsConfig::default();
500 assert_eq!(m.embedding, "voyage-context-3");
501 assert_eq!(m.code_embedding, "voyage-code-3");
502 assert_eq!(m.voyage_output_dimension, 1024);
503 assert_eq!(m.voyage_output_dtype, "float");
504 assert_eq!(m.voyage_timeout_secs, 120);
505 assert!(m.voyage_api_key.is_none());
506 }
507
508 #[test]
509 fn models_config_roundtrips_through_toml() {
510 let toml_src = r#"
511embedding = "voyage-code-3"
512voyage_output_dimension = 1024
513voyage_output_dtype = "float"
514"#;
515 let m: ModelsConfig = toml::from_str(toml_src).unwrap();
516 assert_eq!(m.embedding, "voyage-code-3");
517 assert_eq!(m.code_embedding, "voyage-code-3"); assert_eq!(m.voyage_output_dimension, 1024);
519 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()); }
524
525 #[test]
526 fn resolve_voyage_key_prefers_flag_then_env_then_config() {
527 let cfg = ModelsConfig {
528 voyage_api_key: Some("from-config".into()),
529 ..Default::default()
530 };
531 let env = FakeEnv::default().set("VOYAGE_API_KEY", "from-env");
532
533 assert_eq!(
534 resolve_voyage_api_key(Some("from-flag"), &cfg, &env).as_deref(),
535 Some("from-flag")
536 );
537 assert_eq!(resolve_voyage_api_key(None, &cfg, &env).as_deref(), Some("from-env"));
538
539 let empty = FakeEnv::default();
540 assert_eq!(resolve_voyage_api_key(None, &cfg, &empty).as_deref(), Some("from-config"));
541
542 assert_eq!(resolve_voyage_api_key(Some(""), &cfg, &env).as_deref(), Some("from-env"));
544 let env_empty = FakeEnv::default().set("VOYAGE_API_KEY", "");
545 assert_eq!(resolve_voyage_api_key(None, &cfg, &env_empty).as_deref(), Some("from-config"));
546 let cfg_none = ModelsConfig::default();
548 assert_eq!(resolve_voyage_api_key(Some(""), &cfg_none, &env_empty), None);
549 }
550
551 #[test]
552 fn resolve_voyage_timeout_prefers_flag_then_env_then_config() {
553 let cfg = ModelsConfig {
554 voyage_timeout_secs: 90,
555 ..Default::default()
556 };
557 let env = FakeEnv::default().set("VOYAGE_TIMEOUT_SECS", "60");
558
559 assert_eq!(resolve_voyage_timeout_secs(Some(45), &cfg, &env), 45);
560 assert_eq!(resolve_voyage_timeout_secs(None, &cfg, &env), 60);
561
562 let empty = FakeEnv::default();
563 assert_eq!(resolve_voyage_timeout_secs(None, &cfg, &empty), 90);
564
565 let env_garbage = FakeEnv::default().set("VOYAGE_TIMEOUT_SECS", "not-a-number");
567 assert_eq!(resolve_voyage_timeout_secs(None, &cfg, &env_garbage), 90);
568 let env_empty = FakeEnv::default().set("VOYAGE_TIMEOUT_SECS", "");
569 assert_eq!(resolve_voyage_timeout_secs(None, &cfg, &env_empty), 90);
570
571 let zero_cfg = ModelsConfig {
574 voyage_timeout_secs: 0,
575 ..Default::default()
576 };
577 assert_eq!(resolve_voyage_timeout_secs(Some(0), &zero_cfg, &empty), 120);
578 assert_eq!(resolve_voyage_timeout_secs(Some(0), &cfg, &empty), 90);
579 let env_zero = FakeEnv::default().set("VOYAGE_TIMEOUT_SECS", "0");
580 assert_eq!(resolve_voyage_timeout_secs(None, &zero_cfg, &env_zero), 120);
581 }
582
583 #[test]
584 fn rerank_config_parses_from_toml() {
585 let toml = r#"
586[rerank]
587location = "server"
588model = "rerank-2.5-lite"
589"#;
590 let cfg: Config = toml::from_str(toml).unwrap();
591 assert_eq!(cfg.rerank.location.as_deref(), Some("server"));
592 assert_eq!(cfg.rerank.model.as_deref(), Some("rerank-2.5-lite"));
593 let cfg: Config = toml::from_str("").unwrap();
595 assert!(cfg.rerank.location.is_none() && cfg.rerank.model.is_none());
596 }
597
598 #[test]
599 fn resolve_rerank_placement_precedence_and_auto() {
600 let cfg = RerankConfig {
601 location: Some("off".into()),
602 model: None,
603 };
604 let env = FakeEnv::default().set("MIDNIGHT_MANUAL_RERANK", "server");
605 assert_eq!(
607 resolve_rerank_placement(Some("local"), &cfg, &env, false),
608 RerankPlacement::Local
609 );
610 assert_eq!(resolve_rerank_placement(None, &cfg, &env, true), RerankPlacement::Server);
611 let no_env = FakeEnv::default();
612 assert_eq!(resolve_rerank_placement(None, &cfg, &no_env, true), RerankPlacement::Off);
613 let empty = RerankConfig::default();
615 assert_eq!(resolve_rerank_placement(None, &empty, &no_env, true), RerankPlacement::Local);
616 assert_eq!(resolve_rerank_placement(None, &empty, &no_env, false), RerankPlacement::Server);
617 assert_eq!(
619 resolve_rerank_placement(Some("auto"), &empty, &no_env, false),
620 RerankPlacement::Server
621 );
622 assert_eq!(
624 resolve_rerank_placement(Some("bogus"), &empty, &no_env, true),
625 RerankPlacement::Local
626 );
627 }
628
629 #[test]
630 fn rerank_placement_wire_strings() {
631 assert_eq!(RerankPlacement::Local.wire(), "local");
635 assert_eq!(RerankPlacement::Server.wire(), "server");
636 assert_eq!(RerankPlacement::Off.wire(), "off");
637 }
638
639 #[test]
640 fn resolve_rerank_model_precedence_and_default() {
641 use crate::rerank::RerankParam;
642 let cfg = RerankConfig {
643 location: None,
644 model: Some("rerank-2.5-lite".into()),
645 };
646 let env = FakeEnv::default().set("MIDNIGHT_MANUAL_RERANK_MODEL", "rerank-2.5");
647 assert_eq!(
648 resolve_rerank_model(Some("rerank-2.5-lite"), &cfg, &env),
649 RerankParam::Rerank25Lite
650 );
651 assert_eq!(resolve_rerank_model(None, &cfg, &env), RerankParam::Rerank25);
652 let no_env = FakeEnv::default();
653 assert_eq!(resolve_rerank_model(None, &cfg, &no_env), RerankParam::Rerank25Lite);
654 assert_eq!(
656 resolve_rerank_model(None, &RerankConfig::default(), &no_env),
657 RerankParam::Rerank25
658 );
659 assert_eq!(
660 resolve_rerank_model(Some("bogus"), &RerankConfig::default(), &no_env),
661 RerankParam::Rerank25
662 );
663 }
664
665 #[test]
666 fn resolve_security_level_precedence_and_default() {
667 use crate::injection::SecurityLevel;
668
669 let cfg = SecurityConfig { level: Some("high".into()) };
670 let env = FakeEnv::default().set("MIDNIGHT_MANUAL_SECURITY", "strict");
671
672 assert_eq!(resolve_security_level(Some("low"), &cfg, &env), SecurityLevel::Low);
674 assert_eq!(resolve_security_level(None, &cfg, &env), SecurityLevel::Strict);
676 let no_env = FakeEnv::default();
678 assert_eq!(resolve_security_level(None, &cfg, &no_env), SecurityLevel::High);
679 let empty = SecurityConfig::default();
681 assert_eq!(resolve_security_level(None, &empty, &no_env), SecurityLevel::Moderate);
682 assert_eq!(resolve_security_level(Some("bogus"), &cfg, &no_env), SecurityLevel::High);
684 assert_eq!(resolve_security_level(Some(""), &empty, &no_env), SecurityLevel::Moderate);
685 }
686}