Skip to main content

notedthat_server/
config.rs

1//! Environment-variable-based configuration for `notedthat-server`.
2//!
3//! There are no CLI flags and no config files — env vars are the only
4//! configuration surface. See `docs/CONFIGURATION.md` for the full reference.
5
6use notedthat_core::{Error, KbSlug, StagingConfig, TenantSlug};
7use notedthat_write::MAX_UPLOAD_BYTES;
8use std::collections::BTreeMap;
9use std::net::SocketAddr;
10
11/// Which storage backend the server runs on.
12///
13/// Parsed separately from its configuration so the selection can be named in an error
14/// message before any backend configuration is read.
15#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum StorageBackendKind {
17    /// An S3-compatible object store.
18    S3,
19    /// A local filesystem tree.
20    Fs,
21}
22
23impl StorageBackendKind {
24    /// The `NOTEDTHAT_STORAGE_BACKEND` value that selects this backend.
25    #[must_use]
26    pub fn as_str(self) -> &'static str {
27        match self {
28            Self::S3 => "s3",
29            Self::Fs => "fs",
30        }
31    }
32}
33
34impl std::fmt::Display for StorageBackendKind {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.write_str(self.as_str())
37    }
38}
39
40/// The selected storage backend together with the configuration it needs.
41///
42/// An enum rather than one `Option` per backend, so "exactly one backend is configured"
43/// is a property of the type and [`crate::run`] has no unreachable error arm.
44#[derive(Debug, Clone)]
45pub enum StorageConfig {
46    /// S3-compatible object store (the default).
47    S3(notedthat_storage_s3::S3Config),
48    /// Local filesystem tree.
49    Fs(notedthat_storage_fs::FsConfig),
50}
51
52impl StorageConfig {
53    /// Which backend this is.
54    #[must_use]
55    pub fn kind(&self) -> StorageBackendKind {
56        match self {
57            Self::S3(_) => StorageBackendKind::S3,
58            Self::Fs(_) => StorageBackendKind::Fs,
59        }
60    }
61}
62
63/// Environment variables owned by one storage backend.
64///
65/// A variable whose owner is not the selected backend is a startup error rather than an
66/// ignored setting — silently ignoring `NOTEDTHAT_FS_ROOT` under the default `s3` backend
67/// is how an operator ends up believing their bytes are on a disk they are not on. Same
68/// reasoning as [`REMOVED_LISTENER_ENV_VARS`] (D39), applied to backend selection.
69///
70/// Deliberately confined to variables this server reads. `AWS_*` is not listed:
71/// `S3Config::build_client` uses a static credential provider and never consults the
72/// ambient credential chain, so rejecting an `AWS_ACCESS_KEY_ID` on a shared runner would
73/// be a pure false positive.
74fn backend_owned_env_vars() -> Vec<(&'static str, StorageBackendKind)> {
75    notedthat_storage_s3::S3_ENV_VARS
76        .iter()
77        .map(|name| (*name, StorageBackendKind::S3))
78        .chain(
79            notedthat_storage_fs::FS_ENV_VARS
80                .iter()
81                .map(|name| (*name, StorageBackendKind::Fs)),
82        )
83        .collect()
84}
85
86/// Parse `NOTEDTHAT_STORAGE_BACKEND`, returning `None` when it is unset.
87///
88/// Strict, unlike `NOTEDTHAT_LOG_FORMAT` and `NOTEDTHAT_S3_FORCE_PATH_STYLE`, which
89/// silently fall back on an unrecognised value. Those two can afford leniency because a
90/// mis-parse announces itself immediately — the wrong log format is visible in the first
91/// line of output, and a wrong path-style setting fails on the first request. A backend
92/// selector cannot: `NOTEDTHAT_STORAGE_BACKEND=fs3` would fall back to `s3`, start
93/// cleanly, provision buckets and serve a knowledge base that looks empty because the
94/// operator's data is on disk. Nothing later in the run would say so.
95fn parse_storage_backend() -> Result<Option<StorageBackendKind>, Error> {
96    let Some(value) = std::env::var_os("NOTEDTHAT_STORAGE_BACKEND") else {
97        return Ok(None);
98    };
99    let value = value.to_str().ok_or_else(|| Error::Config {
100        message: "NOTEDTHAT_STORAGE_BACKEND must be valid UTF-8".into(),
101    })?;
102    if value.is_empty() {
103        return Err(Error::Config {
104            message: "NOTEDTHAT_STORAGE_BACKEND must not be empty".into(),
105        });
106    }
107    match value {
108        "s3" => Ok(Some(StorageBackendKind::S3)),
109        "fs" => Ok(Some(StorageBackendKind::Fs)),
110        other => Err(Error::Config {
111            message: format!(
112                "NOTEDTHAT_STORAGE_BACKEND is invalid: expected \"s3\" or \"fs\", got \"{other}\""
113            ),
114        }),
115    }
116}
117
118/// Refuse to start when variables belonging to the unselected backend are set.
119///
120/// Reports every offender at once: the realistic case is a whole `NOTEDTHAT_S3_*` family
121/// left behind by an operator switching to `fs`, and naming one per restart would take
122/// five restarts. Presence is tested with `var_os`, so an empty value still counts —
123/// matching [`REMOVED_LISTENER_ENV_VARS`].
124///
125/// The check runs when the selector is unset too, and says so. That is the highest-value
126/// case: an operator who sets `NOTEDTHAT_FS_ROOT` and forgets the selector would
127/// otherwise get a perfectly healthy S3 deployment with an unread root.
128fn reject_other_backends_variables(selected: Option<StorageBackendKind>) -> Result<(), Error> {
129    let effective = selected.unwrap_or(StorageBackendKind::S3);
130    let offenders: Vec<&str> = backend_owned_env_vars()
131        .into_iter()
132        .filter(|(name, owner)| *owner != effective && std::env::var_os(name).is_some())
133        .map(|(name, _)| name)
134        .collect();
135
136    if offenders.is_empty() {
137        return Ok(());
138    }
139
140    let owner = if effective == StorageBackendKind::S3 {
141        StorageBackendKind::Fs
142    } else {
143        StorageBackendKind::S3
144    };
145    let selection = match selected {
146        Some(kind) => format!("NOTEDTHAT_STORAGE_BACKEND is {kind}"),
147        None => {
148            "NOTEDTHAT_STORAGE_BACKEND is unset, so the default s3 backend is selected".to_string()
149        }
150    };
151    Err(Error::Config {
152        message: format!(
153            "{selection}, but these variables belong to the {owner} backend and would be ignored: {}. \
154             Unset them or set NOTEDTHAT_STORAGE_BACKEND={owner} to start the server.",
155            offenders.join(", ")
156        ),
157    })
158}
159
160/// An S3 storage config pointed at an unroutable address.
161///
162/// For tests that inject their own [`crate::run::Backends`] and never build a client
163/// from it. A regression that *does* reach for it fails loudly rather than quietly
164/// talking to something real.
165#[cfg(any(test, feature = "test-support"))]
166#[must_use]
167pub fn unroutable_storage_placeholder() -> StorageConfig {
168    StorageConfig::S3(notedthat_storage_s3::S3Config {
169        endpoint_url: Some("http://127.0.0.1:1".to_string()),
170        region: "us-east-1".to_string(),
171        access_key_id: "any".to_string(),
172        secret_access_key: "any".to_string(),
173        force_path_style: true,
174    })
175}
176
177/// Server-wide configuration, parsed from environment variables.
178#[derive(Debug, Clone)]
179pub struct Config {
180    /// Static Bearer token for API authentication (`NOTEDTHAT_API_TOKEN`).
181    pub api_token: String,
182    /// Declared knowledge bases, as a sorted map of slug string → [`KbSlug`].
183    pub kbs: BTreeMap<String, KbSlug>,
184    /// Tenant slug — hardcoded to `"default"` per Metis directive.
185    pub tenant_slug: TenantSlug,
186    /// Socket address the HTTP server binds to (`NOTEDTHAT_LISTEN_ADDR`; default `0.0.0.0:8080`).
187    pub listen_addr: SocketAddr,
188    /// The selected storage backend and its configuration
189    /// (`NOTEDTHAT_STORAGE_BACKEND`; default `s3`).
190    pub storage: StorageConfig,
191    /// Log output format (`NOTEDTHAT_LOG_FORMAT`; `pretty` or `json`).
192    pub log_format: LogFormat,
193    /// Qdrant client configuration.
194    pub qdrant: ServerQdrantConfig,
195    /// Embedder configuration.
196    pub embedder: EmbedderConfig,
197    /// `WebDAV` Basic authentication username (`NOTEDTHAT_WEBDAV_USERNAME`; required).
198    pub webdav_username: String,
199    /// `WebDAV` Basic authentication password (`NOTEDTHAT_WEBDAV_PASSWORD`; required).
200    pub webdav_password: String,
201    /// Allowed origins for MCP HTTP CORS (`NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS`; empty → `["null"]`).
202    pub mcp_http_allowed_origins: Vec<String>,
203    /// Allowed hosts for MCP HTTP Host header validation (`NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS`; empty → `["127.0.0.1", "localhost", "::1"]`).
204    pub mcp_http_allowed_hosts: Vec<String>,
205    /// Maximum patchable object size in bytes (`NOTEDTHAT_MAX_PATCHABLE_SIZE`; default 100 MiB).
206    pub max_patchable_size: u64,
207    /// Shared private staging directory for uploads and index snapshots (`NOTEDTHAT_UPLOAD_TMP_DIR`).
208    pub staging: StagingConfig,
209}
210
211/// Environment variables removed when the API, `WebDAV`, and MCP surfaces moved
212/// onto one listener, each paired with the setup that replaces it.
213///
214/// Leaving one of these set is a silent exposure change on upgrade — a
215/// `WebDAV` listener that was bound to loopback becomes reachable at `/webdav` on the
216/// public listener, and `NOTEDTHAT_MCP_HTTP_ENABLED=false` no longer disables
217/// `/mcp`. Per D39 the server refuses to start instead, naming the replacement.
218const REMOVED_LISTENER_ENV_VARS: [(&str, &str); 3] = [
219    (
220        "NOTEDTHAT_WEBDAV_LISTEN_ADDR",
221        "WebDAV is always served at /webdav on NOTEDTHAT_LISTEN_ADDR",
222    ),
223    (
224        "NOTEDTHAT_MCP_HTTP_BIND",
225        "MCP HTTP is always served at /mcp on NOTEDTHAT_LISTEN_ADDR",
226    ),
227    (
228        "NOTEDTHAT_MCP_HTTP_ENABLED",
229        "MCP HTTP is always served at /mcp on NOTEDTHAT_LISTEN_ADDR",
230    ),
231];
232
233/// Tracing output format.
234#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub enum LogFormat {
236    /// Human-readable multi-line output (default).
237    Pretty,
238    /// Machine-readable JSON (one line per event).
239    Json,
240}
241
242impl Config {
243    /// Parse configuration from environment variables.
244    ///
245    /// # Errors
246    ///
247    /// Returns `Err(Error::Config { .. })` if any required variable is missing,
248    /// if any value is invalid (empty token, bad slug, duplicate slug, etc.), or
249    /// if any [`REMOVED_LISTENER_ENV_VARS`] entry is still set.
250    #[allow(clippy::too_many_lines)]
251    pub fn from_env() -> Result<Self, Error> {
252        for (key, replacement) in REMOVED_LISTENER_ENV_VARS {
253            if std::env::var_os(key).is_some() {
254                return Err(Error::Config {
255                    message: format!(
256                        "{key} was removed: {replacement}. Unset {key} to start the server."
257                    ),
258                });
259            }
260        }
261
262        let api_token = std::env::var("NOTEDTHAT_API_TOKEN").map_err(|_| Error::Config {
263            message: "NOTEDTHAT_API_TOKEN is required".into(),
264        })?;
265        if api_token.is_empty() {
266            return Err(Error::Config {
267                message: "NOTEDTHAT_API_TOKEN must not be empty".into(),
268            });
269        }
270
271        let kbs_raw = std::env::var("NOTEDTHAT_KBS").map_err(|_| Error::Config {
272            message: "NOTEDTHAT_KBS is required".into(),
273        })?;
274        if kbs_raw.trim().is_empty() {
275            return Err(Error::Config {
276                message: "NOTEDTHAT_KBS must declare at least one knowledge base".into(),
277            });
278        }
279
280        let mut kbs = BTreeMap::new();
281        for token in kbs_raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
282            let slug = KbSlug::try_new(token).map_err(|e| Error::Config {
283                message: format!("invalid KB slug {token:?}: {e}"),
284            })?;
285            if kbs.insert(slug.as_str().to_string(), slug).is_some() {
286                return Err(Error::Config {
287                    message: format!("duplicate KB slug in NOTEDTHAT_KBS: {token:?}"),
288                });
289            }
290        }
291        if kbs.is_empty() {
292            return Err(Error::Config {
293                message: "NOTEDTHAT_KBS must declare at least one knowledge base".into(),
294            });
295        }
296
297        // Tenant slug is hardcoded to "default" per Metis directive.
298        // NOTEDTHAT_TENANT_SLUG env var intentionally not read.
299        let tenant_slug = TenantSlug::default();
300
301        let listen_addr_str =
302            std::env::var("NOTEDTHAT_LISTEN_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string());
303        let listen_addr: SocketAddr = listen_addr_str.parse().map_err(|e| Error::Config {
304            message: format!("NOTEDTHAT_LISTEN_ADDR is invalid: {e}"),
305        })?;
306
307        let selected = parse_storage_backend()?;
308        reject_other_backends_variables(selected)?;
309        let storage = match selected.unwrap_or(StorageBackendKind::S3) {
310            StorageBackendKind::S3 => {
311                StorageConfig::S3(notedthat_storage_s3::S3Config::from_env()?)
312            }
313            StorageBackendKind::Fs => {
314                StorageConfig::Fs(notedthat_storage_fs::FsConfig::from_env()?)
315            }
316        };
317
318        let log_format = match std::env::var("NOTEDTHAT_LOG_FORMAT").as_deref() {
319            Ok("json") => LogFormat::Json,
320            _ => LogFormat::Pretty,
321        };
322
323        let qdrant = ServerQdrantConfig::from_env()?;
324        let embedder = EmbedderConfig::from_env()?;
325
326        let webdav_username =
327            std::env::var("NOTEDTHAT_WEBDAV_USERNAME").map_err(|_| Error::Config {
328                message: "NOTEDTHAT_WEBDAV_USERNAME is required".into(),
329            })?;
330        if webdav_username.is_empty() {
331            return Err(Error::Config {
332                message: "NOTEDTHAT_WEBDAV_USERNAME is required and must not be empty".into(),
333            });
334        }
335
336        let webdav_password =
337            std::env::var("NOTEDTHAT_WEBDAV_PASSWORD").map_err(|_| Error::Config {
338                message: "NOTEDTHAT_WEBDAV_PASSWORD is required".into(),
339            })?;
340        if webdav_password.is_empty() {
341            return Err(Error::Config {
342                message: "NOTEDTHAT_WEBDAV_PASSWORD is required and must not be empty".into(),
343            });
344        }
345
346        if api_token.trim().is_empty() {
347            return Err(Error::Config {
348                message: "NOTEDTHAT_API_TOKEN must not be empty".into(),
349            });
350        }
351
352        let mcp_http_allowed_origins =
353            match std::env::var("NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS").as_deref() {
354                Ok(s) if !s.trim().is_empty() => s
355                    .split(',')
356                    .map(|v| v.trim().to_string())
357                    .filter(|v| !v.is_empty())
358                    .collect(),
359                _ => vec!["null".to_string()],
360            };
361
362        let mcp_http_allowed_hosts =
363            match std::env::var("NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS").as_deref() {
364                Ok(s) if !s.trim().is_empty() => s
365                    .split(',')
366                    .map(|v| v.trim().to_string())
367                    .filter(|v| !v.is_empty())
368                    .collect(),
369                _ => vec![
370                    "127.0.0.1".to_string(),
371                    "localhost".to_string(),
372                    "::1".to_string(),
373                ],
374            };
375
376        let max_patchable_size = std::env::var("NOTEDTHAT_MAX_PATCHABLE_SIZE")
377            .unwrap_or_else(|_| (100 * 1024 * 1024u64).to_string())
378            .parse::<u64>()
379            .map_err(|_e: std::num::ParseIntError| Error::Config {
380                message: "NOTEDTHAT_MAX_PATCHABLE_SIZE must be a valid u64 integer".into(),
381            })?;
382        if max_patchable_size == 0 {
383            return Err(Error::Config {
384                message: "NOTEDTHAT_MAX_PATCHABLE_SIZE must be > 0".into(),
385            });
386        }
387        if max_patchable_size > MAX_UPLOAD_BYTES {
388            return Err(Error::Config {
389                message: "NOTEDTHAT_MAX_PATCHABLE_SIZE must not exceed MAX_UPLOAD_BYTES (5 GiB)"
390                    .into(),
391            });
392        }
393
394        let staging = StagingConfig::from_env().map_err(|error| Error::Config {
395            message: error.to_string(),
396        })?;
397
398        Ok(Self {
399            api_token,
400            kbs,
401            tenant_slug,
402            listen_addr,
403            storage,
404            log_format,
405            qdrant,
406            embedder,
407            webdav_username,
408            webdav_password,
409            mcp_http_allowed_origins,
410            mcp_http_allowed_hosts,
411            max_patchable_size,
412            staging,
413        })
414    }
415}
416
417/// Qdrant client configuration, parsed from env vars.
418#[derive(Debug, Clone)]
419pub struct ServerQdrantConfig {
420    /// Qdrant gRPC/HTTP endpoint (`NOTEDTHAT_QDRANT_URL`; required).
421    pub url: String,
422    /// Optional Qdrant API key (`NOTEDTHAT_QDRANT_API_KEY`).
423    pub api_key: Option<String>,
424    /// Per-RPC timeout in milliseconds (`NOTEDTHAT_QDRANT_TIMEOUT_MS`; default 30 000).
425    ///
426    /// `qdrant-client`'s own default is 5 s, which is too tight for a full
427    /// embedding batch upserted with `wait(true)`.
428    pub timeout_ms: u64,
429    /// Connection-establishment timeout in milliseconds
430    /// (`NOTEDTHAT_QDRANT_CONNECT_TIMEOUT_MS`; default 10 000).
431    pub connect_timeout_ms: u64,
432}
433
434impl ServerQdrantConfig {
435    /// Parse Qdrant configuration from environment variables.
436    ///
437    /// # Errors
438    ///
439    /// Returns `Err(Error::Config { .. })` if `NOTEDTHAT_QDRANT_URL` is missing.
440    pub fn from_env() -> Result<Self, Error> {
441        let url = std::env::var("NOTEDTHAT_QDRANT_URL").map_err(|_| Error::Config {
442            message: "NOTEDTHAT_QDRANT_URL is required".into(),
443        })?;
444        let api_key = std::env::var("NOTEDTHAT_QDRANT_API_KEY").ok();
445        let timeout_ms = parse_millis("NOTEDTHAT_QDRANT_TIMEOUT_MS", 30_000)?;
446        let connect_timeout_ms = parse_millis("NOTEDTHAT_QDRANT_CONNECT_TIMEOUT_MS", 10_000)?;
447        Ok(Self {
448            url,
449            api_key,
450            timeout_ms,
451            connect_timeout_ms,
452        })
453    }
454}
455
456/// Parse a millisecond duration from the environment, rejecting zero.
457fn parse_millis(var: &str, default: u64) -> Result<u64, Error> {
458    let Ok(raw) = std::env::var(var) else {
459        return Ok(default);
460    };
461    let value = raw.parse::<u64>().map_err(|_| Error::Config {
462        message: format!("{var} must be a valid u64 integer"),
463    })?;
464    if value == 0 {
465        return Err(Error::Config {
466            message: format!("{var} must be > 0"),
467        });
468    }
469    Ok(value)
470}
471
472/// Embedder configuration, parsed from env vars.
473#[derive(Debug, Clone)]
474pub struct EmbedderConfig {
475    /// OpenAI-compatible embedding endpoint URL (`EMBEDDING_ENDPOINT_URL`; required).
476    pub endpoint_url: String,
477    /// Embedding model name (`EMBEDDING_MODEL`; required).
478    pub model: String,
479    /// API key for the embedding endpoint (`EMBEDDING_API_KEY`; required).
480    pub api_key: String,
481    /// Output vector dimensions (`EMBEDDING_DIMENSIONS`; required).
482    pub dimensions: u32,
483    /// Number of texts per embedding batch (`EMBEDDING_BATCH_SIZE`; default `32`).
484    pub batch_size: usize,
485    /// HTTP request timeout in milliseconds (`EMBEDDING_TIMEOUT_MS`; default `30000`).
486    pub timeout_ms: u64,
487    /// Maximum number of retries on transient failures (`EMBEDDING_MAX_RETRIES`; default `3`).
488    pub max_retries: u32,
489    /// Maximum tokens per input text (`EMBEDDING_MAX_INPUT_TOKENS`; default `8192`).
490    pub max_input_tokens: usize,
491}
492
493impl EmbedderConfig {
494    /// Parse embedder configuration from environment variables.
495    ///
496    /// # Errors
497    ///
498    /// Returns `Err(Error::Config { .. })` if any required variable is missing or invalid.
499    pub fn from_env() -> Result<Self, Error> {
500        let endpoint_url = std::env::var("EMBEDDING_ENDPOINT_URL").map_err(|_| Error::Config {
501            message: "EMBEDDING_ENDPOINT_URL is required".into(),
502        })?;
503        let model = std::env::var("EMBEDDING_MODEL").map_err(|_| Error::Config {
504            message: "EMBEDDING_MODEL is required".into(),
505        })?;
506        let api_key = std::env::var("EMBEDDING_API_KEY").map_err(|_| Error::Config {
507            message: "EMBEDDING_API_KEY is required".into(),
508        })?;
509        let dimensions: u32 = std::env::var("EMBEDDING_DIMENSIONS")
510            .map_err(|_| Error::Config {
511                message: "EMBEDDING_DIMENSIONS is required".into(),
512            })?
513            .parse()
514            .map_err(|e: std::num::ParseIntError| Error::Config {
515                message: format!("EMBEDDING_DIMENSIONS is invalid: {e}"),
516            })?;
517        let batch_size: usize = std::env::var("EMBEDDING_BATCH_SIZE")
518            .unwrap_or_else(|_| "32".to_string())
519            .parse()
520            .map_err(|e: std::num::ParseIntError| Error::Config {
521                message: format!("EMBEDDING_BATCH_SIZE is invalid: {e}"),
522            })?;
523        let timeout_ms: u64 = std::env::var("EMBEDDING_TIMEOUT_MS")
524            .unwrap_or_else(|_| "30000".to_string())
525            .parse()
526            .map_err(|e: std::num::ParseIntError| Error::Config {
527                message: format!("EMBEDDING_TIMEOUT_MS is invalid: {e}"),
528            })?;
529        let max_retries: u32 = std::env::var("EMBEDDING_MAX_RETRIES")
530            .unwrap_or_else(|_| "3".to_string())
531            .parse()
532            .map_err(|e: std::num::ParseIntError| Error::Config {
533                message: format!("EMBEDDING_MAX_RETRIES is invalid: {e}"),
534            })?;
535        let max_input_tokens: usize = std::env::var("EMBEDDING_MAX_INPUT_TOKENS")
536            .unwrap_or_else(|_| "8192".to_string())
537            .parse()
538            .map_err(|e: std::num::ParseIntError| Error::Config {
539                message: format!("EMBEDDING_MAX_INPUT_TOKENS is invalid: {e}"),
540            })?;
541        Ok(Self {
542            endpoint_url,
543            model,
544            api_key,
545            dimensions,
546            batch_size,
547            timeout_ms,
548            max_retries,
549            max_input_tokens,
550        })
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557
558    const ALL_ENV_KEYS: [&str; 34] = [
559        "NOTEDTHAT_API_TOKEN",
560        "NOTEDTHAT_KBS",
561        "NOTEDTHAT_STORAGE_BACKEND",
562        "NOTEDTHAT_FS_ROOT",
563        "NOTEDTHAT_FS_METADATA",
564        "NOTEDTHAT_FS_FILE_MODE",
565        "NOTEDTHAT_FS_DIR_MODE",
566        "NOTEDTHAT_FS_ALLOW_LOSSY_NAMES",
567        "NOTEDTHAT_S3_REGION",
568        "NOTEDTHAT_S3_ACCESS_KEY_ID",
569        "NOTEDTHAT_S3_SECRET_ACCESS_KEY",
570        "NOTEDTHAT_LISTEN_ADDR",
571        "NOTEDTHAT_LOG_FORMAT",
572        "NOTEDTHAT_S3_ENDPOINT_URL",
573        "NOTEDTHAT_S3_FORCE_PATH_STYLE",
574        "NOTEDTHAT_QDRANT_URL",
575        "NOTEDTHAT_QDRANT_API_KEY",
576        "NOTEDTHAT_WEBDAV_USERNAME",
577        "NOTEDTHAT_WEBDAV_PASSWORD",
578        "NOTEDTHAT_WEBDAV_LISTEN_ADDR",
579        "NOTEDTHAT_MCP_HTTP_BIND",
580        "NOTEDTHAT_MCP_HTTP_ENABLED",
581        "NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS",
582        "NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS",
583        "NOTEDTHAT_MAX_PATCHABLE_SIZE",
584        "NOTEDTHAT_UPLOAD_TMP_DIR",
585        "EMBEDDING_ENDPOINT_URL",
586        "EMBEDDING_MODEL",
587        "EMBEDDING_API_KEY",
588        "EMBEDDING_DIMENSIONS",
589        "EMBEDDING_BATCH_SIZE",
590        "EMBEDDING_TIMEOUT_MS",
591        "EMBEDDING_MAX_RETRIES",
592        "EMBEDDING_MAX_INPUT_TOKENS",
593    ];
594
595    fn run_with_env<F: FnOnce() -> R, R>(overrides: &[(&str, Option<&str>)], f: F) -> R {
596        let mut vars: Vec<(&str, Option<&str>)> = vec![
597            ("NOTEDTHAT_API_TOKEN", Some("test-token")),
598            ("NOTEDTHAT_KBS", Some("notes,docs")),
599            ("NOTEDTHAT_STORAGE_BACKEND", None),
600            ("NOTEDTHAT_FS_ROOT", None),
601            ("NOTEDTHAT_FS_METADATA", None),
602            ("NOTEDTHAT_FS_FILE_MODE", None),
603            ("NOTEDTHAT_FS_DIR_MODE", None),
604            ("NOTEDTHAT_FS_ALLOW_LOSSY_NAMES", None),
605            ("NOTEDTHAT_S3_REGION", Some("us-east-1")),
606            ("NOTEDTHAT_S3_ACCESS_KEY_ID", Some("key")),
607            ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", Some("secret")),
608            ("NOTEDTHAT_LISTEN_ADDR", None),
609            ("NOTEDTHAT_LOG_FORMAT", None),
610            ("NOTEDTHAT_S3_ENDPOINT_URL", None),
611            ("NOTEDTHAT_S3_FORCE_PATH_STYLE", None),
612            ("NOTEDTHAT_QDRANT_URL", Some("http://localhost:6334")),
613            ("NOTEDTHAT_QDRANT_API_KEY", None),
614            ("NOTEDTHAT_WEBDAV_USERNAME", Some("webdav-user")),
615            ("NOTEDTHAT_WEBDAV_PASSWORD", Some("webdav-pass")),
616            ("NOTEDTHAT_WEBDAV_LISTEN_ADDR", None),
617            ("NOTEDTHAT_MCP_HTTP_BIND", None),
618            ("NOTEDTHAT_MCP_HTTP_ENABLED", None),
619            ("NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS", None),
620            ("NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", None),
621            ("NOTEDTHAT_MAX_PATCHABLE_SIZE", None),
622            ("NOTEDTHAT_UPLOAD_TMP_DIR", None),
623            ("EMBEDDING_ENDPOINT_URL", Some("https://api.openai.com")),
624            ("EMBEDDING_MODEL", Some("text-embedding-3-small")),
625            ("EMBEDDING_API_KEY", Some("sk-test")),
626            ("EMBEDDING_DIMENSIONS", Some("1536")),
627            ("EMBEDDING_BATCH_SIZE", None),
628            ("EMBEDDING_TIMEOUT_MS", None),
629            ("EMBEDDING_MAX_RETRIES", None),
630            ("EMBEDDING_MAX_INPUT_TOKENS", None),
631        ];
632
633        for (key, value) in overrides {
634            if let Some((_, slot)) = vars.iter_mut().find(|(existing, _)| existing == key) {
635                *slot = *value;
636            }
637        }
638
639        temp_env::with_vars(vars, f)
640    }
641
642    #[test]
643    fn test_empty_kbs_rejected() {
644        let result = run_with_env(&[("NOTEDTHAT_KBS", Some(""))], Config::from_env);
645        assert!(result.is_err());
646        assert!(
647            result
648                .unwrap_err()
649                .to_string()
650                .contains("at least one knowledge base")
651        );
652    }
653
654    #[test]
655    fn test_duplicate_slug_rejected() {
656        let result = run_with_env(&[("NOTEDTHAT_KBS", Some("notes,notes"))], Config::from_env);
657        assert!(result.is_err(), "duplicate slugs should fail");
658        let msg = result.unwrap_err().to_string();
659        assert!(
660            msg.contains("duplicate"),
661            "error should mention 'duplicate'"
662        );
663    }
664
665    #[test]
666    fn test_no_tenant_slug_env_var() {
667        let cfg = run_with_env(&[], Config::from_env).unwrap();
668        assert_eq!(cfg.tenant_slug.as_str(), "default");
669    }
670
671    #[test]
672    fn test_log_format_json() {
673        let cfg =
674            run_with_env(&[("NOTEDTHAT_LOG_FORMAT", Some("json"))], Config::from_env).unwrap();
675        assert_eq!(cfg.log_format, LogFormat::Json);
676    }
677
678    #[test]
679    fn test_log_format_default_pretty() {
680        let cfg = run_with_env(&[], Config::from_env).unwrap();
681        assert_eq!(cfg.log_format, LogFormat::Pretty);
682    }
683
684    #[test]
685    fn test_default_listen_addr() {
686        let cfg = run_with_env(&[], Config::from_env).unwrap();
687        assert_eq!(cfg.listen_addr.to_string(), "0.0.0.0:8080");
688    }
689
690    #[test]
691    fn test_default_staging_directory() {
692        let cfg = run_with_env(&[], Config::from_env).unwrap();
693        assert_eq!(cfg.staging.directory(), std::env::temp_dir());
694    }
695
696    #[test]
697    fn test_invalid_listen_addr() {
698        let result = run_with_env(
699            &[("NOTEDTHAT_LISTEN_ADDR", Some("not-a-socket-addr"))],
700            Config::from_env,
701        );
702        assert!(result.is_err());
703    }
704
705    #[test]
706    fn test_kbs_parsed_correctly() {
707        let cfg = run_with_env(&[], Config::from_env).unwrap();
708        assert_eq!(cfg.kbs.len(), 2);
709        assert!(cfg.kbs.contains_key("notes"));
710        assert!(cfg.kbs.contains_key("docs"));
711    }
712
713    #[test]
714    fn test_missing_api_token_rejected() {
715        let result = run_with_env(&[("NOTEDTHAT_API_TOKEN", None)], Config::from_env);
716        assert!(result.is_err());
717        assert!(
718            result
719                .unwrap_err()
720                .to_string()
721                .contains("NOTEDTHAT_API_TOKEN")
722        );
723    }
724
725    #[test]
726    fn test_missing_webdav_username_rejected() {
727        let result = run_with_env(&[("NOTEDTHAT_WEBDAV_USERNAME", None)], Config::from_env);
728        assert!(result.is_err());
729        assert!(
730            result
731                .unwrap_err()
732                .to_string()
733                .contains("NOTEDTHAT_WEBDAV_USERNAME")
734        );
735    }
736
737    #[test]
738    fn test_empty_webdav_username_rejected() {
739        let result = run_with_env(&[("NOTEDTHAT_WEBDAV_USERNAME", Some(""))], Config::from_env);
740        assert!(result.is_err());
741        assert!(
742            result
743                .unwrap_err()
744                .to_string()
745                .contains("NOTEDTHAT_WEBDAV_USERNAME")
746        );
747    }
748
749    #[test]
750    fn test_missing_webdav_password_rejected() {
751        let result = run_with_env(&[("NOTEDTHAT_WEBDAV_PASSWORD", None)], Config::from_env);
752        assert!(result.is_err());
753        assert!(
754            result
755                .unwrap_err()
756                .to_string()
757                .contains("NOTEDTHAT_WEBDAV_PASSWORD")
758        );
759    }
760
761    #[test]
762    fn test_empty_webdav_password_rejected() {
763        let result = run_with_env(&[("NOTEDTHAT_WEBDAV_PASSWORD", Some(""))], Config::from_env);
764        assert!(result.is_err());
765        assert!(
766            result
767                .unwrap_err()
768                .to_string()
769                .contains("NOTEDTHAT_WEBDAV_PASSWORD")
770        );
771    }
772
773    #[test]
774    fn removed_listener_variables_are_rejected_with_their_replacement() {
775        for (key, replacement) in REMOVED_LISTENER_ENV_VARS {
776            let result = run_with_env(&[(key, Some("some-stale-value"))], Config::from_env);
777            let message = result.map_or_else(
778                |e| e.to_string(),
779                |_| panic!("{key} must be rejected at startup"),
780            );
781
782            assert!(message.contains(key), "{key} error must name the variable");
783            assert!(
784                message.contains(replacement),
785                "{key} error must name its replacement"
786            );
787        }
788    }
789
790    #[test]
791    fn removed_listener_variables_are_rejected_even_when_empty() {
792        let result = run_with_env(
793            &[("NOTEDTHAT_MCP_HTTP_ENABLED", Some(""))],
794            Config::from_env,
795        );
796
797        assert!(
798            result.is_err(),
799            "an empty removed variable is still an explicit operator setting"
800        );
801    }
802
803    #[test]
804    fn unset_removed_listener_variables_leave_the_default_listener() {
805        let config = run_with_env(&[], Config::from_env)
806            .expect("configuration must parse when no removed variable is set");
807
808        assert_eq!(config.listen_addr.to_string(), "0.0.0.0:8080");
809    }
810
811    #[test]
812    fn test_webdav_credentials_propagated() {
813        let cfg = run_with_env(
814            &[
815                ("NOTEDTHAT_WEBDAV_USERNAME", Some("myuser")),
816                ("NOTEDTHAT_WEBDAV_PASSWORD", Some("mypass")),
817            ],
818            Config::from_env,
819        )
820        .unwrap();
821        assert_eq!(cfg.webdav_username, "myuser");
822        assert_eq!(cfg.webdav_password, "mypass");
823    }
824
825    #[test]
826    fn all_env_keys_are_accounted_for() {
827        assert_eq!(ALL_ENV_KEYS.len(), 34);
828    }
829
830    #[test]
831    fn max_patchable_size_defaults_to_100_mib() {
832        let cfg =
833            run_with_env(&[("NOTEDTHAT_MAX_PATCHABLE_SIZE", None)], Config::from_env).unwrap();
834        assert_eq!(cfg.max_patchable_size, 100 * 1024 * 1024);
835    }
836
837    #[test]
838    fn max_patchable_size_accepts_explicit_bytes() {
839        let cfg = run_with_env(
840            &[("NOTEDTHAT_MAX_PATCHABLE_SIZE", Some("52428800"))],
841            Config::from_env,
842        )
843        .unwrap();
844        assert_eq!(cfg.max_patchable_size, 50 * 1024 * 1024);
845    }
846
847    #[test]
848    fn max_patchable_size_rejects_zero() {
849        let result = run_with_env(
850            &[("NOTEDTHAT_MAX_PATCHABLE_SIZE", Some("0"))],
851            Config::from_env,
852        );
853        assert!(matches!(result, Err(Error::Config { .. })));
854        assert!(
855            result
856                .unwrap_err()
857                .to_string()
858                .contains("NOTEDTHAT_MAX_PATCHABLE_SIZE must be > 0")
859        );
860    }
861
862    #[test]
863    fn max_patchable_size_rejects_values_over_max_upload_bytes() {
864        let result = run_with_env(
865            &[("NOTEDTHAT_MAX_PATCHABLE_SIZE", Some("6442450944"))],
866            Config::from_env,
867        );
868        assert!(matches!(result, Err(Error::Config { .. })));
869        assert!(
870            result
871                .unwrap_err()
872                .to_string()
873                .contains("NOTEDTHAT_MAX_PATCHABLE_SIZE must not exceed MAX_UPLOAD_BYTES (5 GiB)")
874        );
875    }
876
877    #[test]
878    fn max_patchable_size_rejects_non_numeric_values() {
879        let result = run_with_env(
880            &[("NOTEDTHAT_MAX_PATCHABLE_SIZE", Some("not-a-number"))],
881            Config::from_env,
882        );
883        assert!(matches!(result, Err(Error::Config { .. })));
884        assert!(
885            result
886                .unwrap_err()
887                .to_string()
888                .contains("NOTEDTHAT_MAX_PATCHABLE_SIZE must be a valid u64 integer")
889        );
890    }
891
892    #[test]
893    fn qdrant_url_missing_returns_error() {
894        let result = run_with_env(&[("NOTEDTHAT_QDRANT_URL", None)], Config::from_env);
895        assert!(result.is_err());
896        let msg = result.unwrap_err().to_string();
897        assert!(
898            msg.contains("NOTEDTHAT_QDRANT_URL"),
899            "error should mention the missing var: {msg}"
900        );
901    }
902
903    #[test]
904    fn qdrant_api_key_optional() {
905        let cfg = run_with_env(&[("NOTEDTHAT_QDRANT_API_KEY", None)], Config::from_env).unwrap();
906        assert!(
907            cfg.qdrant.api_key.is_none(),
908            "api_key should be None when env var is unset"
909        );
910    }
911
912    #[test]
913    fn qdrant_api_key_set_when_present() {
914        let cfg = run_with_env(
915            &[("NOTEDTHAT_QDRANT_API_KEY", Some("my-secret-key"))],
916            Config::from_env,
917        )
918        .unwrap();
919        assert_eq!(cfg.qdrant.api_key.as_deref(), Some("my-secret-key"));
920    }
921
922    #[test]
923    fn qdrant_url_propagated_to_config() {
924        let cfg = run_with_env(
925            &[(
926                "NOTEDTHAT_QDRANT_URL",
927                Some("http://qdrant.example.com:6334"),
928            )],
929            Config::from_env,
930        )
931        .unwrap();
932        assert_eq!(cfg.qdrant.url, "http://qdrant.example.com:6334");
933    }
934
935    #[test]
936    fn embedding_endpoint_url_missing() {
937        let result = run_with_env(&[("EMBEDDING_ENDPOINT_URL", None)], Config::from_env);
938        assert!(result.is_err());
939        let msg = result.unwrap_err().to_string();
940        assert!(
941            msg.contains("EMBEDDING_ENDPOINT_URL"),
942            "error should mention the missing var: {msg}"
943        );
944    }
945
946    #[test]
947    fn embedding_model_missing() {
948        let result = run_with_env(&[("EMBEDDING_MODEL", None)], Config::from_env);
949        assert!(result.is_err());
950        let msg = result.unwrap_err().to_string();
951        assert!(
952            msg.contains("EMBEDDING_MODEL"),
953            "error should mention the missing var: {msg}"
954        );
955    }
956
957    #[test]
958    fn embedding_api_key_missing() {
959        let result = run_with_env(&[("EMBEDDING_API_KEY", None)], Config::from_env);
960        assert!(result.is_err());
961        let msg = result.unwrap_err().to_string();
962        assert!(
963            msg.contains("EMBEDDING_API_KEY"),
964            "error should mention the missing var: {msg}"
965        );
966    }
967
968    #[test]
969    fn embedding_dimensions_missing() {
970        let result = run_with_env(&[("EMBEDDING_DIMENSIONS", None)], Config::from_env);
971        assert!(result.is_err());
972        let msg = result.unwrap_err().to_string();
973        assert!(
974            msg.contains("EMBEDDING_DIMENSIONS"),
975            "error should mention the missing var: {msg}"
976        );
977    }
978
979    #[test]
980    fn embedding_dimensions_invalid() {
981        let result = run_with_env(
982            &[("EMBEDDING_DIMENSIONS", Some("not-a-number"))],
983            Config::from_env,
984        );
985        assert!(result.is_err());
986        let msg = result.unwrap_err().to_string();
987        assert!(
988            msg.contains("EMBEDDING_DIMENSIONS"),
989            "error should mention the invalid var: {msg}"
990        );
991    }
992
993    #[test]
994    fn embedding_batch_size_default() {
995        let cfg = run_with_env(&[("EMBEDDING_BATCH_SIZE", None)], Config::from_env).unwrap();
996        assert_eq!(cfg.embedder.batch_size, 32);
997    }
998
999    #[test]
1000    fn embedding_timeout_ms_default() {
1001        let cfg = run_with_env(&[("EMBEDDING_TIMEOUT_MS", None)], Config::from_env).unwrap();
1002        assert_eq!(cfg.embedder.timeout_ms, 30_000);
1003    }
1004
1005    #[test]
1006    fn embedding_max_retries_default() {
1007        let cfg = run_with_env(&[("EMBEDDING_MAX_RETRIES", None)], Config::from_env).unwrap();
1008        assert_eq!(cfg.embedder.max_retries, 3);
1009    }
1010
1011    #[test]
1012    fn embedding_max_input_tokens_default() {
1013        let cfg = run_with_env(&[("EMBEDDING_MAX_INPUT_TOKENS", None)], Config::from_env).unwrap();
1014        assert_eq!(cfg.embedder.max_input_tokens, 8192);
1015    }
1016
1017    #[test]
1018    fn embedder_fields_propagated_to_config() {
1019        let cfg = run_with_env(&[], Config::from_env).unwrap();
1020        assert_eq!(cfg.embedder.endpoint_url, "https://api.openai.com");
1021        assert_eq!(cfg.embedder.model, "text-embedding-3-small");
1022        assert_eq!(cfg.embedder.api_key, "sk-test");
1023        assert_eq!(cfg.embedder.dimensions, 1536);
1024    }
1025
1026    mod mcp_http {
1027        use super::*;
1028
1029        #[test]
1030        fn mcp_http_defaults() {
1031            let cfg = run_with_env(&[], Config::from_env).unwrap();
1032            assert_eq!(cfg.mcp_http_allowed_origins, vec!["null"]);
1033            assert_eq!(
1034                cfg.mcp_http_allowed_hosts,
1035                vec!["127.0.0.1", "localhost", "::1"]
1036            );
1037        }
1038
1039        #[test]
1040        fn mcp_http_empty_origins_defaults_to_null() {
1041            let cfg = run_with_env(
1042                &[("NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS", Some(""))],
1043                Config::from_env,
1044            )
1045            .unwrap();
1046            assert_eq!(cfg.mcp_http_allowed_origins, vec!["null"]);
1047        }
1048
1049        #[test]
1050        fn mcp_http_whitespace_origins_defaults_to_null() {
1051            let cfg = run_with_env(
1052                &[("NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS", Some("   "))],
1053                Config::from_env,
1054            )
1055            .unwrap();
1056            assert_eq!(cfg.mcp_http_allowed_origins, vec!["null"]);
1057        }
1058
1059        #[test]
1060        fn mcp_http_single_origin() {
1061            let cfg = run_with_env(
1062                &[(
1063                    "NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS",
1064                    Some("https://example.com"),
1065                )],
1066                Config::from_env,
1067            )
1068            .unwrap();
1069            assert_eq!(cfg.mcp_http_allowed_origins, vec!["https://example.com"]);
1070        }
1071
1072        #[test]
1073        fn mcp_http_multiple_origins_comma_separated() {
1074            let cfg = run_with_env(
1075                &[(
1076                    "NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS",
1077                    Some("https://example.com,https://other.com"),
1078                )],
1079                Config::from_env,
1080            )
1081            .unwrap();
1082            assert_eq!(
1083                cfg.mcp_http_allowed_origins,
1084                vec!["https://example.com", "https://other.com"]
1085            );
1086        }
1087
1088        #[test]
1089        fn mcp_http_origins_with_whitespace_trimmed() {
1090            let cfg = run_with_env(
1091                &[(
1092                    "NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS",
1093                    Some("  https://example.com  ,  https://other.com  "),
1094                )],
1095                Config::from_env,
1096            )
1097            .unwrap();
1098            assert_eq!(
1099                cfg.mcp_http_allowed_origins,
1100                vec!["https://example.com", "https://other.com"]
1101            );
1102        }
1103
1104        #[test]
1105        fn mcp_http_empty_hosts_defaults_to_loopback() {
1106            let cfg = run_with_env(
1107                &[("NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", Some(""))],
1108                Config::from_env,
1109            )
1110            .unwrap();
1111            assert_eq!(
1112                cfg.mcp_http_allowed_hosts,
1113                vec!["127.0.0.1", "localhost", "::1"]
1114            );
1115        }
1116
1117        #[test]
1118        fn mcp_http_whitespace_hosts_defaults_to_loopback() {
1119            let cfg = run_with_env(
1120                &[("NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", Some("   "))],
1121                Config::from_env,
1122            )
1123            .unwrap();
1124            assert_eq!(
1125                cfg.mcp_http_allowed_hosts,
1126                vec!["127.0.0.1", "localhost", "::1"]
1127            );
1128        }
1129
1130        #[test]
1131        fn mcp_http_single_host() {
1132            let cfg = run_with_env(
1133                &[("NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", Some("example.com"))],
1134                Config::from_env,
1135            )
1136            .unwrap();
1137            assert_eq!(cfg.mcp_http_allowed_hosts, vec!["example.com"]);
1138        }
1139
1140        #[test]
1141        fn mcp_http_multiple_hosts_comma_separated() {
1142            let cfg = run_with_env(
1143                &[(
1144                    "NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS",
1145                    Some("example.com,other.com"),
1146                )],
1147                Config::from_env,
1148            )
1149            .unwrap();
1150            assert_eq!(cfg.mcp_http_allowed_hosts, vec!["example.com", "other.com"]);
1151        }
1152
1153        #[test]
1154        fn mcp_http_hosts_with_whitespace_trimmed() {
1155            let cfg = run_with_env(
1156                &[(
1157                    "NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS",
1158                    Some("  example.com  ,  other.com  "),
1159                )],
1160                Config::from_env,
1161            )
1162            .unwrap();
1163            assert_eq!(cfg.mcp_http_allowed_hosts, vec!["example.com", "other.com"]);
1164        }
1165
1166        #[test]
1167        fn mcp_http_with_empty_token_fails() {
1168            let result = run_with_env(&[("NOTEDTHAT_API_TOKEN", Some(""))], Config::from_env);
1169            assert!(result.is_err());
1170            let msg = result.unwrap_err().to_string();
1171            assert!(
1172                msg.contains("NOTEDTHAT_API_TOKEN"),
1173                "error should mention NOTEDTHAT_API_TOKEN: {msg}"
1174            );
1175        }
1176
1177        #[test]
1178        fn mcp_http_with_whitespace_token_fails() {
1179            let result = run_with_env(&[("NOTEDTHAT_API_TOKEN", Some("   "))], Config::from_env);
1180            assert!(result.is_err());
1181            let msg = result.unwrap_err().to_string();
1182            assert!(
1183                msg.contains("NOTEDTHAT_API_TOKEN"),
1184                "error should mention NOTEDTHAT_API_TOKEN: {msg}"
1185            );
1186        }
1187    }
1188
1189    mod storage_backend {
1190        use super::*;
1191
1192        #[test]
1193        fn the_default_is_s3_so_existing_deployments_are_unaffected() {
1194            run_with_env(&[], || {
1195                let config = Config::from_env().expect("valid");
1196                assert_eq!(config.storage.kind(), StorageBackendKind::S3);
1197            });
1198        }
1199
1200        #[test]
1201        fn selecting_fs_reads_the_fs_variables_and_stops_requiring_s3() {
1202            run_with_env(
1203                &[
1204                    ("NOTEDTHAT_STORAGE_BACKEND", Some("fs")),
1205                    ("NOTEDTHAT_FS_ROOT", Some("/srv/notedthat")),
1206                    ("NOTEDTHAT_S3_REGION", None),
1207                    ("NOTEDTHAT_S3_ACCESS_KEY_ID", None),
1208                    ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", None),
1209                ],
1210                || {
1211                    let config = Config::from_env().expect("valid");
1212                    assert_eq!(config.storage.kind(), StorageBackendKind::Fs);
1213                },
1214            );
1215        }
1216
1217        /// Unlike `NOTEDTHAT_LOG_FORMAT`, a typo here must not fall back — it would
1218        /// silently point the server at a different store.
1219        #[test]
1220        fn an_unknown_backend_is_refused_rather_than_defaulted() {
1221            run_with_env(&[("NOTEDTHAT_STORAGE_BACKEND", Some("filesystem"))], || {
1222                let error = Config::from_env().unwrap_err().to_string();
1223                assert!(error.contains("expected \"s3\" or \"fs\""), "{error}");
1224                assert!(error.contains("filesystem"), "{error}");
1225            });
1226        }
1227
1228        #[test]
1229        fn an_empty_backend_selector_is_refused() {
1230            run_with_env(&[("NOTEDTHAT_STORAGE_BACKEND", Some(""))], || {
1231                let error = Config::from_env().unwrap_err().to_string();
1232                assert!(error.contains("must not be empty"), "{error}");
1233            });
1234        }
1235
1236        #[test]
1237        fn selecting_fs_without_a_root_names_the_variable() {
1238            run_with_env(
1239                &[
1240                    ("NOTEDTHAT_STORAGE_BACKEND", Some("fs")),
1241                    ("NOTEDTHAT_S3_REGION", None),
1242                    ("NOTEDTHAT_S3_ACCESS_KEY_ID", None),
1243                    ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", None),
1244                ],
1245                || {
1246                    let error = Config::from_env().unwrap_err().to_string();
1247                    assert!(error.contains("NOTEDTHAT_FS_ROOT is required"), "{error}");
1248                },
1249            );
1250        }
1251
1252        #[test]
1253        fn leftover_s3_variables_under_fs_are_reported_together() {
1254            run_with_env(
1255                &[
1256                    ("NOTEDTHAT_STORAGE_BACKEND", Some("fs")),
1257                    ("NOTEDTHAT_FS_ROOT", Some("/srv/notedthat")),
1258                ],
1259                || {
1260                    let error = Config::from_env().unwrap_err().to_string();
1261                    assert!(error.contains("belong to the s3 backend"), "{error}");
1262                    // All of them at once, not one per restart.
1263                    assert!(error.contains("NOTEDTHAT_S3_REGION"), "{error}");
1264                    assert!(error.contains("NOTEDTHAT_S3_ACCESS_KEY_ID"), "{error}");
1265                    assert!(error.contains("NOTEDTHAT_S3_SECRET_ACCESS_KEY"), "{error}");
1266                    assert!(error.contains("NOTEDTHAT_STORAGE_BACKEND=s3"), "{error}");
1267                },
1268            );
1269        }
1270
1271        /// The case this check exists for: the operator sets a root and forgets the
1272        /// selector, and would otherwise get a healthy S3 deployment with an unread root.
1273        #[test]
1274        fn an_fs_root_without_the_selector_is_refused_and_says_why() {
1275            run_with_env(&[("NOTEDTHAT_FS_ROOT", Some("/srv/notedthat"))], || {
1276                let error = Config::from_env().unwrap_err().to_string();
1277                assert!(
1278                    error.contains("NOTEDTHAT_STORAGE_BACKEND is unset"),
1279                    "{error}"
1280                );
1281                assert!(error.contains("NOTEDTHAT_FS_ROOT"), "{error}");
1282                assert!(error.contains("NOTEDTHAT_STORAGE_BACKEND=fs"), "{error}");
1283            });
1284        }
1285
1286        /// An empty value is still a value — matching how removed variables are checked.
1287        #[test]
1288        fn an_empty_cross_backend_variable_still_counts() {
1289            run_with_env(
1290                &[
1291                    ("NOTEDTHAT_STORAGE_BACKEND", Some("fs")),
1292                    ("NOTEDTHAT_FS_ROOT", Some("/srv/notedthat")),
1293                    ("NOTEDTHAT_S3_REGION", Some("")),
1294                    ("NOTEDTHAT_S3_ACCESS_KEY_ID", None),
1295                    ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", None),
1296                ],
1297                || {
1298                    let error = Config::from_env().unwrap_err().to_string();
1299                    assert!(error.contains("NOTEDTHAT_S3_REGION"), "{error}");
1300                },
1301            );
1302        }
1303
1304        /// The rejection table is built from each adapter's own inventory, so it cannot
1305        /// drift from what those adapters actually read.
1306        #[test]
1307        fn the_rejection_table_matches_each_adapter_inventory() {
1308            let table = backend_owned_env_vars();
1309            let s3: Vec<&str> = table
1310                .iter()
1311                .filter(|(_, kind)| *kind == StorageBackendKind::S3)
1312                .map(|(name, _)| *name)
1313                .collect();
1314            let fs: Vec<&str> = table
1315                .iter()
1316                .filter(|(_, kind)| *kind == StorageBackendKind::Fs)
1317                .map(|(name, _)| *name)
1318                .collect();
1319            assert_eq!(s3, notedthat_storage_s3::S3_ENV_VARS.to_vec());
1320            assert_eq!(fs, notedthat_storage_fs::FS_ENV_VARS.to_vec());
1321
1322            for (name, _) in &table {
1323                assert!(
1324                    ALL_ENV_KEYS.contains(name),
1325                    "{name} is read but missing from ALL_ENV_KEYS"
1326                );
1327            }
1328        }
1329    }
1330}