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}
36
37/// `[server]` — cloud endpoint settings.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct ServerConfig {
40    /// Cloud server base URL (defaults to the production Fly.io deployment).
41    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/// `[models]` — ML model selection and Voyage API settings.
53#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54pub struct ModelsConfig {
55    /// General corpus embedding model name (e.g. "voyage-context-3").
56    pub embedding: String,
57    /// Code-specialised embedding model name (dual embeddings, D1).
58    #[serde(default = "default_code_embedding")]
59    pub code_embedding: String,
60    /// Override for the on-disk model cache directory. When `None`, the
61    /// discoverer resolves to `$XDG_DATA_HOME/midnight-manual/models/`.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub cache_dir: Option<PathBuf>,
64    /// Voyage API key (BYOK). Resolved with flag > env > config precedence;
65    /// this is the config-file fallback only.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub voyage_api_key: Option<String>,
68    /// Voyage output dimension (Matryoshka): 256/512/1024/2048.
69    #[serde(default = "default_voyage_dim")]
70    pub voyage_output_dimension: u32,
71    /// Voyage output dtype: "float" | "int8" | "uint8" | "binary" | "ubinary".
72    #[serde(default = "default_voyage_dtype")]
73    pub voyage_output_dtype: String,
74    /// Per-request timeout (seconds) for BYOK Voyage embedding calls. `voyage-code-3`
75    /// embedding of a few-hundred-chunk batch can take ~38s+, so the default sits
76    /// well above the old 30s ceiling. Resolved with flag > env > config precedence.
77    #[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/// `[telemetry]` — opt-out telemetry knobs.
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
113pub struct TelemetryConfig {
114    /// Master enable flag. Three mechanisms (FR-107) — env, this flag, or `mnm
115    /// telemetry disable` — turn the entire pipeline off.
116    pub enabled: bool,
117}
118
119impl Default for TelemetryConfig {
120    fn default() -> Self {
121        Self { enabled: true }
122    }
123}
124
125/// `[cli]` — admin-visibility et al.
126#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
127pub struct CliConfig {
128    /// When `true`, `mnm --help` shows admin commands even without the env
129    /// override. Per D23, this never gates invocation.
130    #[serde(default)]
131    pub show_admin_cmds: bool,
132}
133
134impl Config {
135    /// Discover and load a config from `--config` (`explicit_path`), then the
136    /// `MIDNIGHT_MANUAL_CONFIG` env var, then the XDG location.
137    ///
138    /// If no file is found, returns [`Config::default`]. Returns the loaded
139    /// config and the path the loader resolved (or `None` if defaulted).
140    ///
141    /// # Errors
142    ///
143    /// Returns [`ConfigError::Read`] if the file is unreadable, or
144    /// [`ConfigError::Parse`] if the TOML is malformed.
145    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
172/// Read-only abstraction over environment lookup so config loading is testable
173/// without `std::env` side effects.
174pub trait ConfigEnv {
175    /// Read an environment variable.
176    fn var(&self, name: &str) -> Option<String>;
177}
178
179/// The default `ConfigEnv` impl reads `std::env`.
180#[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
208/// Resolve the Voyage API key with precedence flag > `VOYAGE_API_KEY` env > config.
209///
210/// An empty string at a level is treated as absent and falls through to the
211/// next source. If all sources are absent or empty, returns `None`.
212pub 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
223/// Resolve the Voyage request timeout (seconds): flag > `VOYAGE_TIMEOUT_SECS` env > config.
224///
225/// The env var is parsed as `u64`; a non-numeric, empty, or **zero** value at
226/// any level is treated as absent and falls through. Zero is rejected because
227/// reqwest treats `timeout(0)` as an immediately-expiring deadline (which would
228/// fail every request), not "unlimited". If every source is absent/zero, the
229/// built-in default (`default_voyage_timeout_secs`, 120s) is used.
230pub 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/// `[rerank]` — client-side rerank placement and model selection (spec §4).
246#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize, Serialize)]
247#[serde(default)]
248pub struct RerankConfig {
249    /// Where reranking runs: `"auto"` (default) | `"local"` | `"server"` | `"off"`.
250    pub location: Option<String>,
251    /// `VoyageAI` rerank model: `"rerank-2.5"` (default) | `"rerank-2.5-lite"`.
252    pub model: Option<String>,
253}
254
255/// Where a client runs reranking after placement resolution.
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257pub enum RerankPlacement {
258    /// Call `VoyageAI` directly with the user's own key; tell the server `none`.
259    Local,
260    /// Ask the server to rerank inline in `/v1/search`.
261    Server,
262    /// No reranking anywhere; tell the server `none`.
263    Off,
264}
265
266impl RerankPlacement {
267    /// Stable telemetry wire string (`"local"` | `"server"` | `"off"`), matching
268    /// the `EventPayload::Rerank.placement` field. This is the single source of
269    /// truth shared by the CLI and MCP clients so the placement label stays
270    /// byte-aligned across both (and with the server-side telemetry validator).
271    #[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/// Resolve rerank placement with precedence flag > `MIDNIGHT_MANUAL_RERANK` env > config.
282///
283/// The config fallback is `[rerank].location`, ending at auto. Auto (and any
284/// unrecognized value) resolves by key detection: a Voyage key present means
285/// local BYOK, absent means server (D6). Mirrors the embedding-path defaulting.
286///
287/// An empty flag or env value is treated as absent and falls through to the
288/// next source, matching the other config resolvers.
289#[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, // "auto" or unknown: fall through
301    };
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/// Resolve the rerank model with precedence flag > `MIDNIGHT_MANUAL_RERANK_MODEL` env > config.
319///
320/// The config fallback is `[rerank].model`, ending at `rerank-2.5`. Returns a
321/// model variant only — never [`crate::rerank::RerankParam::None`] (placement
322/// handles "off").
323///
324/// An empty flag or env value is treated as absent and falls through to the
325/// next source, matching the other config resolvers.
326#[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/// All the ways config discovery can fail.
351#[derive(Debug, Error)]
352pub enum ConfigError {
353    /// I/O failure reading the resolved config file.
354    #[error("failed to read config file `{}`: {message}", path.display())]
355    Read {
356        /// File path that failed to read.
357        path: PathBuf,
358        /// Underlying I/O error message.
359        message: String,
360    },
361    /// TOML parse failure on the resolved config file.
362    #[error("failed to parse config file `{}`: {message}", path.display())]
363    Parse {
364        /// File path that failed to parse.
365        path: PathBuf,
366        /// Underlying parser error message.
367        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"); // default filled in
474        assert_eq!(m.voyage_output_dimension, 1024);
475        assert_eq!(m.voyage_output_dtype, "float"); // default filled in
476        assert_eq!(m.voyage_timeout_secs, 120); // default filled in
477        assert!(m.voyage_api_key.is_none()); // Option default
478        assert!(m.cache_dir.is_none()); // Option default
479    }
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        // An empty value at a level is absent and falls through to the next source.
499        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        // All sources absent or empty → None.
503        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        // Non-numeric and empty env values are ignored and fall through to config.
522        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        // Zero at any level is rejected (reqwest `timeout(0)` fails every
528        // request) and falls through; all-zero yields the 120s default.
529        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        // Absent section -> defaults (both None).
550        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        // flag > env > config.
562        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        // Auto everywhere -> key detection: key => local, no key => server.
570        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        // Explicit "auto" at any level falls through to key detection.
574        assert_eq!(
575            resolve_rerank_placement(Some("auto"), &empty, &no_env, false),
576            RerankPlacement::Server
577        );
578        // Unknown value falls through to the next level (lenient, like other resolvers).
579        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        // These wire strings are the single source of truth shared by the CLI
588        // and MCP telemetry paths; they must match the telemetry validator's
589        // expectations byte-for-byte.
590        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        // Nothing anywhere -> rerank-2.5; unknown strings fall through.
611        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}