1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
25pub enum StorageBackendKind {
26 S3,
28 Fs,
30}
31
32impl StorageBackendKind {
33 #[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#[derive(Debug, Clone)]
54pub enum StorageConfig {
55 S3(notedthat_storage_s3::S3Config),
57 Fs(notedthat_storage_fs::FsConfig),
59}
60
61impl StorageConfig {
62 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
74pub enum EventsBackendKind {
75 None,
77 Memory,
79 Nats,
81}
82
83impl EventsBackendKind {
84 #[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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
109pub enum McpAnonymous {
110 #[default]
113 Auto,
114 Never,
118}
119
120impl McpAnonymous {
121 #[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#[derive(Debug, Clone)]
139pub enum EventsConfig {
140 None,
142 Memory(MemoryConfig),
144 Nats(NatsConfig),
146}
147
148impl EventsConfig {
149 #[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
160fn 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
222fn 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
242fn 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
275fn 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
304fn 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
328const DEFAULT_METRICS_LISTEN_ADDR: &str = "127.0.0.1:9090";
330
331fn 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 None | Some("false") => false,
358 Some("true") => true,
359 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 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
417fn 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#[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#[derive(Debug, Clone)]
497pub struct Config {
498 pub api_token: String,
500 pub kbs: BTreeMap<String, KbSlug>,
502 pub tenant_slug: TenantSlug,
504 pub listen_addr: SocketAddr,
506 pub storage: StorageConfig,
509 pub events: EventsConfig,
512 pub log_format: LogFormat,
514 pub qdrant: ServerQdrantConfig,
516 pub embedder: EmbedderConfig,
518 pub webdav_username: String,
520 pub webdav_password: String,
522 pub mcp_http_allowed_origins: Vec<String>,
524 pub mcp_http_allowed_hosts: Vec<String>,
526 pub mcp_anonymous: McpAnonymous,
528 pub max_patchable_size: u64,
530 pub mcp_max_read_bytes: u64,
533 pub mcp_max_sessions: usize,
536 pub ready_probe_interval_ms: u64,
540 pub metrics_listen_addr: Option<SocketAddr>,
549 pub staging: StagingConfig,
551 pub oidc: Option<OidcSettings>,
553}
554
555const 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
583pub enum LogFormat {
584 Pretty,
586 Json,
588}
589
590impl Config {
591 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 #[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 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 let tenant_slug = TenantSlug::default();
681
682 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 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 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
910fn 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
931fn 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
1037fn 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
1054struct 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
1063fn 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#[derive(Debug, Clone)]
1081pub struct ServerQdrantConfig {
1082 pub url: String,
1084 pub api_key: Option<String>,
1086 pub timeout_ms: u64,
1091 pub connect_timeout_ms: u64,
1094}
1095
1096impl ServerQdrantConfig {
1097 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
1125fn 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
1141struct 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#[derive(Debug, Clone)]
1158pub struct EmbedderConfig {
1159 pub endpoint_url: String,
1161 pub model: String,
1163 pub api_key: String,
1165 pub dimensions: u32,
1167 pub batch_size: usize,
1169 pub timeout_ms: u64,
1171 pub max_retries: u32,
1173 pub max_input_tokens: usize,
1175}
1176
1177impl EmbedderConfig {
1178 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
1217fn 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 fn names_setting(message: &str, env_var: &str, complaint: &str) -> bool {
1300 message.contains(env_var)
1301 && message.contains(¬edthat_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 #[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 #[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 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 #[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 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 #[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 #[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}