use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use crate::error::{Error, Result};
use crate::paths;
const DOCS_URL: &str = "https://clickhouse.com/docs/concepts/features/interfaces/cli#telemetry";
const DEFAULT_ENDPOINT: &str = "https://chctl.clickhouse.com/v1/telemetry";
const URL_ENV: &str = "CHCTL_TELEMETRY_URL";
const PAYLOAD_ENV: &str = "CHCTL_TELEMETRY_PAYLOAD";
const DEBUG_ENV: &str = "CHCTL_TELEMETRY_DEBUG";
const DNT_ENV: &str = "DO_NOT_TRACK";
const CI_ENV: &str = "CI";
const SEND_TIMEOUT: Duration = Duration::from_secs(2);
static EXE_PATH: OnceLock<PathBuf> = OnceLock::new();
pub fn init() {
if let Ok(exe) = std::env::current_exe() {
let _ = EXE_PATH.set(exe);
}
}
const MAX_FLAGS: usize = 64;
#[derive(serde::Serialize, serde::Deserialize)]
struct StateFile {
disabled: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum State {
Missing,
Enabled,
Disabled,
}
fn state_path() -> Option<PathBuf> {
paths::base_dir().ok().map(|dir| dir.join("telemetry.json"))
}
fn load_state_from(path: &Path) -> State {
match std::fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str::<StateFile>(&contents) {
Ok(state) if !state.disabled => State::Enabled,
_ => State::Disabled,
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => State::Missing,
Err(_) => State::Disabled,
}
}
fn save_state_to(path: &Path, disabled: bool) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let json = serde_json::to_string(&StateFile { disabled })
.expect("StateFile serialization cannot fail");
std::fs::write(path, json)
}
type EnvLookup<'a> = &'a dyn Fn(&str) -> Option<String>;
fn real_env_lookup(key: &str) -> Option<String> {
std::env::var_os(key).map(|v| v.to_string_lossy().into_owned())
}
fn env_truthy(value: Option<String>) -> bool {
matches!(value.as_deref(), Some(v) if !v.is_empty() && v != "0" && v != "false")
}
#[derive(Debug, serde::Serialize)]
struct Payload {
command: String,
flags: Vec<String>,
exit_code: i32,
outcome: &'static str,
suggestion: Option<String>,
is_agent: bool,
agent: Option<String>,
ci: bool,
version: &'static str,
os: &'static str,
arch: &'static str,
}
fn dispatched_outcome(outcome: &'static str, exit_code: i32) -> &'static str {
if outcome != "ok" {
return outcome;
}
match exit_code {
0 => "ok",
2 => "cancelled",
4 => "auth_required",
_ => "error",
}
}
fn build_payload(invocation: &Invocation, exit_code: i32, env: EnvLookup<'_>) -> Payload {
let mut flags = invocation.flags.clone();
flags.truncate(MAX_FLAGS);
let detected = is_ai_agent::detect();
Payload {
command: invocation.command.clone(),
flags,
exit_code,
outcome: dispatched_outcome(invocation.outcome, exit_code),
suggestion: invocation.suggestion.clone(),
is_agent: detected.is_some(),
agent: detected.map(|a| a.id.as_str().to_string()),
ci: env_truthy(env(CI_ENV)),
version: env!("CARGO_PKG_VERSION"),
os: std::env::consts::OS,
arch: std::env::consts::ARCH,
}
}
#[derive(Clone)]
pub struct Invocation {
command: String,
flags: Vec<String>,
outcome: &'static str,
suggestion: Option<String>,
}
fn outcome_for_error(kind: clap::error::ErrorKind) -> &'static str {
use clap::error::ErrorKind as K;
match kind {
K::DisplayHelp => "help",
K::DisplayVersion => "version",
K::InvalidSubcommand => "invalid_subcommand",
K::UnknownArgument => "unknown_argument",
K::MissingSubcommand | K::DisplayHelpOnMissingArgumentOrSubcommand => "missing_subcommand",
K::MissingRequiredArgument => "missing_required",
K::InvalidValue | K::ValueValidation => "invalid_value",
_ => "other_parse_error",
}
}
fn suggestion_for_error(root: &clap::Command, error: &clap::Error) -> Option<String> {
use clap::error::{ContextKind, ContextValue};
[ContextKind::SuggestedSubcommand, ContextKind::SuggestedArg]
.iter()
.find_map(|kind| match error.get(*kind) {
Some(ContextValue::String(s)) => resolve_suggestion(root, s),
Some(ContextValue::Strings(s)) => {
s.iter().rev().find_map(|s| resolve_suggestion(root, s))
}
_ => None,
})
}
fn resolve_suggestion(root: &clap::Command, suggested: &str) -> Option<String> {
let name = suggested.trim_start_matches('-');
find_defined_name(root, name).map(str::to_string)
}
fn find_defined_name<'a>(cmd: &'a clap::Command, name: &str) -> Option<&'a str> {
cmd.get_arguments()
.filter_map(|a| a.get_long())
.find(|&long| long == name)
.or_else(|| {
cmd.get_subcommands().find_map(|sub| {
Some(sub.get_name())
.filter(|&sub_name| sub_name == name)
.or_else(|| find_defined_name(sub, name))
})
})
}
pub fn capture(root: &clap::Command, matches: &clap::ArgMatches) -> Invocation {
use clap::parser::ValueSource;
let mut path: Vec<&str> = Vec::new();
let mut stack: Vec<&clap::Command> = vec![root];
let mut flags = std::collections::BTreeSet::new();
let mut current = matches;
loop {
for id in current.ids() {
if !matches!(current.try_contains_id(id.as_str()), Ok(true)) {
continue;
}
if current.value_source(id.as_str()) != Some(ValueSource::CommandLine) {
continue;
}
let Some(arg) = stack
.iter()
.rev()
.find_map(|cmd| cmd.get_arguments().find(|a| a.get_id() == id))
else {
continue;
};
if arg.is_positional() {
continue;
}
flags.insert(arg.get_long().unwrap_or(id.as_str()).to_string());
}
let Some((name, sub_matches)) = current.subcommand() else {
break;
};
let Some(sub_cmd) = stack
.last()
.expect("stack starts non-empty and only grows")
.find_subcommand(name)
else {
break;
};
path.push(sub_cmd.get_name());
stack.push(sub_cmd);
current = sub_matches;
}
Invocation {
command: path.join(" "),
flags: flags.into_iter().collect(),
outcome: "ok",
suggestion: None,
}
}
pub fn capture_lossy(
root: &mut clap::Command,
argv: &[std::ffi::OsString],
error: &clap::Error,
) -> Invocation {
root.build();
let mut stack: Vec<&clap::Command> = vec![root];
let mut path: Vec<&str> = Vec::new();
let mut flags = std::collections::BTreeSet::new();
let mut tokens = argv.iter().skip(1);
'walk: while let Some(token) = tokens.next() {
let Some(token) = token.to_str() else { break };
if token == "--" {
break;
}
if let Some(rest) = token.strip_prefix("--") {
let (name, has_inline_value) = match rest.split_once('=') {
Some((name, _value)) => (name, true),
None => (rest, false),
};
let Some(arg) = stack.iter().rev().find_map(|cmd| {
cmd.get_arguments().find(|a| {
a.get_long() == Some(name)
|| a.get_all_aliases()
.is_some_and(|aliases| aliases.contains(&name))
})
}) else {
break;
};
flags.extend(arg.get_long().map(str::to_string));
if !has_inline_value && arg.get_action().takes_values() {
tokens.next();
}
} else if let Some(cluster) = token.strip_prefix('-').filter(|rest| !rest.is_empty()) {
for (i, ch) in cluster.char_indices() {
let Some(arg) = stack.iter().rev().find_map(|cmd| {
cmd.get_arguments().find(|a| {
a.get_short() == Some(ch)
|| a.get_all_short_aliases().is_some_and(|s| s.contains(&ch))
})
}) else {
break 'walk;
};
flags.insert(arg.get_long().unwrap_or(arg.get_id().as_str()).to_string());
if arg.get_action().takes_values() {
if cluster[i + ch.len_utf8()..].is_empty() {
tokens.next();
}
break;
}
}
} else if let Some(sub) = stack
.last()
.expect("stack starts non-empty and only grows")
.find_subcommand(token)
{
path.push(sub.get_name());
stack.push(sub);
} else {
break;
}
}
Invocation {
command: path.join(" "),
flags: flags.into_iter().collect(),
outcome: outcome_for_error(error.kind()),
suggestion: suggestion_for_error(root, error),
}
}
#[derive(Debug, PartialEq, Eq)]
enum Action {
Silent,
Notice,
Send(String),
Debug(String),
}
fn decide(path: &Path, invocation: &Invocation, exit_code: i32, env: EnvLookup<'_>) -> Action {
if env_truthy(env(DNT_ENV)) {
return Action::Silent;
}
match load_state_from(path) {
State::Missing => {
if save_state_to(path, false).is_ok() {
Action::Notice
} else {
Action::Silent
}
}
State::Disabled => Action::Silent,
State::Enabled => {
let json = serde_json::to_string(&build_payload(invocation, exit_code, env))
.expect("Payload serialization cannot fail");
if env_truthy(env(DEBUG_ENV)) {
Action::Debug(json)
} else {
Action::Send(json)
}
}
}
}
static STASHED_INVOCATION: OnceLock<Invocation> = OnceLock::new();
pub fn stash_invocation(invocation: Invocation) {
let _ = STASHED_INVOCATION.set(invocation);
}
static FINALIZED: AtomicBool = AtomicBool::new(false);
fn claim(guard: &AtomicBool) -> bool {
!guard.swap(true, Ordering::SeqCst)
}
fn exec_invocation(stashed: &Invocation) -> Invocation {
Invocation {
outcome: "exec",
..stashed.clone()
}
}
pub fn finalize(invocation: Invocation, exit_code: i32) {
if !claim(&FINALIZED) {
return;
}
finalize_inner(&invocation, exit_code);
}
pub fn finalize_before_exec() {
let Some(stashed) = STASHED_INVOCATION.get() else {
return;
};
if !claim(&FINALIZED) {
return;
}
finalize_inner(&exec_invocation(stashed), 0);
}
fn finalize_inner(invocation: &Invocation, exit_code: i32) {
let Some(path) = state_path() else { return };
match decide(&path, invocation, exit_code, &real_env_lookup) {
Action::Silent => {}
Action::Notice => print_first_run_notice(),
Action::Debug(json) => {
use std::io::Write;
let _ = writeln!(std::io::stderr(), "{json}");
}
Action::Send(json) => spawn_send_child(&json),
}
}
fn print_first_run_notice() {
use std::io::Write;
let _ = writeln!(
std::io::stderr(),
"\nNote: clickhousectl collects anonymous usage data to help improve the CLI:\n\
command name, flag names (never values or arguments), success/failure, version,\n\
OS/arch, and CI/agent detection. No user or machine IDs. Nothing was sent this run.\n\
Opt out: `clickhousectl telemetry disable` or DO_NOT_TRACK=1.\n\
Details: {DOCS_URL}"
);
}
fn spawn_send_child(payload_json: &str) {
use std::process::{Command, Stdio};
let Some(exe) = EXE_PATH.get() else {
return;
};
let _ = Command::new(exe)
.args(["telemetry", "send"])
.env(PAYLOAD_ENV, payload_json)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
pub async fn run_child_send() {
let Ok(payload) = std::env::var(PAYLOAD_ENV) else {
return;
};
let url = std::env::var(URL_ENV).unwrap_or_else(|_| DEFAULT_ENDPOINT.to_string());
let Ok(client) = reqwest::Client::builder()
.user_agent(crate::user_agent::user_agent())
.timeout(SEND_TIMEOUT)
.build()
else {
return;
};
let _ = client
.post(&url)
.header(reqwest::header::CONTENT_TYPE, "application/json")
.body(payload)
.send()
.await;
}
pub fn run_command(cmd: crate::cli::TelemetryCommands) -> Result<()> {
use crate::cli::TelemetryCommands;
match cmd {
TelemetryCommands::Enable => {
set_disabled(false)?;
println!("Telemetry enabled.");
if env_truthy(real_env_lookup(DNT_ENV)) {
use std::io::Write;
let _ = writeln!(
std::io::stderr(),
"Note: the DO_NOT_TRACK environment variable is set; telemetry will remain silent while it is set."
);
}
Ok(())
}
TelemetryCommands::Disable => {
set_disabled(true)?;
println!("Telemetry disabled.");
Ok(())
}
TelemetryCommands::Status => {
print_status();
Ok(())
}
TelemetryCommands::Send => unreachable!("handled before dispatch in main"),
}
}
fn set_disabled(disabled: bool) -> Result<()> {
let path = state_path().ok_or_else(|| {
Error::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Could not determine home directory",
))
})?;
save_state_to(&path, disabled).map_err(Error::Io)
}
fn print_status() {
if env_truthy(real_env_lookup(DNT_ENV)) {
println!("Telemetry is disabled (DO_NOT_TRACK environment variable is set).");
return;
}
let Some(path) = state_path() else {
println!("Telemetry is disabled (could not determine home directory).");
return;
};
match load_state_from(&path) {
State::Missing => {
println!("Telemetry is not yet configured; nothing has been sent.");
}
State::Disabled => {
println!("Telemetry is disabled ({}).", path.display());
}
State::Enabled => {
println!(
"Telemetry is enabled. Disable with `clickhousectl telemetry disable` or DO_NOT_TRACK=1.\nDetails: {DOCS_URL}"
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
fn env_of(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: std::collections::HashMap<String, String> = pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect();
move |key: &str| map.get(key).cloned()
}
fn invocation() -> Invocation {
Invocation {
command: "local list".into(),
flags: vec!["json".into()],
outcome: "ok",
suggestion: None,
}
}
#[test]
fn env_truthy_truth_table() {
assert!(!env_truthy(None));
assert!(!env_truthy(Some("".into())));
assert!(!env_truthy(Some("0".into())));
assert!(!env_truthy(Some("false".into())));
assert!(env_truthy(Some("1".into())));
assert!(env_truthy(Some("true".into())));
assert!(env_truthy(Some("anything".into())));
}
#[test]
fn do_not_track_wins_over_everything() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("telemetry.json");
save_state_to(&path, false).unwrap();
let env = env_of(&[("DO_NOT_TRACK", "1")]);
assert_eq!(decide(&path, &invocation(), 0, &env), Action::Silent);
}
#[test]
fn do_not_track_prevents_first_run_marker() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("telemetry.json");
let env = env_of(&[("DO_NOT_TRACK", "1")]);
assert_eq!(decide(&path, &invocation(), 0, &env), Action::Silent);
assert!(!path.exists(), "DNT must not write the marker file");
}
#[test]
fn first_run_writes_marker_and_notices_without_sending() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("telemetry.json");
let env = env_of(&[]);
assert_eq!(decide(&path, &invocation(), 0, &env), Action::Notice);
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents, r#"{"disabled":false}"#);
}
#[test]
fn unwritable_dir_fails_open_to_silent() {
let dir = tempfile::tempdir().unwrap();
let blocker = dir.path().join("blocker");
std::fs::write(&blocker, "").unwrap();
let path = blocker.join("telemetry.json");
let env = env_of(&[]);
assert_eq!(decide(&path, &invocation(), 0, &env), Action::Silent);
assert_eq!(decide(&path, &invocation(), 0, &env), Action::Silent);
}
#[test]
fn disabled_state_is_silent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("telemetry.json");
save_state_to(&path, true).unwrap();
let env = env_of(&[]);
assert_eq!(decide(&path, &invocation(), 0, &env), Action::Silent);
}
#[test]
fn corrupt_state_file_is_treated_as_disabled() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("telemetry.json");
std::fs::write(&path, "not json{{").unwrap();
assert_eq!(load_state_from(&path), State::Disabled);
let env = env_of(&[]);
assert_eq!(decide(&path, &invocation(), 0, &env), Action::Silent);
}
#[test]
fn enabled_state_sends_payload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("telemetry.json");
save_state_to(&path, false).unwrap();
let env = env_of(&[("CI", "1")]);
let Action::Send(json) = decide(&path, &invocation(), 4, &env) else {
panic!("expected Send");
};
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["command"], "local list");
assert_eq!(value["flags"], serde_json::json!(["json"]));
assert_eq!(value["exit_code"], 4);
assert_eq!(value["outcome"], "auth_required");
assert_eq!(value["suggestion"], serde_json::Value::Null);
assert_eq!(value["ci"], true);
assert_eq!(value["version"], env!("CARGO_PKG_VERSION"));
assert_eq!(value["os"], std::env::consts::OS);
assert_eq!(value["arch"], std::env::consts::ARCH);
}
#[test]
fn dispatched_outcome_derives_from_the_gh_style_exit_code() {
assert_eq!(dispatched_outcome("ok", 0), "ok");
assert_eq!(dispatched_outcome("ok", 1), "error");
assert_eq!(dispatched_outcome("ok", 2), "cancelled");
assert_eq!(dispatched_outcome("ok", 4), "auth_required");
assert_eq!(dispatched_outcome("ok", 3), "error");
assert_eq!(
dispatched_outcome("unknown_argument", 2),
"unknown_argument"
);
assert_eq!(dispatched_outcome("exec", 0), "exec");
}
fn decided_outcome(invocation: &Invocation, exit_code: i32) -> String {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("telemetry.json");
save_state_to(&path, false).unwrap();
let Action::Send(json) = decide(&path, invocation, exit_code, &env_of(&[])) else {
panic!("expected Send");
};
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
value["outcome"].as_str().unwrap().to_string()
}
#[test]
fn dispatched_payload_outcomes_track_the_exit_code() {
assert_eq!(decided_outcome(&invocation(), 0), "ok");
assert_eq!(decided_outcome(&invocation(), 1), "error");
assert_eq!(decided_outcome(&invocation(), 2), "cancelled");
}
#[test]
fn lossy_payload_outcome_is_not_rewritten_by_the_exit_code() {
let inv = Invocation {
command: "local".into(),
flags: vec![],
outcome: "unknown_argument",
suggestion: None,
};
assert_eq!(decided_outcome(&inv, 2), "unknown_argument");
}
#[test]
fn debug_env_prints_instead_of_sending() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("telemetry.json");
save_state_to(&path, false).unwrap();
let env = env_of(&[("CHCTL_TELEMETRY_DEBUG", "1")]);
assert!(matches!(
decide(&path, &invocation(), 0, &env),
Action::Debug(_)
));
}
#[test]
fn payload_serializes_exactly_the_wire_fields() {
let payload = build_payload(&invocation(), 0, &env_of(&[]));
let value = serde_json::to_value(&payload).unwrap();
let keys: Vec<&str> = value
.as_object()
.unwrap()
.keys()
.map(|k| k.as_str())
.collect();
assert_eq!(
keys,
[
"command",
"flags",
"exit_code",
"outcome",
"suggestion",
"is_agent",
"agent",
"ci",
"version",
"os",
"arch"
]
);
assert_eq!(
value["is_agent"].as_bool().unwrap(),
!value["agent"].is_null()
);
}
#[test]
fn flags_truncated_to_worker_cap() {
let inv = Invocation {
command: "x".into(),
flags: (0..100).map(|i| format!("flag-{i}")).collect(),
outcome: "ok",
suggestion: None,
};
let payload = build_payload(&inv, 0, &env_of(&[]));
assert_eq!(payload.flags.len(), MAX_FLAGS);
}
#[test]
fn claim_yields_true_exactly_once() {
let guard = AtomicBool::new(false);
assert!(claim(&guard));
assert!(!claim(&guard));
assert!(!claim(&guard));
}
#[test]
fn exec_invocation_rewrites_only_the_outcome() {
let stashed = Invocation {
command: "local client".into(),
flags: vec!["port".into()],
outcome: "ok",
suggestion: None,
};
let inv = exec_invocation(&stashed);
assert_eq!(inv.outcome, "exec");
assert_eq!(inv.command, "local client");
assert_eq!(inv.flags, ["port"]);
assert_eq!(inv.suggestion, None);
}
#[test]
fn exec_outcome_sends_the_expected_payload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("telemetry.json");
save_state_to(&path, false).unwrap();
let inv = exec_invocation(&Invocation {
command: "local client".into(),
flags: vec!["query".into()],
outcome: "ok",
suggestion: None,
});
let Action::Send(json) = decide(&path, &inv, 0, &env_of(&[])) else {
panic!("expected Send");
};
let value: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(value["command"], "local client");
assert_eq!(value["flags"], serde_json::json!(["query"]));
assert_eq!(value["outcome"], "exec");
assert_eq!(value["exit_code"], 0);
}
fn capture_from(args: &[&str]) -> Invocation {
let mut cmd = crate::cli::Cli::command();
let matches = cmd.try_get_matches_from_mut(args).unwrap();
capture(&cmd, &matches)
}
#[test]
fn capture_reports_names_only_never_values_or_positionals() {
let inv = capture_from(&[
"clickhousectl",
"cloud",
"--json",
"service",
"get",
"SECRET-SERVICE-ID",
"--org-id",
"SECRET-ORG",
]);
assert_eq!(inv.command, "cloud service get");
assert_eq!(inv.flags, ["json", "org-id"]);
let json = serde_json::to_string(&build_payload(&inv, 0, &env_of(&[]))).unwrap();
assert!(!json.contains("SECRET"), "payload leaked a value: {json}");
}
#[test]
fn capture_dedupes_propagated_global_flags() {
let inv = capture_from(&["clickhousectl", "cloud", "--json", "service", "list"]);
assert_eq!(inv.command, "cloud service list");
assert_eq!(inv.flags, ["json"]);
}
#[test]
fn capture_excludes_default_valued_args() {
use clap::{Arg, ArgAction, Command};
let mut cmd = Command::new("root").subcommand(
Command::new("sub")
.arg(Arg::new("level").long("level").default_value("info"))
.arg(
Arg::new("verbose")
.long("verbose")
.action(ArgAction::SetTrue),
)
.arg(Arg::new("target")),
);
let matches = cmd
.try_get_matches_from_mut(["root", "sub", "--verbose", "user-data"])
.unwrap();
let inv = capture(&cmd, &matches);
assert_eq!(inv.command, "sub");
assert_eq!(inv.flags, ["verbose"]);
}
#[test]
fn capture_with_no_flags_is_empty() {
let inv = capture_from(&["clickhousectl", "local", "list"]);
assert_eq!(inv.command, "local list");
assert!(inv.flags.is_empty());
}
fn capture_lossy_from(args: &[&str]) -> Invocation {
let mut cmd = crate::cli::Cli::command();
let argv: Vec<std::ffi::OsString> = args.iter().map(Into::into).collect();
let error = cmd
.try_get_matches_from_mut(&argv)
.expect_err("argv must fail to parse for capture_lossy tests");
capture_lossy(&mut cmd, &argv, &error)
}
#[test]
fn lossy_bare_invocation_is_missing_subcommand() {
let inv = capture_lossy_from(&["clickhousectl"]);
assert_eq!(inv.command, "");
assert!(inv.flags.is_empty());
assert_eq!(inv.outcome, "missing_subcommand");
assert_eq!(inv.suggestion, None);
}
#[test]
fn lossy_root_help_records_the_help_flag() {
let inv = capture_lossy_from(&["clickhousectl", "--help"]);
assert_eq!(inv.command, "");
assert_eq!(inv.flags, ["help"]);
assert_eq!(inv.outcome, "help");
}
#[test]
fn lossy_nested_help_keeps_the_command_path() {
let inv = capture_lossy_from(&["clickhousectl", "cloud", "service", "--help"]);
assert_eq!(inv.command, "cloud service");
assert_eq!(inv.flags, ["help"]);
assert_eq!(inv.outcome, "help");
}
#[test]
fn lossy_short_help_and_version_record_long_names() {
let inv = capture_lossy_from(&["clickhousectl", "local", "-h"]);
assert_eq!(inv.command, "local");
assert_eq!(inv.flags, ["help"]);
assert_eq!(inv.outcome, "help");
let inv = capture_lossy_from(&["clickhousectl", "-V"]);
assert_eq!(inv.command, "");
assert_eq!(inv.flags, ["version"]);
assert_eq!(inv.outcome, "version");
}
#[test]
fn lossy_typoed_subcommand_stops_and_carries_the_suggestion() {
let inv = capture_lossy_from(&["clickhousectl", "cloud", "servce", "list"]);
assert_eq!(inv.command, "cloud");
assert!(inv.flags.is_empty());
assert_eq!(inv.outcome, "invalid_subcommand");
assert_eq!(inv.suggestion.as_deref(), Some("service"));
let json = serde_json::to_string(&build_payload(&inv, 2, &env_of(&[]))).unwrap();
assert!(!json.contains("servce"), "typo leaked into payload: {json}");
}
#[test]
fn lossy_suggestion_picks_the_most_similar_candidate() {
use clap::Command;
use clap::error::{ContextKind, ContextValue};
let mut cmd = Command::new("root")
.subcommand(Command::new("stash"))
.subcommand(Command::new("start"));
let argv: Vec<std::ffi::OsString> = ["root", "starz"].iter().map(Into::into).collect();
let error = cmd.try_get_matches_from_mut(&argv).unwrap_err();
assert_eq!(
error.get(ContextKind::SuggestedSubcommand),
Some(&ContextValue::Strings(vec!["stash".into(), "start".into()]))
);
let inv = capture_lossy(&mut cmd, &argv, &error);
assert_eq!(inv.suggestion.as_deref(), Some("start"));
}
#[test]
fn lossy_flag_suggestion_records_the_bare_definition_name() {
let inv = capture_lossy_from(&["clickhousectl", "cloud", "service", "list", "--jsn"]);
assert_eq!(inv.outcome, "unknown_argument");
assert_eq!(inv.suggestion.as_deref(), Some("json"));
}
#[test]
fn suggestion_matching_no_definition_is_dropped() {
use clap::error::{ContextKind, ContextValue, ErrorKind};
let mut cmd = crate::cli::Cli::command();
cmd.build();
let mut error = clap::Error::new(ErrorKind::InvalidSubcommand);
error.insert(
ContextKind::SuggestedSubcommand,
ContextValue::Strings(vec!["not-a-defined-name".into()]),
);
assert_eq!(suggestion_for_error(&cmd, &error), None);
let mut error = clap::Error::new(ErrorKind::InvalidSubcommand);
error.insert(
ContextKind::SuggestedSubcommand,
ContextValue::Strings(vec!["service".into(), "not-a-defined-name".into()]),
);
assert_eq!(
suggestion_for_error(&cmd, &error).as_deref(),
Some("service")
);
}
#[test]
fn lossy_unknown_flag_stops_the_walk() {
let inv = capture_lossy_from(&["clickhousectl", "local", "--frobnicate", "list"]);
assert_eq!(inv.command, "local");
assert!(inv.flags.is_empty());
assert_eq!(inv.outcome, "unknown_argument");
let json = serde_json::to_string(&build_payload(&inv, 2, &env_of(&[]))).unwrap();
assert!(!json.contains("frobnicate"), "unknown flag leaked: {json}");
}
#[test]
fn lossy_flag_value_equal_to_a_subcommand_name_is_skipped() {
let inv = capture_lossy_from(&[
"clickhousectl",
"cloud",
"service",
"get",
"--org-id",
"list",
]);
assert_eq!(inv.command, "cloud service get");
assert_eq!(inv.flags, ["org-id"]);
assert_eq!(inv.outcome, "missing_required");
}
#[test]
fn lossy_inline_flag_value_is_discarded() {
let inv = capture_lossy_from(&[
"clickhousectl",
"cloud",
"service",
"list",
"--org-id=SECRET-ORG",
"junk-token",
]);
assert_eq!(inv.command, "cloud service list");
assert_eq!(inv.flags, ["org-id"]);
let json = serde_json::to_string(&build_payload(&inv, 2, &env_of(&[]))).unwrap();
assert!(!json.contains("SECRET"), "inline value leaked: {json}");
}
#[test]
fn lossy_long_aliases_record_the_canonical_names() {
let inv = capture_lossy_from(&[
"clickhousectl",
"local",
"server",
"start",
"--fg",
"--config-file",
"SECRET-CONFIG",
"--http-port",
"SECRET-PORT",
]);
assert_eq!(inv.command, "local server start");
assert_eq!(inv.flags, ["config", "foreground", "http-port"]);
assert_eq!(inv.outcome, "invalid_value");
let json = serde_json::to_string(&build_payload(&inv, 2, &env_of(&[]))).unwrap();
assert!(!json.contains("SECRET"), "flag value leaked: {json}");
}
#[test]
fn lossy_short_flag_value_equal_to_a_subcommand_name_is_skipped() {
let inv = capture_lossy_from(&[
"clickhousectl",
"local",
"client",
"-q",
"list",
"-p",
"SECRET-NOT-A-PORT",
]);
assert_eq!(inv.command, "local client");
assert_eq!(inv.flags, ["port", "query"]);
assert_eq!(inv.outcome, "invalid_value");
let json = serde_json::to_string(&build_payload(&inv, 2, &env_of(&[]))).unwrap();
assert!(!json.contains("SECRET"), "flag value leaked: {json}");
}
#[test]
fn lossy_short_cluster_resolves_each_char_and_discards_attached_value() {
use clap::{Arg, ArgAction, Command};
let mut cmd = Command::new("root").subcommand(
Command::new("sub")
.arg(
Arg::new("verbose")
.long("verbose")
.short('v')
.action(ArgAction::SetTrue),
)
.arg(Arg::new("quiet").short('q').action(ArgAction::SetTrue))
.arg(Arg::new("level").long("level").short('l').short_alias('L')),
);
let argv: Vec<std::ffi::OsString> = ["root", "sub", "-qvLSECRET-LEVEL", "junk-token"]
.iter()
.map(Into::into)
.collect();
let error = cmd.try_get_matches_from_mut(&argv).unwrap_err();
let inv = capture_lossy(&mut cmd, &argv, &error);
assert_eq!(inv.command, "sub");
assert_eq!(inv.flags, ["level", "quiet", "verbose"]);
let json = serde_json::to_string(&build_payload(&inv, 2, &env_of(&[]))).unwrap();
assert!(!json.contains("SECRET"), "attached value leaked: {json}");
}
#[test]
fn lossy_unknown_short_stops_the_walk() {
let inv = capture_lossy_from(&[
"clickhousectl",
"cloud",
"service",
"list",
"-Z",
"--org-id",
"SECRET-ORG",
]);
assert_eq!(inv.command, "cloud service list");
assert!(inv.flags.is_empty());
assert_eq!(inv.outcome, "unknown_argument");
let json = serde_json::to_string(&build_payload(&inv, 2, &env_of(&[]))).unwrap();
assert!(!json.contains("SECRET"), "post-break token leaked: {json}");
use clap::{Arg, ArgAction, Command};
let mut cmd = Command::new("root").subcommand(
Command::new("sub").arg(
Arg::new("verbose")
.long("verbose")
.short('v')
.action(ArgAction::SetTrue),
),
);
let argv: Vec<std::ffi::OsString> = ["root", "sub", "-vZ"].iter().map(Into::into).collect();
let error = cmd.try_get_matches_from_mut(&argv).unwrap_err();
let inv = capture_lossy(&mut cmd, &argv, &error);
assert_eq!(inv.command, "sub");
assert_eq!(inv.flags, ["verbose"]);
}
#[test]
fn lossy_double_dash_stops_the_walk() {
let inv = capture_lossy_from(&["clickhousectl", "local", "--", "SECRET-POSITIONAL"]);
assert_eq!(inv.command, "local");
assert!(inv.flags.is_empty());
let json = serde_json::to_string(&build_payload(&inv, 2, &env_of(&[]))).unwrap();
assert!(!json.contains("SECRET"), "post-`--` token leaked: {json}");
}
#[test]
fn lossy_hostile_argv_never_reaches_the_payload() {
let inv = capture_lossy_from(&[
"clickhousectl",
"cloud",
"--json",
"service",
"get",
"SECRET-SERVICE-ID",
"--org-id",
"SECRET-ORG",
"--wat",
"SECRET-TRAILING",
]);
assert_eq!(inv.command, "cloud service get");
assert_eq!(inv.outcome, "unknown_argument");
let json = serde_json::to_string(&build_payload(&inv, 2, &env_of(&[]))).unwrap();
assert!(!json.contains("SECRET"), "payload leaked a value: {json}");
}
#[test]
fn init_snapshots_the_executable_path_once() {
init();
let first = EXE_PATH.get().expect("init must snapshot the exe path");
init();
assert_eq!(EXE_PATH.get(), Some(first));
}
#[test]
fn state_path_is_telemetry_json_under_base_dir() {
let path = state_path().unwrap();
assert!(path.ends_with(".clickhouse/telemetry.json"));
}
}