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(long, env = "NOTEDTHAT_LOG_FORMAT", value_name = "FORMAT")]
68 pub log_format: Option<String>,
69
70 #[arg(long, env = "NOTEDTHAT_MAX_PATCHABLE_SIZE", value_name = "BYTES")]
72 pub max_patchable_size: Option<String>,
73
74 #[arg(long, env = "NOTEDTHAT_READY_PROBE_INTERVAL_MS", value_name = "MS")]
77 pub ready_probe_interval_ms: Option<String>,
78
79 #[arg(long, env = "NOTEDTHAT_UPLOAD_TMP_DIR", value_name = "DIR")]
82 pub upload_tmp_dir: Option<OsString>,
83
84 #[arg(long, env = "NOTEDTHAT_WEBDAV_USERNAME", value_name = "USER")]
86 pub webdav_username: Option<String>,
87
88 #[arg(
90 long,
91 env = "NOTEDTHAT_WEBDAV_PASSWORD",
92 value_name = "PASSWORD",
93 hide_env_values = true
94 )]
95 pub webdav_password: Option<String>,
96
97 #[arg(
99 long,
100 env = "NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS",
101 value_name = "ORIGINS"
102 )]
103 pub mcp_http_allowed_origins: Option<String>,
104
105 #[arg(long, env = "NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", value_name = "HOSTS")]
107 pub mcp_http_allowed_hosts: Option<String>,
108
109 #[arg(long, env = "NOTEDTHAT_MCP_ANONYMOUS", value_name = "MODE")]
112 pub mcp_anonymous: Option<String>,
113
114 #[arg(long, env = "NOTEDTHAT_MCP_MAX_READ_BYTES", value_name = "BYTES")]
117 pub mcp_max_read_bytes: Option<String>,
118
119 #[arg(long, env = "NOTEDTHAT_OIDC_ISSUER", value_name = "URL")]
122 pub oidc_issuer: Option<String>,
123
124 #[arg(long, env = "NOTEDTHAT_OIDC_AUDIENCE", value_name = "AUDIENCES")]
126 pub oidc_audience: Option<String>,
127
128 #[arg(long, env = "NOTEDTHAT_OIDC_USERNAME_CLAIM", value_name = "CLAIM")]
130 pub oidc_username_claim: Option<String>,
131
132 #[arg(long, env = "NOTEDTHAT_OIDC_GROUPS_CLAIM", value_name = "CLAIM")]
134 pub oidc_groups_claim: Option<String>,
135
136 #[arg(long, env = "NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS", value_name = "MS")]
138 pub oidc_http_timeout_ms: Option<String>,
139
140 #[arg(long, env = "NOTEDTHAT_OIDC_RESOURCE", value_name = "URL")]
142 pub oidc_resource: Option<String>,
143
144 #[arg(long, env = "NOTEDTHAT_OIDC_CA_CERT", value_name = "FILE")]
147 pub oidc_ca_cert: Option<OsString>,
148
149 #[arg(long, env = "NOTEDTHAT_STORAGE_BACKEND", value_name = "BACKEND")]
151 pub storage_backend: Option<OsString>,
152
153 #[arg(long, env = "NOTEDTHAT_S3_REGION", value_name = "REGION")]
155 pub s3_region: Option<String>,
156
157 #[arg(
159 long,
160 env = "NOTEDTHAT_S3_ACCESS_KEY_ID",
161 value_name = "KEY_ID",
162 hide_env_values = true
163 )]
164 pub s3_access_key_id: Option<String>,
165
166 #[arg(
168 long,
169 env = "NOTEDTHAT_S3_SECRET_ACCESS_KEY",
170 value_name = "SECRET",
171 hide_env_values = true
172 )]
173 pub s3_secret_access_key: Option<String>,
174
175 #[arg(long, env = "NOTEDTHAT_S3_ENDPOINT_URL", value_name = "URL")]
177 pub s3_endpoint_url: Option<String>,
178
179 #[arg(
181 long,
182 env = "NOTEDTHAT_S3_FORCE_PATH_STYLE",
183 value_name = "BOOL",
184 num_args = 0..=1,
185 default_missing_value = BOOL_FLAG,
186 )]
187 pub s3_force_path_style: Option<String>,
188
189 #[arg(
192 long,
193 env = "NOTEDTHAT_S3_RECONCILE",
194 value_name = "BOOL",
195 num_args = 0..=1,
196 default_missing_value = BOOL_FLAG,
197 )]
198 pub s3_reconcile: Option<String>,
199
200 #[arg(long, env = "NOTEDTHAT_FS_ROOT", value_name = "DIR")]
202 pub fs_root: Option<OsString>,
203
204 #[arg(long, env = "NOTEDTHAT_FS_METADATA", value_name = "MODE")]
206 pub fs_metadata: Option<OsString>,
207
208 #[arg(long, env = "NOTEDTHAT_FS_FILE_MODE", value_name = "MODE")]
210 pub fs_file_mode: Option<OsString>,
211
212 #[arg(long, env = "NOTEDTHAT_FS_DIR_MODE", value_name = "MODE")]
214 pub fs_dir_mode: Option<OsString>,
215
216 #[arg(
219 long,
220 env = "NOTEDTHAT_FS_ALLOW_LOSSY_NAMES",
221 value_name = "BOOL",
222 num_args = 0..=1,
223 default_missing_value = BOOL_FLAG,
224 )]
225 pub fs_allow_lossy_names: Option<OsString>,
226
227 #[arg(
230 long,
231 env = "NOTEDTHAT_FS_WATCH",
232 value_name = "BOOL",
233 num_args = 0..=1,
234 default_missing_value = BOOL_FLAG,
235 )]
236 pub fs_watch: Option<OsString>,
237
238 #[arg(long, env = "NOTEDTHAT_FS_WATCH_DEBOUNCE_MS", value_name = "MS")]
241 pub fs_watch_debounce_ms: Option<OsString>,
242
243 #[arg(long, env = "NOTEDTHAT_EVENTS_BACKEND", value_name = "BACKEND")]
245 pub events_backend: Option<OsString>,
246
247 #[arg(long, env = "NOTEDTHAT_EVENTS_MEMORY_CAPACITY", value_name = "COUNT")]
249 pub events_memory_capacity: Option<OsString>,
250
251 #[arg(
254 long,
255 env = "NOTEDTHAT_NATS_URL",
256 value_name = "URL",
257 hide_env_values = true
258 )]
259 pub nats_url: Option<String>,
260
261 #[arg(long, env = "NOTEDTHAT_NATS_STREAM", value_name = "NAME")]
263 pub nats_stream: Option<String>,
264
265 #[arg(long, env = "NOTEDTHAT_NATS_MAX_AGE_SECS", value_name = "SECS")]
267 pub nats_max_age_secs: Option<OsString>,
268
269 #[arg(long, env = "NOTEDTHAT_QDRANT_URL", value_name = "URL")]
271 pub qdrant_url: Option<String>,
272
273 #[arg(
275 long,
276 env = "NOTEDTHAT_QDRANT_API_KEY",
277 value_name = "KEY",
278 hide_env_values = true
279 )]
280 pub qdrant_api_key: Option<String>,
281
282 #[arg(long, env = "NOTEDTHAT_QDRANT_TIMEOUT_MS", value_name = "MS")]
284 pub qdrant_timeout_ms: Option<String>,
285
286 #[arg(long, env = "NOTEDTHAT_QDRANT_CONNECT_TIMEOUT_MS", value_name = "MS")]
288 pub qdrant_connect_timeout_ms: Option<String>,
289
290 #[arg(long, env = "EMBEDDING_ENDPOINT_URL", value_name = "URL")]
292 pub embedding_endpoint_url: Option<String>,
293
294 #[arg(long, env = "EMBEDDING_MODEL", value_name = "MODEL")]
296 pub embedding_model: Option<String>,
297
298 #[arg(
300 long,
301 env = "EMBEDDING_API_KEY",
302 value_name = "KEY",
303 hide_env_values = true
304 )]
305 pub embedding_api_key: Option<String>,
306
307 #[arg(long, env = "EMBEDDING_DIMENSIONS", value_name = "N")]
310 pub embedding_dimensions: Option<String>,
311
312 #[arg(long, env = "EMBEDDING_BATCH_SIZE", value_name = "N")]
314 pub embedding_batch_size: Option<String>,
315
316 #[arg(long, env = "EMBEDDING_TIMEOUT_MS", value_name = "MS")]
318 pub embedding_timeout_ms: Option<String>,
319
320 #[arg(long, env = "EMBEDDING_MAX_RETRIES", value_name = "N")]
322 pub embedding_max_retries: Option<String>,
323
324 #[arg(long, env = "EMBEDDING_MAX_INPUT_TOKENS", value_name = "N")]
326 pub embedding_max_input_tokens: Option<String>,
327
328 #[arg(long, env = "NOTEDTHAT_WEBDAV_LISTEN_ADDR", hide = true)]
337 pub webdav_listen_addr: Option<OsString>,
338
339 #[arg(long, env = "NOTEDTHAT_MCP_HTTP_BIND", hide = true)]
341 pub mcp_http_bind: Option<OsString>,
342
343 #[arg(long, env = "NOTEDTHAT_MCP_HTTP_ENABLED", hide = true)]
345 pub mcp_http_enabled: Option<OsString>,
346}
347
348impl ServerCli {
349 pub fn from_env() -> Result<Self, clap::Error> {
356 Self::try_parse_from(["notedthat-server"])
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::ServerCli;
363 use clap::{CommandFactory as _, Parser as _};
364 use std::collections::BTreeSet;
365 use std::ffi::OsString;
366
367 fn parse(vars: &[(&str, Option<&str>)], args: &[&str]) -> ServerCli {
370 let command_line: Vec<&str> = std::iter::once("notedthat-server")
371 .chain(args.iter().copied())
372 .collect();
373 temp_env::with_vars(vars, || {
374 ServerCli::try_parse_from(&command_line).expect("arguments must parse")
375 })
376 }
377
378 #[test]
379 fn a_flag_wins_over_the_variable_it_mirrors() {
380 let cli = parse(
381 &[("NOTEDTHAT_LISTEN_ADDR", Some("0.0.0.0:9999"))],
382 &["--listen-addr", "127.0.0.1:8081"],
383 );
384 assert_eq!(cli.listen_addr.as_deref(), Some("127.0.0.1:8081"));
385 }
386
387 #[test]
388 fn the_variable_is_used_when_no_flag_is_given() {
389 let cli = parse(&[("NOTEDTHAT_LISTEN_ADDR", Some("0.0.0.0:9999"))], &[]);
390 assert_eq!(cli.listen_addr.as_deref(), Some("0.0.0.0:9999"));
391 }
392
393 #[test]
394 fn a_flag_alone_is_enough_with_the_environment_empty() {
395 let cli = parse(
396 &[("NOTEDTHAT_API_TOKEN", None)],
397 &["--api-token", "flag-only"],
398 );
399 assert_eq!(cli.api_token.as_deref(), Some("flag-only"));
400 }
401
402 #[test]
403 fn a_setting_neither_source_supplied_stays_absent() {
404 let cli = parse(&[("NOTEDTHAT_LISTEN_ADDR", None)], &[]);
405 assert!(cli.listen_addr.is_none());
406 }
407
408 #[test]
411 fn an_empty_flag_value_is_still_a_supplied_value() {
412 let cli = parse(&[("NOTEDTHAT_S3_REGION", None)], &["--s3-region", ""]);
413 assert_eq!(cli.s3_region.as_deref(), Some(""));
414 }
415
416 #[test]
419 fn a_path_setting_keeps_its_value_unmangled() {
420 let cli = parse(
421 &[("NOTEDTHAT_FS_ROOT", None)],
422 &["--fs-root", "/srv/notedthat"],
423 );
424 assert_eq!(cli.fs_root, Some(OsString::from("/srv/notedthat")));
425 }
426
427 #[test]
428 fn a_comma_separated_setting_arrives_whole_for_the_validator_to_split() {
429 let cli = parse(&[("NOTEDTHAT_KBS", None)], &["--kbs", "notes,scratch"]);
430 assert_eq!(cli.kbs.as_deref(), Some("notes,scratch"));
431 }
432
433 #[test]
434 fn a_bare_boolean_flag_means_true() {
435 let cli = parse(
436 &[("NOTEDTHAT_S3_FORCE_PATH_STYLE", None)],
437 &["--s3-force-path-style"],
438 );
439 assert_eq!(cli.s3_force_path_style.as_deref(), Some("true"));
440 }
441
442 #[test]
445 fn a_boolean_flag_can_still_be_given_false_explicitly() {
446 let cli = parse(
447 &[("NOTEDTHAT_S3_FORCE_PATH_STYLE", Some("true"))],
448 &["--s3-force-path-style=false"],
449 );
450 assert_eq!(cli.s3_force_path_style.as_deref(), Some("false"));
451 }
452
453 #[test]
457 fn a_removed_setting_is_accepted_by_the_parser_so_startup_can_explain_it() {
458 let cli = parse(
459 &[("NOTEDTHAT_MCP_HTTP_ENABLED", None)],
460 &["--mcp-http-enabled", "false"],
461 );
462 assert_eq!(cli.mcp_http_enabled, Some(OsString::from("false")));
463 }
464
465 #[test]
466 fn an_unknown_flag_is_refused() {
467 assert!(ServerCli::try_parse_from(["notedthat-server", "--nope"]).is_err());
468 }
469
470 #[test]
477 fn every_setting_has_both_a_flag_and_a_variable() {
478 let command = ServerCli::command();
479 let wired: BTreeSet<String> = command
480 .get_arguments()
481 .filter_map(|arg| Some(arg.get_env()?.to_string_lossy().into_owned()))
482 .collect();
483
484 let expected: BTreeSet<String> = crate::config::tests::ALL_ENV_KEYS
485 .iter()
486 .map(|name| (*name).to_string())
487 .collect();
488
489 assert_eq!(wired, expected);
490 }
491
492 #[test]
497 fn each_flag_is_spelled_the_way_diagnostics_will_name_it() {
498 for arg in ServerCli::command().get_arguments() {
499 let Some(env_var) = arg.get_env() else {
500 continue;
501 };
502 let env_var = env_var.to_string_lossy();
503 let expected = notedthat_core::flag_for(&env_var);
504 let actual = format!(
505 "--{}",
506 arg.get_long().expect("every setting has a long flag")
507 );
508 assert_eq!(actual, expected, "{env_var} is wired to the wrong flag");
509 }
510 }
511
512 #[test]
514 fn help_never_echoes_a_credential_it_can_see_in_the_environment() {
515 let vars = [
516 ("NOTEDTHAT_API_TOKEN", Some("token-leak-canary")),
517 ("NOTEDTHAT_WEBDAV_PASSWORD", Some("password-leak-canary")),
518 ("NOTEDTHAT_S3_ACCESS_KEY_ID", Some("key-id-leak-canary")),
519 ("NOTEDTHAT_S3_SECRET_ACCESS_KEY", Some("secret-leak-canary")),
520 ("NOTEDTHAT_QDRANT_API_KEY", Some("qdrant-leak-canary")),
521 (
522 "NOTEDTHAT_NATS_URL",
523 Some("nats://u:nats-leak-canary@broker:4222"),
524 ),
525 ("EMBEDDING_API_KEY", Some("embedding-leak-canary")),
526 ("NOTEDTHAT_LISTEN_ADDR", Some("127.0.0.1:9999")),
528 ];
529 let help =
530 temp_env::with_vars(vars, || ServerCli::command().render_long_help().to_string());
531
532 assert!(!help.contains("leak-canary"), "{help}");
533 assert!(help.contains("NOTEDTHAT_API_TOKEN"), "{help}");
534 assert!(help.contains("127.0.0.1:9999"), "{help}");
535 }
536
537 #[test]
538 fn the_help_text_warns_that_a_secret_on_the_command_line_is_visible() {
539 let help = ServerCli::command().render_long_help().to_string();
540 assert!(help.contains("ps"), "{help}");
541 assert!(help.contains("--api-token"), "{help}");
542 }
543}