Skip to main content

mnm_core/
config.rs

1//! Config-file discovery and shape.
2//!
3//! Precedence (D17/D18, FR-016): explicit `--config <path>` flag > env vars
4//! (`MIDNIGHT_MANUAL_CONFIG`, `MIDNIGHT_MANUAL_SERVER`, etc.) > XDG-discovered
5//! `$XDG_CONFIG_HOME/midnight-manual/config.toml` > compiled-in defaults. The
6//! [`Config`] struct here is the merged in-memory result; the loader
7//! [`Config::discover`] performs the precedence walk.
8//!
9//! No keychain access in v1 (D28) — tokens live in the sibling `auth.toml`
10//! handled by [`crate::auth_file`].
11
12use std::path::{Path, PathBuf};
13
14use serde::{Deserialize, Serialize};
15use thiserror::Error;
16
17/// The merged-and-resolved configuration handed to every CLI/MCP/server entrypoint.
18#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
19pub struct Config {
20    /// `[server]` section.
21    #[serde(default)]
22    pub server: ServerConfig,
23    /// `[models]` section.
24    #[serde(default)]
25    pub models: ModelsConfig,
26    /// `[rerank]` section.
27    #[serde(default)]
28    pub rerank: RerankConfig,
29    /// `[telemetry]` section.
30    #[serde(default)]
31    pub telemetry: TelemetryConfig,
32    /// `[cli]` section — admin-visibility flag etc.
33    #[serde(default)]
34    pub cli: CliConfig,
35    /// `[security]` section — MCP client injection-guarding level.
36    #[serde(default)]
37    pub security: SecurityConfig,
38}
39
40/// Compiled-in production cloud base URL. Single source of truth for the
41/// default server endpoint — every other layer (the CLI resolver, the MCP
42/// `ServerConfig` defaults) sources its fallback from here.
43pub const DEFAULT_SERVER_URL: &str = "https://midnight-manual.midnightntwrk.expert";
44
45/// `[server]` — cloud endpoint settings.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct ServerConfig {
48    /// Cloud server base URL (defaults to the production Fly.io deployment).
49    pub url: String,
50}
51
52impl Default for ServerConfig {
53    fn default() -> Self {
54        Self { url: DEFAULT_SERVER_URL.into() }
55    }
56}
57
58/// `[models]` — ML model selection and Voyage API settings.
59#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
60pub struct ModelsConfig {
61    /// General corpus embedding model name (e.g. "voyage-context-3").
62    pub embedding: String,
63    /// Code-specialised embedding model name (dual embeddings, D1).
64    #[serde(default = "default_code_embedding")]
65    pub code_embedding: String,
66    /// Override for the on-disk model cache directory. When `None`, the
67    /// discoverer resolves to `$XDG_DATA_HOME/midnight-manual/models/`.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub cache_dir: Option<PathBuf>,
70    /// Voyage API key (BYOK). Resolved with flag > env > config precedence;
71    /// this is the config-file fallback only.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub voyage_api_key: Option<String>,
74    /// Voyage output dimension (Matryoshka): 256/512/1024/2048.
75    #[serde(default = "default_voyage_dim")]
76    pub voyage_output_dimension: u32,
77    /// Voyage output dtype: "float" | "int8" | "uint8" | "binary" | "ubinary".
78    #[serde(default = "default_voyage_dtype")]
79    pub voyage_output_dtype: String,
80    /// Per-request timeout (seconds) for BYOK Voyage embedding calls. `voyage-code-3`
81    /// embedding of a few-hundred-chunk batch can take ~38s+, so the default sits
82    /// well above the old 30s ceiling. Resolved with flag > env > config precedence.
83    #[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/// `[telemetry]` — opt-out telemetry knobs.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct TelemetryConfig {
120    /// Master enable flag. Three mechanisms (FR-107) — env, this flag, or `mnm
121    /// telemetry disable` — turn the entire pipeline off.
122    pub enabled: bool,
123}
124
125impl Default for TelemetryConfig {
126    fn default() -> Self {
127        Self { enabled: true }
128    }
129}
130
131/// `[cli]` — admin-visibility et al.
132#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
133pub struct CliConfig {
134    /// When `true`, `mnm --help` shows admin commands even without the env
135    /// override. Per D23, this never gates invocation.
136    #[serde(default)]
137    pub show_admin_cmds: bool,
138}
139
140/// `[security]` — MCP client prompt-injection guarding level (issue #103).
141#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
142#[serde(default)]
143pub struct SecurityConfig {
144    /// Guarding level: `"disabled"` | `"low"` | `"moderate"` | `"high"` |
145    /// `"strict"`. Unknown/empty falls through to the resolver default.
146    pub level: Option<String>,
147}
148
149impl Config {
150    /// Discover and load a config from `--config` (`explicit_path`), then the
151    /// `MIDNIGHT_MANUAL_CONFIG` env var, then the XDG location.
152    ///
153    /// If no file is found, returns [`Config::default`]. Returns the loaded
154    /// config and the path the loader resolved (or `None` if defaulted).
155    ///
156    /// # Errors
157    ///
158    /// Returns [`ConfigError::Read`] if the file is unreadable, or
159    /// [`ConfigError::Parse`] if the TOML is malformed.
160    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
187/// Read-only abstraction over environment lookup so config loading is testable
188/// without `std::env` side effects.
189pub trait ConfigEnv {
190    /// Read an environment variable.
191    fn var(&self, name: &str) -> Option<String>;
192}
193
194/// The default `ConfigEnv` impl reads `std::env`.
195#[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
223/// Resolve the Voyage API key with precedence flag > `VOYAGE_API_KEY` env > config.
224///
225/// An empty string at a level is treated as absent and falls through to the
226/// next source. If all sources are absent or empty, returns `None`.
227pub 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
238/// Resolve the Voyage request timeout (seconds): flag > `VOYAGE_TIMEOUT_SECS` env > config.
239///
240/// The env var is parsed as `u64`; a non-numeric, empty, or **zero** value at
241/// any level is treated as absent and falls through. Zero is rejected because
242/// reqwest treats `timeout(0)` as an immediately-expiring deadline (which would
243/// fail every request), not "unlimited". If every source is absent/zero, the
244/// built-in default (`default_voyage_timeout_secs`, 120s) is used.
245pub 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/// `[rerank]` — client-side rerank placement and model selection (spec §4).
261#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
262#[serde(default)]
263pub struct RerankConfig {
264    /// Where reranking runs: `"auto"` (default) | `"local"` | `"server"` | `"off"`.
265    pub location: Option<String>,
266    /// `VoyageAI` rerank model: `"rerank-2.5"` (default) | `"rerank-2.5-lite"`.
267    pub model: Option<String>,
268}
269
270/// Where a client runs reranking after placement resolution.
271#[derive(Debug, Clone, Copy, PartialEq, Eq)]
272pub enum RerankPlacement {
273    /// Call `VoyageAI` directly with the user's own key; tell the server `none`.
274    Local,
275    /// Ask the server to rerank inline in `/v1/search`.
276    Server,
277    /// No reranking anywhere; tell the server `none`.
278    Off,
279}
280
281impl RerankPlacement {
282    /// Stable telemetry wire string (`"local"` | `"server"` | `"off"`), matching
283    /// the `EventPayload::Rerank.placement` field. This is the single source of
284    /// truth shared by the CLI and MCP clients so the placement label stays
285    /// byte-aligned across both (and with the server-side telemetry validator).
286    #[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/// Resolve rerank placement with precedence flag > `MIDNIGHT_MANUAL_RERANK` env > config.
297///
298/// The config fallback is `[rerank].location`, ending at auto. Auto (and any
299/// unrecognized value) resolves by key detection: a Voyage key present means
300/// local BYOK, absent means server (D6). Mirrors the embedding-path defaulting.
301///
302/// An empty flag or env value is treated as absent and falls through to the
303/// next source, matching the other config resolvers.
304#[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, // "auto" or unknown: fall through
316    };
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/// Resolve the rerank model with precedence flag > `MIDNIGHT_MANUAL_RERANK_MODEL` env > config.
334///
335/// The config fallback is `[rerank].model`, ending at `rerank-2.5`. Returns a
336/// model variant only — never [`crate::rerank::RerankParam::None`] (placement
337/// handles "off").
338///
339/// An empty flag or env value is treated as absent and falls through to the
340/// next source, matching the other config resolvers.
341#[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/// Resolve the MCP client security level with precedence flag >
366/// `MIDNIGHT_MANUAL_SECURITY` env > `[security].level` config > default
367/// [`crate::injection::SecurityLevel::Moderate`].
368///
369/// An empty or unrecognized value at any level is treated as absent and falls
370/// through to the next source, matching the other config resolvers.
371#[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/// All the ways config discovery can fail.
395#[derive(Debug, Error)]
396pub enum ConfigError {
397    /// I/O failure reading the resolved config file.
398    #[error("failed to read config file `{}`: {message}", path.display())]
399    Read {
400        /// File path that failed to read.
401        path: PathBuf,
402        /// Underlying I/O error message.
403        message: String,
404    },
405    /// TOML parse failure on the resolved config file.
406    #[error("failed to parse config file `{}`: {message}", path.display())]
407    Parse {
408        /// File path that failed to parse.
409        path: PathBuf,
410        /// Underlying parser error message.
411        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"); // default filled in
518        assert_eq!(m.voyage_output_dimension, 1024);
519        assert_eq!(m.voyage_output_dtype, "float"); // default filled in
520        assert_eq!(m.voyage_timeout_secs, 120); // default filled in
521        assert!(m.voyage_api_key.is_none()); // Option default
522        assert!(m.cache_dir.is_none()); // Option default
523    }
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        // An empty value at a level is absent and falls through to the next source.
543        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        // All sources absent or empty → None.
547        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        // Non-numeric and empty env values are ignored and fall through to config.
566        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        // Zero at any level is rejected (reqwest `timeout(0)` fails every
572        // request) and falls through; all-zero yields the 120s default.
573        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        // Absent section -> defaults (both None).
594        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        // flag > env > config.
606        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        // Auto everywhere -> key detection: key => local, no key => server.
614        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        // Explicit "auto" at any level falls through to key detection.
618        assert_eq!(
619            resolve_rerank_placement(Some("auto"), &empty, &no_env, false),
620            RerankPlacement::Server
621        );
622        // Unknown value falls through to the next level (lenient, like other resolvers).
623        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        // These wire strings are the single source of truth shared by the CLI
632        // and MCP telemetry paths; they must match the telemetry validator's
633        // expectations byte-for-byte.
634        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        // Nothing anywhere -> rerank-2.5; unknown strings fall through.
655        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        // flag > env > config.
673        assert_eq!(resolve_security_level(Some("low"), &cfg, &env), SecurityLevel::Low);
674        // No flag -> env wins over config.
675        assert_eq!(resolve_security_level(None, &cfg, &env), SecurityLevel::Strict);
676        // No flag, no env -> config wins.
677        let no_env = FakeEnv::default();
678        assert_eq!(resolve_security_level(None, &cfg, &no_env), SecurityLevel::High);
679        // Nothing anywhere -> default Moderate.
680        let empty = SecurityConfig::default();
681        assert_eq!(resolve_security_level(None, &empty, &no_env), SecurityLevel::Moderate);
682        // Unknown/empty flag falls through to the next level.
683        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}