Skip to main content

notedthat_server/
cli.rs

1//! The `notedthat-server` command line.
2//!
3//! Every setting the server reads is reachable two ways: as an environment
4//! variable, and as the long flag named after it. When both are supplied the
5//! flag wins — that precedence is `clap`'s, granted by `env = "..."` on each
6//! argument, and it is the whole reason this layer exists.
7//!
8//! What this type deliberately does *not* do is validate. Every field is an
9//! `Option` of an unparsed value, so requiredness, ranges, enumerations and
10//! cross-setting rules all stay in [`crate::config`], stated once and reached
11//! identically from either source. It also keeps the diagnostics: `clap`'s
12//! own "the following required arguments were not provided" would replace
13//! messages that name the environment variable an operator is looking for.
14
15use clap::Parser;
16use std::ffi::OsString;
17
18/// A setting supplied as a bare flag means "true"; `--flag=false` still turns it
19/// off, and the environment keeps its strict `true`/`false` parse either way.
20const BOOL_FLAG: &str = "true";
21
22/// Command-line arguments, each mirroring one environment variable.
23///
24/// Construct it with [`clap::Parser::parse`] in a binary, or with
25/// [`ServerCli::from_env`] to read the environment alone.
26// Every doc comment below is rendered verbatim by `--help`, so it is written for
27// a terminal rather than for rustdoc: backticks around `WebDAV` or `SeaweedFS`
28// would reach the operator as literal characters.
29//
30// `--help` shows each setting's current value from the environment, which is a
31// useful thing to be able to check — except for a credential, where it would put
32// a live secret on the terminal of anyone who asked for help. Those carry
33// `hide_env_values`, so their variable is named and their value is not.
34#[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    /// Static Bearer token for authenticated API access and every HTTP write.
50    #[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    /// Comma-separated knowledge base slugs to declare, e.g. `notes,scratch`.
59    #[arg(long, env = "NOTEDTHAT_KBS", value_name = "SLUGS")]
60    pub kbs: Option<String>,
61
62    /// Address and port the server binds to [default: 0.0.0.0:8080].
63    #[arg(long, env = "NOTEDTHAT_LISTEN_ADDR", value_name = "HOST:PORT")]
64    pub listen_addr: Option<String>,
65
66    /// Serve the Prometheus exposition on its own listener [default: false].
67    #[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    /// Address and port the metrics listener binds to; loopback by default,
77    /// because the exposition is unauthenticated [default: 127.0.0.1:9090].
78    #[arg(long, env = "NOTEDTHAT_METRICS_LISTEN_ADDR", value_name = "HOST:PORT")]
79    pub metrics_listen_addr: Option<String>,
80
81    /// Log output format: `pretty` or `json` [default: pretty].
82    #[arg(long, env = "NOTEDTHAT_LOG_FORMAT", value_name = "FORMAT")]
83    pub log_format: Option<String>,
84
85    /// Largest object eligible for PATCH, in bytes [default: 104857600].
86    #[arg(long, env = "NOTEDTHAT_MAX_PATCHABLE_SIZE", value_name = "BYTES")]
87    pub max_patchable_size: Option<String>,
88
89    /// How often /readyz probes the storage backend and Qdrant, in milliseconds;
90    /// also each probe's deadline [default: 5000].
91    #[arg(long, env = "NOTEDTHAT_READY_PROBE_INTERVAL_MS", value_name = "MS")]
92    pub ready_probe_interval_ms: Option<String>,
93
94    /// Private staging directory for uploads and index snapshots [default: the
95    /// platform temporary directory].
96    #[arg(long, env = "NOTEDTHAT_UPLOAD_TMP_DIR", value_name = "DIR")]
97    pub upload_tmp_dir: Option<OsString>,
98
99    /// HTTP Basic auth username for WebDAV.
100    #[arg(long, env = "NOTEDTHAT_WEBDAV_USERNAME", value_name = "USER")]
101    pub webdav_username: Option<String>,
102
103    /// HTTP Basic auth password for WebDAV.
104    #[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    /// Allowed `Origin` values for MCP over HTTP [default: null, i.e. loopback only].
113    #[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    /// Allowed `Host` values for MCP over HTTP [default: 127.0.0.1,localhost,::1].
121    #[arg(long, env = "NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", value_name = "HOSTS")]
122    pub mcp_http_allowed_hosts: Option<String>,
123
124    /// Whether `/mcp` admits a request with no credential: `auto` (yes, when a
125    /// knowledge base grants `anyone` something) or `never` [default: auto].
126    #[arg(long, env = "NOTEDTHAT_MCP_ANONYMOUS", value_name = "MODE")]
127    pub mcp_anonymous: Option<String>,
128
129    /// Most bytes one MCP object read may fetch; larger objects are read in
130    /// slices [default: 16777216].
131    #[arg(long, env = "NOTEDTHAT_MCP_MAX_READ_BYTES", value_name = "BYTES")]
132    pub mcp_max_read_bytes: Option<String>,
133
134    /// Most MCP sessions this process holds at once; a `POST` that would open
135    /// another is refused until one ends [default: 256].
136    #[arg(long, env = "NOTEDTHAT_MCP_MAX_SESSIONS", value_name = "COUNT")]
137    pub mcp_max_sessions: Option<String>,
138
139    /// OIDC issuer URL, spelled exactly as the provider's `iss` claim. Enables
140    /// identity-provider bearer tokens.
141    #[arg(long, env = "NOTEDTHAT_OIDC_ISSUER", value_name = "URL")]
142    pub oidc_issuer: Option<String>,
143
144    /// Comma-separated audiences an identity token may carry (usually the client id).
145    #[arg(long, env = "NOTEDTHAT_OIDC_AUDIENCE", value_name = "AUDIENCES")]
146    pub oidc_audience: Option<String>,
147
148    /// The claim `user:` rules match against [default: preferred_username].
149    #[arg(long, env = "NOTEDTHAT_OIDC_USERNAME_CLAIM", value_name = "CLAIM")]
150    pub oidc_username_claim: Option<String>,
151
152    /// The claim `group:` rules match against [default: groups].
153    #[arg(long, env = "NOTEDTHAT_OIDC_GROUPS_CLAIM", value_name = "CLAIM")]
154    pub oidc_groups_claim: Option<String>,
155
156    /// Timeout for discovery and key-set requests to the issuer [default: 5000].
157    #[arg(long, env = "NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS", value_name = "MS")]
158    pub oidc_http_timeout_ms: Option<String>,
159
160    /// This deployment's public URL; publishes RFC 9728 metadata for MCP clients.
161    #[arg(long, env = "NOTEDTHAT_OIDC_RESOURCE", value_name = "URL")]
162    pub oidc_resource: Option<String>,
163
164    /// PEM bundle of extra CA certificates to trust when reaching the issuer —
165    /// for an internal or self-signed CA.
166    #[arg(long, env = "NOTEDTHAT_OIDC_CA_CERT", value_name = "FILE")]
167    pub oidc_ca_cert: Option<OsString>,
168
169    /// Object store to run on: `s3` or `fs` [default: s3].
170    #[arg(long, env = "NOTEDTHAT_STORAGE_BACKEND", value_name = "BACKEND")]
171    pub storage_backend: Option<OsString>,
172
173    /// S3 region. Required with the `s3` backend, even behind a custom endpoint.
174    #[arg(long, env = "NOTEDTHAT_S3_REGION", value_name = "REGION")]
175    pub s3_region: Option<String>,
176
177    /// S3 access key ID. No ambient credential chain is consulted.
178    #[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    /// S3 secret access key.
187    #[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    /// Custom S3-compatible endpoint, for SeaweedFS, MinIO, Ceph, Garage or R2.
196    #[arg(long, env = "NOTEDTHAT_S3_ENDPOINT_URL", value_name = "URL")]
197    pub s3_endpoint_url: Option<String>,
198
199    /// Use path-style addressing, `endpoint/bucket/key` [default: false].
200    #[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    /// Compare every knowledge base's bucket against the search index once at startup,
210    /// re-indexing what changed outside NotedThat [default: true].
211    #[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    /// Absolute path of the storage root. Required with the `fs` backend.
221    #[arg(long, env = "NOTEDTHAT_FS_ROOT", value_name = "DIR")]
222    pub fs_root: Option<OsString>,
223
224    /// Where per-object metadata is kept [default: sidecar].
225    #[arg(long, env = "NOTEDTHAT_FS_METADATA", value_name = "MODE")]
226    pub fs_metadata: Option<OsString>,
227
228    /// Octal mode for created object files [default: 0644].
229    #[arg(long, env = "NOTEDTHAT_FS_FILE_MODE", value_name = "MODE")]
230    pub fs_file_mode: Option<OsString>,
231
232    /// Octal mode for created directories [default: 0755].
233    #[arg(long, env = "NOTEDTHAT_FS_DIR_MODE", value_name = "MODE")]
234    pub fs_dir_mode: Option<OsString>,
235
236    /// Start even on a filesystem that folds case or normalizes Unicode
237    /// [default: false].
238    #[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    /// Re-index objects changed in the tree by anything other than NotedThat
248    /// [default: true].
249    #[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    /// How long a file must go quiet before a change to it is acted on, in
259    /// milliseconds [default: 500].
260    #[arg(long, env = "NOTEDTHAT_FS_WATCH_DEBOUNCE_MS", value_name = "MS")]
261    pub fs_watch_debounce_ms: Option<OsString>,
262
263    /// Object change event log: `none`, `memory` or `nats` [default: none].
264    #[arg(long, env = "NOTEDTHAT_EVENTS_BACKEND", value_name = "BACKEND")]
265    pub events_backend: Option<OsString>,
266
267    /// Events the `memory` log retains for replay [default: 10000].
268    #[arg(long, env = "NOTEDTHAT_EVENTS_MEMORY_CAPACITY", value_name = "COUNT")]
269    pub events_memory_capacity: Option<OsString>,
270
271    /// NATS server URL, `nats://[user:pass@]host:4222`. Required with the
272    /// `nats` events backend; credentials travel in the URL.
273    #[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    /// `JetStream` stream holding the event log [default: notedthat-events].
282    #[arg(long, env = "NOTEDTHAT_NATS_STREAM", value_name = "NAME")]
283    pub nats_stream: Option<String>,
284
285    /// How long the stream retains an event, in seconds [default: 604800].
286    #[arg(long, env = "NOTEDTHAT_NATS_MAX_AGE_SECS", value_name = "SECS")]
287    pub nats_max_age_secs: Option<OsString>,
288
289    /// Qdrant gRPC endpoint, e.g. `http://127.0.0.1:6334`.
290    #[arg(long, env = "NOTEDTHAT_QDRANT_URL", value_name = "URL")]
291    pub qdrant_url: Option<String>,
292
293    /// API key for an authenticated Qdrant instance.
294    #[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    /// Per-RPC Qdrant timeout in milliseconds [default: 30000].
303    #[arg(long, env = "NOTEDTHAT_QDRANT_TIMEOUT_MS", value_name = "MS")]
304    pub qdrant_timeout_ms: Option<String>,
305
306    /// Qdrant connection-establishment timeout in milliseconds [default: 10000].
307    #[arg(long, env = "NOTEDTHAT_QDRANT_CONNECT_TIMEOUT_MS", value_name = "MS")]
308    pub qdrant_connect_timeout_ms: Option<String>,
309
310    /// Base URL of the OpenAI-compatible embedding endpoint.
311    #[arg(long, env = "EMBEDDING_ENDPOINT_URL", value_name = "URL")]
312    pub embedding_endpoint_url: Option<String>,
313
314    /// Embedding model name, e.g. `text-embedding-3-small`.
315    #[arg(long, env = "EMBEDDING_MODEL", value_name = "MODEL")]
316    pub embedding_model: Option<String>,
317
318    /// Bearer token for the embedding endpoint.
319    #[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    /// Output vector dimensions. Must match the model and is baked into the
328    /// Qdrant collection at first provisioning.
329    #[arg(long, env = "EMBEDDING_DIMENSIONS", value_name = "N")]
330    pub embedding_dimensions: Option<String>,
331
332    /// Text chunks per embedding request [default: 32].
333    #[arg(long, env = "EMBEDDING_BATCH_SIZE", value_name = "N")]
334    pub embedding_batch_size: Option<String>,
335
336    /// Per-request embedding HTTP timeout in milliseconds [default: 30000].
337    #[arg(long, env = "EMBEDDING_TIMEOUT_MS", value_name = "MS")]
338    pub embedding_timeout_ms: Option<String>,
339
340    /// Retry attempts on HTTP 429 or 5xx from the embedder [default: 3].
341    #[arg(long, env = "EMBEDDING_MAX_RETRIES", value_name = "N")]
342    pub embedding_max_retries: Option<String>,
343
344    /// Chunks longer than this are dropped rather than truncated [default: 8192].
345    #[arg(long, env = "EMBEDDING_MAX_INPUT_TOKENS", value_name = "N")]
346    pub embedding_max_input_tokens: Option<String>,
347
348    // The three settings below were removed when the API, WebDAV and MCP
349    // surfaces moved onto one listener. They are still accepted by the parser,
350    // and hidden from `--help`, so that supplying one produces the startup
351    // error naming its replacement (see `REMOVED_LISTENER_ENV_VARS`) instead of
352    // clap's bare "unexpected argument", which would say nothing about what to
353    // do next. Removing them from the parser entirely would make the flag form
354    // less helpful than the variable form.
355    /// Removed: WebDAV is served at /webdav on the main listener.
356    #[arg(long, env = "NOTEDTHAT_WEBDAV_LISTEN_ADDR", hide = true)]
357    pub webdav_listen_addr: Option<OsString>,
358
359    /// Removed: MCP HTTP is served at /mcp on the main listener.
360    #[arg(long, env = "NOTEDTHAT_MCP_HTTP_BIND", hide = true)]
361    pub mcp_http_bind: Option<OsString>,
362
363    /// Removed: MCP HTTP is always served at /mcp on the main listener.
364    #[arg(long, env = "NOTEDTHAT_MCP_HTTP_ENABLED", hide = true)]
365    pub mcp_http_enabled: Option<OsString>,
366}
367
368impl ServerCli {
369    /// Read every setting from the environment alone, ignoring `argv`.
370    ///
371    /// # Errors
372    ///
373    /// Returns a `clap` error if a variable holds a value this parser cannot
374    /// accept — in practice, non-UTF-8 in a setting typed as `String`.
375    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    /// Parse `args` with `vars` in the environment, as a real invocation would see
388    /// both. The binary name is prepended, matching `argv`.
389    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    /// Presence, not value: this is what makes `--s3-region ""` count as an
429    /// offender in the cross-backend check, exactly as `NOTEDTHAT_S3_REGION=` does.
430    #[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    /// A path is not obliged to be UTF-8, and neither source should be the place
437    /// that loses one.
438    #[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    /// The bare form must not become a one-way switch: a deployment that sets the
463    /// variable to `true` has to be able to turn it off for one run.
464    #[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    /// Removed settings are still parsed — hidden from `--help`, but accepted — so
474    /// that supplying one reaches the startup error naming its replacement rather
475    /// than clap's bare "unexpected argument".
476    #[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    /// Every setting the server reads is reachable from the command line.
491    ///
492    /// The list of variables lives in `config::tests::ALL_ENV_KEYS`, which already
493    /// guards the storage adapters' own inventories; tying the parser to it means a
494    /// setting added later without a flag fails the build rather than quietly
495    /// staying environment-only.
496    #[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    /// The flag name is derived from the variable name by one mechanical rule, and
513    /// `notedthat_core::flag_for` is what error messages use to name it. If a flag
514    /// were spelled by hand differently, a diagnostic would point at a flag that
515    /// does not exist.
516    #[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    /// Asking a running deployment for help must not print its credentials.
533    #[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            // A non-credential, to show the value is hidden only where it must be.
547            ("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}