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