Skip to main content

notedthat_server/
config.rs

1//! Configuration for `notedthat-server`.
2//!
3//! Every setting arrives from one of two places: the command line, or the
4//! process environment. [`crate::cli::ServerCli`] resolves which — the flag wins
5//! — and hands the raw values here; nothing in this module reads the
6//! environment itself. See `docs/CONFIGURATION.md` for the full reference.
7
8use crate::cli::ServerCli;
9use crate::oidc::OidcSettings;
10use notedthat_core::{Error, KbSlug, StagingConfig, TenantSlug, setting};
11use notedthat_events::{MemoryConfig, MemorySettings, NatsConfig, NatsSettings};
12use notedthat_storage_fs::FsSettings;
13use notedthat_storage_s3::S3Settings;
14use notedthat_write::MAX_UPLOAD_BYTES;
15use std::collections::BTreeMap;
16use std::ffi::OsStr;
17use std::net::SocketAddr;
18use std::time::Duration;
19
20/// Which storage backend the server runs on.
21///
22/// Parsed separately from its configuration so the selection can be named in an error
23/// message before any backend configuration is read.
24#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
25pub enum StorageBackendKind {
26    /// An S3-compatible object store.
27    S3,
28    /// A local filesystem tree.
29    Fs,
30}
31
32impl StorageBackendKind {
33    /// The `NOTEDTHAT_STORAGE_BACKEND` value that selects this backend.
34    #[must_use]
35    pub fn as_str(self) -> &'static str {
36        match self {
37            Self::S3 => "s3",
38            Self::Fs => "fs",
39        }
40    }
41}
42
43impl std::fmt::Display for StorageBackendKind {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.write_str(self.as_str())
46    }
47}
48
49/// The selected storage backend together with the configuration it needs.
50///
51/// An enum rather than one `Option` per backend, so "exactly one backend is configured"
52/// is a property of the type and [`crate::run`] has no unreachable error arm.
53#[derive(Debug, Clone)]
54pub enum StorageConfig {
55    /// S3-compatible object store (the default).
56    S3(notedthat_storage_s3::S3Config),
57    /// Local filesystem tree.
58    Fs(notedthat_storage_fs::FsConfig),
59}
60
61impl StorageConfig {
62    /// Which backend this is.
63    #[must_use]
64    pub fn kind(&self) -> StorageBackendKind {
65        match self {
66            Self::S3(_) => StorageBackendKind::S3,
67            Self::Fs(_) => StorageBackendKind::Fs,
68        }
69    }
70}
71
72/// Which object change event log the server publishes to (`NOTEDTHAT_EVENTS_BACKEND`).
73#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
74pub enum EventsBackendKind {
75    /// No log: writes are not announced and the events route answers 404.
76    None,
77    /// A process-local ring — replay across reconnects, not restarts or replicas.
78    Memory,
79    /// A NATS `JetStream` stream shared by every replica.
80    Nats,
81}
82
83impl EventsBackendKind {
84    /// The `NOTEDTHAT_EVENTS_BACKEND` value that selects this backend.
85    #[must_use]
86    pub fn as_str(self) -> &'static str {
87        match self {
88            Self::None => "none",
89            Self::Memory => "memory",
90            Self::Nats => "nats",
91        }
92    }
93}
94
95impl std::fmt::Display for EventsBackendKind {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        f.write_str(self.as_str())
98    }
99}
100
101/// Whether `/mcp` admits a request that presents no credential (`NOTEDTHAT_MCP_ANONYMOUS`).
102///
103/// The MCP surface acts as its caller on the loopback API, so an anonymous caller is bound
104/// by the manifests' `anyone` rules exactly as a direct anonymous request is. What this
105/// setting decides is only whether such a request is let in at all, because the alternative
106/// — a `401` with the bearer challenge — is what an OAuth-capable MCP client needs to see
107/// before it will sign in.
108#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
109pub enum McpAnonymous {
110    /// Admit anonymous callers when at least one declared knowledge base grants `anyone`
111    /// something; otherwise `401`. The default.
112    #[default]
113    Auto,
114    /// Always `401` a missing credential, whatever the manifests grant — for a deployment
115    /// with public knowledge bases and an identity provider whose operator wants OAuth
116    /// clients challenged on connect rather than signed in by hand.
117    Never,
118}
119
120impl McpAnonymous {
121    /// The `NOTEDTHAT_MCP_ANONYMOUS` value that selects this mode.
122    #[must_use]
123    pub fn as_str(self) -> &'static str {
124        match self {
125            Self::Auto => "auto",
126            Self::Never => "never",
127        }
128    }
129}
130
131impl std::fmt::Display for McpAnonymous {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        f.write_str(self.as_str())
134    }
135}
136
137/// The selected events backend together with the configuration it needs.
138#[derive(Debug, Clone)]
139pub enum EventsConfig {
140    /// No event log (the default).
141    None,
142    /// The in-process ring.
143    Memory(MemoryConfig),
144    /// A NATS `JetStream` stream.
145    Nats(NatsConfig),
146}
147
148impl EventsConfig {
149    /// Which backend this is.
150    #[must_use]
151    pub fn kind(&self) -> EventsBackendKind {
152        match self {
153            Self::None => EventsBackendKind::None,
154            Self::Memory(_) => EventsBackendKind::Memory,
155            Self::Nats(_) => EventsBackendKind::Nats,
156        }
157    }
158}
159
160/// Every setting owned by one storage backend, paired with its backend and with
161/// whether this run supplied it at all.
162///
163/// A setting whose owner is not the selected backend is a startup error rather than an
164/// ignored setting — silently ignoring `NOTEDTHAT_FS_ROOT` under the default `s3` backend
165/// is how an operator ends up believing their bytes are on a disk they are not on. Same
166/// reasoning as [`REMOVED_LISTENER_ENV_VARS`] (D39), applied to backend selection.
167///
168/// "Supplied" means presence, not value, and spans both sources: `--s3-region ""` and
169/// `NOTEDTHAT_S3_REGION=` both count, matching how an empty variable counted when the
170/// environment was the only source.
171///
172/// Deliberately confined to settings this server reads. `AWS_*` is not listed:
173/// `S3Config::build_client` uses a static credential provider and never consults the
174/// ambient credential chain, so rejecting an `AWS_ACCESS_KEY_ID` on a shared runner would
175/// be a pure false positive.
176///
177/// The names are asserted against each adapter's own inventory — `S3_ENV_VARS` and
178/// `FS_ENV_VARS` — by a test, so this table cannot drift from what those adapters read.
179fn backend_owned_settings(cli: &ServerCli) -> Vec<(&'static str, StorageBackendKind, bool)> {
180    use StorageBackendKind::{Fs, S3};
181    vec![
182        ("NOTEDTHAT_S3_REGION", S3, cli.s3_region.is_some()),
183        (
184            "NOTEDTHAT_S3_ACCESS_KEY_ID",
185            S3,
186            cli.s3_access_key_id.is_some(),
187        ),
188        (
189            "NOTEDTHAT_S3_SECRET_ACCESS_KEY",
190            S3,
191            cli.s3_secret_access_key.is_some(),
192        ),
193        (
194            "NOTEDTHAT_S3_ENDPOINT_URL",
195            S3,
196            cli.s3_endpoint_url.is_some(),
197        ),
198        (
199            "NOTEDTHAT_S3_FORCE_PATH_STYLE",
200            S3,
201            cli.s3_force_path_style.is_some(),
202        ),
203        ("NOTEDTHAT_S3_RECONCILE", S3, cli.s3_reconcile.is_some()),
204        ("NOTEDTHAT_FS_ROOT", Fs, cli.fs_root.is_some()),
205        ("NOTEDTHAT_FS_METADATA", Fs, cli.fs_metadata.is_some()),
206        ("NOTEDTHAT_FS_FILE_MODE", Fs, cli.fs_file_mode.is_some()),
207        ("NOTEDTHAT_FS_DIR_MODE", Fs, cli.fs_dir_mode.is_some()),
208        (
209            "NOTEDTHAT_FS_ALLOW_LOSSY_NAMES",
210            Fs,
211            cli.fs_allow_lossy_names.is_some(),
212        ),
213        ("NOTEDTHAT_FS_WATCH", Fs, cli.fs_watch.is_some()),
214        (
215            "NOTEDTHAT_FS_WATCH_DEBOUNCE_MS",
216            Fs,
217            cli.fs_watch_debounce_ms.is_some(),
218        ),
219    ]
220}
221
222/// The `NOTEDTHAT_EVENTS_*` and `NOTEDTHAT_NATS_*` settings, each with the events
223/// backend that reads it. Same purpose and same guard as [`backend_owned_settings`].
224fn events_owned_settings(cli: &ServerCli) -> Vec<(&'static str, EventsBackendKind, bool)> {
225    use EventsBackendKind::{Memory, Nats};
226    vec![
227        (
228            "NOTEDTHAT_EVENTS_MEMORY_CAPACITY",
229            Memory,
230            cli.events_memory_capacity.is_some(),
231        ),
232        ("NOTEDTHAT_NATS_URL", Nats, cli.nats_url.is_some()),
233        ("NOTEDTHAT_NATS_STREAM", Nats, cli.nats_stream.is_some()),
234        (
235            "NOTEDTHAT_NATS_MAX_AGE_SECS",
236            Nats,
237            cli.nats_max_age_secs.is_some(),
238        ),
239    ]
240}
241
242/// Parse the backend selector, returning `None` when it was not supplied.
243///
244/// Strict, unlike `NOTEDTHAT_LOG_FORMAT`, which silently falls back on an unrecognised
245/// value. That one can afford leniency because a mis-parse announces itself immediately:
246/// the wrong log format is visible in the first line of output. A backend selector
247/// cannot: `NOTEDTHAT_STORAGE_BACKEND=fs3` would fall back to `s3`, start cleanly,
248/// provision buckets and serve a knowledge base that looks empty because the operator's
249/// data is on disk. Nothing later in the run would say so. Every `NOTEDTHAT_S3_*` switch
250/// is strict for the same reason — `NOTEDTHAT_S3_FORCE_PATH_STYLE=yes` used to become
251/// `false`, and the `SeaweedFS` or `MinIO` deployment it was set for then failed with DNS
252/// errors naming nothing.
253fn parse_storage_backend(supplied: Option<&OsStr>) -> Result<Option<StorageBackendKind>, Error> {
254    let Some(value) = supplied else {
255        return Ok(None);
256    };
257    let name = setting("NOTEDTHAT_STORAGE_BACKEND");
258    let value = value.to_str().ok_or_else(|| Error::Config {
259        message: format!("{name} must be valid UTF-8"),
260    })?;
261    if value.is_empty() {
262        return Err(Error::Config {
263            message: format!("{name} must not be empty"),
264        });
265    }
266    match value {
267        "s3" => Ok(Some(StorageBackendKind::S3)),
268        "fs" => Ok(Some(StorageBackendKind::Fs)),
269        other => Err(Error::Config {
270            message: format!("{name} is invalid: expected \"s3\" or \"fs\", got \"{other}\""),
271        }),
272    }
273}
274
275/// Parse the events backend selector, returning `None` when it was not supplied.
276///
277/// Strict for the same reason as [`parse_storage_backend`]: a mis-spelled selector
278/// that fell back to `none` would start cleanly and simply never announce anything.
279fn parse_events_backend(supplied: Option<&OsStr>) -> Result<Option<EventsBackendKind>, Error> {
280    let Some(value) = supplied else {
281        return Ok(None);
282    };
283    let name = setting("NOTEDTHAT_EVENTS_BACKEND");
284    let value = value.to_str().ok_or_else(|| Error::Config {
285        message: format!("{name} must be valid UTF-8"),
286    })?;
287    if value.is_empty() {
288        return Err(Error::Config {
289            message: format!("{name} must not be empty"),
290        });
291    }
292    match value {
293        "none" => Ok(Some(EventsBackendKind::None)),
294        "memory" => Ok(Some(EventsBackendKind::Memory)),
295        "nats" => Ok(Some(EventsBackendKind::Nats)),
296        other => Err(Error::Config {
297            message: format!(
298                "{name} is invalid: expected \"none\", \"memory\" or \"nats\", got \"{other}\""
299            ),
300        }),
301    }
302}
303
304/// Parse `NOTEDTHAT_MCP_ANONYMOUS`.
305///
306/// An empty or blank value is the default, as it is for the sibling
307/// `NOTEDTHAT_MCP_HTTP_*` settings: Compose passes every MCP variable through as `${VAR-}`,
308/// so an operator who never set it hands the server an empty string. Anything else that is
309/// not a mode is refused, as the backend selectors are — the wrong spelling of `never` must
310/// not quietly become `auto`.
311fn parse_mcp_anonymous(supplied: Option<&str>) -> Result<McpAnonymous, Error> {
312    let value = supplied.map(str::trim).unwrap_or_default();
313    if value.is_empty() {
314        return Ok(McpAnonymous::default());
315    }
316    match value.to_ascii_lowercase().as_str() {
317        "auto" => Ok(McpAnonymous::Auto),
318        "never" => Ok(McpAnonymous::Never),
319        other => Err(Error::Config {
320            message: format!(
321                "{} is invalid: expected \"auto\" or \"never\", got \"{other}\"",
322                setting("NOTEDTHAT_MCP_ANONYMOUS")
323            ),
324        }),
325    }
326}
327
328/// The default metrics bind: loopback, and deliberately so.
329const DEFAULT_METRICS_LISTEN_ADDR: &str = "127.0.0.1:9090";
330
331/// Parse `NOTEDTHAT_METRICS_ENABLED` and `NOTEDTHAT_METRICS_LISTEN_ADDR` into
332/// the one thing the server needs: where to serve the exposition, if anywhere.
333///
334/// Strict like every other selector (§6.5): a value that is neither `true` nor
335/// `false` refuses startup rather than reading as off, because a deployment
336/// that believes it is being scraped and is not looks exactly like a healthy
337/// one until someone needs the graph. An empty value is refused for the same
338/// reason — it says nothing about which was meant.
339///
340/// The address is refused when metrics are off, in the spirit of
341/// [`reject_unselected_settings`]: an operator who set a bind address has
342/// decided they want this served, and silently ignoring it would leave them
343/// waiting on a port nothing listens to.
344///
345/// The default is loopback, unlike [`Config::listen_addr`]'s `0.0.0.0`. The
346/// exposition carries no bearer and answers anyone who can reach it, so the
347/// safe bind is the one that reaches nobody, and exposing it is an edit an
348/// operator makes on purpose (D69).
349fn parse_metrics(
350    enabled: Option<&str>,
351    listen_addr: Option<&str>,
352) -> Result<Option<SocketAddr>, Error> {
353    let supplied = enabled.map(str::trim);
354    let enabled = match supplied {
355        // Absent and `false` are the same state — off — and the refusal below
356        // is what tells the two apart, where the difference is worth a word.
357        None | Some("false") => false,
358        Some("true") => true,
359        // Named apart from the generic refusal below: an empty value is almost
360        // always a variable that was passed through without one, and "must not
361        // be empty" points at that, where quoting `""` back does not.
362        Some("") => {
363            return Err(Error::Config {
364                message: format!("{} must not be empty", setting("NOTEDTHAT_METRICS_ENABLED")),
365            });
366        }
367        Some(value) => {
368            return Err(Error::Config {
369                message: format!(
370                    "{} is invalid: expected \"true\" or \"false\", got \"{value}\"",
371                    setting("NOTEDTHAT_METRICS_ENABLED")
372                ),
373            });
374        }
375    };
376
377    // Normalised once, before either branch reads it. The enabled path below
378    // used to trim and filter while this one tested the raw value, so the same
379    // empty string was the default with metrics on and a startup refusal with
380    // them off — and off is the default. Compose passes an unset variable
381    // through as `${VAR-}`, which arrives as the empty string, so an operator
382    // who never configured an address would be refused for one, exactly as
383    // `NOTEDTHAT_MCP_ANONYMOUS` next door is careful not to do. The refusal is
384    // for an address that really was supplied and really would be ignored.
385    let listen_addr = listen_addr.map(str::trim).filter(|value| !value.is_empty());
386
387    if !enabled {
388        if listen_addr.is_some() {
389            return Err(Error::Config {
390                message: format!(
391                    "{enabled_name} {state}, but {addr_name} would be ignored: no metrics \
392                     listener is opened. Unset it or set NOTEDTHAT_METRICS_ENABLED=true to \
393                     start the server.",
394                    enabled_name = setting("NOTEDTHAT_METRICS_ENABLED"),
395                    state = if supplied.is_some() {
396                        "is false"
397                    } else {
398                        "is unset, so metrics are off"
399                    },
400                    addr_name = setting("NOTEDTHAT_METRICS_LISTEN_ADDR"),
401                ),
402            });
403        }
404        return Ok(None);
405    }
406
407    let supplied = listen_addr.unwrap_or(DEFAULT_METRICS_LISTEN_ADDR);
408    let addr: SocketAddr = supplied.parse().map_err(|e| Error::Config {
409        message: format!(
410            "{} is invalid: {e}",
411            setting("NOTEDTHAT_METRICS_LISTEN_ADDR")
412        ),
413    })?;
414    Ok(Some(addr))
415}
416
417/// Refuse to start when settings belonging to an unselected backend are supplied.
418///
419/// Reports every offender at once: the realistic case is a whole `NOTEDTHAT_S3_*` family
420/// left behind by an operator switching to `fs`, and naming one per restart would take
421/// five restarts.
422///
423/// The check runs when the selector is unset too, and says so. That is the highest-value
424/// case: an operator who sets `NOTEDTHAT_FS_ROOT` and forgets the selector would
425/// otherwise get a perfectly healthy S3 deployment with an unread root.
426///
427/// Generic over the selector so the storage and events backends share one message
428/// shape. Offenders are grouped by the backend that owns them, since with three
429/// events backends the unselected ones are not a single "other".
430fn reject_unselected_settings<K: Copy + Eq + Ord + std::fmt::Display>(
431    selector: &'static str,
432    selected: Option<K>,
433    default: K,
434    table: Vec<(&'static str, K, bool)>,
435) -> Result<(), Error> {
436    let effective = selected.unwrap_or(default);
437    let mut by_owner: BTreeMap<K, Vec<String>> = BTreeMap::new();
438    for (name, owner, supplied) in table {
439        if supplied && owner != effective {
440            by_owner.entry(owner).or_default().push(setting(name));
441        }
442    }
443
444    if by_owner.is_empty() {
445        return Ok(());
446    }
447
448    let selection = match selected {
449        Some(kind) => format!("{} is {kind}", setting(selector)),
450        None => format!(
451            "{} is unset, so the default {default} backend is selected",
452            setting(selector)
453        ),
454    };
455    let complaints: Vec<String> = by_owner
456        .iter()
457        .map(|(owner, names)| {
458            format!(
459                "these settings belong to the {owner} backend and would be ignored: {}",
460                names.join(", ")
461            )
462        })
463        .collect();
464    let fixes: Vec<String> = by_owner
465        .keys()
466        .map(|owner| format!("{selector}={owner}"))
467        .collect();
468    Err(Error::Config {
469        message: format!(
470            "{selection}, but {}. Unset them or set {} to start the server.",
471            complaints.join("; "),
472            fixes.join(" or ")
473        ),
474    })
475}
476
477/// An S3 storage config pointed at an unroutable address.
478///
479/// For tests that inject their own [`crate::run::Backends`] and never build a client
480/// from it. A regression that *does* reach for it fails loudly rather than quietly
481/// talking to something real.
482#[cfg(any(test, feature = "test-support"))]
483#[must_use]
484pub fn unroutable_storage_placeholder() -> StorageConfig {
485    StorageConfig::S3(notedthat_storage_s3::S3Config {
486        endpoint_url: Some("http://127.0.0.1:1".to_string()),
487        region: "us-east-1".to_string(),
488        access_key_id: "any".to_string(),
489        secret_access_key: "any".to_string(),
490        force_path_style: true,
491        reconcile_on_startup: true,
492    })
493}
494
495/// Server-wide configuration.
496#[derive(Debug, Clone)]
497pub struct Config {
498    /// Static Bearer token for API authentication (`NOTEDTHAT_API_TOKEN`).
499    pub api_token: String,
500    /// Declared knowledge bases, as a sorted map of slug string → [`KbSlug`].
501    pub kbs: BTreeMap<String, KbSlug>,
502    /// Tenant slug — hardcoded to `"default"` per Metis directive.
503    pub tenant_slug: TenantSlug,
504    /// Socket address the HTTP server binds to (`NOTEDTHAT_LISTEN_ADDR`; default `0.0.0.0:8080`).
505    pub listen_addr: SocketAddr,
506    /// The selected storage backend and its configuration
507    /// (`NOTEDTHAT_STORAGE_BACKEND`; default `s3`).
508    pub storage: StorageConfig,
509    /// The selected object change event log and its configuration
510    /// (`NOTEDTHAT_EVENTS_BACKEND`; default `none`).
511    pub events: EventsConfig,
512    /// Log output format (`NOTEDTHAT_LOG_FORMAT`; `pretty` or `json`).
513    pub log_format: LogFormat,
514    /// Qdrant client configuration.
515    pub qdrant: ServerQdrantConfig,
516    /// Embedder configuration.
517    pub embedder: EmbedderConfig,
518    /// `WebDAV` Basic authentication username (`NOTEDTHAT_WEBDAV_USERNAME`; required).
519    pub webdav_username: String,
520    /// `WebDAV` Basic authentication password (`NOTEDTHAT_WEBDAV_PASSWORD`; required).
521    pub webdav_password: String,
522    /// Allowed origins for MCP HTTP CORS (`NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS`; empty → `["null"]`).
523    pub mcp_http_allowed_origins: Vec<String>,
524    /// Allowed hosts for MCP HTTP Host header validation (`NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS`; empty → `["127.0.0.1", "localhost", "::1"]`).
525    pub mcp_http_allowed_hosts: Vec<String>,
526    /// Whether `/mcp` admits anonymous callers (`NOTEDTHAT_MCP_ANONYMOUS`; default `auto`).
527    pub mcp_anonymous: McpAnonymous,
528    /// Maximum patchable object size in bytes (`NOTEDTHAT_MAX_PATCHABLE_SIZE`; default 100 MiB).
529    pub max_patchable_size: u64,
530    /// Most bytes one MCP object read may fetch (`NOTEDTHAT_MCP_MAX_READ_BYTES`; default 16 MiB,
531    /// the API body cap). Larger objects are read in slices.
532    pub mcp_max_read_bytes: u64,
533    /// Most MCP sessions this process holds at once (`NOTEDTHAT_MCP_MAX_SESSIONS`; default 256).
534    /// An `initialize` past the bound is refused `503` with `Retry-After: 5` (D38).
535    pub mcp_max_sessions: usize,
536    /// How often `/readyz`'s poller probes the storage backend and Qdrant, in
537    /// milliseconds; also each probe's deadline (`NOTEDTHAT_READY_PROBE_INTERVAL_MS`;
538    /// default 5000).
539    pub ready_probe_interval_ms: u64,
540    /// Where the Prometheus exposition is served, or `None` while metrics are
541    /// off (`NOTEDTHAT_METRICS_ENABLED`, `NOTEDTHAT_METRICS_LISTEN_ADDR`; D69).
542    ///
543    /// One field rather than a flag beside an address, so "enabled, and here"
544    /// and "off" are the only two states anything downstream can see. The pair
545    /// of settings is reconciled once, in [`Self::from_cli`]; an address
546    /// supplied while the feature is off refuses startup rather than arriving
547    /// here as a value nobody reads.
548    pub metrics_listen_addr: Option<SocketAddr>,
549    /// Shared private staging directory for uploads and index snapshots (`NOTEDTHAT_UPLOAD_TMP_DIR`).
550    pub staging: StagingConfig,
551    /// Identity-provider settings (`NOTEDTHAT_OIDC_*`); `None` when no issuer is set.
552    pub oidc: Option<OidcSettings>,
553}
554
555/// Settings removed when the API, `WebDAV`, and MCP surfaces moved onto one
556/// listener, each paired with the setup that replaces it.
557///
558/// Leaving one of these set is a silent exposure change on upgrade — a
559/// `WebDAV` listener that was bound to loopback becomes reachable at `/webdav` on the
560/// public listener, and `NOTEDTHAT_MCP_HTTP_ENABLED=false` no longer disables
561/// `/mcp`. Per D39 the server refuses to start instead, naming the replacement.
562///
563/// [`ServerCli`] still accepts each one as a hidden flag for the same reason it is
564/// checked here: an operator who reaches for the removed setting deserves the
565/// replacement, not "unexpected argument".
566const REMOVED_LISTENER_ENV_VARS: [(&str, &str); 3] = [
567    (
568        "NOTEDTHAT_WEBDAV_LISTEN_ADDR",
569        "WebDAV is always served at /webdav on NOTEDTHAT_LISTEN_ADDR",
570    ),
571    (
572        "NOTEDTHAT_MCP_HTTP_BIND",
573        "MCP HTTP is always served at /mcp on NOTEDTHAT_LISTEN_ADDR",
574    ),
575    (
576        "NOTEDTHAT_MCP_HTTP_ENABLED",
577        "MCP HTTP is always served at /mcp on NOTEDTHAT_LISTEN_ADDR",
578    ),
579];
580
581/// Tracing output format.
582#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583pub enum LogFormat {
584    /// Human-readable multi-line output (default).
585    Pretty,
586    /// Machine-readable JSON (one line per event).
587    Json,
588}
589
590impl Config {
591    /// Parse configuration from the environment alone.
592    ///
593    /// Equivalent to [`Config::from_cli`] over an empty `argv`, which is what a
594    /// container that passes no arguments gets.
595    ///
596    /// # Errors
597    ///
598    /// As [`Config::from_cli`], plus `Err(Error::Config { .. })` if a variable holds a
599    /// value the parser cannot accept at all.
600    pub fn from_env() -> Result<Self, Error> {
601        let cli = ServerCli::from_env().map_err(|error| Error::Config {
602            message: error.to_string(),
603        })?;
604        Self::from_cli(cli)
605    }
606
607    /// Validate the settings this run supplied, from either source.
608    ///
609    /// # Errors
610    ///
611    /// Returns `Err(Error::Config { .. })` if any required setting is missing,
612    /// if any value is invalid (empty token, bad slug, duplicate slug, etc.), or
613    /// if any [`REMOVED_LISTENER_ENV_VARS`] entry was supplied.
614    #[allow(clippy::too_many_lines)]
615    pub fn from_cli(mut cli: ServerCli) -> Result<Self, Error> {
616        let removed = [
617            cli.webdav_listen_addr.is_some(),
618            cli.mcp_http_bind.is_some(),
619            cli.mcp_http_enabled.is_some(),
620        ];
621        for ((key, replacement), supplied) in REMOVED_LISTENER_ENV_VARS.iter().zip(removed) {
622            if supplied {
623                return Err(Error::Config {
624                    message: format!(
625                        "{key} was removed: {replacement}. Unset {key} to start the server."
626                    ),
627                });
628            }
629        }
630
631        // Borrows the whole CLI, so it runs before the field-by-field moves below.
632        let oidc = parse_oidc(&cli)?;
633
634        let api_token = cli.api_token.take().ok_or_else(|| Error::Config {
635            message: format!("{} is required", setting("NOTEDTHAT_API_TOKEN")),
636        })?;
637        if api_token.trim().is_empty() {
638            return Err(Error::Config {
639                message: format!("{} must not be empty", setting("NOTEDTHAT_API_TOKEN")),
640            });
641        }
642
643        let kbs_raw = cli.kbs.take().ok_or_else(|| Error::Config {
644            message: format!("{} is required", setting("NOTEDTHAT_KBS")),
645        })?;
646        if kbs_raw.trim().is_empty() {
647            return Err(Error::Config {
648                message: format!(
649                    "{} must declare at least one knowledge base",
650                    setting("NOTEDTHAT_KBS")
651                ),
652            });
653        }
654
655        let mut kbs = BTreeMap::new();
656        for token in kbs_raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
657            let slug = KbSlug::try_new(token).map_err(|e| Error::Config {
658                message: format!("invalid KB slug {token:?}: {e}"),
659            })?;
660            if kbs.insert(slug.as_str().to_string(), slug).is_some() {
661                return Err(Error::Config {
662                    message: format!(
663                        "duplicate KB slug in {}: {token:?}",
664                        setting("NOTEDTHAT_KBS")
665                    ),
666                });
667            }
668        }
669        if kbs.is_empty() {
670            return Err(Error::Config {
671                message: format!(
672                    "{} must declare at least one knowledge base",
673                    setting("NOTEDTHAT_KBS")
674                ),
675            });
676        }
677
678        // Tenant slug is hardcoded to "default" per Metis directive.
679        // NOTEDTHAT_TENANT_SLUG intentionally not read.
680        let tenant_slug = TenantSlug::default();
681
682        // Taken, not moved: the backend-rejection check below needs the whole
683        // `cli` by reference, and reordering the two would change which error an
684        // operator sees when both are wrong.
685        let listen_addr_str = cli
686            .listen_addr
687            .take()
688            .unwrap_or_else(|| "0.0.0.0:8080".to_string());
689        let listen_addr: SocketAddr = listen_addr_str.parse().map_err(|e| Error::Config {
690            message: format!("{} is invalid: {e}", setting("NOTEDTHAT_LISTEN_ADDR")),
691        })?;
692
693        let selected = parse_storage_backend(cli.storage_backend.as_deref())?;
694        reject_unselected_settings(
695            "NOTEDTHAT_STORAGE_BACKEND",
696            selected,
697            StorageBackendKind::S3,
698            backend_owned_settings(&cli),
699        )?;
700        let selected_events = parse_events_backend(cli.events_backend.as_deref())?;
701        reject_unselected_settings(
702            "NOTEDTHAT_EVENTS_BACKEND",
703            selected_events,
704            EventsBackendKind::None,
705            events_owned_settings(&cli),
706        )?;
707        let storage = match selected.unwrap_or(StorageBackendKind::S3) {
708            StorageBackendKind::S3 => {
709                StorageConfig::S3(notedthat_storage_s3::S3Config::from_settings(S3Settings {
710                    region: cli.s3_region,
711                    access_key_id: cli.s3_access_key_id,
712                    secret_access_key: cli.s3_secret_access_key,
713                    endpoint_url: cli.s3_endpoint_url,
714                    force_path_style: cli.s3_force_path_style,
715                    reconcile: cli.s3_reconcile,
716                })?)
717            }
718            StorageBackendKind::Fs => {
719                StorageConfig::Fs(notedthat_storage_fs::FsConfig::from_settings(FsSettings {
720                    root: cli.fs_root,
721                    metadata: cli.fs_metadata,
722                    file_mode: cli.fs_file_mode,
723                    dir_mode: cli.fs_dir_mode,
724                    allow_lossy_names: cli.fs_allow_lossy_names,
725                    watch: cli.fs_watch,
726                    watch_debounce_ms: cli.fs_watch_debounce_ms,
727                })?)
728            }
729        };
730
731        let events = match selected_events.unwrap_or(EventsBackendKind::None) {
732            EventsBackendKind::None => EventsConfig::None,
733            EventsBackendKind::Memory => {
734                EventsConfig::Memory(MemoryConfig::from_settings(&MemorySettings {
735                    capacity: cli.events_memory_capacity,
736                })?)
737            }
738            EventsBackendKind::Nats => {
739                EventsConfig::Nats(NatsConfig::from_settings(NatsSettings {
740                    url: cli.nats_url,
741                    stream: cli.nats_stream,
742                    max_age_secs: cli.nats_max_age_secs,
743                })?)
744            }
745        };
746
747        let log_format = match cli.log_format.as_deref() {
748            Some("json") => LogFormat::Json,
749            _ => LogFormat::Pretty,
750        };
751
752        let qdrant = ServerQdrantConfig::from_parts(
753            cli.qdrant_url,
754            cli.qdrant_api_key,
755            cli.qdrant_timeout_ms.as_deref(),
756            cli.qdrant_connect_timeout_ms.as_deref(),
757        )?;
758        let embedder = EmbedderConfig::from_parts(EmbedderParts {
759            endpoint_url: cli.embedding_endpoint_url,
760            model: cli.embedding_model,
761            api_key: cli.embedding_api_key,
762            dimensions: cli.embedding_dimensions,
763            batch_size: cli.embedding_batch_size,
764            timeout_ms: cli.embedding_timeout_ms,
765            max_retries: cli.embedding_max_retries,
766            max_input_tokens: cli.embedding_max_input_tokens,
767        })?;
768
769        let webdav_username = cli.webdav_username.ok_or_else(|| Error::Config {
770            message: format!("{} is required", setting("NOTEDTHAT_WEBDAV_USERNAME")),
771        })?;
772        if webdav_username.is_empty() {
773            return Err(Error::Config {
774                message: format!(
775                    "{} is required and must not be empty",
776                    setting("NOTEDTHAT_WEBDAV_USERNAME")
777                ),
778            });
779        }
780
781        let webdav_password = cli.webdav_password.ok_or_else(|| Error::Config {
782            message: format!("{} is required", setting("NOTEDTHAT_WEBDAV_PASSWORD")),
783        })?;
784        if webdav_password.is_empty() {
785            return Err(Error::Config {
786                message: format!(
787                    "{} is required and must not be empty",
788                    setting("NOTEDTHAT_WEBDAV_PASSWORD")
789                ),
790            });
791        }
792
793        let mcp_http_allowed_origins =
794            comma_list(cli.mcp_http_allowed_origins.as_deref(), &["null"]);
795        let mcp_http_allowed_hosts = comma_list(
796            cli.mcp_http_allowed_hosts.as_deref(),
797            &["127.0.0.1", "localhost", "::1"],
798        );
799        let mcp_anonymous = parse_mcp_anonymous(cli.mcp_anonymous.as_deref())?;
800
801        let max_patchable_size = cli
802            .max_patchable_size
803            .unwrap_or_else(|| (100 * 1024 * 1024u64).to_string())
804            .parse::<u64>()
805            .map_err(|_e: std::num::ParseIntError| Error::Config {
806                message: format!(
807                    "{} must be a valid u64 integer",
808                    setting("NOTEDTHAT_MAX_PATCHABLE_SIZE")
809                ),
810            })?;
811        if max_patchable_size == 0 {
812            return Err(Error::Config {
813                message: format!("{} must be > 0", setting("NOTEDTHAT_MAX_PATCHABLE_SIZE")),
814            });
815        }
816        if max_patchable_size > MAX_UPLOAD_BYTES {
817            return Err(Error::Config {
818                message: format!(
819                    "{} must not exceed MAX_UPLOAD_BYTES (5 GiB)",
820                    setting("NOTEDTHAT_MAX_PATCHABLE_SIZE")
821                ),
822            });
823        }
824
825        // Empty or blank is the default, as for every sibling NOTEDTHAT_MCP_*
826        // setting: Compose passes them through as `${VAR-}`, so a deployment
827        // that never set this hands the server an empty string.
828        let mcp_max_read_bytes = match cli
829            .mcp_max_read_bytes
830            .as_deref()
831            .map(str::trim)
832            .filter(|value| !value.is_empty())
833        {
834            None => notedthat_mcp::DEFAULT_MAX_READ_BYTES,
835            Some(value) => value.parse::<u64>().map_err(|_e| Error::Config {
836                message: format!(
837                    "{} must be a valid u64 integer",
838                    setting("NOTEDTHAT_MCP_MAX_READ_BYTES")
839                ),
840            })?,
841        };
842        if mcp_max_read_bytes == 0 {
843            return Err(Error::Config {
844                message: format!("{} must be > 0", setting("NOTEDTHAT_MCP_MAX_READ_BYTES")),
845            });
846        }
847        // Neither `fs` nor `s3` owns this one, so it is deliberately absent
848        // from `backend_owned_settings`: it bounds the transport, not a backend.
849        let mcp_max_sessions = match cli
850            .mcp_max_sessions
851            .as_deref()
852            .map(str::trim)
853            .filter(|value| !value.is_empty())
854        {
855            None => notedthat_mcp::DEFAULT_MAX_SESSIONS,
856            Some(value) => value.parse::<usize>().map_err(|_e| Error::Config {
857                message: format!(
858                    "{} must be a valid positive integer",
859                    setting("NOTEDTHAT_MCP_MAX_SESSIONS")
860                ),
861            })?,
862        };
863        if mcp_max_sessions == 0 {
864            return Err(Error::Config {
865                message: format!("{} must be > 0", setting("NOTEDTHAT_MCP_MAX_SESSIONS")),
866            });
867        }
868        let ready_probe_interval_ms = parse_millis(
869            "NOTEDTHAT_READY_PROBE_INTERVAL_MS",
870            cli.ready_probe_interval_ms.as_deref(),
871            5_000,
872        )?;
873
874        let metrics_listen_addr = parse_metrics(
875            cli.metrics_enabled.as_deref(),
876            cli.metrics_listen_addr.as_deref(),
877        )?;
878
879        let staging =
880            StagingConfig::from_setting(cli.upload_tmp_dir).map_err(|error| Error::Config {
881                message: error.to_string(),
882            })?;
883
884        Ok(Self {
885            api_token,
886            kbs,
887            tenant_slug,
888            listen_addr,
889            storage,
890            events,
891            log_format,
892            qdrant,
893            embedder,
894            webdav_username,
895            webdav_password,
896            mcp_http_allowed_origins,
897            mcp_http_allowed_hosts,
898            mcp_anonymous,
899            max_patchable_size,
900            mcp_max_read_bytes,
901            mcp_max_sessions,
902            ready_probe_interval_ms,
903            metrics_listen_addr,
904            staging,
905            oidc,
906        })
907    }
908}
909
910/// The `NOTEDTHAT_OIDC_*` settings that only mean something once an issuer is set.
911fn oidc_dependent_settings(cli: &ServerCli) -> [(&'static str, bool); 6] {
912    [
913        ("NOTEDTHAT_OIDC_AUDIENCE", cli.oidc_audience.is_some()),
914        (
915            "NOTEDTHAT_OIDC_USERNAME_CLAIM",
916            cli.oidc_username_claim.is_some(),
917        ),
918        (
919            "NOTEDTHAT_OIDC_GROUPS_CLAIM",
920            cli.oidc_groups_claim.is_some(),
921        ),
922        (
923            "NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS",
924            cli.oidc_http_timeout_ms.is_some(),
925        ),
926        ("NOTEDTHAT_OIDC_RESOURCE", cli.oidc_resource.is_some()),
927        ("NOTEDTHAT_OIDC_CA_CERT", cli.oidc_ca_cert.is_some()),
928    ]
929}
930
931/// Parse the identity-provider settings.
932///
933/// `NOTEDTHAT_OIDC_ISSUER` is the switch. Without it, any other `NOTEDTHAT_OIDC_*`
934/// setting is refused rather than ignored, for the same reason a setting of
935/// the unselected storage backend is: a deployment that sets an audience and
936/// no issuer believed it had configured identity tokens, and silently running
937/// without them is the wrong way to find out.
938fn parse_oidc(cli: &ServerCli) -> Result<Option<OidcSettings>, Error> {
939    let Some(issuer) = cli.oidc_issuer.as_deref().map(str::trim) else {
940        let offenders: Vec<String> = oidc_dependent_settings(cli)
941            .into_iter()
942            .filter(|(_, supplied)| *supplied)
943            .map(|(name, _)| setting(name))
944            .collect();
945        if offenders.is_empty() {
946            return Ok(None);
947        }
948        return Err(Error::Config {
949            message: format!(
950                "{} is unset, so identity tokens are not accepted, but {} {} set; set the \
951                 issuer or unset {}",
952                setting("NOTEDTHAT_OIDC_ISSUER"),
953                offenders.join(", "),
954                if offenders.len() == 1 { "is" } else { "are" },
955                if offenders.len() == 1 { "it" } else { "them" },
956            ),
957        });
958    };
959
960    let issuer_url = absolute_http_url("NOTEDTHAT_OIDC_ISSUER", issuer)?;
961    let audiences = comma_list(cli.oidc_audience.as_deref(), &[]);
962    if audiences.is_empty() {
963        return Err(Error::Config {
964            message: format!(
965                "{} is required when {} is set: name the audience the provider puts in \
966                 its tokens, usually the client id",
967                setting("NOTEDTHAT_OIDC_AUDIENCE"),
968                setting("NOTEDTHAT_OIDC_ISSUER"),
969            ),
970        });
971    }
972    let claim = |var: &str, supplied: Option<&str>, default: &str| -> Result<String, Error> {
973        match supplied.map(str::trim) {
974            None => Ok(default.to_string()),
975            Some("") => Err(Error::Config {
976                message: format!("{} must not be empty", setting(var)),
977            }),
978            Some(name) => Ok(name.to_string()),
979        }
980    };
981    let username_claim = claim(
982        "NOTEDTHAT_OIDC_USERNAME_CLAIM",
983        cli.oidc_username_claim.as_deref(),
984        OidcSettings::DEFAULT_USERNAME_CLAIM,
985    )?;
986    let groups_claim = claim(
987        "NOTEDTHAT_OIDC_GROUPS_CLAIM",
988        cli.oidc_groups_claim.as_deref(),
989        OidcSettings::DEFAULT_GROUPS_CLAIM,
990    )?;
991    let http_timeout = Duration::from_millis(parse_millis(
992        "NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS",
993        cli.oidc_http_timeout_ms.as_deref(),
994        OidcSettings::DEFAULT_HTTP_TIMEOUT_MS,
995    )?);
996    let resource = cli
997        .oidc_resource
998        .as_deref()
999        .map(str::trim)
1000        .map(|resource| absolute_http_url("NOTEDTHAT_OIDC_RESOURCE", resource))
1001        .transpose()?
1002        .map(|url| url.to_string().trim_end_matches('/').to_string());
1003
1004    let ca_cert = match cli.oidc_ca_cert.as_deref() {
1005        None => None,
1006        Some(path) if path.is_empty() => {
1007            return Err(Error::Config {
1008                message: format!("{} must not be empty", setting("NOTEDTHAT_OIDC_CA_CERT")),
1009            });
1010        }
1011        Some(path) => {
1012            let path = std::path::PathBuf::from(path);
1013            if !path.is_file() {
1014                return Err(Error::Config {
1015                    message: format!(
1016                        "{} is not a readable file: {}",
1017                        setting("NOTEDTHAT_OIDC_CA_CERT"),
1018                        path.display()
1019                    ),
1020                });
1021            }
1022            Some(path)
1023        }
1024    };
1025
1026    Ok(Some(OidcSettings {
1027        issuer: issuer_url.to_string(),
1028        audiences,
1029        username_claim,
1030        groups_claim,
1031        http_timeout,
1032        resource,
1033        ca_cert,
1034    }))
1035}
1036
1037/// Parse an `http(s)` URL setting, keeping the operator's spelling.
1038///
1039/// Returns the parsed URL only to prove it parses; the `Display` of a parsed
1040/// URL can differ from the input (a bare origin gains a trailing slash), and
1041/// the issuer has to be compared byte-for-byte with the provider's `iss`.
1042fn absolute_http_url(var: &str, raw: &str) -> Result<UrlSpelling, Error> {
1043    let parsed = url::Url::parse(raw).map_err(|error| Error::Config {
1044        message: format!("{} is not an absolute URL: {error}", setting(var)),
1045    })?;
1046    if !matches!(parsed.scheme(), "http" | "https") {
1047        return Err(Error::Config {
1048            message: format!("{} must use http or https", setting(var)),
1049        });
1050    }
1051    Ok(UrlSpelling(raw.to_string()))
1052}
1053
1054/// A URL that parsed, kept in the operator's own spelling.
1055struct UrlSpelling(String);
1056
1057impl std::fmt::Display for UrlSpelling {
1058    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1059        f.write_str(&self.0)
1060    }
1061}
1062
1063/// Split a comma-separated allowlist, falling back to `default` when nothing usable
1064/// was supplied.
1065///
1066/// An empty or whitespace-only value means "not configured" rather than "allow
1067/// nothing", because both defaults here are the safe, loopback-only ones.
1068fn comma_list(supplied: Option<&str>, default: &[&str]) -> Vec<String> {
1069    match supplied {
1070        Some(s) if !s.trim().is_empty() => s
1071            .split(',')
1072            .map(|v| v.trim().to_string())
1073            .filter(|v| !v.is_empty())
1074            .collect(),
1075        _ => default.iter().map(|v| (*v).to_string()).collect(),
1076    }
1077}
1078
1079/// Qdrant client configuration.
1080#[derive(Debug, Clone)]
1081pub struct ServerQdrantConfig {
1082    /// Qdrant gRPC/HTTP endpoint (`NOTEDTHAT_QDRANT_URL`; required).
1083    pub url: String,
1084    /// Optional Qdrant API key (`NOTEDTHAT_QDRANT_API_KEY`).
1085    pub api_key: Option<String>,
1086    /// Per-RPC timeout in milliseconds (`NOTEDTHAT_QDRANT_TIMEOUT_MS`; default 30 000).
1087    ///
1088    /// `qdrant-client`'s own default is 5 s, which is too tight for a full
1089    /// embedding batch upserted with `wait(true)`.
1090    pub timeout_ms: u64,
1091    /// Connection-establishment timeout in milliseconds
1092    /// (`NOTEDTHAT_QDRANT_CONNECT_TIMEOUT_MS`; default 10 000).
1093    pub connect_timeout_ms: u64,
1094}
1095
1096impl ServerQdrantConfig {
1097    /// Validate the Qdrant settings this run supplied.
1098    ///
1099    /// # Errors
1100    ///
1101    /// Returns `Err(Error::Config { .. })` if the URL is missing or a timeout is
1102    /// not a positive integer.
1103    fn from_parts(
1104        url: Option<String>,
1105        api_key: Option<String>,
1106        timeout_ms: Option<&str>,
1107        connect_timeout_ms: Option<&str>,
1108    ) -> Result<Self, Error> {
1109        let url = url.ok_or_else(|| Error::Config {
1110            message: format!("{} is required", setting("NOTEDTHAT_QDRANT_URL")),
1111        })?;
1112        Ok(Self {
1113            url,
1114            api_key,
1115            timeout_ms: parse_millis("NOTEDTHAT_QDRANT_TIMEOUT_MS", timeout_ms, 30_000)?,
1116            connect_timeout_ms: parse_millis(
1117                "NOTEDTHAT_QDRANT_CONNECT_TIMEOUT_MS",
1118                connect_timeout_ms,
1119                10_000,
1120            )?,
1121        })
1122    }
1123}
1124
1125/// Parse a millisecond duration, rejecting zero.
1126fn parse_millis(var: &str, supplied: Option<&str>, default: u64) -> Result<u64, Error> {
1127    let Some(raw) = supplied else {
1128        return Ok(default);
1129    };
1130    let value = raw.parse::<u64>().map_err(|_| Error::Config {
1131        message: format!("{} must be a valid u64 integer", setting(var)),
1132    })?;
1133    if value == 0 {
1134        return Err(Error::Config {
1135            message: format!("{} must be > 0", setting(var)),
1136        });
1137    }
1138    Ok(value)
1139}
1140
1141/// The raw embedder settings, before validation.
1142///
1143/// A struct rather than eight positional arguments, because eight `Option<String>`
1144/// parameters in a row is a call site nothing can typecheck.
1145struct EmbedderParts {
1146    endpoint_url: Option<String>,
1147    model: Option<String>,
1148    api_key: Option<String>,
1149    dimensions: Option<String>,
1150    batch_size: Option<String>,
1151    timeout_ms: Option<String>,
1152    max_retries: Option<String>,
1153    max_input_tokens: Option<String>,
1154}
1155
1156/// Embedder configuration.
1157#[derive(Debug, Clone)]
1158pub struct EmbedderConfig {
1159    /// OpenAI-compatible embedding endpoint URL (`EMBEDDING_ENDPOINT_URL`; required).
1160    pub endpoint_url: String,
1161    /// Embedding model name (`EMBEDDING_MODEL`; required).
1162    pub model: String,
1163    /// API key for the embedding endpoint (`EMBEDDING_API_KEY`; required).
1164    pub api_key: String,
1165    /// Output vector dimensions (`EMBEDDING_DIMENSIONS`; required).
1166    pub dimensions: u32,
1167    /// Number of texts per embedding batch (`EMBEDDING_BATCH_SIZE`; default `32`).
1168    pub batch_size: usize,
1169    /// HTTP request timeout in milliseconds (`EMBEDDING_TIMEOUT_MS`; default `30000`).
1170    pub timeout_ms: u64,
1171    /// Maximum number of retries on transient failures (`EMBEDDING_MAX_RETRIES`; default `3`).
1172    pub max_retries: u32,
1173    /// Maximum tokens per input text (`EMBEDDING_MAX_INPUT_TOKENS`; default `8192`).
1174    pub max_input_tokens: usize,
1175}
1176
1177impl EmbedderConfig {
1178    /// Validate the embedder settings this run supplied.
1179    ///
1180    /// # Errors
1181    ///
1182    /// Returns `Err(Error::Config { .. })` if any required setting is missing or invalid.
1183    fn from_parts(parts: EmbedderParts) -> Result<Self, Error> {
1184        let endpoint_url = parts.endpoint_url.ok_or_else(|| Error::Config {
1185            message: format!("{} is required", setting("EMBEDDING_ENDPOINT_URL")),
1186        })?;
1187        let model = parts.model.ok_or_else(|| Error::Config {
1188            message: format!("{} is required", setting("EMBEDDING_MODEL")),
1189        })?;
1190        let api_key = parts.api_key.ok_or_else(|| Error::Config {
1191            message: format!("{} is required", setting("EMBEDDING_API_KEY")),
1192        })?;
1193        let dimensions = parse_number("EMBEDDING_DIMENSIONS", parts.dimensions.as_deref())?
1194            .ok_or_else(|| Error::Config {
1195                message: format!("{} is required", setting("EMBEDDING_DIMENSIONS")),
1196            })?;
1197        Ok(Self {
1198            endpoint_url,
1199            model,
1200            api_key,
1201            dimensions,
1202            batch_size: parse_number("EMBEDDING_BATCH_SIZE", parts.batch_size.as_deref())?
1203                .unwrap_or(32),
1204            timeout_ms: parse_number("EMBEDDING_TIMEOUT_MS", parts.timeout_ms.as_deref())?
1205                .unwrap_or(30_000),
1206            max_retries: parse_number("EMBEDDING_MAX_RETRIES", parts.max_retries.as_deref())?
1207                .unwrap_or(3),
1208            max_input_tokens: parse_number(
1209                "EMBEDDING_MAX_INPUT_TOKENS",
1210                parts.max_input_tokens.as_deref(),
1211            )?
1212            .unwrap_or(8192),
1213        })
1214    }
1215}
1216
1217/// Parse an optional integer setting, naming it on failure.
1218fn parse_number<T>(var: &str, supplied: Option<&str>) -> Result<Option<T>, Error>
1219where
1220    T: std::str::FromStr<Err = std::num::ParseIntError>,
1221{
1222    supplied
1223        .map(|raw| {
1224            raw.parse::<T>().map_err(|e| Error::Config {
1225                message: format!("{} is invalid: {e}", setting(var)),
1226            })
1227        })
1228        .transpose()
1229}
1230
1231#[cfg(test)]
1232pub(crate) mod tests {
1233    use super::*;
1234
1235    pub(crate) const ALL_ENV_KEYS: [&str; 57] = [
1236        "NOTEDTHAT_API_TOKEN",
1237        "NOTEDTHAT_KBS",
1238        "NOTEDTHAT_STORAGE_BACKEND",
1239        "NOTEDTHAT_FS_ROOT",
1240        "NOTEDTHAT_FS_METADATA",
1241        "NOTEDTHAT_FS_FILE_MODE",
1242        "NOTEDTHAT_FS_DIR_MODE",
1243        "NOTEDTHAT_FS_ALLOW_LOSSY_NAMES",
1244        "NOTEDTHAT_FS_WATCH",
1245        "NOTEDTHAT_FS_WATCH_DEBOUNCE_MS",
1246        "NOTEDTHAT_S3_REGION",
1247        "NOTEDTHAT_S3_ACCESS_KEY_ID",
1248        "NOTEDTHAT_S3_SECRET_ACCESS_KEY",
1249        "NOTEDTHAT_LISTEN_ADDR",
1250        "NOTEDTHAT_METRICS_ENABLED",
1251        "NOTEDTHAT_METRICS_LISTEN_ADDR",
1252        "NOTEDTHAT_LOG_FORMAT",
1253        "NOTEDTHAT_S3_ENDPOINT_URL",
1254        "NOTEDTHAT_S3_FORCE_PATH_STYLE",
1255        "NOTEDTHAT_S3_RECONCILE",
1256        "NOTEDTHAT_EVENTS_BACKEND",
1257        "NOTEDTHAT_EVENTS_MEMORY_CAPACITY",
1258        "NOTEDTHAT_NATS_URL",
1259        "NOTEDTHAT_NATS_STREAM",
1260        "NOTEDTHAT_NATS_MAX_AGE_SECS",
1261        "NOTEDTHAT_QDRANT_URL",
1262        "NOTEDTHAT_QDRANT_API_KEY",
1263        "NOTEDTHAT_QDRANT_TIMEOUT_MS",
1264        "NOTEDTHAT_QDRANT_CONNECT_TIMEOUT_MS",
1265        "NOTEDTHAT_WEBDAV_USERNAME",
1266        "NOTEDTHAT_WEBDAV_PASSWORD",
1267        "NOTEDTHAT_WEBDAV_LISTEN_ADDR",
1268        "NOTEDTHAT_MCP_HTTP_BIND",
1269        "NOTEDTHAT_MCP_HTTP_ENABLED",
1270        "NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS",
1271        "NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS",
1272        "NOTEDTHAT_MCP_ANONYMOUS",
1273        "NOTEDTHAT_MCP_MAX_READ_BYTES",
1274        "NOTEDTHAT_MCP_MAX_SESSIONS",
1275        "NOTEDTHAT_MAX_PATCHABLE_SIZE",
1276        "NOTEDTHAT_READY_PROBE_INTERVAL_MS",
1277        "NOTEDTHAT_UPLOAD_TMP_DIR",
1278        "NOTEDTHAT_OIDC_ISSUER",
1279        "NOTEDTHAT_OIDC_AUDIENCE",
1280        "NOTEDTHAT_OIDC_USERNAME_CLAIM",
1281        "NOTEDTHAT_OIDC_GROUPS_CLAIM",
1282        "NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS",
1283        "NOTEDTHAT_OIDC_RESOURCE",
1284        "NOTEDTHAT_OIDC_CA_CERT",
1285        "EMBEDDING_ENDPOINT_URL",
1286        "EMBEDDING_MODEL",
1287        "EMBEDDING_API_KEY",
1288        "EMBEDDING_DIMENSIONS",
1289        "EMBEDDING_BATCH_SIZE",
1290        "EMBEDDING_TIMEOUT_MS",
1291        "EMBEDDING_MAX_RETRIES",
1292        "EMBEDDING_MAX_INPUT_TOKENS",
1293    ];
1294
1295    /// A configuration diagnostic has to be actionable from either direction, so
1296    /// it names the environment variable, the flag that overrides it, and what is
1297    /// wrong. Asserting on all three at once keeps the check readable while making
1298    /// it stricter than a single `contains`.
1299    fn names_setting(message: &str, env_var: &str, complaint: &str) -> bool {
1300        message.contains(env_var)
1301            && message.contains(&notedthat_core::flag_for(env_var))
1302            && message.contains(complaint)
1303    }
1304
1305    fn run_with_env<F: FnOnce() -> R, R>(overrides: &[(&str, Option<&str>)], f: F) -> R {
1306        let mut vars: Vec<(&str, Option<&str>)> = vec![
1307            ("NOTEDTHAT_API_TOKEN", Some("test-token")),
1308            ("NOTEDTHAT_KBS", Some("notes,docs")),
1309            ("NOTEDTHAT_STORAGE_BACKEND", None),
1310            ("NOTEDTHAT_FS_ROOT", None),
1311            ("NOTEDTHAT_FS_METADATA", None),
1312            ("NOTEDTHAT_FS_FILE_MODE", None),
1313            ("NOTEDTHAT_FS_DIR_MODE", None),
1314            ("NOTEDTHAT_FS_ALLOW_LOSSY_NAMES", None),
1315            ("NOTEDTHAT_S3_REGION", Some("us-east-1")),
1316            ("NOTEDTHAT_S3_ACCESS_KEY_ID", Some("key")),
1317            ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", Some("secret")),
1318            ("NOTEDTHAT_LISTEN_ADDR", None),
1319            ("NOTEDTHAT_METRICS_ENABLED", None),
1320            ("NOTEDTHAT_METRICS_LISTEN_ADDR", None),
1321            ("NOTEDTHAT_LOG_FORMAT", None),
1322            ("NOTEDTHAT_S3_ENDPOINT_URL", None),
1323            ("NOTEDTHAT_S3_FORCE_PATH_STYLE", None),
1324            ("NOTEDTHAT_S3_RECONCILE", None),
1325            ("NOTEDTHAT_EVENTS_BACKEND", None),
1326            ("NOTEDTHAT_EVENTS_MEMORY_CAPACITY", None),
1327            ("NOTEDTHAT_NATS_URL", None),
1328            ("NOTEDTHAT_NATS_STREAM", None),
1329            ("NOTEDTHAT_NATS_MAX_AGE_SECS", None),
1330            ("NOTEDTHAT_QDRANT_URL", Some("http://localhost:6334")),
1331            ("NOTEDTHAT_QDRANT_API_KEY", None),
1332            ("NOTEDTHAT_QDRANT_TIMEOUT_MS", None),
1333            ("NOTEDTHAT_QDRANT_CONNECT_TIMEOUT_MS", None),
1334            ("NOTEDTHAT_WEBDAV_USERNAME", Some("webdav-user")),
1335            ("NOTEDTHAT_WEBDAV_PASSWORD", Some("webdav-pass")),
1336            ("NOTEDTHAT_WEBDAV_LISTEN_ADDR", None),
1337            ("NOTEDTHAT_MCP_HTTP_BIND", None),
1338            ("NOTEDTHAT_MCP_HTTP_ENABLED", None),
1339            ("NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS", None),
1340            ("NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", None),
1341            ("NOTEDTHAT_MCP_ANONYMOUS", None),
1342            ("NOTEDTHAT_MCP_MAX_READ_BYTES", None),
1343            ("NOTEDTHAT_MCP_MAX_SESSIONS", None),
1344            ("NOTEDTHAT_MAX_PATCHABLE_SIZE", None),
1345            ("NOTEDTHAT_READY_PROBE_INTERVAL_MS", None),
1346            ("NOTEDTHAT_UPLOAD_TMP_DIR", None),
1347            ("NOTEDTHAT_OIDC_ISSUER", None),
1348            ("NOTEDTHAT_OIDC_AUDIENCE", None),
1349            ("NOTEDTHAT_OIDC_USERNAME_CLAIM", None),
1350            ("NOTEDTHAT_OIDC_GROUPS_CLAIM", None),
1351            ("NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS", None),
1352            ("NOTEDTHAT_OIDC_RESOURCE", None),
1353            ("NOTEDTHAT_OIDC_CA_CERT", None),
1354            ("EMBEDDING_ENDPOINT_URL", Some("https://api.openai.com")),
1355            ("EMBEDDING_MODEL", Some("text-embedding-3-small")),
1356            ("EMBEDDING_API_KEY", Some("sk-test")),
1357            ("EMBEDDING_DIMENSIONS", Some("1536")),
1358            ("EMBEDDING_BATCH_SIZE", None),
1359            ("EMBEDDING_TIMEOUT_MS", None),
1360            ("EMBEDDING_MAX_RETRIES", None),
1361            ("EMBEDDING_MAX_INPUT_TOKENS", None),
1362        ];
1363
1364        for (key, value) in overrides {
1365            if let Some((_, slot)) = vars.iter_mut().find(|(existing, _)| existing == key) {
1366                *slot = *value;
1367            }
1368        }
1369
1370        temp_env::with_vars(vars, f)
1371    }
1372
1373    #[test]
1374    fn test_empty_kbs_rejected() {
1375        let result = run_with_env(&[("NOTEDTHAT_KBS", Some(""))], Config::from_env);
1376        assert!(result.is_err());
1377        assert!(
1378            result
1379                .unwrap_err()
1380                .to_string()
1381                .contains("at least one knowledge base")
1382        );
1383    }
1384
1385    #[test]
1386    fn test_duplicate_slug_rejected() {
1387        let result = run_with_env(&[("NOTEDTHAT_KBS", Some("notes,notes"))], Config::from_env);
1388        assert!(result.is_err(), "duplicate slugs should fail");
1389        let msg = result.unwrap_err().to_string();
1390        assert!(
1391            msg.contains("duplicate"),
1392            "error should mention 'duplicate'"
1393        );
1394    }
1395
1396    #[test]
1397    fn test_no_tenant_slug_env_var() {
1398        let cfg = run_with_env(&[], Config::from_env).unwrap();
1399        assert_eq!(cfg.tenant_slug.as_str(), "default");
1400    }
1401
1402    #[test]
1403    fn test_log_format_json() {
1404        let cfg =
1405            run_with_env(&[("NOTEDTHAT_LOG_FORMAT", Some("json"))], Config::from_env).unwrap();
1406        assert_eq!(cfg.log_format, LogFormat::Json);
1407    }
1408
1409    #[test]
1410    fn test_log_format_default_pretty() {
1411        let cfg = run_with_env(&[], Config::from_env).unwrap();
1412        assert_eq!(cfg.log_format, LogFormat::Pretty);
1413    }
1414
1415    #[test]
1416    fn test_default_listen_addr() {
1417        let cfg = run_with_env(&[], Config::from_env).unwrap();
1418        assert_eq!(cfg.listen_addr.to_string(), "0.0.0.0:8080");
1419    }
1420
1421    #[test]
1422    fn test_default_staging_directory() {
1423        let cfg = run_with_env(&[], Config::from_env).unwrap();
1424        assert_eq!(cfg.staging.directory(), std::env::temp_dir());
1425    }
1426
1427    #[test]
1428    fn test_invalid_listen_addr() {
1429        let result = run_with_env(
1430            &[("NOTEDTHAT_LISTEN_ADDR", Some("not-a-socket-addr"))],
1431            Config::from_env,
1432        );
1433        assert!(result.is_err());
1434    }
1435
1436    #[test]
1437    fn test_kbs_parsed_correctly() {
1438        let cfg = run_with_env(&[], Config::from_env).unwrap();
1439        assert_eq!(cfg.kbs.len(), 2);
1440        assert!(cfg.kbs.contains_key("notes"));
1441        assert!(cfg.kbs.contains_key("docs"));
1442    }
1443
1444    #[test]
1445    fn test_missing_api_token_rejected() {
1446        let result = run_with_env(&[("NOTEDTHAT_API_TOKEN", None)], Config::from_env);
1447        assert!(result.is_err());
1448        assert!(
1449            result
1450                .unwrap_err()
1451                .to_string()
1452                .contains("NOTEDTHAT_API_TOKEN")
1453        );
1454    }
1455
1456    #[test]
1457    fn test_missing_webdav_username_rejected() {
1458        let result = run_with_env(&[("NOTEDTHAT_WEBDAV_USERNAME", None)], Config::from_env);
1459        assert!(result.is_err());
1460        assert!(
1461            result
1462                .unwrap_err()
1463                .to_string()
1464                .contains("NOTEDTHAT_WEBDAV_USERNAME")
1465        );
1466    }
1467
1468    #[test]
1469    fn test_empty_webdav_username_rejected() {
1470        let result = run_with_env(&[("NOTEDTHAT_WEBDAV_USERNAME", Some(""))], Config::from_env);
1471        assert!(result.is_err());
1472        assert!(
1473            result
1474                .unwrap_err()
1475                .to_string()
1476                .contains("NOTEDTHAT_WEBDAV_USERNAME")
1477        );
1478    }
1479
1480    #[test]
1481    fn test_missing_webdav_password_rejected() {
1482        let result = run_with_env(&[("NOTEDTHAT_WEBDAV_PASSWORD", None)], Config::from_env);
1483        assert!(result.is_err());
1484        assert!(
1485            result
1486                .unwrap_err()
1487                .to_string()
1488                .contains("NOTEDTHAT_WEBDAV_PASSWORD")
1489        );
1490    }
1491
1492    #[test]
1493    fn test_empty_webdav_password_rejected() {
1494        let result = run_with_env(&[("NOTEDTHAT_WEBDAV_PASSWORD", Some(""))], Config::from_env);
1495        assert!(result.is_err());
1496        assert!(
1497            result
1498                .unwrap_err()
1499                .to_string()
1500                .contains("NOTEDTHAT_WEBDAV_PASSWORD")
1501        );
1502    }
1503
1504    #[test]
1505    fn removed_listener_variables_are_rejected_with_their_replacement() {
1506        for (key, replacement) in REMOVED_LISTENER_ENV_VARS {
1507            let result = run_with_env(&[(key, Some("some-stale-value"))], Config::from_env);
1508            let message = result.map_or_else(
1509                |e| e.to_string(),
1510                |_| panic!("{key} must be rejected at startup"),
1511            );
1512
1513            assert!(message.contains(key), "{key} error must name the variable");
1514            assert!(
1515                message.contains(replacement),
1516                "{key} error must name its replacement"
1517            );
1518        }
1519    }
1520
1521    #[test]
1522    fn removed_listener_variables_are_rejected_even_when_empty() {
1523        let result = run_with_env(
1524            &[("NOTEDTHAT_MCP_HTTP_ENABLED", Some(""))],
1525            Config::from_env,
1526        );
1527
1528        assert!(
1529            result.is_err(),
1530            "an empty removed variable is still an explicit operator setting"
1531        );
1532    }
1533
1534    #[test]
1535    fn unset_removed_listener_variables_leave_the_default_listener() {
1536        let config = run_with_env(&[], Config::from_env)
1537            .expect("configuration must parse when no removed variable is set");
1538
1539        assert_eq!(config.listen_addr.to_string(), "0.0.0.0:8080");
1540    }
1541
1542    #[test]
1543    fn test_webdav_credentials_propagated() {
1544        let cfg = run_with_env(
1545            &[
1546                ("NOTEDTHAT_WEBDAV_USERNAME", Some("myuser")),
1547                ("NOTEDTHAT_WEBDAV_PASSWORD", Some("mypass")),
1548            ],
1549            Config::from_env,
1550        )
1551        .unwrap();
1552        assert_eq!(cfg.webdav_username, "myuser");
1553        assert_eq!(cfg.webdav_password, "mypass");
1554    }
1555
1556    /// The inventory is what `cli::tests::every_setting_has_both_a_flag_and_a_variable`
1557    /// checks the parser against, so a setting missing from here is a setting that
1558    /// can silently lose its flag.
1559    #[test]
1560    fn all_env_keys_are_accounted_for() {
1561        assert_eq!(ALL_ENV_KEYS.len(), 57);
1562    }
1563
1564    #[test]
1565    fn mcp_max_read_bytes_defaults_to_the_api_body_cap() {
1566        let cfg =
1567            run_with_env(&[("NOTEDTHAT_MCP_MAX_READ_BYTES", None)], Config::from_env).unwrap();
1568        assert_eq!(cfg.mcp_max_read_bytes, 16 * 1024 * 1024);
1569    }
1570
1571    #[test]
1572    fn mcp_max_read_bytes_accepts_explicit_bytes() {
1573        let cfg = run_with_env(
1574            &[("NOTEDTHAT_MCP_MAX_READ_BYTES", Some("4096"))],
1575            Config::from_env,
1576        )
1577        .unwrap();
1578        assert_eq!(cfg.mcp_max_read_bytes, 4096);
1579    }
1580
1581    #[test]
1582    fn mcp_max_read_bytes_empty_or_blank_is_the_default_like_its_siblings() {
1583        for value in ["", "   "] {
1584            let cfg = run_with_env(
1585                &[("NOTEDTHAT_MCP_MAX_READ_BYTES", Some(value))],
1586                Config::from_env,
1587            )
1588            .unwrap();
1589            assert_eq!(
1590                cfg.mcp_max_read_bytes,
1591                notedthat_mcp::DEFAULT_MAX_READ_BYTES,
1592                "{value:?}"
1593            );
1594        }
1595    }
1596
1597    #[test]
1598    fn mcp_max_read_bytes_rejects_zero_and_non_numbers() {
1599        for (value, fragment) in [("0", "must be > 0"), ("lots", "must be a valid u64")] {
1600            let result = run_with_env(
1601                &[("NOTEDTHAT_MCP_MAX_READ_BYTES", Some(value))],
1602                Config::from_env,
1603            );
1604            assert!(matches!(result, Err(Error::Config { .. })));
1605            assert!(names_setting(
1606                &result.unwrap_err().to_string(),
1607                "NOTEDTHAT_MCP_MAX_READ_BYTES",
1608                fragment
1609            ));
1610        }
1611    }
1612
1613    #[test]
1614    fn mcp_max_sessions_defaults_to_the_documented_bound() {
1615        let cfg = run_with_env(&[("NOTEDTHAT_MCP_MAX_SESSIONS", None)], Config::from_env).unwrap();
1616        assert_eq!(cfg.mcp_max_sessions, notedthat_mcp::DEFAULT_MAX_SESSIONS);
1617        assert_eq!(cfg.mcp_max_sessions, 256);
1618    }
1619
1620    #[test]
1621    fn mcp_max_sessions_accepts_an_explicit_count() {
1622        let cfg = run_with_env(
1623            &[("NOTEDTHAT_MCP_MAX_SESSIONS", Some("1024"))],
1624            Config::from_env,
1625        )
1626        .unwrap();
1627        assert_eq!(cfg.mcp_max_sessions, 1024);
1628    }
1629
1630    #[test]
1631    fn mcp_max_sessions_empty_or_blank_is_the_default_like_its_siblings() {
1632        for value in ["", "   "] {
1633            let cfg = run_with_env(
1634                &[("NOTEDTHAT_MCP_MAX_SESSIONS", Some(value))],
1635                Config::from_env,
1636            )
1637            .unwrap();
1638            assert_eq!(
1639                cfg.mcp_max_sessions,
1640                notedthat_mcp::DEFAULT_MAX_SESSIONS,
1641                "{value:?}"
1642            );
1643        }
1644    }
1645
1646    #[test]
1647    fn mcp_max_sessions_rejects_zero_and_non_numbers() {
1648        for (value, fragment) in [
1649            ("0", "must be > 0"),
1650            ("-1", "must be a valid positive integer"),
1651            ("many", "must be a valid positive integer"),
1652            ("1.5", "must be a valid positive integer"),
1653        ] {
1654            let result = run_with_env(
1655                &[("NOTEDTHAT_MCP_MAX_SESSIONS", Some(value))],
1656                Config::from_env,
1657            );
1658            assert!(matches!(result, Err(Error::Config { .. })), "{value:?}");
1659            assert!(
1660                names_setting(
1661                    &result.unwrap_err().to_string(),
1662                    "NOTEDTHAT_MCP_MAX_SESSIONS",
1663                    fragment
1664                ),
1665                "{value:?}"
1666            );
1667        }
1668    }
1669
1670    /// The session bound is neither `fs`'s nor `s3`'s, so it must not be in the
1671    /// list that refuses a setting the active backend does not own — the list a
1672    /// new `NOTEDTHAT_*` variable is most often wrongly added to.
1673    #[test]
1674    fn mcp_max_sessions_is_not_a_backend_owned_setting() {
1675        assert!(
1676            !backend_owned_settings(&ServerCli::default())
1677                .iter()
1678                .any(|(name, _, _)| *name == "NOTEDTHAT_MCP_MAX_SESSIONS")
1679        );
1680    }
1681
1682    #[test]
1683    fn ready_probe_interval_defaults_to_five_seconds() {
1684        let cfg = run_with_env(
1685            &[("NOTEDTHAT_READY_PROBE_INTERVAL_MS", None)],
1686            Config::from_env,
1687        )
1688        .unwrap();
1689        assert_eq!(cfg.ready_probe_interval_ms, 5_000);
1690    }
1691
1692    #[test]
1693    fn ready_probe_interval_is_parsed() {
1694        let cfg = run_with_env(
1695            &[("NOTEDTHAT_READY_PROBE_INTERVAL_MS", Some("250"))],
1696            Config::from_env,
1697        )
1698        .unwrap();
1699        assert_eq!(cfg.ready_probe_interval_ms, 250);
1700    }
1701
1702    #[test]
1703    fn ready_probe_interval_rejects_zero_and_nonsense() {
1704        for (value, complaint) in [("0", "must be > 0"), ("soon", "must be a valid u64")] {
1705            let error = run_with_env(
1706                &[("NOTEDTHAT_READY_PROBE_INTERVAL_MS", Some(value))],
1707                Config::from_env,
1708            )
1709            .unwrap_err();
1710            assert!(
1711                names_setting(
1712                    &error.to_string(),
1713                    "NOTEDTHAT_READY_PROBE_INTERVAL_MS",
1714                    complaint
1715                ),
1716                "{value}: {error}"
1717            );
1718        }
1719    }
1720
1721    #[test]
1722    fn max_patchable_size_defaults_to_100_mib() {
1723        let cfg =
1724            run_with_env(&[("NOTEDTHAT_MAX_PATCHABLE_SIZE", None)], Config::from_env).unwrap();
1725        assert_eq!(cfg.max_patchable_size, 100 * 1024 * 1024);
1726    }
1727
1728    #[test]
1729    fn max_patchable_size_accepts_explicit_bytes() {
1730        let cfg = run_with_env(
1731            &[("NOTEDTHAT_MAX_PATCHABLE_SIZE", Some("52428800"))],
1732            Config::from_env,
1733        )
1734        .unwrap();
1735        assert_eq!(cfg.max_patchable_size, 50 * 1024 * 1024);
1736    }
1737
1738    #[test]
1739    fn max_patchable_size_rejects_zero() {
1740        let result = run_with_env(
1741            &[("NOTEDTHAT_MAX_PATCHABLE_SIZE", Some("0"))],
1742            Config::from_env,
1743        );
1744        assert!(matches!(result, Err(Error::Config { .. })));
1745        assert!(names_setting(
1746            &result.unwrap_err().to_string(),
1747            "NOTEDTHAT_MAX_PATCHABLE_SIZE",
1748            "must be > 0"
1749        ));
1750    }
1751
1752    #[test]
1753    fn max_patchable_size_rejects_values_over_max_upload_bytes() {
1754        let result = run_with_env(
1755            &[("NOTEDTHAT_MAX_PATCHABLE_SIZE", Some("6442450944"))],
1756            Config::from_env,
1757        );
1758        assert!(matches!(result, Err(Error::Config { .. })));
1759        assert!(names_setting(
1760            &result.unwrap_err().to_string(),
1761            "NOTEDTHAT_MAX_PATCHABLE_SIZE",
1762            "must not exceed MAX_UPLOAD_BYTES (5 GiB)"
1763        ));
1764    }
1765
1766    #[test]
1767    fn max_patchable_size_rejects_non_numeric_values() {
1768        let result = run_with_env(
1769            &[("NOTEDTHAT_MAX_PATCHABLE_SIZE", Some("not-a-number"))],
1770            Config::from_env,
1771        );
1772        assert!(matches!(result, Err(Error::Config { .. })));
1773        assert!(names_setting(
1774            &result.unwrap_err().to_string(),
1775            "NOTEDTHAT_MAX_PATCHABLE_SIZE",
1776            "must be a valid u64 integer"
1777        ));
1778    }
1779
1780    #[test]
1781    fn qdrant_url_missing_returns_error() {
1782        let result = run_with_env(&[("NOTEDTHAT_QDRANT_URL", None)], Config::from_env);
1783        assert!(result.is_err());
1784        let msg = result.unwrap_err().to_string();
1785        assert!(
1786            msg.contains("NOTEDTHAT_QDRANT_URL"),
1787            "error should mention the missing var: {msg}"
1788        );
1789    }
1790
1791    #[test]
1792    fn qdrant_api_key_optional() {
1793        let cfg = run_with_env(&[("NOTEDTHAT_QDRANT_API_KEY", None)], Config::from_env).unwrap();
1794        assert!(
1795            cfg.qdrant.api_key.is_none(),
1796            "api_key should be None when env var is unset"
1797        );
1798    }
1799
1800    #[test]
1801    fn qdrant_api_key_set_when_present() {
1802        let cfg = run_with_env(
1803            &[("NOTEDTHAT_QDRANT_API_KEY", Some("my-secret-key"))],
1804            Config::from_env,
1805        )
1806        .unwrap();
1807        assert_eq!(cfg.qdrant.api_key.as_deref(), Some("my-secret-key"));
1808    }
1809
1810    #[test]
1811    fn qdrant_url_propagated_to_config() {
1812        let cfg = run_with_env(
1813            &[(
1814                "NOTEDTHAT_QDRANT_URL",
1815                Some("http://qdrant.example.com:6334"),
1816            )],
1817            Config::from_env,
1818        )
1819        .unwrap();
1820        assert_eq!(cfg.qdrant.url, "http://qdrant.example.com:6334");
1821    }
1822
1823    #[test]
1824    fn embedding_endpoint_url_missing() {
1825        let result = run_with_env(&[("EMBEDDING_ENDPOINT_URL", None)], Config::from_env);
1826        assert!(result.is_err());
1827        let msg = result.unwrap_err().to_string();
1828        assert!(
1829            msg.contains("EMBEDDING_ENDPOINT_URL"),
1830            "error should mention the missing var: {msg}"
1831        );
1832    }
1833
1834    #[test]
1835    fn embedding_model_missing() {
1836        let result = run_with_env(&[("EMBEDDING_MODEL", None)], Config::from_env);
1837        assert!(result.is_err());
1838        let msg = result.unwrap_err().to_string();
1839        assert!(
1840            msg.contains("EMBEDDING_MODEL"),
1841            "error should mention the missing var: {msg}"
1842        );
1843    }
1844
1845    #[test]
1846    fn embedding_api_key_missing() {
1847        let result = run_with_env(&[("EMBEDDING_API_KEY", None)], Config::from_env);
1848        assert!(result.is_err());
1849        let msg = result.unwrap_err().to_string();
1850        assert!(
1851            msg.contains("EMBEDDING_API_KEY"),
1852            "error should mention the missing var: {msg}"
1853        );
1854    }
1855
1856    #[test]
1857    fn embedding_dimensions_missing() {
1858        let result = run_with_env(&[("EMBEDDING_DIMENSIONS", None)], Config::from_env);
1859        assert!(result.is_err());
1860        let msg = result.unwrap_err().to_string();
1861        assert!(
1862            msg.contains("EMBEDDING_DIMENSIONS"),
1863            "error should mention the missing var: {msg}"
1864        );
1865    }
1866
1867    #[test]
1868    fn embedding_dimensions_invalid() {
1869        let result = run_with_env(
1870            &[("EMBEDDING_DIMENSIONS", Some("not-a-number"))],
1871            Config::from_env,
1872        );
1873        assert!(result.is_err());
1874        let msg = result.unwrap_err().to_string();
1875        assert!(
1876            msg.contains("EMBEDDING_DIMENSIONS"),
1877            "error should mention the invalid var: {msg}"
1878        );
1879    }
1880
1881    #[test]
1882    fn embedding_batch_size_default() {
1883        let cfg = run_with_env(&[("EMBEDDING_BATCH_SIZE", None)], Config::from_env).unwrap();
1884        assert_eq!(cfg.embedder.batch_size, 32);
1885    }
1886
1887    #[test]
1888    fn embedding_timeout_ms_default() {
1889        let cfg = run_with_env(&[("EMBEDDING_TIMEOUT_MS", None)], Config::from_env).unwrap();
1890        assert_eq!(cfg.embedder.timeout_ms, 30_000);
1891    }
1892
1893    #[test]
1894    fn embedding_max_retries_default() {
1895        let cfg = run_with_env(&[("EMBEDDING_MAX_RETRIES", None)], Config::from_env).unwrap();
1896        assert_eq!(cfg.embedder.max_retries, 3);
1897    }
1898
1899    #[test]
1900    fn embedding_max_input_tokens_default() {
1901        let cfg = run_with_env(&[("EMBEDDING_MAX_INPUT_TOKENS", None)], Config::from_env).unwrap();
1902        assert_eq!(cfg.embedder.max_input_tokens, 8192);
1903    }
1904
1905    #[test]
1906    fn embedder_fields_propagated_to_config() {
1907        let cfg = run_with_env(&[], Config::from_env).unwrap();
1908        assert_eq!(cfg.embedder.endpoint_url, "https://api.openai.com");
1909        assert_eq!(cfg.embedder.model, "text-embedding-3-small");
1910        assert_eq!(cfg.embedder.api_key, "sk-test");
1911        assert_eq!(cfg.embedder.dimensions, 1536);
1912    }
1913
1914    mod mcp_http {
1915        use super::*;
1916
1917        #[test]
1918        fn mcp_http_defaults() {
1919            let cfg = run_with_env(&[], Config::from_env).unwrap();
1920            assert_eq!(cfg.mcp_http_allowed_origins, vec!["null"]);
1921            assert_eq!(
1922                cfg.mcp_http_allowed_hosts,
1923                vec!["127.0.0.1", "localhost", "::1"]
1924            );
1925        }
1926
1927        #[test]
1928        fn mcp_http_empty_origins_defaults_to_null() {
1929            let cfg = run_with_env(
1930                &[("NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS", Some(""))],
1931                Config::from_env,
1932            )
1933            .unwrap();
1934            assert_eq!(cfg.mcp_http_allowed_origins, vec!["null"]);
1935        }
1936
1937        #[test]
1938        fn mcp_http_whitespace_origins_defaults_to_null() {
1939            let cfg = run_with_env(
1940                &[("NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS", Some("   "))],
1941                Config::from_env,
1942            )
1943            .unwrap();
1944            assert_eq!(cfg.mcp_http_allowed_origins, vec!["null"]);
1945        }
1946
1947        #[test]
1948        fn mcp_http_single_origin() {
1949            let cfg = run_with_env(
1950                &[(
1951                    "NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS",
1952                    Some("https://example.com"),
1953                )],
1954                Config::from_env,
1955            )
1956            .unwrap();
1957            assert_eq!(cfg.mcp_http_allowed_origins, vec!["https://example.com"]);
1958        }
1959
1960        #[test]
1961        fn mcp_http_multiple_origins_comma_separated() {
1962            let cfg = run_with_env(
1963                &[(
1964                    "NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS",
1965                    Some("https://example.com,https://other.com"),
1966                )],
1967                Config::from_env,
1968            )
1969            .unwrap();
1970            assert_eq!(
1971                cfg.mcp_http_allowed_origins,
1972                vec!["https://example.com", "https://other.com"]
1973            );
1974        }
1975
1976        #[test]
1977        fn mcp_http_origins_with_whitespace_trimmed() {
1978            let cfg = run_with_env(
1979                &[(
1980                    "NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS",
1981                    Some("  https://example.com  ,  https://other.com  "),
1982                )],
1983                Config::from_env,
1984            )
1985            .unwrap();
1986            assert_eq!(
1987                cfg.mcp_http_allowed_origins,
1988                vec!["https://example.com", "https://other.com"]
1989            );
1990        }
1991
1992        #[test]
1993        fn mcp_http_empty_hosts_defaults_to_loopback() {
1994            let cfg = run_with_env(
1995                &[("NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", Some(""))],
1996                Config::from_env,
1997            )
1998            .unwrap();
1999            assert_eq!(
2000                cfg.mcp_http_allowed_hosts,
2001                vec!["127.0.0.1", "localhost", "::1"]
2002            );
2003        }
2004
2005        #[test]
2006        fn mcp_http_whitespace_hosts_defaults_to_loopback() {
2007            let cfg = run_with_env(
2008                &[("NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", Some("   "))],
2009                Config::from_env,
2010            )
2011            .unwrap();
2012            assert_eq!(
2013                cfg.mcp_http_allowed_hosts,
2014                vec!["127.0.0.1", "localhost", "::1"]
2015            );
2016        }
2017
2018        #[test]
2019        fn mcp_http_single_host() {
2020            let cfg = run_with_env(
2021                &[("NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", Some("example.com"))],
2022                Config::from_env,
2023            )
2024            .unwrap();
2025            assert_eq!(cfg.mcp_http_allowed_hosts, vec!["example.com"]);
2026        }
2027
2028        #[test]
2029        fn mcp_http_multiple_hosts_comma_separated() {
2030            let cfg = run_with_env(
2031                &[(
2032                    "NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS",
2033                    Some("example.com,other.com"),
2034                )],
2035                Config::from_env,
2036            )
2037            .unwrap();
2038            assert_eq!(cfg.mcp_http_allowed_hosts, vec!["example.com", "other.com"]);
2039        }
2040
2041        #[test]
2042        fn mcp_http_hosts_with_whitespace_trimmed() {
2043            let cfg = run_with_env(
2044                &[(
2045                    "NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS",
2046                    Some("  example.com  ,  other.com  "),
2047                )],
2048                Config::from_env,
2049            )
2050            .unwrap();
2051            assert_eq!(cfg.mcp_http_allowed_hosts, vec!["example.com", "other.com"]);
2052        }
2053
2054        #[test]
2055        fn mcp_anonymous_defaults_to_auto() {
2056            let cfg = run_with_env(&[], Config::from_env).unwrap();
2057            assert_eq!(cfg.mcp_anonymous, McpAnonymous::Auto);
2058        }
2059
2060        #[test]
2061        fn mcp_anonymous_empty_or_blank_is_auto_like_its_siblings() {
2062            // Compose hands every MCP variable through as `${VAR-}`, so an
2063            // unset variable arrives as an empty string.
2064            for value in ["", "   "] {
2065                let cfg = run_with_env(
2066                    &[("NOTEDTHAT_MCP_ANONYMOUS", Some(value))],
2067                    Config::from_env,
2068                )
2069                .unwrap();
2070                assert_eq!(cfg.mcp_anonymous, McpAnonymous::Auto, "{value:?}");
2071            }
2072        }
2073
2074        #[test]
2075        fn mcp_anonymous_never_in_any_case() {
2076            for value in ["never", "NEVER", " Never "] {
2077                let cfg = run_with_env(
2078                    &[("NOTEDTHAT_MCP_ANONYMOUS", Some(value))],
2079                    Config::from_env,
2080                )
2081                .unwrap();
2082                assert_eq!(cfg.mcp_anonymous, McpAnonymous::Never, "{value:?}");
2083            }
2084        }
2085
2086        #[test]
2087        fn an_unknown_mcp_anonymous_mode_is_refused_rather_than_defaulted() {
2088            let err = run_with_env(
2089                &[("NOTEDTHAT_MCP_ANONYMOUS", Some("nevr"))],
2090                Config::from_env,
2091            )
2092            .unwrap_err()
2093            .to_string();
2094            assert!(
2095                err.contains("NOTEDTHAT_MCP_ANONYMOUS") && err.contains("nevr"),
2096                "names the setting and the value: {err}"
2097            );
2098        }
2099
2100        #[test]
2101        fn mcp_http_with_empty_token_fails() {
2102            let result = run_with_env(&[("NOTEDTHAT_API_TOKEN", Some(""))], Config::from_env);
2103            assert!(result.is_err());
2104            let msg = result.unwrap_err().to_string();
2105            assert!(
2106                msg.contains("NOTEDTHAT_API_TOKEN"),
2107                "error should mention NOTEDTHAT_API_TOKEN: {msg}"
2108            );
2109        }
2110
2111        #[test]
2112        fn mcp_http_with_whitespace_token_fails() {
2113            let result = run_with_env(&[("NOTEDTHAT_API_TOKEN", Some("   "))], Config::from_env);
2114            assert!(result.is_err());
2115            let msg = result.unwrap_err().to_string();
2116            assert!(
2117                msg.contains("NOTEDTHAT_API_TOKEN"),
2118                "error should mention NOTEDTHAT_API_TOKEN: {msg}"
2119            );
2120        }
2121    }
2122
2123    mod storage_backend {
2124        use super::*;
2125
2126        #[test]
2127        fn the_default_is_s3_so_existing_deployments_are_unaffected() {
2128            run_with_env(&[], || {
2129                let config = Config::from_env().expect("valid");
2130                assert_eq!(config.storage.kind(), StorageBackendKind::S3);
2131            });
2132        }
2133
2134        #[test]
2135        fn selecting_fs_reads_the_fs_variables_and_stops_requiring_s3() {
2136            run_with_env(
2137                &[
2138                    ("NOTEDTHAT_STORAGE_BACKEND", Some("fs")),
2139                    ("NOTEDTHAT_FS_ROOT", Some("/srv/notedthat")),
2140                    ("NOTEDTHAT_S3_REGION", None),
2141                    ("NOTEDTHAT_S3_ACCESS_KEY_ID", None),
2142                    ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", None),
2143                ],
2144                || {
2145                    let config = Config::from_env().expect("valid");
2146                    assert_eq!(config.storage.kind(), StorageBackendKind::Fs);
2147                },
2148            );
2149        }
2150
2151        /// Unlike `NOTEDTHAT_LOG_FORMAT`, a typo here must not fall back — it would
2152        /// silently point the server at a different store.
2153        #[test]
2154        fn an_unknown_backend_is_refused_rather_than_defaulted() {
2155            run_with_env(&[("NOTEDTHAT_STORAGE_BACKEND", Some("filesystem"))], || {
2156                let error = Config::from_env().unwrap_err().to_string();
2157                assert!(error.contains("expected \"s3\" or \"fs\""), "{error}");
2158                assert!(error.contains("filesystem"), "{error}");
2159            });
2160        }
2161
2162        #[test]
2163        fn an_empty_backend_selector_is_refused() {
2164            run_with_env(&[("NOTEDTHAT_STORAGE_BACKEND", Some(""))], || {
2165                let error = Config::from_env().unwrap_err().to_string();
2166                assert!(error.contains("must not be empty"), "{error}");
2167            });
2168        }
2169
2170        #[test]
2171        fn selecting_fs_without_a_root_names_the_variable() {
2172            run_with_env(
2173                &[
2174                    ("NOTEDTHAT_STORAGE_BACKEND", Some("fs")),
2175                    ("NOTEDTHAT_S3_REGION", None),
2176                    ("NOTEDTHAT_S3_ACCESS_KEY_ID", None),
2177                    ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", None),
2178                ],
2179                || {
2180                    let error = Config::from_env().unwrap_err().to_string();
2181                    assert!(
2182                        names_setting(&error, "NOTEDTHAT_FS_ROOT", "is required"),
2183                        "{error}"
2184                    );
2185                },
2186            );
2187        }
2188
2189        #[test]
2190        fn leftover_s3_variables_under_fs_are_reported_together() {
2191            run_with_env(
2192                &[
2193                    ("NOTEDTHAT_STORAGE_BACKEND", Some("fs")),
2194                    ("NOTEDTHAT_FS_ROOT", Some("/srv/notedthat")),
2195                ],
2196                || {
2197                    let error = Config::from_env().unwrap_err().to_string();
2198                    assert!(error.contains("belong to the s3 backend"), "{error}");
2199                    // All of them at once, not one per restart.
2200                    assert!(error.contains("NOTEDTHAT_S3_REGION"), "{error}");
2201                    assert!(error.contains("NOTEDTHAT_S3_ACCESS_KEY_ID"), "{error}");
2202                    assert!(error.contains("NOTEDTHAT_S3_SECRET_ACCESS_KEY"), "{error}");
2203                    assert!(error.contains("NOTEDTHAT_STORAGE_BACKEND=s3"), "{error}");
2204                },
2205            );
2206        }
2207
2208        #[test]
2209        fn s3_reconcile_defaults_on_and_is_parsed() {
2210            let cfg = run_with_env(&[("NOTEDTHAT_S3_RECONCILE", None)], Config::from_env).unwrap();
2211            let StorageConfig::S3(s3) = &cfg.storage else {
2212                panic!("the placeholder selects s3")
2213            };
2214            assert!(s3.reconcile_on_startup);
2215
2216            let cfg = run_with_env(
2217                &[("NOTEDTHAT_S3_RECONCILE", Some("false"))],
2218                Config::from_env,
2219            )
2220            .unwrap();
2221            let StorageConfig::S3(s3) = &cfg.storage else {
2222                panic!("the placeholder selects s3")
2223            };
2224            assert!(!s3.reconcile_on_startup);
2225
2226            let error = run_with_env(
2227                &[("NOTEDTHAT_S3_RECONCILE", Some("sometimes"))],
2228                Config::from_env,
2229            )
2230            .unwrap_err()
2231            .to_string();
2232            assert!(
2233                names_setting(
2234                    &error,
2235                    "NOTEDTHAT_S3_RECONCILE",
2236                    "expected \"true\" or \"false\""
2237                ),
2238                "{error}"
2239            );
2240        }
2241
2242        #[test]
2243        fn s3_reconcile_under_fs_is_refused() {
2244            run_with_env(
2245                &[
2246                    ("NOTEDTHAT_STORAGE_BACKEND", Some("fs")),
2247                    ("NOTEDTHAT_FS_ROOT", Some("/srv/notedthat")),
2248                    ("NOTEDTHAT_S3_REGION", None),
2249                    ("NOTEDTHAT_S3_ACCESS_KEY_ID", None),
2250                    ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", None),
2251                    ("NOTEDTHAT_S3_RECONCILE", Some("true")),
2252                ],
2253                || {
2254                    let error = Config::from_env().unwrap_err().to_string();
2255                    assert!(error.contains("belong to the s3 backend"), "{error}");
2256                    assert!(error.contains("NOTEDTHAT_S3_RECONCILE"), "{error}");
2257                },
2258            );
2259        }
2260
2261        /// The case this check exists for: the operator sets a root and forgets the
2262        /// selector, and would otherwise get a healthy S3 deployment with an unread root.
2263        #[test]
2264        fn an_fs_root_without_the_selector_is_refused_and_says_why() {
2265            run_with_env(&[("NOTEDTHAT_FS_ROOT", Some("/srv/notedthat"))], || {
2266                let error = Config::from_env().unwrap_err().to_string();
2267                assert!(
2268                    names_setting(&error, "NOTEDTHAT_STORAGE_BACKEND", "is unset"),
2269                    "{error}"
2270                );
2271                assert!(error.contains("NOTEDTHAT_FS_ROOT"), "{error}");
2272                assert!(error.contains("NOTEDTHAT_STORAGE_BACKEND=fs"), "{error}");
2273            });
2274        }
2275
2276        /// An empty value is still a value — matching how removed variables are checked.
2277        #[test]
2278        fn an_empty_cross_backend_variable_still_counts() {
2279            run_with_env(
2280                &[
2281                    ("NOTEDTHAT_STORAGE_BACKEND", Some("fs")),
2282                    ("NOTEDTHAT_FS_ROOT", Some("/srv/notedthat")),
2283                    ("NOTEDTHAT_S3_REGION", Some("")),
2284                    ("NOTEDTHAT_S3_ACCESS_KEY_ID", None),
2285                    ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", None),
2286                ],
2287                || {
2288                    let error = Config::from_env().unwrap_err().to_string();
2289                    assert!(error.contains("NOTEDTHAT_S3_REGION"), "{error}");
2290                },
2291            );
2292        }
2293
2294        /// The rejection table is checked against each adapter's own inventory, so it
2295        /// cannot drift from what those adapters actually read.
2296        #[test]
2297        fn the_rejection_table_matches_each_adapter_inventory() {
2298            let table = backend_owned_settings(&ServerCli::default());
2299            let s3: Vec<&str> = table
2300                .iter()
2301                .filter(|(_, kind, _)| *kind == StorageBackendKind::S3)
2302                .map(|(name, _, _)| *name)
2303                .collect();
2304            let fs: Vec<&str> = table
2305                .iter()
2306                .filter(|(_, kind, _)| *kind == StorageBackendKind::Fs)
2307                .map(|(name, _, _)| *name)
2308                .collect();
2309            assert_eq!(s3, notedthat_storage_s3::S3_ENV_VARS.to_vec());
2310            assert_eq!(fs, notedthat_storage_fs::FS_ENV_VARS.to_vec());
2311
2312            for (name, _, _) in &table {
2313                assert!(
2314                    ALL_ENV_KEYS.contains(name),
2315                    "{name} is read but missing from ALL_ENV_KEYS"
2316                );
2317            }
2318        }
2319
2320        /// A default `ServerCli` supplies nothing, so nothing can be an offender —
2321        /// the guard against a field being wired to the wrong entry in the table.
2322        #[test]
2323        fn nothing_is_supplied_by_a_default_command_line() {
2324            assert!(
2325                backend_owned_settings(&ServerCli::default())
2326                    .iter()
2327                    .all(|(_, _, supplied)| !supplied)
2328            );
2329        }
2330    }
2331
2332    mod events_backend {
2333        use super::*;
2334
2335        #[test]
2336        fn the_default_is_none_so_existing_deployments_publish_nothing() {
2337            run_with_env(&[], || {
2338                let config = Config::from_env().expect("valid");
2339                assert_eq!(config.events.kind(), EventsBackendKind::None);
2340            });
2341        }
2342
2343        #[test]
2344        fn memory_reads_its_capacity_and_defaults_it() {
2345            run_with_env(&[("NOTEDTHAT_EVENTS_BACKEND", Some("memory"))], || {
2346                let config = Config::from_env().expect("valid");
2347                match config.events {
2348                    EventsConfig::Memory(memory) => {
2349                        assert_eq!(memory.capacity, notedthat_events::DEFAULT_MEMORY_CAPACITY);
2350                    }
2351                    other => panic!("expected memory, got {other:?}"),
2352                }
2353            });
2354            run_with_env(
2355                &[
2356                    ("NOTEDTHAT_EVENTS_BACKEND", Some("memory")),
2357                    ("NOTEDTHAT_EVENTS_MEMORY_CAPACITY", Some("250")),
2358                ],
2359                || {
2360                    let config = Config::from_env().expect("valid");
2361                    match config.events {
2362                        EventsConfig::Memory(memory) => assert_eq!(memory.capacity, 250),
2363                        other => panic!("expected memory, got {other:?}"),
2364                    }
2365                },
2366            );
2367        }
2368
2369        #[test]
2370        fn nats_requires_its_url_and_names_the_variable() {
2371            run_with_env(&[("NOTEDTHAT_EVENTS_BACKEND", Some("nats"))], || {
2372                let error = Config::from_env().unwrap_err().to_string();
2373                assert!(
2374                    names_setting(&error, "NOTEDTHAT_NATS_URL", "is required"),
2375                    "{error}"
2376                );
2377            });
2378            run_with_env(
2379                &[
2380                    ("NOTEDTHAT_EVENTS_BACKEND", Some("nats")),
2381                    ("NOTEDTHAT_NATS_URL", Some("nats://broker:4222")),
2382                    ("NOTEDTHAT_NATS_STREAM", Some("evt")),
2383                    ("NOTEDTHAT_NATS_MAX_AGE_SECS", Some("60")),
2384                ],
2385                || {
2386                    let config = Config::from_env().expect("valid");
2387                    match config.events {
2388                        EventsConfig::Nats(nats) => {
2389                            assert_eq!(nats.url, "nats://broker:4222");
2390                            assert_eq!(nats.stream, "evt");
2391                            assert_eq!(nats.max_age, Duration::from_secs(60));
2392                        }
2393                        other => panic!("expected nats, got {other:?}"),
2394                    }
2395                },
2396            );
2397        }
2398
2399        /// A typo must not fall back to `none`: the server would start and simply
2400        /// never announce a change.
2401        #[test]
2402        fn an_unknown_events_backend_is_refused_rather_than_defaulted() {
2403            run_with_env(&[("NOTEDTHAT_EVENTS_BACKEND", Some("kafka"))], || {
2404                let error = Config::from_env().unwrap_err().to_string();
2405                assert!(
2406                    error.contains("expected \"none\", \"memory\" or \"nats\""),
2407                    "{error}"
2408                );
2409                assert!(error.contains("kafka"), "{error}");
2410            });
2411            run_with_env(&[("NOTEDTHAT_EVENTS_BACKEND", Some(""))], || {
2412                let error = Config::from_env().unwrap_err().to_string();
2413                assert!(
2414                    names_setting(&error, "NOTEDTHAT_EVENTS_BACKEND", "must not be empty"),
2415                    "{error}"
2416                );
2417            });
2418        }
2419
2420        #[test]
2421        fn nats_variables_under_memory_are_reported_together_with_the_fix() {
2422            run_with_env(
2423                &[
2424                    ("NOTEDTHAT_EVENTS_BACKEND", Some("memory")),
2425                    ("NOTEDTHAT_NATS_URL", Some("nats://broker:4222")),
2426                    ("NOTEDTHAT_NATS_STREAM", Some("evt")),
2427                ],
2428                || {
2429                    let error = Config::from_env().unwrap_err().to_string();
2430                    assert!(
2431                        names_setting(&error, "NOTEDTHAT_EVENTS_BACKEND", "is memory"),
2432                        "{error}"
2433                    );
2434                    assert!(error.contains("belong to the nats backend"), "{error}");
2435                    assert!(error.contains("NOTEDTHAT_NATS_URL"), "{error}");
2436                    assert!(error.contains("NOTEDTHAT_NATS_STREAM"), "{error}");
2437                    assert!(error.contains("NOTEDTHAT_EVENTS_BACKEND=nats"), "{error}");
2438                },
2439            );
2440        }
2441
2442        /// The operator configures a broker and forgets the selector: the highest-value
2443        /// case, since the server would otherwise start healthy and silent.
2444        #[test]
2445        fn a_nats_url_without_the_selector_is_refused_and_says_why() {
2446            run_with_env(
2447                &[("NOTEDTHAT_NATS_URL", Some("nats://broker:4222"))],
2448                || {
2449                    let error = Config::from_env().unwrap_err().to_string();
2450                    assert!(
2451                        names_setting(&error, "NOTEDTHAT_EVENTS_BACKEND", "is unset"),
2452                        "{error}"
2453                    );
2454                    assert!(error.contains("default none backend"), "{error}");
2455                    assert!(error.contains("NOTEDTHAT_EVENTS_BACKEND=nats"), "{error}");
2456                },
2457            );
2458        }
2459
2460        /// Both unselected backends' variables at once: each owner is named, and
2461        /// each fix is offered.
2462        #[test]
2463        fn offenders_from_two_backends_are_grouped_by_owner() {
2464            run_with_env(
2465                &[
2466                    ("NOTEDTHAT_EVENTS_MEMORY_CAPACITY", Some("10")),
2467                    ("NOTEDTHAT_NATS_URL", Some("nats://broker:4222")),
2468                ],
2469                || {
2470                    let error = Config::from_env().unwrap_err().to_string();
2471                    assert!(error.contains("belong to the memory backend"), "{error}");
2472                    assert!(error.contains("belong to the nats backend"), "{error}");
2473                    assert!(
2474                        error.contains(
2475                            "NOTEDTHAT_EVENTS_BACKEND=memory or NOTEDTHAT_EVENTS_BACKEND=nats"
2476                        ),
2477                        "{error}"
2478                    );
2479                },
2480            );
2481        }
2482
2483        #[test]
2484        fn the_rejection_table_matches_each_adapter_inventory() {
2485            let table = events_owned_settings(&ServerCli::default());
2486            let memory: Vec<&str> = table
2487                .iter()
2488                .filter(|(_, kind, _)| *kind == EventsBackendKind::Memory)
2489                .map(|(name, _, _)| *name)
2490                .collect();
2491            let nats: Vec<&str> = table
2492                .iter()
2493                .filter(|(_, kind, _)| *kind == EventsBackendKind::Nats)
2494                .map(|(name, _, _)| *name)
2495                .collect();
2496            assert_eq!(memory, notedthat_events::MEMORY_ENV_VARS.to_vec());
2497            assert_eq!(nats, notedthat_events::NATS_ENV_VARS.to_vec());
2498
2499            for (name, _, _) in &table {
2500                assert!(
2501                    ALL_ENV_KEYS.contains(name),
2502                    "{name} is read but missing from ALL_ENV_KEYS"
2503                );
2504            }
2505            assert!(
2506                table.iter().all(|(_, _, supplied)| !supplied),
2507                "a default command line supplies nothing"
2508            );
2509        }
2510    }
2511
2512    /// The metrics listener (D69).
2513    mod metrics {
2514        use super::*;
2515
2516        fn config_with(
2517            overrides: &[(&str, Option<&str>)],
2518        ) -> Result<super::super::Config, super::super::Error> {
2519            run_with_env(overrides, Config::from_env)
2520        }
2521
2522        #[test]
2523        fn the_default_is_off_so_nothing_is_bound_and_nothing_is_recorded() {
2524            let config = config_with(&[]).expect("a default configuration is valid");
2525            assert!(config.metrics_listen_addr.is_none());
2526        }
2527
2528        #[test]
2529        fn enabling_it_binds_loopback_by_default() {
2530            let config = config_with(&[("NOTEDTHAT_METRICS_ENABLED", Some("true"))])
2531                .expect("metrics on their own are enough to enable the listener");
2532            assert_eq!(
2533                config
2534                    .metrics_listen_addr
2535                    .expect("an enabled listener has an address")
2536                    .to_string(),
2537                "127.0.0.1:9090",
2538                "the default bind must stay loopback: the exposition is unauthenticated"
2539            );
2540        }
2541
2542        #[test]
2543        fn an_explicit_address_is_taken_verbatim() {
2544            let config = config_with(&[
2545                ("NOTEDTHAT_METRICS_ENABLED", Some("true")),
2546                ("NOTEDTHAT_METRICS_LISTEN_ADDR", Some("0.0.0.0:9187")),
2547            ])
2548            .expect("an operator may publish the listener deliberately");
2549            assert_eq!(
2550                config.metrics_listen_addr.map(|a| a.to_string()),
2551                Some("0.0.0.0:9187".to_string())
2552            );
2553        }
2554
2555        /// §6.5: a mis-spelled value must not quietly read as off. A deployment
2556        /// that believes it is being scraped and is not looks exactly like a
2557        /// healthy one until someone needs the graph.
2558        #[test]
2559        fn an_unknown_value_is_refused_rather_than_defaulted() {
2560            let error = config_with(&[("NOTEDTHAT_METRICS_ENABLED", Some("yes"))])
2561                .expect_err("\"yes\" is not a value this setting takes");
2562            let message = error.to_string();
2563            assert!(
2564                names_setting(&message, "NOTEDTHAT_METRICS_ENABLED", "is invalid"),
2565                "{message}"
2566            );
2567            assert!(
2568                message.contains("yes"),
2569                "the refusal quotes the value: {message}"
2570            );
2571        }
2572
2573        #[test]
2574        fn an_empty_value_is_refused_too() {
2575            let error = config_with(&[("NOTEDTHAT_METRICS_ENABLED", Some(""))])
2576                .expect_err("an empty value says nothing about which was meant");
2577            assert!(
2578                names_setting(
2579                    &error.to_string(),
2580                    "NOTEDTHAT_METRICS_ENABLED",
2581                    "must not be empty"
2582                ),
2583                "{error}"
2584            );
2585        }
2586
2587        /// An address nothing binds is the same mistake as a setting nothing
2588        /// reads, so it is refused the way an unselected backend's settings are.
2589        #[test]
2590        fn an_address_without_the_switch_is_refused_and_says_why() {
2591            let error = config_with(&[("NOTEDTHAT_METRICS_LISTEN_ADDR", Some("0.0.0.0:9090"))])
2592                .expect_err("an address alone opens no listener");
2593            let message = error.to_string();
2594            assert!(
2595                message.contains("NOTEDTHAT_METRICS_LISTEN_ADDR"),
2596                "{message}"
2597            );
2598            assert!(
2599                message.contains("NOTEDTHAT_METRICS_ENABLED=true"),
2600                "the refusal names the fix: {message}"
2601            );
2602        }
2603
2604        #[test]
2605        fn an_address_while_explicitly_disabled_is_refused_too() {
2606            let error = config_with(&[
2607                ("NOTEDTHAT_METRICS_ENABLED", Some("false")),
2608                ("NOTEDTHAT_METRICS_LISTEN_ADDR", Some("0.0.0.0:9090")),
2609            ])
2610            .expect_err("false plus an address is still an address nothing binds");
2611            assert!(
2612                names_setting(&error.to_string(), "NOTEDTHAT_METRICS_ENABLED", "is false"),
2613                "{error}"
2614            );
2615        }
2616
2617        /// An empty value is no value, as it is for its siblings.
2618        ///
2619        /// Compose passes an unset variable through as `${VAR-}`, which reaches
2620        /// the server as the empty string — the reason `NOTEDTHAT_MCP_ANONYMOUS`
2621        /// documents "unset or empty means `auto`" and the two MCP bounds have
2622        /// `_empty_or_blank_is_the_default_like_its_siblings`. Refusing here
2623        /// would stop a deployment that names the variable without setting it,
2624        /// over an address nobody configured. The refusal is for an address that
2625        /// really was supplied and really would be ignored, which the test above
2626        /// pins.
2627        #[test]
2628        fn an_empty_address_is_no_address() {
2629            for blank in ["", "   "] {
2630                let config = config_with(&[("NOTEDTHAT_METRICS_LISTEN_ADDR", Some(blank))])
2631                    .expect("a blank address is no address, and metrics stay off");
2632                assert_eq!(config.metrics_listen_addr, None, "{blank:?}");
2633            }
2634        }
2635
2636        #[test]
2637        fn an_invalid_address_is_refused() {
2638            let error = config_with(&[
2639                ("NOTEDTHAT_METRICS_ENABLED", Some("true")),
2640                ("NOTEDTHAT_METRICS_LISTEN_ADDR", Some("not-a-socket-addr")),
2641            ])
2642            .expect_err("the address is parsed, not trusted");
2643            assert!(
2644                names_setting(
2645                    &error.to_string(),
2646                    "NOTEDTHAT_METRICS_LISTEN_ADDR",
2647                    "is invalid"
2648                ),
2649                "{error}"
2650            );
2651        }
2652    }
2653
2654    mod oidc {
2655        use super::*;
2656
2657        const ISSUER: &str = "https://auth.example.com/application/o/notedthat/";
2658
2659        #[test]
2660        fn no_oidc_settings_means_no_verifier() {
2661            let cfg = run_with_env(&[], Config::from_env).expect("valid config");
2662            assert!(cfg.oidc.is_none());
2663        }
2664
2665        #[test]
2666        fn an_issuer_with_an_audience_enables_oidc_with_the_defaults() {
2667            let cfg = run_with_env(
2668                &[
2669                    ("NOTEDTHAT_OIDC_ISSUER", Some(ISSUER)),
2670                    ("NOTEDTHAT_OIDC_AUDIENCE", Some("notedthat, mcp-client")),
2671                ],
2672                Config::from_env,
2673            )
2674            .expect("valid config");
2675            let oidc = cfg.oidc.expect("configured");
2676            assert_eq!(oidc.issuer, ISSUER, "the operator's spelling is kept");
2677            assert_eq!(oidc.audiences, vec!["notedthat", "mcp-client"]);
2678            assert_eq!(oidc.username_claim, "preferred_username");
2679            assert_eq!(oidc.groups_claim, "groups");
2680            assert_eq!(oidc.http_timeout, Duration::from_millis(5000));
2681            assert_eq!(oidc.resource, None);
2682            assert_eq!(oidc.ca_cert, None);
2683            assert_eq!(
2684                oidc.discovery_url(),
2685                "https://auth.example.com/application/o/notedthat/.well-known/openid-configuration"
2686            );
2687        }
2688
2689        #[test]
2690        fn every_oidc_setting_is_read() {
2691            let cfg = run_with_env(
2692                &[
2693                    ("NOTEDTHAT_OIDC_ISSUER", Some("https://auth.example.com")),
2694                    ("NOTEDTHAT_OIDC_AUDIENCE", Some("notedthat")),
2695                    ("NOTEDTHAT_OIDC_USERNAME_CLAIM", Some("email")),
2696                    (
2697                        "NOTEDTHAT_OIDC_GROUPS_CLAIM",
2698                        Some("urn:zitadel:iam:org:project:roles"),
2699                    ),
2700                    ("NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS", Some("250")),
2701                    (
2702                        "NOTEDTHAT_OIDC_RESOURCE",
2703                        Some("https://notes.example.com/"),
2704                    ),
2705                ],
2706                Config::from_env,
2707            )
2708            .expect("valid config");
2709            let oidc = cfg.oidc.expect("configured");
2710            assert_eq!(oidc.username_claim, "email");
2711            assert_eq!(oidc.groups_claim, "urn:zitadel:iam:org:project:roles");
2712            assert_eq!(oidc.http_timeout, Duration::from_millis(250));
2713            assert_eq!(
2714                oidc.resource.as_deref(),
2715                Some("https://notes.example.com"),
2716                "the resource is an origin, so its trailing slash is dropped"
2717            );
2718        }
2719
2720        #[test]
2721        fn oidc_settings_without_an_issuer_are_rejected() {
2722            let error = run_with_env(
2723                &[
2724                    ("NOTEDTHAT_OIDC_AUDIENCE", Some("notedthat")),
2725                    ("NOTEDTHAT_OIDC_GROUPS_CLAIM", Some("roles")),
2726                ],
2727                Config::from_env,
2728            )
2729            .expect_err("refused");
2730            let message = error.to_string();
2731            assert!(
2732                names_setting(&message, "NOTEDTHAT_OIDC_ISSUER", "unset"),
2733                "{message}"
2734            );
2735            assert!(message.contains("NOTEDTHAT_OIDC_AUDIENCE"), "{message}");
2736            assert!(message.contains("NOTEDTHAT_OIDC_GROUPS_CLAIM"), "{message}");
2737        }
2738
2739        #[test]
2740        fn an_issuer_without_an_audience_is_rejected() {
2741            let error = run_with_env(&[("NOTEDTHAT_OIDC_ISSUER", Some(ISSUER))], Config::from_env)
2742                .expect_err("refused");
2743            assert!(
2744                names_setting(&error.to_string(), "NOTEDTHAT_OIDC_AUDIENCE", "required"),
2745                "{error}"
2746            );
2747        }
2748
2749        #[test]
2750        fn an_issuer_that_is_not_an_http_url_is_rejected() {
2751            for bad in ["auth.example.com", "ldap://auth.example.com", ""] {
2752                let error = run_with_env(
2753                    &[
2754                        ("NOTEDTHAT_OIDC_ISSUER", Some(bad)),
2755                        ("NOTEDTHAT_OIDC_AUDIENCE", Some("notedthat")),
2756                    ],
2757                    Config::from_env,
2758                )
2759                .expect_err("refused");
2760                assert!(
2761                    error.to_string().contains("NOTEDTHAT_OIDC_ISSUER"),
2762                    "{bad}: {error}"
2763                );
2764            }
2765        }
2766
2767        #[test]
2768        fn an_empty_claim_name_and_a_zero_timeout_are_rejected() {
2769            for (var, value) in [
2770                ("NOTEDTHAT_OIDC_USERNAME_CLAIM", " "),
2771                ("NOTEDTHAT_OIDC_GROUPS_CLAIM", ""),
2772                ("NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS", "0"),
2773                ("NOTEDTHAT_OIDC_RESOURCE", "notes.example.com"),
2774                ("NOTEDTHAT_OIDC_CA_CERT", "/nonexistent/ca.pem"),
2775            ] {
2776                let error = run_with_env(
2777                    &[
2778                        ("NOTEDTHAT_OIDC_ISSUER", Some(ISSUER)),
2779                        ("NOTEDTHAT_OIDC_AUDIENCE", Some("notedthat")),
2780                        (var, Some(value)),
2781                    ],
2782                    Config::from_env,
2783                )
2784                .expect_err("refused");
2785                assert!(error.to_string().contains(var), "{var}: {error}");
2786            }
2787        }
2788    }
2789}