1use clap::Parser;
16use std::ffi::OsString;
17
18const BOOL_FLAG: &str = "true";
21
22#[allow(clippy::doc_markdown)]
35#[derive(Parser, Debug, Default, Clone)]
36#[command(
37 name = "notedthat-server",
38 version,
39 about = "NotedThat server — HTTP API, WebDAV and remote MCP in one process",
40 long_about = "NotedThat server — HTTP API, WebDAV and remote MCP in one process.\n\n\
41 Every setting can be given as the flag shown below or as the environment \
42 variable beside it; the flag wins when both are set.\n\n\
43 Arguments are visible to any user on the host via `ps`, and are recorded in \
44 shell history and in `docker inspect`. Prefer the environment variable for \
45 --api-token, --webdav-password, --s3-secret-access-key, --qdrant-api-key and \
46 --embedding-api-key on a shared machine."
47)]
48pub struct ServerCli {
49 #[arg(
51 long,
52 env = "NOTEDTHAT_API_TOKEN",
53 value_name = "TOKEN",
54 hide_env_values = true
55 )]
56 pub api_token: Option<String>,
57
58 #[arg(long, env = "NOTEDTHAT_KBS", value_name = "SLUGS")]
60 pub kbs: Option<String>,
61
62 #[arg(long, env = "NOTEDTHAT_LISTEN_ADDR", value_name = "HOST:PORT")]
64 pub listen_addr: Option<String>,
65
66 #[arg(
68 long,
69 env = "NOTEDTHAT_METRICS_ENABLED",
70 value_name = "BOOL",
71 num_args = 0..=1,
72 default_missing_value = BOOL_FLAG,
73 )]
74 pub metrics_enabled: Option<String>,
75
76 #[arg(long, env = "NOTEDTHAT_METRICS_LISTEN_ADDR", value_name = "HOST:PORT")]
79 pub metrics_listen_addr: Option<String>,
80
81 #[arg(long, env = "NOTEDTHAT_LOG_FORMAT", value_name = "FORMAT")]
83 pub log_format: Option<String>,
84
85 #[arg(long, env = "NOTEDTHAT_MAX_PATCHABLE_SIZE", value_name = "BYTES")]
87 pub max_patchable_size: Option<String>,
88
89 #[arg(long, env = "NOTEDTHAT_READY_PROBE_INTERVAL_MS", value_name = "MS")]
92 pub ready_probe_interval_ms: Option<String>,
93
94 #[arg(long, env = "NOTEDTHAT_UPLOAD_TMP_DIR", value_name = "DIR")]
97 pub upload_tmp_dir: Option<OsString>,
98
99 #[arg(long, env = "NOTEDTHAT_WEBDAV_USERNAME", value_name = "USER")]
101 pub webdav_username: Option<String>,
102
103 #[arg(
105 long,
106 env = "NOTEDTHAT_WEBDAV_PASSWORD",
107 value_name = "PASSWORD",
108 hide_env_values = true
109 )]
110 pub webdav_password: Option<String>,
111
112 #[arg(
114 long,
115 env = "NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS",
116 value_name = "ORIGINS"
117 )]
118 pub mcp_http_allowed_origins: Option<String>,
119
120 #[arg(long, env = "NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", value_name = "HOSTS")]
122 pub mcp_http_allowed_hosts: Option<String>,
123
124 #[arg(long, env = "NOTEDTHAT_MCP_ANONYMOUS", value_name = "MODE")]
127 pub mcp_anonymous: Option<String>,
128
129 #[arg(long, env = "NOTEDTHAT_MCP_MAX_READ_BYTES", value_name = "BYTES")]
132 pub mcp_max_read_bytes: Option<String>,
133
134 #[arg(long, env = "NOTEDTHAT_MCP_MAX_SESSIONS", value_name = "COUNT")]
137 pub mcp_max_sessions: Option<String>,
138
139 #[arg(long, env = "NOTEDTHAT_OIDC_ISSUER", value_name = "URL")]
142 pub oidc_issuer: Option<String>,
143
144 #[arg(long, env = "NOTEDTHAT_OIDC_AUDIENCE", value_name = "AUDIENCES")]
146 pub oidc_audience: Option<String>,
147
148 #[arg(long, env = "NOTEDTHAT_OIDC_USERNAME_CLAIM", value_name = "CLAIM")]
150 pub oidc_username_claim: Option<String>,
151
152 #[arg(long, env = "NOTEDTHAT_OIDC_GROUPS_CLAIM", value_name = "CLAIM")]
154 pub oidc_groups_claim: Option<String>,
155
156 #[arg(long, env = "NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS", value_name = "MS")]
158 pub oidc_http_timeout_ms: Option<String>,
159
160 #[arg(long, env = "NOTEDTHAT_OIDC_RESOURCE", value_name = "URL")]
162 pub oidc_resource: Option<String>,
163
164 #[arg(long, env = "NOTEDTHAT_OIDC_CA_CERT", value_name = "FILE")]
167 pub oidc_ca_cert: Option<OsString>,
168
169 #[arg(long, env = "NOTEDTHAT_STORAGE_BACKEND", value_name = "BACKEND")]
171 pub storage_backend: Option<OsString>,
172
173 #[arg(long, env = "NOTEDTHAT_S3_REGION", value_name = "REGION")]
175 pub s3_region: Option<String>,
176
177 #[arg(
179 long,
180 env = "NOTEDTHAT_S3_ACCESS_KEY_ID",
181 value_name = "KEY_ID",
182 hide_env_values = true
183 )]
184 pub s3_access_key_id: Option<String>,
185
186 #[arg(
188 long,
189 env = "NOTEDTHAT_S3_SECRET_ACCESS_KEY",
190 value_name = "SECRET",
191 hide_env_values = true
192 )]
193 pub s3_secret_access_key: Option<String>,
194
195 #[arg(long, env = "NOTEDTHAT_S3_ENDPOINT_URL", value_name = "URL")]
197 pub s3_endpoint_url: Option<String>,
198
199 #[arg(
201 long,
202 env = "NOTEDTHAT_S3_FORCE_PATH_STYLE",
203 value_name = "BOOL",
204 num_args = 0..=1,
205 default_missing_value = BOOL_FLAG,
206 )]
207 pub s3_force_path_style: Option<String>,
208
209 #[arg(
212 long,
213 env = "NOTEDTHAT_S3_RECONCILE",
214 value_name = "BOOL",
215 num_args = 0..=1,
216 default_missing_value = BOOL_FLAG,
217 )]
218 pub s3_reconcile: Option<String>,
219
220 #[arg(long, env = "NOTEDTHAT_FS_ROOT", value_name = "DIR")]
222 pub fs_root: Option<OsString>,
223
224 #[arg(long, env = "NOTEDTHAT_FS_METADATA", value_name = "MODE")]
226 pub fs_metadata: Option<OsString>,
227
228 #[arg(long, env = "NOTEDTHAT_FS_FILE_MODE", value_name = "MODE")]
230 pub fs_file_mode: Option<OsString>,
231
232 #[arg(long, env = "NOTEDTHAT_FS_DIR_MODE", value_name = "MODE")]
234 pub fs_dir_mode: Option<OsString>,
235
236 #[arg(
239 long,
240 env = "NOTEDTHAT_FS_ALLOW_LOSSY_NAMES",
241 value_name = "BOOL",
242 num_args = 0..=1,
243 default_missing_value = BOOL_FLAG,
244 )]
245 pub fs_allow_lossy_names: Option<OsString>,
246
247 #[arg(
250 long,
251 env = "NOTEDTHAT_FS_WATCH",
252 value_name = "BOOL",
253 num_args = 0..=1,
254 default_missing_value = BOOL_FLAG,
255 )]
256 pub fs_watch: Option<OsString>,
257
258 #[arg(long, env = "NOTEDTHAT_FS_WATCH_DEBOUNCE_MS", value_name = "MS")]
261 pub fs_watch_debounce_ms: Option<OsString>,
262
263 #[arg(long, env = "NOTEDTHAT_EVENTS_BACKEND", value_name = "BACKEND")]
265 pub events_backend: Option<OsString>,
266
267 #[arg(long, env = "NOTEDTHAT_EVENTS_MEMORY_CAPACITY", value_name = "COUNT")]
269 pub events_memory_capacity: Option<OsString>,
270
271 #[arg(
274 long,
275 env = "NOTEDTHAT_NATS_URL",
276 value_name = "URL",
277 hide_env_values = true
278 )]
279 pub nats_url: Option<String>,
280
281 #[arg(long, env = "NOTEDTHAT_NATS_STREAM", value_name = "NAME")]
283 pub nats_stream: Option<String>,
284
285 #[arg(long, env = "NOTEDTHAT_NATS_MAX_AGE_SECS", value_name = "SECS")]
287 pub nats_max_age_secs: Option<OsString>,
288
289 #[arg(long, env = "NOTEDTHAT_QDRANT_URL", value_name = "URL")]
291 pub qdrant_url: Option<String>,
292
293 #[arg(
295 long,
296 env = "NOTEDTHAT_QDRANT_API_KEY",
297 value_name = "KEY",
298 hide_env_values = true
299 )]
300 pub qdrant_api_key: Option<String>,
301
302 #[arg(long, env = "NOTEDTHAT_QDRANT_TIMEOUT_MS", value_name = "MS")]
304 pub qdrant_timeout_ms: Option<String>,
305
306 #[arg(long, env = "NOTEDTHAT_QDRANT_CONNECT_TIMEOUT_MS", value_name = "MS")]
308 pub qdrant_connect_timeout_ms: Option<String>,
309
310 #[arg(long, env = "EMBEDDING_ENDPOINT_URL", value_name = "URL")]
312 pub embedding_endpoint_url: Option<String>,
313
314 #[arg(long, env = "EMBEDDING_MODEL", value_name = "MODEL")]
316 pub embedding_model: Option<String>,
317
318 #[arg(
320 long,
321 env = "EMBEDDING_API_KEY",
322 value_name = "KEY",
323 hide_env_values = true
324 )]
325 pub embedding_api_key: Option<String>,
326
327 #[arg(long, env = "EMBEDDING_DIMENSIONS", value_name = "N")]
330 pub embedding_dimensions: Option<String>,
331
332 #[arg(long, env = "EMBEDDING_BATCH_SIZE", value_name = "N")]
334 pub embedding_batch_size: Option<String>,
335
336 #[arg(long, env = "EMBEDDING_TIMEOUT_MS", value_name = "MS")]
338 pub embedding_timeout_ms: Option<String>,
339
340 #[arg(long, env = "EMBEDDING_MAX_RETRIES", value_name = "N")]
342 pub embedding_max_retries: Option<String>,
343
344 #[arg(long, env = "EMBEDDING_MAX_INPUT_TOKENS", value_name = "N")]
346 pub embedding_max_input_tokens: Option<String>,
347
348 #[arg(long, env = "NOTEDTHAT_WEBDAV_LISTEN_ADDR", hide = true)]
357 pub webdav_listen_addr: Option<OsString>,
358
359 #[arg(long, env = "NOTEDTHAT_MCP_HTTP_BIND", hide = true)]
361 pub mcp_http_bind: Option<OsString>,
362
363 #[arg(long, env = "NOTEDTHAT_MCP_HTTP_ENABLED", hide = true)]
365 pub mcp_http_enabled: Option<OsString>,
366}
367
368impl ServerCli {
369 pub fn from_env() -> Result<Self, clap::Error> {
376 Self::try_parse_from(["notedthat-server"])
377 }
378}
379
380#[cfg(test)]
381mod tests {
382 use super::ServerCli;
383 use clap::{CommandFactory as _, Parser as _};
384 use std::collections::BTreeSet;
385 use std::ffi::OsString;
386
387 fn parse(vars: &[(&str, Option<&str>)], args: &[&str]) -> ServerCli {
390 let command_line: Vec<&str> = std::iter::once("notedthat-server")
391 .chain(args.iter().copied())
392 .collect();
393 temp_env::with_vars(vars, || {
394 ServerCli::try_parse_from(&command_line).expect("arguments must parse")
395 })
396 }
397
398 #[test]
399 fn a_flag_wins_over_the_variable_it_mirrors() {
400 let cli = parse(
401 &[("NOTEDTHAT_LISTEN_ADDR", Some("0.0.0.0:9999"))],
402 &["--listen-addr", "127.0.0.1:8081"],
403 );
404 assert_eq!(cli.listen_addr.as_deref(), Some("127.0.0.1:8081"));
405 }
406
407 #[test]
408 fn the_variable_is_used_when_no_flag_is_given() {
409 let cli = parse(&[("NOTEDTHAT_LISTEN_ADDR", Some("0.0.0.0:9999"))], &[]);
410 assert_eq!(cli.listen_addr.as_deref(), Some("0.0.0.0:9999"));
411 }
412
413 #[test]
414 fn a_flag_alone_is_enough_with_the_environment_empty() {
415 let cli = parse(
416 &[("NOTEDTHAT_API_TOKEN", None)],
417 &["--api-token", "flag-only"],
418 );
419 assert_eq!(cli.api_token.as_deref(), Some("flag-only"));
420 }
421
422 #[test]
423 fn a_setting_neither_source_supplied_stays_absent() {
424 let cli = parse(&[("NOTEDTHAT_LISTEN_ADDR", None)], &[]);
425 assert!(cli.listen_addr.is_none());
426 }
427
428 #[test]
431 fn an_empty_flag_value_is_still_a_supplied_value() {
432 let cli = parse(&[("NOTEDTHAT_S3_REGION", None)], &["--s3-region", ""]);
433 assert_eq!(cli.s3_region.as_deref(), Some(""));
434 }
435
436 #[test]
439 fn a_path_setting_keeps_its_value_unmangled() {
440 let cli = parse(
441 &[("NOTEDTHAT_FS_ROOT", None)],
442 &["--fs-root", "/srv/notedthat"],
443 );
444 assert_eq!(cli.fs_root, Some(OsString::from("/srv/notedthat")));
445 }
446
447 #[test]
448 fn a_comma_separated_setting_arrives_whole_for_the_validator_to_split() {
449 let cli = parse(&[("NOTEDTHAT_KBS", None)], &["--kbs", "notes,scratch"]);
450 assert_eq!(cli.kbs.as_deref(), Some("notes,scratch"));
451 }
452
453 #[test]
454 fn a_bare_boolean_flag_means_true() {
455 let cli = parse(
456 &[("NOTEDTHAT_S3_FORCE_PATH_STYLE", None)],
457 &["--s3-force-path-style"],
458 );
459 assert_eq!(cli.s3_force_path_style.as_deref(), Some("true"));
460 }
461
462 #[test]
465 fn a_boolean_flag_can_still_be_given_false_explicitly() {
466 let cli = parse(
467 &[("NOTEDTHAT_S3_FORCE_PATH_STYLE", Some("true"))],
468 &["--s3-force-path-style=false"],
469 );
470 assert_eq!(cli.s3_force_path_style.as_deref(), Some("false"));
471 }
472
473 #[test]
477 fn a_removed_setting_is_accepted_by_the_parser_so_startup_can_explain_it() {
478 let cli = parse(
479 &[("NOTEDTHAT_MCP_HTTP_ENABLED", None)],
480 &["--mcp-http-enabled", "false"],
481 );
482 assert_eq!(cli.mcp_http_enabled, Some(OsString::from("false")));
483 }
484
485 #[test]
486 fn an_unknown_flag_is_refused() {
487 assert!(ServerCli::try_parse_from(["notedthat-server", "--nope"]).is_err());
488 }
489
490 #[test]
497 fn every_setting_has_both_a_flag_and_a_variable() {
498 let command = ServerCli::command();
499 let wired: BTreeSet<String> = command
500 .get_arguments()
501 .filter_map(|arg| Some(arg.get_env()?.to_string_lossy().into_owned()))
502 .collect();
503
504 let expected: BTreeSet<String> = crate::config::tests::ALL_ENV_KEYS
505 .iter()
506 .map(|name| (*name).to_string())
507 .collect();
508
509 assert_eq!(wired, expected);
510 }
511
512 #[test]
517 fn each_flag_is_spelled_the_way_diagnostics_will_name_it() {
518 for arg in ServerCli::command().get_arguments() {
519 let Some(env_var) = arg.get_env() else {
520 continue;
521 };
522 let env_var = env_var.to_string_lossy();
523 let expected = notedthat_core::flag_for(&env_var);
524 let actual = format!(
525 "--{}",
526 arg.get_long().expect("every setting has a long flag")
527 );
528 assert_eq!(actual, expected, "{env_var} is wired to the wrong flag");
529 }
530 }
531
532 #[test]
534 fn help_never_echoes_a_credential_it_can_see_in_the_environment() {
535 let vars = [
536 ("NOTEDTHAT_API_TOKEN", Some("token-leak-canary")),
537 ("NOTEDTHAT_WEBDAV_PASSWORD", Some("password-leak-canary")),
538 ("NOTEDTHAT_S3_ACCESS_KEY_ID", Some("key-id-leak-canary")),
539 ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", Some("secret-leak-canary")),
540 ("NOTEDTHAT_QDRANT_API_KEY", Some("qdrant-leak-canary")),
541 (
542 "NOTEDTHAT_NATS_URL",
543 Some("nats://u:nats-leak-canary@broker:4222"),
544 ),
545 ("EMBEDDING_API_KEY", Some("embedding-leak-canary")),
546 ("NOTEDTHAT_LISTEN_ADDR", Some("127.0.0.1:9999")),
548 ];
549 let help =
550 temp_env::with_vars(vars, || ServerCli::command().render_long_help().to_string());
551
552 assert!(!help.contains("leak-canary"), "{help}");
553 assert!(help.contains("NOTEDTHAT_API_TOKEN"), "{help}");
554 assert!(help.contains("127.0.0.1:9999"), "{help}");
555 }
556
557 #[test]
558 fn the_help_text_warns_that_a_secret_on_the_command_line_is_visible() {
559 let help = ServerCli::command().render_long_help().to_string();
560 assert!(help.contains("ps"), "{help}");
561 assert!(help.contains("--api-token"), "{help}");
562 }
563}