use std::collections::BTreeSet;
use keyhog::args::command;
const BASE_SUBCOMMANDS: &[&str] = &[
"action-report",
"backend",
"bloom-diagnostic",
"calibrate",
"calibrate-autoroute",
"compile-execution-packs",
"completion",
"config",
"daemon",
"detectors",
"diff",
"doctor",
"explain",
"guard",
"hook",
"repair",
"scan",
"scan-system",
"uninstall",
"update",
"watch",
];
fn expected_subcommands() -> BTreeSet<String> {
BASE_SUBCOMMANDS.iter().map(|s| s.to_string()).collect()
}
const BASE_SCAN_LONG_FLAGS: &[&str] = &[
"access-targets",
"action-receipt",
"autoroute-cache",
"matcher-cache",
"autoroute-calibrate",
"autoroute-gpu",
"backend",
"baseline",
"batch-pipeline",
"benchmark",
"cache-dir",
"calibration-cache",
"config",
"correlate",
"create-baseline",
"daemon",
"daemon-socket",
"decode-depth",
"decode-size-limit",
"dedup",
"deep",
"detectors",
"detectors-mode",
"dogfood",
"entropy-bpe-max-bytes-per-token",
"entropy-source-files",
"entropy-threshold",
"exclude-paths",
"fast",
"format",
"fused-batch",
"fused-depth",
"hide-client-safe",
"incremental",
"incremental-cache",
"limit-stdin-bytes",
"lockdown",
"max-file-size",
"gpu-batch-input-limit",
"min-confidence",
"min-secret-len",
"ml-threshold",
"ml-weight",
"no-config",
"no-autoroute-gpu",
"no-batch-pipeline",
"no-decode",
"no-default-excludes",
"no-entropy",
"no-entropy-ml-scoring",
"no-keyword-low-entropy",
"no-ml",
"no-gpu",
"no-color",
"no-suppress-test-fixtures",
"no-unicode-norm",
"no-verify",
"output",
"path",
"per-chunk-timeout-ms",
"perf-trace",
"precision",
"profile",
"profile-out",
"progress",
"quiet",
"regex-dfa-limit",
"reader-threads",
"require-gpu",
"scan-comments",
"severity",
"show-secrets",
"source",
"stdin",
"stream",
"threads",
"timeout",
"update-baseline",
];
fn expected_scan_long_flags() -> BTreeSet<String> {
let mut set: BTreeSet<String> = BASE_SCAN_LONG_FLAGS.iter().map(|s| s.to_string()).collect();
let mut add = |f: &str| {
set.insert(f.to_string());
};
#[cfg(feature = "git")]
{
add("git-blobs");
add("git-diff");
add("git-diff-path");
add("git-history");
add("git-staged");
add("limit-git-blob-bytes");
add("limit-git-chunks");
add("limit-git-line-bytes");
add("limit-git-total-bytes");
add("max-commits");
}
#[cfg(feature = "github")]
{
add("github-all");
add("github-api-endpoint");
add("github-collaboration");
add("github-discussions");
add("github-gists");
add("github-issues");
add("github-releases");
add("github-org");
add("github-pull-requests");
add("github-token");
add("github-wiki");
add("github-wiki-url");
}
#[cfg(feature = "gitlab")]
{
add("gitlab-endpoint");
add("gitlab-group");
add("gitlab-token");
}
#[cfg(feature = "bitbucket")]
{
add("bitbucket-endpoint");
add("bitbucket-token");
add("bitbucket-username");
add("bitbucket-workspace");
}
#[cfg(feature = "gcs")]
{
add("allow-gcs-token-forward");
add("gcs-bucket");
add("gcs-endpoint");
add("gcs-prefix");
add("limit-gcs-object-bytes");
}
#[cfg(feature = "azure")]
{
add("azure-container-url");
add("azure-prefix");
add("limit-azure-blob-bytes");
}
#[cfg(feature = "s3")]
{
add("allow-s3-credential-forward");
add("limit-s3-object-bytes");
add("s3-bucket");
add("s3-endpoint");
add("s3-prefix");
}
#[cfg(any(feature = "s3", feature = "gcs", feature = "azure"))]
{
add("allow-private-cloud-endpoint");
add("limit-cloud-max-objects");
}
#[cfg(any(feature = "github", feature = "gitlab", feature = "bitbucket"))]
{
add("limit-hosted-git-pages");
}
#[cfg(feature = "docker")]
{
add("docker-image");
add("limit-docker-image-config-bytes");
add("limit-docker-tar-entry-bytes");
add("limit-docker-tar-total-bytes");
}
#[cfg(feature = "web")]
{
add("limit-web-response-bytes");
add("url");
}
#[cfg(feature = "binary")]
{
add("binary");
add("limit-binary-decompiled-bytes");
add("limit-binary-read-bytes");
}
#[cfg(any(
feature = "web",
feature = "github",
feature = "gitlab",
feature = "bitbucket",
feature = "s3",
feature = "gcs",
feature = "azure"
))]
{
add("proxy");
add("insecure");
}
#[cfg(feature = "verify")]
{
add("allow-script-verify");
add("verify");
add("verify-batch");
add("verify-concurrency");
add("verify-oob");
add("verify-rate");
add("oob-server");
add("oob-timeout");
}
let _ = &mut add;
set
}
fn diff_message(kind: &str, expected: &BTreeSet<String>, actual: &BTreeSet<String>) -> String {
let added: Vec<&String> = actual.difference(expected).collect();
let removed: Vec<&String> = expected.difference(actual).collect();
format!(
"{kind} surface drifted from the pinned snapshot (CLI-08).\n \
ADDED (present in binary, missing from snapshot): {added:?}\n \
REMOVED (in snapshot, gone from binary): {removed:?}\n \
If this change is intentional, update {} in \
crates/cli/tests/gate/cli_surface_snapshot.rs in the SAME commit \
(a feature-gated flag goes in the matching `#[cfg]` block of the \
builder, NOT the base list).",
if kind == "subcommand" {
"BASE_SUBCOMMANDS / expected_subcommands()"
} else {
"BASE_SCAN_LONG_FLAGS / expected_scan_long_flags()"
},
)
}
#[test]
fn top_level_subcommand_set_matches_pinned_snapshot() {
let cmd = command();
let actual: BTreeSet<String> = cmd
.get_subcommands()
.map(|s| s.get_name().to_string())
.filter(|name| name != "help")
.collect();
let expected = expected_subcommands();
assert_eq!(
actual,
expected,
"{}",
diff_message("subcommand", &expected, &actual)
);
}
#[test]
fn scan_long_flag_set_matches_pinned_snapshot() {
let cmd = command();
let scan = cmd
.get_subcommands()
.find(|s| s.get_name() == "scan")
.expect("scan subcommand must exist");
let actual: BTreeSet<String> = scan
.get_arguments()
.filter_map(|a| a.get_long())
.map(str::to_string)
.filter(|long| long != "help")
.collect();
let expected = expected_scan_long_flags();
assert_eq!(
actual,
expected,
"{}",
diff_message("scan flag", &expected, &actual)
);
}