1use notedthat_core::{Error, KbSlug, StagingConfig, TenantSlug};
7use notedthat_write::MAX_UPLOAD_BYTES;
8use std::collections::BTreeMap;
9use std::net::SocketAddr;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16pub enum StorageBackendKind {
17 S3,
19 Fs,
21}
22
23impl StorageBackendKind {
24 #[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#[derive(Debug, Clone)]
45pub enum StorageConfig {
46 S3(notedthat_storage_s3::S3Config),
48 Fs(notedthat_storage_fs::FsConfig),
50}
51
52impl StorageConfig {
53 #[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
63fn 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
86fn 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
118fn 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#[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#[derive(Debug, Clone)]
179pub struct Config {
180 pub api_token: String,
182 pub kbs: BTreeMap<String, KbSlug>,
184 pub tenant_slug: TenantSlug,
186 pub listen_addr: SocketAddr,
188 pub storage: StorageConfig,
191 pub log_format: LogFormat,
193 pub qdrant: ServerQdrantConfig,
195 pub embedder: EmbedderConfig,
197 pub webdav_username: String,
199 pub webdav_password: String,
201 pub mcp_http_allowed_origins: Vec<String>,
203 pub mcp_http_allowed_hosts: Vec<String>,
205 pub max_patchable_size: u64,
207 pub staging: StagingConfig,
209}
210
211const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
235pub enum LogFormat {
236 Pretty,
238 Json,
240}
241
242impl Config {
243 #[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 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#[derive(Debug, Clone)]
419pub struct ServerQdrantConfig {
420 pub url: String,
422 pub api_key: Option<String>,
424 pub timeout_ms: u64,
429 pub connect_timeout_ms: u64,
432}
433
434impl ServerQdrantConfig {
435 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
456fn 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#[derive(Debug, Clone)]
474pub struct EmbedderConfig {
475 pub endpoint_url: String,
477 pub model: String,
479 pub api_key: String,
481 pub dimensions: u32,
483 pub batch_size: usize,
485 pub timeout_ms: u64,
487 pub max_retries: u32,
489 pub max_input_tokens: usize,
491}
492
493impl EmbedderConfig {
494 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 #[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 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 #[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 #[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 #[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}