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    /// Log output format: `pretty` or `json` [default: pretty].
67    #[arg(long, env = "NOTEDTHAT_LOG_FORMAT", value_name = "FORMAT")]
68    pub log_format: Option<String>,
69
70    /// Largest object eligible for PATCH, in bytes [default: 104857600].
71    #[arg(long, env = "NOTEDTHAT_MAX_PATCHABLE_SIZE", value_name = "BYTES")]
72    pub max_patchable_size: Option<String>,
73
74    /// How often /readyz probes the storage backend and Qdrant, in milliseconds;
75    /// also each probe's deadline [default: 5000].
76    #[arg(long, env = "NOTEDTHAT_READY_PROBE_INTERVAL_MS", value_name = "MS")]
77    pub ready_probe_interval_ms: Option<String>,
78
79    /// Private staging directory for uploads and index snapshots [default: the
80    /// platform temporary directory].
81    #[arg(long, env = "NOTEDTHAT_UPLOAD_TMP_DIR", value_name = "DIR")]
82    pub upload_tmp_dir: Option<OsString>,
83
84    /// HTTP Basic auth username for WebDAV.
85    #[arg(long, env = "NOTEDTHAT_WEBDAV_USERNAME", value_name = "USER")]
86    pub webdav_username: Option<String>,
87
88    /// HTTP Basic auth password for WebDAV.
89    #[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    /// Allowed `Origin` values for MCP over HTTP [default: null, i.e. loopback only].
98    #[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    /// Allowed `Host` values for MCP over HTTP [default: 127.0.0.1,localhost,::1].
106    #[arg(long, env = "NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", value_name = "HOSTS")]
107    pub mcp_http_allowed_hosts: Option<String>,
108
109    /// Whether `/mcp` admits a request with no credential: `auto` (yes, when a
110    /// knowledge base grants `anyone` something) or `never` [default: auto].
111    #[arg(long, env = "NOTEDTHAT_MCP_ANONYMOUS", value_name = "MODE")]
112    pub mcp_anonymous: Option<String>,
113
114    /// Most bytes one MCP object read may fetch; larger objects are read in
115    /// slices [default: 16777216].
116    #[arg(long, env = "NOTEDTHAT_MCP_MAX_READ_BYTES", value_name = "BYTES")]
117    pub mcp_max_read_bytes: Option<String>,
118
119    /// OIDC issuer URL, spelled exactly as the provider's `iss` claim. Enables
120    /// identity-provider bearer tokens.
121    #[arg(long, env = "NOTEDTHAT_OIDC_ISSUER", value_name = "URL")]
122    pub oidc_issuer: Option<String>,
123
124    /// Comma-separated audiences an identity token may carry (usually the client id).
125    #[arg(long, env = "NOTEDTHAT_OIDC_AUDIENCE", value_name = "AUDIENCES")]
126    pub oidc_audience: Option<String>,
127
128    /// The claim `user:` rules match against [default: preferred_username].
129    #[arg(long, env = "NOTEDTHAT_OIDC_USERNAME_CLAIM", value_name = "CLAIM")]
130    pub oidc_username_claim: Option<String>,
131
132    /// The claim `group:` rules match against [default: groups].
133    #[arg(long, env = "NOTEDTHAT_OIDC_GROUPS_CLAIM", value_name = "CLAIM")]
134    pub oidc_groups_claim: Option<String>,
135
136    /// Timeout for discovery and key-set requests to the issuer [default: 5000].
137    #[arg(long, env = "NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS", value_name = "MS")]
138    pub oidc_http_timeout_ms: Option<String>,
139
140    /// This deployment's public URL; publishes RFC 9728 metadata for MCP clients.
141    #[arg(long, env = "NOTEDTHAT_OIDC_RESOURCE", value_name = "URL")]
142    pub oidc_resource: Option<String>,
143
144    /// PEM bundle of extra CA certificates to trust when reaching the issuer —
145    /// for an internal or self-signed CA.
146    #[arg(long, env = "NOTEDTHAT_OIDC_CA_CERT", value_name = "FILE")]
147    pub oidc_ca_cert: Option<OsString>,
148
149    /// Object store to run on: `s3` or `fs` [default: s3].
150    #[arg(long, env = "NOTEDTHAT_STORAGE_BACKEND", value_name = "BACKEND")]
151    pub storage_backend: Option<OsString>,
152
153    /// S3 region. Required with the `s3` backend, even behind a custom endpoint.
154    #[arg(long, env = "NOTEDTHAT_S3_REGION", value_name = "REGION")]
155    pub s3_region: Option<String>,
156
157    /// S3 access key ID. No ambient credential chain is consulted.
158    #[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    /// S3 secret access key.
167    #[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    /// Custom S3-compatible endpoint, for SeaweedFS, MinIO, Ceph, Garage or R2.
176    #[arg(long, env = "NOTEDTHAT_S3_ENDPOINT_URL", value_name = "URL")]
177    pub s3_endpoint_url: Option<String>,
178
179    /// Use path-style addressing, `endpoint/bucket/key` [default: false].
180    #[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    /// Compare every knowledge base's bucket against the search index once at startup,
190    /// re-indexing what changed outside NotedThat [default: true].
191    #[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    /// Absolute path of the storage root. Required with the `fs` backend.
201    #[arg(long, env = "NOTEDTHAT_FS_ROOT", value_name = "DIR")]
202    pub fs_root: Option<OsString>,
203
204    /// Where per-object metadata is kept [default: sidecar].
205    #[arg(long, env = "NOTEDTHAT_FS_METADATA", value_name = "MODE")]
206    pub fs_metadata: Option<OsString>,
207
208    /// Octal mode for created object files [default: 0644].
209    #[arg(long, env = "NOTEDTHAT_FS_FILE_MODE", value_name = "MODE")]
210    pub fs_file_mode: Option<OsString>,
211
212    /// Octal mode for created directories [default: 0755].
213    #[arg(long, env = "NOTEDTHAT_FS_DIR_MODE", value_name = "MODE")]
214    pub fs_dir_mode: Option<OsString>,
215
216    /// Start even on a filesystem that folds case or normalizes Unicode
217    /// [default: false].
218    #[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    /// Re-index objects changed in the tree by anything other than NotedThat
228    /// [default: true].
229    #[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    /// How long a file must go quiet before a change to it is acted on, in
239    /// milliseconds [default: 500].
240    #[arg(long, env = "NOTEDTHAT_FS_WATCH_DEBOUNCE_MS", value_name = "MS")]
241    pub fs_watch_debounce_ms: Option<OsString>,
242
243    /// Object change event log: `none`, `memory` or `nats` [default: none].
244    #[arg(long, env = "NOTEDTHAT_EVENTS_BACKEND", value_name = "BACKEND")]
245    pub events_backend: Option<OsString>,
246
247    /// Events the `memory` log retains for replay [default: 10000].
248    #[arg(long, env = "NOTEDTHAT_EVENTS_MEMORY_CAPACITY", value_name = "COUNT")]
249    pub events_memory_capacity: Option<OsString>,
250
251    /// NATS server URL, `nats://[user:pass@]host:4222`. Required with the
252    /// `nats` events backend; credentials travel in the URL.
253    #[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    /// `JetStream` stream holding the event log [default: notedthat-events].
262    #[arg(long, env = "NOTEDTHAT_NATS_STREAM", value_name = "NAME")]
263    pub nats_stream: Option<String>,
264
265    /// How long the stream retains an event, in seconds [default: 604800].
266    #[arg(long, env = "NOTEDTHAT_NATS_MAX_AGE_SECS", value_name = "SECS")]
267    pub nats_max_age_secs: Option<OsString>,
268
269    /// Qdrant gRPC endpoint, e.g. `http://127.0.0.1:6334`.
270    #[arg(long, env = "NOTEDTHAT_QDRANT_URL", value_name = "URL")]
271    pub qdrant_url: Option<String>,
272
273    /// API key for an authenticated Qdrant instance.
274    #[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    /// Per-RPC Qdrant timeout in milliseconds [default: 30000].
283    #[arg(long, env = "NOTEDTHAT_QDRANT_TIMEOUT_MS", value_name = "MS")]
284    pub qdrant_timeout_ms: Option<String>,
285
286    /// Qdrant connection-establishment timeout in milliseconds [default: 10000].
287    #[arg(long, env = "NOTEDTHAT_QDRANT_CONNECT_TIMEOUT_MS", value_name = "MS")]
288    pub qdrant_connect_timeout_ms: Option<String>,
289
290    /// Base URL of the OpenAI-compatible embedding endpoint.
291    #[arg(long, env = "EMBEDDING_ENDPOINT_URL", value_name = "URL")]
292    pub embedding_endpoint_url: Option<String>,
293
294    /// Embedding model name, e.g. `text-embedding-3-small`.
295    #[arg(long, env = "EMBEDDING_MODEL", value_name = "MODEL")]
296    pub embedding_model: Option<String>,
297
298    /// Bearer token for the embedding endpoint.
299    #[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    /// Output vector dimensions. Must match the model and is baked into the
308    /// Qdrant collection at first provisioning.
309    #[arg(long, env = "EMBEDDING_DIMENSIONS", value_name = "N")]
310    pub embedding_dimensions: Option<String>,
311
312    /// Text chunks per embedding request [default: 32].
313    #[arg(long, env = "EMBEDDING_BATCH_SIZE", value_name = "N")]
314    pub embedding_batch_size: Option<String>,
315
316    /// Per-request embedding HTTP timeout in milliseconds [default: 30000].
317    #[arg(long, env = "EMBEDDING_TIMEOUT_MS", value_name = "MS")]
318    pub embedding_timeout_ms: Option<String>,
319
320    /// Retry attempts on HTTP 429 or 5xx from the embedder [default: 3].
321    #[arg(long, env = "EMBEDDING_MAX_RETRIES", value_name = "N")]
322    pub embedding_max_retries: Option<String>,
323
324    /// Chunks longer than this are dropped rather than truncated [default: 8192].
325    #[arg(long, env = "EMBEDDING_MAX_INPUT_TOKENS", value_name = "N")]
326    pub embedding_max_input_tokens: Option<String>,
327
328    // The three settings below were removed when the API, WebDAV and MCP
329    // surfaces moved onto one listener. They are still accepted by the parser,
330    // and hidden from `--help`, so that supplying one produces the startup
331    // error naming its replacement (see `REMOVED_LISTENER_ENV_VARS`) instead of
332    // clap's bare "unexpected argument", which would say nothing about what to
333    // do next. Removing them from the parser entirely would make the flag form
334    // less helpful than the variable form.
335    /// Removed: WebDAV is served at /webdav on the main listener.
336    #[arg(long, env = "NOTEDTHAT_WEBDAV_LISTEN_ADDR", hide = true)]
337    pub webdav_listen_addr: Option<OsString>,
338
339    /// Removed: MCP HTTP is served at /mcp on the main listener.
340    #[arg(long, env = "NOTEDTHAT_MCP_HTTP_BIND", hide = true)]
341    pub mcp_http_bind: Option<OsString>,
342
343    /// Removed: MCP HTTP is always served at /mcp on the main listener.
344    #[arg(long, env = "NOTEDTHAT_MCP_HTTP_ENABLED", hide = true)]
345    pub mcp_http_enabled: Option<OsString>,
346}
347
348impl ServerCli {
349    /// Read every setting from the environment alone, ignoring `argv`.
350    ///
351    /// # Errors
352    ///
353    /// Returns a `clap` error if a variable holds a value this parser cannot
354    /// accept — in practice, non-UTF-8 in a setting typed as `String`.
355    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    /// Parse `args` with `vars` in the environment, as a real invocation would see
368    /// both. The binary name is prepended, matching `argv`.
369    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    /// Presence, not value: this is what makes `--s3-region ""` count as an
409    /// offender in the cross-backend check, exactly as `NOTEDTHAT_S3_REGION=` does.
410    #[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    /// A path is not obliged to be UTF-8, and neither source should be the place
417    /// that loses one.
418    #[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    /// The bare form must not become a one-way switch: a deployment that sets the
443    /// variable to `true` has to be able to turn it off for one run.
444    #[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    /// Removed settings are still parsed — hidden from `--help`, but accepted — so
454    /// that supplying one reaches the startup error naming its replacement rather
455    /// than clap's bare "unexpected argument".
456    #[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    /// Every setting the server reads is reachable from the command line.
471    ///
472    /// The list of variables lives in `config::tests::ALL_ENV_KEYS`, which already
473    /// guards the storage adapters' own inventories; tying the parser to it means a
474    /// setting added later without a flag fails the build rather than quietly
475    /// staying environment-only.
476    #[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    /// The flag name is derived from the variable name by one mechanical rule, and
493    /// `notedthat_core::flag_for` is what error messages use to name it. If a flag
494    /// were spelled by hand differently, a diagnostic would point at a flag that
495    /// does not exist.
496    #[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    /// Asking a running deployment for help must not print its credentials.
513    #[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            // A non-credential, to show the value is hidden only where it must be.
527            ("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}