use clap::Parser;
use std::ffi::OsString;
const BOOL_FLAG: &str = "true";
#[allow(clippy::doc_markdown)]
#[derive(Parser, Debug, Default, Clone)]
#[command(
name = "notedthat-server",
version,
about = "NotedThat server — HTTP API, WebDAV and remote MCP in one process",
long_about = "NotedThat server — HTTP API, WebDAV and remote MCP in one process.\n\n\
Every setting can be given as the flag shown below or as the environment \
variable beside it; the flag wins when both are set.\n\n\
Arguments are visible to any user on the host via `ps`, and are recorded in \
shell history and in `docker inspect`. Prefer the environment variable for \
--api-token, --webdav-password, --s3-secret-access-key, --qdrant-api-key and \
--embedding-api-key on a shared machine."
)]
pub struct ServerCli {
#[arg(
long,
env = "NOTEDTHAT_API_TOKEN",
value_name = "TOKEN",
hide_env_values = true
)]
pub api_token: Option<String>,
#[arg(long, env = "NOTEDTHAT_KBS", value_name = "SLUGS")]
pub kbs: Option<String>,
#[arg(long, env = "NOTEDTHAT_LISTEN_ADDR", value_name = "HOST:PORT")]
pub listen_addr: Option<String>,
#[arg(long, env = "NOTEDTHAT_LOG_FORMAT", value_name = "FORMAT")]
pub log_format: Option<String>,
#[arg(long, env = "NOTEDTHAT_MAX_PATCHABLE_SIZE", value_name = "BYTES")]
pub max_patchable_size: Option<String>,
#[arg(long, env = "NOTEDTHAT_UPLOAD_TMP_DIR", value_name = "DIR")]
pub upload_tmp_dir: Option<OsString>,
#[arg(long, env = "NOTEDTHAT_WEBDAV_USERNAME", value_name = "USER")]
pub webdav_username: Option<String>,
#[arg(
long,
env = "NOTEDTHAT_WEBDAV_PASSWORD",
value_name = "PASSWORD",
hide_env_values = true
)]
pub webdav_password: Option<String>,
#[arg(
long,
env = "NOTEDTHAT_MCP_HTTP_ALLOWED_ORIGINS",
value_name = "ORIGINS"
)]
pub mcp_http_allowed_origins: Option<String>,
#[arg(long, env = "NOTEDTHAT_MCP_HTTP_ALLOWED_HOSTS", value_name = "HOSTS")]
pub mcp_http_allowed_hosts: Option<String>,
#[arg(long, env = "NOTEDTHAT_OIDC_ISSUER", value_name = "URL")]
pub oidc_issuer: Option<String>,
#[arg(long, env = "NOTEDTHAT_OIDC_AUDIENCE", value_name = "AUDIENCES")]
pub oidc_audience: Option<String>,
#[arg(long, env = "NOTEDTHAT_OIDC_USERNAME_CLAIM", value_name = "CLAIM")]
pub oidc_username_claim: Option<String>,
#[arg(long, env = "NOTEDTHAT_OIDC_GROUPS_CLAIM", value_name = "CLAIM")]
pub oidc_groups_claim: Option<String>,
#[arg(long, env = "NOTEDTHAT_OIDC_HTTP_TIMEOUT_MS", value_name = "MS")]
pub oidc_http_timeout_ms: Option<String>,
#[arg(long, env = "NOTEDTHAT_OIDC_RESOURCE", value_name = "URL")]
pub oidc_resource: Option<String>,
#[arg(long, env = "NOTEDTHAT_OIDC_CA_CERT", value_name = "FILE")]
pub oidc_ca_cert: Option<OsString>,
#[arg(long, env = "NOTEDTHAT_STORAGE_BACKEND", value_name = "BACKEND")]
pub storage_backend: Option<OsString>,
#[arg(long, env = "NOTEDTHAT_S3_REGION", value_name = "REGION")]
pub s3_region: Option<String>,
#[arg(
long,
env = "NOTEDTHAT_S3_ACCESS_KEY_ID",
value_name = "KEY_ID",
hide_env_values = true
)]
pub s3_access_key_id: Option<String>,
#[arg(
long,
env = "NOTEDTHAT_S3_SECRET_ACCESS_KEY",
value_name = "SECRET",
hide_env_values = true
)]
pub s3_secret_access_key: Option<String>,
#[arg(long, env = "NOTEDTHAT_S3_ENDPOINT_URL", value_name = "URL")]
pub s3_endpoint_url: Option<String>,
#[arg(
long,
env = "NOTEDTHAT_S3_FORCE_PATH_STYLE",
value_name = "BOOL",
num_args = 0..=1,
default_missing_value = BOOL_FLAG,
)]
pub s3_force_path_style: Option<String>,
#[arg(long, env = "NOTEDTHAT_FS_ROOT", value_name = "DIR")]
pub fs_root: Option<OsString>,
#[arg(long, env = "NOTEDTHAT_FS_METADATA", value_name = "MODE")]
pub fs_metadata: Option<OsString>,
#[arg(long, env = "NOTEDTHAT_FS_FILE_MODE", value_name = "MODE")]
pub fs_file_mode: Option<OsString>,
#[arg(long, env = "NOTEDTHAT_FS_DIR_MODE", value_name = "MODE")]
pub fs_dir_mode: Option<OsString>,
#[arg(
long,
env = "NOTEDTHAT_FS_ALLOW_LOSSY_NAMES",
value_name = "BOOL",
num_args = 0..=1,
default_missing_value = BOOL_FLAG,
)]
pub fs_allow_lossy_names: Option<OsString>,
#[arg(
long,
env = "NOTEDTHAT_FS_WATCH",
value_name = "BOOL",
num_args = 0..=1,
default_missing_value = BOOL_FLAG,
)]
pub fs_watch: Option<OsString>,
#[arg(long, env = "NOTEDTHAT_FS_WATCH_DEBOUNCE_MS", value_name = "MS")]
pub fs_watch_debounce_ms: Option<OsString>,
#[arg(long, env = "NOTEDTHAT_QDRANT_URL", value_name = "URL")]
pub qdrant_url: Option<String>,
#[arg(
long,
env = "NOTEDTHAT_QDRANT_API_KEY",
value_name = "KEY",
hide_env_values = true
)]
pub qdrant_api_key: Option<String>,
#[arg(long, env = "NOTEDTHAT_QDRANT_TIMEOUT_MS", value_name = "MS")]
pub qdrant_timeout_ms: Option<String>,
#[arg(long, env = "NOTEDTHAT_QDRANT_CONNECT_TIMEOUT_MS", value_name = "MS")]
pub qdrant_connect_timeout_ms: Option<String>,
#[arg(long, env = "EMBEDDING_ENDPOINT_URL", value_name = "URL")]
pub embedding_endpoint_url: Option<String>,
#[arg(long, env = "EMBEDDING_MODEL", value_name = "MODEL")]
pub embedding_model: Option<String>,
#[arg(
long,
env = "EMBEDDING_API_KEY",
value_name = "KEY",
hide_env_values = true
)]
pub embedding_api_key: Option<String>,
#[arg(long, env = "EMBEDDING_DIMENSIONS", value_name = "N")]
pub embedding_dimensions: Option<String>,
#[arg(long, env = "EMBEDDING_BATCH_SIZE", value_name = "N")]
pub embedding_batch_size: Option<String>,
#[arg(long, env = "EMBEDDING_TIMEOUT_MS", value_name = "MS")]
pub embedding_timeout_ms: Option<String>,
#[arg(long, env = "EMBEDDING_MAX_RETRIES", value_name = "N")]
pub embedding_max_retries: Option<String>,
#[arg(long, env = "EMBEDDING_MAX_INPUT_TOKENS", value_name = "N")]
pub embedding_max_input_tokens: Option<String>,
#[arg(long, env = "NOTEDTHAT_WEBDAV_LISTEN_ADDR", hide = true)]
pub webdav_listen_addr: Option<OsString>,
#[arg(long, env = "NOTEDTHAT_MCP_HTTP_BIND", hide = true)]
pub mcp_http_bind: Option<OsString>,
#[arg(long, env = "NOTEDTHAT_MCP_HTTP_ENABLED", hide = true)]
pub mcp_http_enabled: Option<OsString>,
}
impl ServerCli {
pub fn from_env() -> Result<Self, clap::Error> {
Self::try_parse_from(["notedthat-server"])
}
}
#[cfg(test)]
mod tests {
use super::ServerCli;
use clap::{CommandFactory as _, Parser as _};
use std::collections::BTreeSet;
use std::ffi::OsString;
fn parse(vars: &[(&str, Option<&str>)], args: &[&str]) -> ServerCli {
let command_line: Vec<&str> = std::iter::once("notedthat-server")
.chain(args.iter().copied())
.collect();
temp_env::with_vars(vars, || {
ServerCli::try_parse_from(&command_line).expect("arguments must parse")
})
}
#[test]
fn a_flag_wins_over_the_variable_it_mirrors() {
let cli = parse(
&[("NOTEDTHAT_LISTEN_ADDR", Some("0.0.0.0:9999"))],
&["--listen-addr", "127.0.0.1:8081"],
);
assert_eq!(cli.listen_addr.as_deref(), Some("127.0.0.1:8081"));
}
#[test]
fn the_variable_is_used_when_no_flag_is_given() {
let cli = parse(&[("NOTEDTHAT_LISTEN_ADDR", Some("0.0.0.0:9999"))], &[]);
assert_eq!(cli.listen_addr.as_deref(), Some("0.0.0.0:9999"));
}
#[test]
fn a_flag_alone_is_enough_with_the_environment_empty() {
let cli = parse(
&[("NOTEDTHAT_API_TOKEN", None)],
&["--api-token", "flag-only"],
);
assert_eq!(cli.api_token.as_deref(), Some("flag-only"));
}
#[test]
fn a_setting_neither_source_supplied_stays_absent() {
let cli = parse(&[("NOTEDTHAT_LISTEN_ADDR", None)], &[]);
assert!(cli.listen_addr.is_none());
}
#[test]
fn an_empty_flag_value_is_still_a_supplied_value() {
let cli = parse(&[("NOTEDTHAT_S3_REGION", None)], &["--s3-region", ""]);
assert_eq!(cli.s3_region.as_deref(), Some(""));
}
#[test]
fn a_path_setting_keeps_its_value_unmangled() {
let cli = parse(
&[("NOTEDTHAT_FS_ROOT", None)],
&["--fs-root", "/srv/notedthat"],
);
assert_eq!(cli.fs_root, Some(OsString::from("/srv/notedthat")));
}
#[test]
fn a_comma_separated_setting_arrives_whole_for_the_validator_to_split() {
let cli = parse(&[("NOTEDTHAT_KBS", None)], &["--kbs", "notes,scratch"]);
assert_eq!(cli.kbs.as_deref(), Some("notes,scratch"));
}
#[test]
fn a_bare_boolean_flag_means_true() {
let cli = parse(
&[("NOTEDTHAT_S3_FORCE_PATH_STYLE", None)],
&["--s3-force-path-style"],
);
assert_eq!(cli.s3_force_path_style.as_deref(), Some("true"));
}
#[test]
fn a_boolean_flag_can_still_be_given_false_explicitly() {
let cli = parse(
&[("NOTEDTHAT_S3_FORCE_PATH_STYLE", Some("true"))],
&["--s3-force-path-style=false"],
);
assert_eq!(cli.s3_force_path_style.as_deref(), Some("false"));
}
#[test]
fn a_removed_setting_is_accepted_by_the_parser_so_startup_can_explain_it() {
let cli = parse(
&[("NOTEDTHAT_MCP_HTTP_ENABLED", None)],
&["--mcp-http-enabled", "false"],
);
assert_eq!(cli.mcp_http_enabled, Some(OsString::from("false")));
}
#[test]
fn an_unknown_flag_is_refused() {
assert!(ServerCli::try_parse_from(["notedthat-server", "--nope"]).is_err());
}
#[test]
fn every_setting_has_both_a_flag_and_a_variable() {
let command = ServerCli::command();
let wired: BTreeSet<String> = command
.get_arguments()
.filter_map(|arg| Some(arg.get_env()?.to_string_lossy().into_owned()))
.collect();
let expected: BTreeSet<String> = crate::config::tests::ALL_ENV_KEYS
.iter()
.map(|name| (*name).to_string())
.collect();
assert_eq!(wired, expected);
}
#[test]
fn each_flag_is_spelled_the_way_diagnostics_will_name_it() {
for arg in ServerCli::command().get_arguments() {
let Some(env_var) = arg.get_env() else {
continue;
};
let env_var = env_var.to_string_lossy();
let expected = notedthat_core::flag_for(&env_var);
let actual = format!(
"--{}",
arg.get_long().expect("every setting has a long flag")
);
assert_eq!(actual, expected, "{env_var} is wired to the wrong flag");
}
}
#[test]
fn help_never_echoes_a_credential_it_can_see_in_the_environment() {
let vars = [
("NOTEDTHAT_API_TOKEN", Some("token-leak-canary")),
("NOTEDTHAT_WEBDAV_PASSWORD", Some("password-leak-canary")),
("NOTEDTHAT_S3_ACCESS_KEY_ID", Some("key-id-leak-canary")),
("NOTEDTHAT_S3_SECRET_ACCESS_KEY", Some("secret-leak-canary")),
("NOTEDTHAT_QDRANT_API_KEY", Some("qdrant-leak-canary")),
("EMBEDDING_API_KEY", Some("embedding-leak-canary")),
("NOTEDTHAT_LISTEN_ADDR", Some("127.0.0.1:9999")),
];
let help =
temp_env::with_vars(vars, || ServerCli::command().render_long_help().to_string());
assert!(!help.contains("leak-canary"), "{help}");
assert!(help.contains("NOTEDTHAT_API_TOKEN"), "{help}");
assert!(help.contains("127.0.0.1:9999"), "{help}");
}
#[test]
fn the_help_text_warns_that_a_secret_on_the_command_line_is_visible() {
let help = ServerCli::command().render_long_help().to_string();
assert!(help.contains("ps"), "{help}");
assert!(help.contains("--api-token"), "{help}");
}
}