use std::path::PathBuf;
use clap::builder::{PossibleValuesParser, TypedValueParser};
use clap::{ArgAction, Args as ClapArgs, Parser, Subcommand, ValueEnum};
use crate::extract::Predicate;
use code_moniker_core::core::moniker::Moniker;
use code_moniker_core::core::shape::Shape;
use code_moniker_core::core::uri::{UriConfig, from_uri};
const ASCII_LOGO: &str = "
â—† code+moniker://
└─◆ lang:ts
└─◆ class:Util
";
#[derive(Debug, Parser)]
#[command(name = "code-moniker", before_help = ASCII_LOGO, version)]
pub struct Cli {
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
#[command(about = "Extract a moniker graph from a file or directory.")]
Extract(ExtractArgs),
#[command(about = "Report extraction metrics for a file or directory.")]
Stats(StatsArgs),
#[command(about = "Lint a path against .code-moniker.toml rules.")]
Check(CheckArgs),
#[command(about = "Report git changes as symbol-level facts instead of lines.")]
Diff(DiffArgs),
#[command(about = "Create and toggle the project rules file.")]
Rules(RulesArgs),
#[cfg(feature = "tui")]
#[command(about = "Open a read-only terminal architecture explorer.")]
Ui(UiArgs),
#[cfg(feature = "mcp")]
#[command(about = "Start a local stateless MCP HTTP endpoint.")]
Mcp(McpArgs),
#[command(about = "Manage a code-moniker workspace daemon.")]
Daemon(DaemonArgs),
#[command(about = "Send a human-readable query DSL request to a workspace daemon.")]
Query(QueryArgs),
#[command(about = "Install live agent harness configuration.")]
Harness(HarnessArgs),
#[command(about = "List supported languages, or kinds of one.")]
Langs(LangsArgs),
#[command(about = "Show the shape vocabulary.")]
Shapes(ShapesArgs),
#[command(
about = "Extract declared dependencies from a build manifest (auto-detected by filename) or every manifest under a directory."
)]
Manifest(ManifestArgs),
}
#[derive(Debug, ClapArgs)]
pub struct DaemonArgs {
#[command(subcommand)]
pub command: DaemonCommand,
}
#[derive(Debug, Subcommand)]
pub enum DaemonCommand {
#[command(about = "Start a foreground daemon for a workspace root.")]
Start(DaemonRootArgs),
#[command(about = "Print daemon status for a workspace root.")]
Status(DaemonRootArgs),
#[command(about = "Ask a workspace daemon to shut down.")]
Stop(DaemonRootArgs),
#[command(about = "List daemon registry entries.")]
List,
}
#[derive(Debug, ClapArgs)]
pub struct DaemonRootArgs {
#[arg(value_name = "WORKSPACE_ROOT", default_value = ".", num_args = 1..)]
pub workspace_roots: Vec<PathBuf>,
#[arg(
long,
value_name = "NAME",
help = "project component of the anchor moniker; defaults to '.'"
)]
pub project: Option<String>,
#[arg(
long,
value_name = "DIR",
env = "CODE_MONIKER_CACHE_DIR",
help = "enable on-disk cache of extracted graphs at DIR (empty = disabled)"
)]
pub cache: Option<PathBuf>,
#[arg(
long,
value_enum,
default_value_t = LiveRefresh::OnDemand,
help = "live index policy attached to this daemon identity"
)]
pub live_refresh: LiveRefresh,
}
#[derive(Debug, ClapArgs)]
pub struct QueryArgs {
#[arg(
short = 'r',
long = "root",
value_name = "WORKSPACE_ROOT",
action = ArgAction::Append,
help = "Workspace root attached to the daemon. Repeat for multi-root sessions."
)]
pub workspace_roots: Vec<PathBuf>,
#[arg(
long,
value_name = "NAME",
help = "project component of the daemon identity; defaults to '.'"
)]
pub project: Option<String>,
#[arg(
long,
value_name = "DIR",
env = "CODE_MONIKER_CACHE_DIR",
help = "on-disk cache directory attached to the daemon identity"
)]
pub cache: Option<PathBuf>,
#[arg(
long,
value_enum,
default_value_t = LiveRefresh::OnDemand,
help = "live index policy attached to the daemon identity"
)]
pub live_refresh: LiveRefresh,
#[arg(value_name = "QUERY")]
pub query: String,
#[arg(long, help = "Print the structured query response DTO as JSON.")]
pub json: bool,
#[arg(
long,
value_enum,
default_value_t = QueryConsistency::StaleOk,
help = "Snapshot freshness: stale-ok answers immediately, refresh-if-stale reindexes first, current fails on a stale workspace. A `consistency` field in the query text wins."
)]
pub(crate) consistency: QueryConsistency,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub(crate) enum QueryConsistency {
StaleOk,
RefreshIfStale,
Current,
}
#[derive(Debug, ClapArgs)]
pub struct HarnessArgs {
#[command(subcommand)]
pub command: HarnessCommand,
}
#[derive(Debug, ClapArgs)]
pub struct RulesArgs {
#[command(subcommand)]
pub command: RulesCommand,
}
#[derive(Debug, Subcommand)]
pub enum RulesCommand {
#[command(about = "Create a project .code-moniker.toml with detected aliases.")]
Init(RulesFileArgs),
#[command(about = "Disable embedded default rules in .code-moniker.toml.")]
Disable(RulesFileArgs),
#[command(about = "Enable embedded default rules in .code-moniker.toml.")]
Enable(RulesFileArgs),
#[command(about = "Show the effective compiled rules after defaults, overlay, and profile.")]
Show(RulesShowArgs),
#[command(about = "Print focused DSL documentation and copyable rule snippets.")]
Learn(RulesLearnArgs),
#[command(about = "Evaluate a rules TOML fragment against a source file or stdin.")]
Eval(RulesEvalArgs),
}
#[derive(Debug, ClapArgs)]
pub struct RulesFileArgs {
#[arg(value_name = "ROOT", default_value = ".")]
pub root: PathBuf,
#[arg(
long,
value_name = "PATH",
default_value = ".code-moniker.toml",
help = "project rules file, resolved from ROOT unless absolute"
)]
pub rules: PathBuf,
}
#[derive(Debug, ClapArgs)]
pub struct RulesShowArgs {
#[arg(value_name = "ROOT", default_value = ".")]
pub root: PathBuf,
#[arg(
long,
value_name = "PATH",
default_value = ".code-moniker.toml",
help = "project rules file, resolved from ROOT unless absolute"
)]
pub rules: PathBuf,
#[arg(
long,
value_enum,
default_value_t = RulesShowFormat::Text,
help = "output format"
)]
pub format: RulesShowFormat,
#[arg(
long = "default-rules",
value_enum,
help = "override whether embedded default rules are loaded before applying --rules"
)]
pub default_rules: Option<DefaultRules>,
#[arg(
long,
value_name = "NAME",
help = "filter rules through a named profile from .code-moniker.toml"
)]
pub profile: Option<String>,
}
#[derive(Debug, ClapArgs)]
pub struct RulesLearnArgs {
#[arg(
value_name = "TOPIC",
help = "DSL topic to print, for example basics, paths, refs, collections, or metrics; omit to print every topic"
)]
pub topic: Option<String>,
#[arg(
long,
value_enum,
default_value_t = RulesLearnFormat::Text,
help = "output format"
)]
pub format: RulesLearnFormat,
}
#[derive(Debug, ClapArgs)]
pub struct RulesEvalArgs {
#[arg(
long,
value_name = "PATH",
help = "rules TOML to evaluate: a real .code-moniker.toml fragment (rules, aliases, profiles)"
)]
pub rules: PathBuf,
#[arg(
long,
value_name = "TAG",
help = "language tag of the sample, for example rs, ts, py, go, java, cs, sql"
)]
pub lang: String,
#[arg(
long,
value_name = "NAME",
help = "filter rules through a named profile from the rules TOML"
)]
pub profile: Option<String>,
#[arg(
long = "default-rules",
value_enum,
help = "override whether embedded default rules are loaded before applying the rules TOML"
)]
pub default_rules: Option<DefaultRules>,
#[arg(
long,
value_enum,
default_value_t = RulesShowFormat::Text,
help = "output format"
)]
pub format: RulesShowFormat,
#[arg(
value_name = "FILE",
help = "sample source file to evaluate; reads stdin when omitted"
)]
pub source: Option<PathBuf>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum RulesShowFormat {
Text,
Json,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum RulesLearnFormat {
Text,
Json,
}
#[derive(Debug, Subcommand)]
pub enum HarnessCommand {
#[command(about = "Install a project-local Codex PostToolUse hook.")]
Codex(CodexHarnessArgs),
#[command(about = "Install a project-local Claude Code PostToolUse hook.")]
Claude(CodexHarnessArgs),
#[command(about = "Install a project-local Gemini CLI AfterTool hook.")]
Gemini(CodexHarnessArgs),
#[command(hide = true)]
ToolFiles(HarnessToolFilesArgs),
}
#[derive(Debug, ClapArgs)]
pub struct CodexHarnessArgs {
#[arg(value_name = "ROOT", default_value = ".")]
pub root: PathBuf,
#[arg(
long,
value_name = "PATH",
default_value = ".code-moniker.toml",
help = "project rules file, resolved from ROOT unless absolute"
)]
pub rules: PathBuf,
#[arg(
long,
value_name = "NAME",
help = "optional profile that the live harness must run"
)]
pub profile: Option<String>,
#[arg(
long,
value_name = "PATH",
default_value = ".",
help = "project scope checked by the hook, resolved from ROOT"
)]
pub scope: PathBuf,
#[arg(
long,
value_name = "N",
default_value_t = 10,
help = "maximum violations printed by the generated live harness check"
)]
pub max_violations: usize,
}
#[derive(Debug, ClapArgs)]
pub struct HarnessToolFilesArgs {
#[arg(value_enum)]
pub backend: HarnessToolBackend,
#[arg(value_name = "HOOK_INPUT_JSON")]
pub input: PathBuf,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum HarnessToolBackend {
Codex,
Claude,
Gemini,
}
#[derive(Debug, ClapArgs)]
pub struct ShapesArgs {
#[arg(long, value_enum, default_value_t = LangsFormat::Text)]
pub format: LangsFormat,
}
#[derive(Debug, ClapArgs)]
pub struct LangsArgs {
#[arg(
value_name = "LANG",
help = "language tag (e.g. rs, ts, java, python, go, cs, sql); omit to list every tag"
)]
pub lang: Option<String>,
#[arg(long, value_enum, default_value_t = LangsFormat::Text)]
pub format: LangsFormat,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum LangsFormat {
Text,
Json,
}
#[derive(Debug, ClapArgs)]
pub struct CheckArgs {
#[arg(value_name = "PATH")]
pub path: PathBuf,
#[arg(
long,
value_name = "PATH",
default_value = ".code-moniker.toml",
help = "user TOML rules file; missing file is ignored"
)]
pub rules: PathBuf,
#[arg(
long = "rules-inline",
value_name = "TOML",
action = ArgAction::Append,
help = "inline TOML rules overlay; repeatable and merged after --rules fragments"
)]
pub rules_inline: Vec<String>,
#[arg(long, value_enum, default_value_t = CheckFormat::Text)]
pub format: CheckFormat,
#[arg(
long = "default-rules",
value_enum,
help = "override whether embedded default rules are loaded before applying --rules"
)]
pub default_rules: Option<DefaultRules>,
#[arg(
long,
help = "print per-rule observability, including implication antecedent hit counts"
)]
pub report: bool,
#[arg(
long,
value_name = "NAME",
help = "filter rules through a named profile from .code-moniker.toml"
)]
pub profile: Option<String>,
#[arg(
long,
value_name = "N",
help = "maximum violations to print in text/codex-hook output; selects the largest failed rule group and shows the first N by path"
)]
pub max_violations: Option<usize>,
#[arg(
long = "file",
value_name = "PATH",
help = "when PATH is a directory, check only this touched file; repeatable"
)]
pub files: Vec<PathBuf>,
#[arg(
long,
value_name = "PATH",
hide = true,
help = "unstable: run an executable scenario document (`-` reads stdin); PATH is ignored"
)]
pub scenario: Option<PathBuf>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum CheckFormat {
Text,
Json,
CodexHook,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum DefaultRules {
On,
Off,
}
#[derive(Debug, ClapArgs)]
pub struct DiffArgs {
#[arg(
value_name = "RANGE_OR_PATH",
default_value = ".",
help = "a <base>..<head> / <base>...<head> revision range, or the workspace path"
)]
pub target: String,
#[arg(
value_name = "PATH",
help = "workspace path when the first argument is a range"
)]
pub path: Option<PathBuf>,
#[arg(
long,
value_name = "REV",
help = "base revision compared against the worktree (conflicts with a range)"
)]
pub base: Option<String>,
#[arg(long, value_enum, default_value_t = DiffFormat::Text)]
pub(crate) format: DiffFormat,
#[arg(
long,
help = "detail retargeted references instead of the collapsed per-file count"
)]
pub refs: bool,
#[arg(
long,
value_name = "NAME",
help = "project name for moniker anchors (defaults to the directory name)"
)]
pub project: Option<String>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub(crate) enum DiffFormat {
Text,
Json,
}
impl DefaultRules {
pub fn enabled(self) -> bool {
matches!(self, Self::On)
}
}
#[derive(Copy, Clone, Debug, Default, Eq, PartialEq, ValueEnum)]
pub enum LiveRefresh {
#[default]
OnDemand,
Auto,
}
impl LiveRefresh {
pub fn is_on_demand(self) -> bool {
matches!(self, Self::OnDemand)
}
}
#[cfg(feature = "tui")]
#[derive(Debug, ClapArgs)]
pub struct UiArgs {
#[arg(value_name = "PATH", default_value = ".", num_args = 1..)]
pub paths: Vec<PathBuf>,
#[arg(
long,
value_name = "SCHEME",
help = "URI scheme; defaults to code+moniker://"
)]
pub scheme: Option<String>,
#[arg(
long,
value_name = "NAME",
help = "project component of the anchor moniker; defaults to '.'"
)]
pub project: Option<String>,
#[arg(
long,
value_name = "DIR",
env = "CODE_MONIKER_CACHE_DIR",
help = "enable on-disk cache of extracted graphs at DIR (empty = disabled)"
)]
pub cache: Option<PathBuf>,
#[arg(
long,
value_name = "PATH",
default_value = ".code-moniker.toml",
help = "user TOML overlay for the check view"
)]
pub rules: PathBuf,
#[arg(
long,
value_name = "NAME",
help = "filter check rules through a named profile from .code-moniker.toml"
)]
pub profile: Option<String>,
#[arg(long, help = "show TUI component debug markers")]
pub debug: bool,
#[arg(
long,
value_enum,
default_value_t = LiveRefresh::OnDemand,
help = "live index policy: on-demand marks changes stale until a manual refresh; auto refreshes on every change"
)]
pub live_refresh: LiveRefresh,
}
#[cfg(feature = "mcp")]
#[derive(Debug, ClapArgs)]
pub struct McpArgs {
#[arg(value_name = "PATH", default_value = ".", num_args = 1..)]
pub paths: Vec<PathBuf>,
#[arg(
long,
value_name = "SCHEME",
help = "URI scheme; defaults to code+moniker://"
)]
pub scheme: Option<String>,
#[arg(
long,
value_name = "NAME",
help = "project component of the anchor moniker; defaults to '.'"
)]
pub project: Option<String>,
#[arg(
long,
value_name = "DIR",
env = "CODE_MONIKER_CACHE_DIR",
help = "enable on-disk cache of extracted graphs at DIR (empty = disabled)"
)]
pub cache: Option<PathBuf>,
#[arg(
long,
alias = "mcp-port",
value_name = "PORT",
default_value_t = 3210,
help = "TCP port for the MCP endpoint"
)]
pub port: u16,
#[arg(
long,
value_enum,
default_value_t = LiveRefresh::OnDemand,
help = "live index policy: on-demand marks changes stale until the refresh tool runs; auto refreshes on every change"
)]
pub live_refresh: LiveRefresh,
}
#[derive(Debug, ClapArgs)]
pub struct StatsArgs {
#[arg(value_name = "PATH", num_args = 1..)]
pub paths: Vec<PathBuf>,
#[arg(long, value_enum, default_value_t = StatsFormat::Tsv)]
pub format: StatsFormat,
#[arg(
long,
value_enum,
default_value_t = ColorChoice::Auto,
help = "ANSI color for --format tree: auto = on if stdout is a TTY (honors NO_COLOR / CLICOLOR / CLICOLOR_FORCE)"
)]
pub color: ColorChoice,
#[arg(
long,
value_enum,
default_value_t = Charset::Utf8,
help = "glyph set for --format tree"
)]
pub charset: Charset,
#[arg(
long,
value_name = "NAME",
help = "project component of the anchor moniker; defaults to '.'"
)]
pub project: Option<String>,
#[arg(
long,
value_name = "DIR",
env = "CODE_MONIKER_CACHE_DIR",
help = "enable on-disk cache of extracted graphs at DIR (empty = disabled)"
)]
pub cache: Option<PathBuf>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum StatsFormat {
Tsv,
Json,
#[cfg(feature = "pretty")]
Tree,
}
#[derive(Debug, ClapArgs)]
pub struct ExtractArgs {
#[arg(value_name = "PATH")]
pub path: PathBuf,
#[arg(
long = "where",
value_name = "OP URI",
help = "predicate `<op> <uri>` where op ∈ {=, <, <=, >, >=, @>, <@, ?=}; repeatable, AND-combined"
)]
pub where_: Vec<String>,
#[arg(
long,
value_name = "NAME",
value_delimiter = ',',
help = "concrete kind (e.g. class, fn, calls); repeatable or comma-separated; OR within --kind, AND with --shape. Discover values per language with `code-moniker langs <TAG>`."
)]
pub kind: Vec<String>,
#[arg(
long,
value_name = "REGEX",
help = "regex matched against the last moniker segment name; repeatable; OR within --name, AND with --kind/--shape/--where"
)]
pub name: Vec<String>,
#[arg(
long,
value_name = "SHAPE",
value_delimiter = ',',
value_parser = shape_parser(),
help = "kind family; repeatable or comma-separated; OR within --shape, AND with --kind. See `code-moniker shapes`."
)]
pub shape: Vec<Shape>,
#[arg(long, value_enum, default_value_t = OutputFormat::Text)]
pub format: OutputFormat,
#[arg(
long,
visible_alias = "max-symbols",
value_name = "N",
default_value_t = 1000,
value_parser = parse_positive_usize,
conflicts_with_all = ["all", "count", "quiet"],
help = "maximum matched monikers to emit; use --all to bypass"
)]
pub limit: usize,
#[arg(
long,
value_name = "MONIKER_URI",
conflicts_with_all = ["all", "count", "quiet"],
help = "resume after this moniker URI"
)]
pub after: Option<String>,
#[arg(
long,
conflicts_with_all = ["count", "quiet"],
help = "emit all matched monikers, bypassing --limit"
)]
pub all: bool,
#[arg(
long,
value_enum,
default_value_t = MonikerFormat::Compact,
help = "moniker rendering for text and TSV output"
)]
pub moniker_format: MonikerFormat,
#[arg(
long,
short = 'c',
value_enum,
default_value_t = ColorChoice::Auto,
default_missing_value = "always",
num_args = 0..=1,
require_equals = false,
help = "ANSI color for --format text/tree: auto = on if stdout is a TTY (honors NO_COLOR / CLICOLOR / CLICOLOR_FORCE); -c forces color"
)]
pub color: ColorChoice,
#[arg(
long,
value_enum,
default_value_t = Charset::Utf8,
help = "glyph set for --format tree"
)]
pub charset: Charset,
#[arg(long, conflicts_with = "quiet", help = "print only the match count")]
pub count: bool,
#[arg(
long,
conflicts_with = "count",
help = "suppress output, exit code only"
)]
pub quiet: bool,
#[arg(long = "with-text", help = "include comment text (re-reads source)")]
pub with_text: bool,
#[arg(
long = "path",
value_name = "GLOB",
help = "only extract files whose path relative to PATH matches this glob; repeatable, OR-combined"
)]
pub path_filter: Vec<String>,
#[arg(
long,
value_name = "SCHEME",
help = "URI scheme; defaults to code+moniker://"
)]
pub scheme: Option<String>,
#[arg(
long,
value_name = "NAME",
help = "project component of the anchor moniker; defaults to '.'"
)]
pub project: Option<String>,
#[arg(
long,
value_name = "DIR",
env = "CODE_MONIKER_CACHE_DIR",
help = "enable on-disk cache of extracted graphs at DIR (empty = disabled)"
)]
pub cache: Option<PathBuf>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum OutputFormat {
#[value(alias = "txt")]
Text,
Tsv,
Json,
#[cfg(feature = "pretty")]
Tree,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum MonikerFormat {
Compact,
Uri,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum ColorChoice {
Auto,
Always,
Never,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum Charset {
Utf8,
Ascii,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
pub enum OutputMode {
Default,
Count,
Quiet,
}
#[derive(Debug, ClapArgs)]
pub struct ManifestArgs {
#[arg(
value_name = "PATH",
help = "manifest file (Cargo.toml / package.json / pom.xml / pyproject.toml / go.mod / *.csproj) or a directory to walk for any of those"
)]
pub path: PathBuf,
#[arg(long, value_enum, default_value_t = ManifestFormat::Tsv)]
pub format: ManifestFormat,
#[arg(long, conflicts_with = "quiet", help = "print only the row count")]
pub count: bool,
#[arg(
long,
conflicts_with = "count",
help = "suppress output, exit code only"
)]
pub quiet: bool,
#[arg(
long,
value_name = "SCHEME",
help = "URI scheme for package_moniker; defaults to code+moniker://"
)]
pub scheme: Option<String>,
}
#[derive(Copy, Clone, Debug, Eq, PartialEq, ValueEnum)]
pub enum ManifestFormat {
Tsv,
Json,
#[cfg(feature = "pretty")]
Tree,
}
impl ManifestArgs {
pub fn mode(&self) -> OutputMode {
if self.count {
OutputMode::Count
} else if self.quiet {
OutputMode::Quiet
} else {
OutputMode::Default
}
}
}
impl ExtractArgs {
#[cfg(test)]
pub(crate) fn for_tests() -> Self {
ExtractArgs {
path: "a.ts".into(),
where_: Vec::new(),
kind: vec![],
name: vec![],
shape: vec![],
format: OutputFormat::Text,
limit: 1000,
after: None,
all: false,
moniker_format: MonikerFormat::Compact,
color: ColorChoice::Never,
charset: Charset::Utf8,
count: false,
quiet: false,
with_text: false,
path_filter: Vec::new(),
scheme: None,
project: None,
cache: None,
}
}
pub fn mode(&self) -> OutputMode {
if self.count {
OutputMode::Count
} else if self.quiet {
OutputMode::Quiet
} else {
OutputMode::Default
}
}
pub fn compiled_predicates(&self, default_scheme: &str) -> anyhow::Result<Vec<Predicate>> {
let scheme = self.scheme.as_deref().unwrap_or(default_scheme);
let cfg = UriConfig { scheme };
let mut out = Vec::with_capacity(self.where_.len());
for raw in &self.where_ {
out.push(parse_where(raw, &cfg)?);
}
Ok(out)
}
}
fn shape_parser() -> impl TypedValueParser<Value = Shape> {
PossibleValuesParser::new(Shape::ALL.iter().map(|s| s.as_str())).map(|s| {
Shape::ALL
.iter()
.copied()
.find(|shape| shape.as_str() == s)
.unwrap_or(Shape::Ref)
})
}
fn parse_positive_usize(raw: &str) -> Result<usize, String> {
let n = raw
.parse::<usize>()
.map_err(|e| format!("expected positive integer: {e}"))?;
if n == 0 {
return Err("expected positive integer greater than zero".to_string());
}
Ok(n)
}
const CLI_TWO_CHAR_OPS: &[&str] = &["<=", ">=", "<@", "@>", "?="];
fn parse_where(raw: &str, cfg: &UriConfig<'_>) -> anyhow::Result<Predicate> {
let raw = raw.trim();
let bail = || {
anyhow::anyhow!("--where `{raw}`: expected `<op> <uri>` (op ∈ =, <=, >=, <, >, @>, <@, ?=)")
};
for op in CLI_TWO_CHAR_OPS {
if let Some(rest) = raw.strip_prefix(op) {
return finish_where(op, rest.trim(), cfg, raw);
}
}
for &op in &["<", ">", "="] {
if let Some(rest) = raw.strip_prefix(op) {
return finish_where(op, rest.trim(), cfg, raw);
}
}
Err(bail())
}
fn finish_where(op: &str, uri: &str, cfg: &UriConfig<'_>, raw: &str) -> anyhow::Result<Predicate> {
if uri.is_empty() {
return Err(anyhow::anyhow!("--where `{raw}`: missing URI after `{op}`"));
}
let m: Moniker = from_uri(uri, cfg).map_err(|e| anyhow::anyhow!("--where `{raw}`: {e}"))?;
Ok(match op {
"=" => Predicate::Eq(m),
"<" => Predicate::Lt(m),
"<=" => Predicate::Le(m),
">" => Predicate::Gt(m),
">=" => Predicate::Ge(m),
"@>" => Predicate::AncestorOf(m),
"<@" => Predicate::DescendantOf(m),
"?=" => Predicate::Bind(m),
_ => unreachable!("op set is whitelisted via CLI_TWO_CHAR_OPS / single-char fallthrough"),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(argv: &[&str]) -> Result<Cli, clap::Error> {
let mut full = vec!["code-moniker"];
full.extend_from_slice(argv);
Cli::try_parse_from(full)
}
fn extract(argv: &[&str]) -> ExtractArgs {
let mut full = vec!["extract"];
full.extend_from_slice(argv);
let cli = parse(&full).unwrap();
match cli.command {
Command::Extract(a) => a,
other => panic!("expected Extract, got {other:?}"),
}
}
fn stats(argv: &[&str]) -> StatsArgs {
let mut full = vec!["stats"];
full.extend_from_slice(argv);
let cli = parse(&full).unwrap();
match cli.command {
Command::Stats(a) => a,
other => panic!("expected Stats, got {other:?}"),
}
}
#[cfg(feature = "tui")]
fn ui(argv: &[&str]) -> UiArgs {
let mut full = vec!["ui"];
full.extend_from_slice(argv);
let cli = parse(&full).unwrap();
match cli.command {
Command::Ui(a) => a,
other => panic!("expected Ui, got {other:?}"),
}
}
#[test]
fn no_args_requires_subcommand() {
assert!(
parse(&[]).is_err(),
"empty argv must error — subcommand required"
);
}
#[test]
fn minimal_invocation() {
let a = extract(&["a.ts"]);
assert_eq!(a.path, PathBuf::from("a.ts"));
assert_eq!(a.format, OutputFormat::Text);
assert_eq!(a.mode(), OutputMode::Default);
assert!(a.kind.is_empty());
assert!(!a.with_text);
}
#[test]
fn stats_accepts_multiple_paths() {
let a = stats(&["svc-a", "svc-b"]);
assert_eq!(
a.paths,
vec![PathBuf::from("svc-a"), PathBuf::from("svc-b")]
);
}
#[cfg(feature = "tui")]
#[test]
fn ui_defaults_to_current_dir_and_accepts_multiple_paths() {
assert_eq!(ui(&[]).paths, vec![PathBuf::from(".")]);
assert_eq!(
ui(&["svc-a", "svc-b"]).paths,
vec![PathBuf::from("svc-a"), PathBuf::from("svc-b")]
);
assert!(ui(&["--debug"]).debug);
}
#[test]
fn quiet_and_count_are_mutually_exclusive() {
assert!(parse(&["extract", "a.ts", "--count", "--quiet"]).is_err());
}
#[test]
fn count_mode_detected() {
assert_eq!(extract(&["a.ts", "--count"]).mode(), OutputMode::Count);
}
#[test]
fn quiet_mode_detected() {
assert_eq!(extract(&["a.ts", "--quiet"]).mode(), OutputMode::Quiet);
}
#[test]
fn format_json_recognised() {
assert_eq!(
extract(&["a.ts", "--format", "json"]).format,
OutputFormat::Json
);
}
#[test]
fn extract_pagination_defaults_and_flags() {
let a = extract(&["a.ts"]);
assert_eq!(a.limit, 1000);
assert!(a.after.is_none());
assert!(!a.all);
let a = extract(&[
"a.ts",
"--limit",
"25",
"--after",
"code+moniker://./class:Foo",
]);
assert_eq!(a.limit, 25);
assert_eq!(a.after.as_deref(), Some("code+moniker://./class:Foo"));
}
#[test]
fn extract_max_symbols_aliases_limit() {
let a = extract(&["a.ts", "--max-symbols", "25"]);
assert_eq!(a.limit, 25);
}
#[test]
fn extract_all_conflicts_with_limit_and_after() {
assert!(parse(&["extract", "a.ts", "--all", "--limit", "10"]).is_err());
assert!(parse(&["extract", "a.ts", "--all", "--max-symbols", "10"]).is_err());
assert!(
parse(&[
"extract",
"a.ts",
"--all",
"--after",
"code+moniker://./class:Foo"
])
.is_err()
);
}
#[test]
fn unknown_format_rejected() {
assert!(parse(&["extract", "a.ts", "--format", "xml"]).is_err());
}
#[test]
fn kind_is_repeatable() {
let a = extract(&["a.ts", "--kind", "class", "--kind", "method"]);
assert_eq!(a.kind, vec!["class".to_string(), "method".to_string()]);
}
#[test]
fn with_text_flag() {
assert!(extract(&["a.ts", "--with-text"]).with_text);
}
#[test]
fn path_filter_is_repeatable() {
let a = extract(&[
".",
"--path",
"crates/cli/src/mcp/**",
"--path",
"crates/check/src/check/**",
]);
assert_eq!(
a.path_filter,
vec![
"crates/cli/src/mcp/**".to_string(),
"crates/check/src/check/**".to_string()
]
);
}
#[test]
fn where_descendant_parses() {
let a = extract(&["a.ts", "--where", "<@ code+moniker://./class:Foo"]);
let preds = a.compiled_predicates("code+moniker://").expect("ok");
assert_eq!(preds.len(), 1);
assert!(matches!(preds[0], Predicate::DescendantOf(_)));
}
#[test]
fn where_multiple_predicates_compose_with_and() {
let a = extract(&[
"a.ts",
"--where",
"@> code+moniker://./class:Foo",
"--where",
"= code+moniker://./class:Foo/method:bar",
]);
let preds = a.compiled_predicates("code+moniker://").expect("ok");
assert_eq!(preds.len(), 2);
assert!(matches!(preds[0], Predicate::AncestorOf(_)));
assert!(matches!(preds[1], Predicate::Eq(_)));
}
#[test]
fn where_each_operator_supported() {
for op in &["=", "<", "<=", ">", ">=", "@>", "<@", "?="] {
let a = extract(&[
"a.ts",
"--where",
&format!("{op} code+moniker://./class:Foo"),
]);
let preds = a.compiled_predicates("code+moniker://").expect(op);
assert_eq!(preds.len(), 1, "op {op} failed");
}
}
#[test]
fn where_malformed_is_usage_error() {
let a = extract(&["a.ts", "--where", "garbage uri"]);
let err = a.compiled_predicates("code+moniker://").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("--where"), "{msg}");
}
#[test]
fn where_missing_uri_is_usage_error() {
let a = extract(&["a.ts", "--where", "@>"]);
let err = a.compiled_predicates("code+moniker://").unwrap_err();
let msg = format!("{err:#}");
assert!(msg.contains("missing URI"), "{msg}");
}
#[test]
fn check_subcommand_routes_to_command() {
let cli = parse(&["check", "a.ts"]).unwrap();
match cli.command {
Command::Check(c) => assert_eq!(c.path, PathBuf::from("a.ts")),
other => panic!("expected Check, got {other:?}"),
}
}
#[test]
fn check_subcommand_accepts_rules_and_format() {
let cli = parse(&[
"check",
"a.ts",
"--rules",
"my-rules.toml",
"--format",
"json",
])
.unwrap();
match cli.command {
Command::Check(c) => {
assert_eq!(c.rules, PathBuf::from("my-rules.toml"));
assert_eq!(c.format, CheckFormat::Json);
assert_eq!(c.default_rules, None);
assert!(!c.report);
}
other => panic!("expected Check, got {other:?}"),
}
}
#[test]
fn check_subcommand_accepts_report() {
let cli = parse(&["check", "a.ts", "--report"]).unwrap();
match cli.command {
Command::Check(c) => assert!(c.report),
other => panic!("expected Check, got {other:?}"),
}
}
}