use clap::{Parser, Subcommand};
const HELP_TEMPLATE: &str = "\
{name} {version}\n\
{about}\n\
\n\
USAGE:\n\
{usage}\n\
\n\
{all-args}{after-help}\
";
const AFTER_HELP: &str = "\
ENV:\n\
FORBIDDEN_STRINGS_RULES Default rules list; --rules replaces it if both are set\n\
(never merged). If unset, falls back to\n\
./forbidden-strings.local.txt\n\
FORBIDDEN_STRINGS_CACHE_DIR\n\
Absolute per-user cache-root override.\n\
\n\
RUNTIME CACHE:\n\
Runtime rules use read-write caching by default. Cache identity is exact\n\
rules content plus scanner version and platform. Missing or rejected cache\n\
data emits redacted JSON, compiles authoritative text, and attempts atomic\n\
repair. A failed repair never invalidates the in-memory scan.\n\
\n\
RULES LIST (--rules, FORBIDDEN_STRINGS_RULES):\n\
One value names one or more rules files, separated by commas, read in\n\
that order and scanned as one rule set. An entry ending in '?' may be\n\
absent; it is then skipped and logged. Every other entry must exist\n\
(exit 2, naming the path). Nothing is trimmed, and an empty entry, a\n\
bare '?', or a path named twice is an error. One '?' is the marker, so\n\
a path ending in '?' is named with a second one ('name??'). A path\n\
containing ',' cannot be named. All files must use one format (tail\n\
sections or legacy lines), and section names must be unique across them.\n\
If every entry is optional and absent, the scan fails (exit 2) unless\n\
--builtin-rules supplies the baseline.\n\
\n\
BUILT-IN BASELINE:\n\
--builtin-rules appends the embedded betterleaks-ported baseline after\n\
the resolved rules files (user rule numbering is unchanged). When the\n\
implicit default rules file is absent, the baseline alone is used; an\n\
explicitly named missing file (--rules or env) still errors unless its\n\
entry ends in '?'. Without the flag, the baseline is never read.\n\
\n\
EXIT CODES:\n\
0 No violations.\n\
1 One or more violations (printed to stderr, redacted).\n\
2 Usage error or rule-file error.\n\
\n\
EXAMPLES:\n\
# Scan a few files\n\
forbidden-strings --rules ./rules.txt src/main.ts README.md\n\
\n\
# Scan the whole working tree\n\
FORBIDDEN_STRINGS_RULES=./rules.txt forbidden-strings --all\n\
\n\
# Scan with a shared file plus an optional local one\n\
forbidden-strings --rules ./shared.txt,./local.txt? src/main.ts\n\
\n\
# Eagerly compile runtime rules without scanning\n\
forbidden-strings compile-rules --rules ./rules.txt\n\
\n\
RULE FORMAT (autodetected per file, never mixed):\n\
Tail format -> '==> name <==' headers open one-rule sections;\n\
the name ([a-z0-9], then [a-z0-9.-]) is the\n\
rule's identity in findings. One significant\n\
body line classifies as below; several are one\n\
verbatim always-verbose pattern.\n\
Legacy format -> one rule per line, unnamed:\n\
Bare line -> case-sensitive literal; under 8 bytes it is\n\
word-boundary gated at word-byte ends\n\
/PATTERN/FLAGS -> regex in the forbidden-regex dialect\n\
# ... -> comment\n\
Empty line -> skipped\n\
\n\
DIALECT:\n\
Supported: literals, classes [a-z] and \\d \\w \\s, '.', (?:a|b),\n\
bounded repetition a{3,6}, anchors ^ $ \\b, and set algebra A & B\n\
and ~(A). Flags 'm' and 'x' are accepted no-ops (multiline and\n\
verbose are always on); any other flag letter is a hard load error.\n\
Rejected at compile time (fail-closed): '*', '+', unbounded {n,},\n\
capturing '(', lookaround and inline flags, backreferences, and any\n\
pattern matching the empty string.\n\
\n\
OUTPUT:\n\
PATH:LINE rule=<token> Content finding (columnless).\n\
PATH:name:SEGMENT rule=<token> Pathname finding (columnless); SEGMENT\n\
counts directory names and filename from 1. Each name is matched\n\
separately, always on, with no flag to disable pathname scanning.\n\
Every offending pathname segment is replaced with [REDACTED] in all\n\
findings; matched substrings are NEVER printed. The token is the\n\
rule's section name (baseline uses betterleaks id); unnamed legacy\n\
rules use the 0-based index. --name-path PATH pairs a logical name\n\
with each positional content file, in order; these findings carry\n\
input=N to identify the 0-based operand without exposing its name.\n\
\n\
See README.md for the full dialect, set-algebra examples, and CI integration.\n\
";
#[derive(Subcommand, Debug, PartialEq)]
pub enum CliCommand {
#[command(name = "compile-rules")]
CompileRules {
#[arg(
long = "rules",
value_name = "PATH",
allow_hyphen_values = true,
help = "Comma-separated rules files to compile (a trailing '?' marks an optional entry)"
)]
rules_path: String,
},
}
impl CliCommand {
pub fn rules_path(&self) -> &str {
let Self::CompileRules { rules_path } = self;
return rules_path
}
}
#[derive(Parser, Debug, PartialEq)]
#[command(
name = "forbidden-strings",
version,
about = "Linear-time deny-list scanner for Git repos.",
help_template = HELP_TEMPLATE,
after_help = AFTER_HELP,
args_override_self = true,
)]
pub struct Cli {
#[command(subcommand)]
pub command: Option<CliCommand>,
#[arg(
long = "rules",
value_name = "PATH",
allow_hyphen_values = true,
help = "Comma-separated rule files (tail-format sections or legacy lines); a trailing '?' marks an optional entry"
)]
pub rules_path: Option<String>,
#[arg(long = "all", help = "Scan every git-tracked file under cwd")]
pub all: bool,
#[arg(
long = "builtin-rules",
help = "Also scan with the embedded betterleaks-ported baseline rules"
)]
pub builtin_rules: bool,
#[arg(long = "name-path", value_name = "PATH", allow_hyphen_values = true)]
pub name_paths: Vec<String>,
#[arg(value_name = "FILE", num_args = 0.., help = "File path to scan")]
pub files: Vec<String>,
}