use clap::{Parser, Subcommand};
use super::args;
const _: () = assert!(crate::cli::config::DEFAULT_LIMIT == 5);
#[derive(Clone, Debug, clap::ValueEnum)]
pub enum OutputFormat {
Text,
Json,
Mermaid,
}
impl std::fmt::Display for OutputFormat {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Text => write!(f, "text"),
Self::Json => write!(f, "json"),
Self::Mermaid => write!(f, "mermaid"),
}
}
}
#[derive(Clone, Debug, clap::Args)]
pub struct OutputArgs {
#[arg(long, default_value = "text")]
pub format: OutputFormat,
#[arg(long, conflicts_with = "format")]
pub json: bool,
}
impl OutputArgs {
pub fn effective_format(&self) -> OutputFormat {
if self.json {
OutputFormat::Json
} else {
self.format.clone()
}
}
}
#[derive(Clone, Debug, clap::Args)]
pub struct TextJsonArgs {
#[arg(long)]
pub json: bool,
}
impl TextJsonArgs {
pub fn effective_format(&self) -> OutputFormat {
if self.json {
OutputFormat::Json
} else {
OutputFormat::Text
}
}
}
pub use cqs::ci::GateThreshold;
#[derive(Clone, Debug, clap::ValueEnum)]
pub enum AuditModeState {
On,
Off,
}
pub(crate) fn parse_nonzero_usize(s: &str) -> std::result::Result<usize, String> {
let val: usize = s.parse().map_err(|e| format!("{e}"))?;
if val == 0 {
return Err("value must be at least 1".to_string());
}
Ok(val)
}
pub(crate) fn validate_finite_f32(val: f32, name: &str) -> anyhow::Result<f32> {
if val.is_finite() {
Ok(val)
} else {
anyhow::bail!("Invalid {name}: {val} (must be a finite number)")
}
}
#[derive(Parser)]
#[command(name = "cqs")]
#[command(about = "Semantic code search with local embeddings")]
#[command(version)]
pub struct Cli {
#[command(subcommand)]
pub(super) command: Option<Commands>,
pub query: Option<String>,
#[arg(short = 'n', long, default_value = "5")]
pub limit: usize,
#[arg(short = 't', long, default_value = "0.3")]
pub threshold: f32,
#[arg(long, default_value = "0.2")]
pub name_boost: f32,
#[arg(short = 'l', long)]
pub lang: Option<String>,
#[arg(long, alias = "chunk-type")]
pub include_type: Option<Vec<String>>,
#[arg(long)]
pub exclude_type: Option<Vec<String>>,
#[arg(short = 'p', long)]
pub path: Option<String>,
#[arg(long)]
pub pattern: Option<String>,
#[arg(long)]
pub name_only: bool,
#[arg(long)]
pub rrf: bool,
#[arg(long)]
pub include_docs: bool,
#[arg(long)]
pub rerank: bool,
#[arg(long)]
pub splade: bool,
#[arg(long, default_value = "0.7")]
pub splade_alpha: f32,
#[arg(long)]
pub json: bool,
#[arg(long)]
pub no_content: bool,
#[arg(short = 'C', long)]
pub context: Option<usize>,
#[arg(long)]
pub expand: bool,
#[arg(long = "ref")]
pub ref_name: Option<String>,
#[arg(long)]
pub include_refs: bool,
#[arg(long, value_parser = parse_nonzero_usize)]
pub tokens: Option<usize>,
#[arg(short, long)]
pub quiet: bool,
#[arg(long)]
pub no_stale_check: bool,
#[arg(long)]
pub no_demote: bool,
#[arg(long)]
pub model: Option<String>,
#[arg(short, long)]
pub verbose: bool,
#[arg(skip)]
pub resolved_model: Option<cqs::embedder::ModelConfig>,
}
impl Cli {
pub fn try_model_config(&self) -> anyhow::Result<&cqs::embedder::ModelConfig> {
self.resolved_model
.as_ref()
.ok_or_else(|| anyhow::anyhow!("ModelConfig not resolved — call resolve_model() first"))
}
#[deprecated(note = "use try_model_config() which returns Result instead of panicking")]
pub fn model_config(&self) -> &cqs::embedder::ModelConfig {
self.resolved_model
.as_ref()
.expect("BUG: ModelConfig not resolved — dispatch() must call resolve_model() first")
}
}
#[derive(Subcommand)]
pub(super) enum Commands {
Init,
Brief {
path: String,
#[command(flatten)]
output: TextJsonArgs,
},
Doctor {
#[arg(long)]
fix: bool,
},
Index {
#[command(flatten)]
args: args::IndexArgs,
},
Stats {
#[command(flatten)]
output: TextJsonArgs,
},
Watch {
#[arg(long, default_value = "500")]
debounce: u64,
#[arg(long)]
no_ignore: bool,
#[arg(long)]
poll: bool,
#[arg(long)]
serve: bool,
},
Affected {
#[arg(long)]
base: Option<String>,
#[command(flatten)]
output: TextJsonArgs,
},
Batch,
Blame {
#[command(flatten)]
args: args::BlameArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Chat,
Completions {
#[arg(value_enum)]
shell: clap_complete::Shell,
},
Deps {
name: String,
#[arg(long)]
reverse: bool,
#[arg(long)]
cross_project: bool,
#[command(flatten)]
output: TextJsonArgs,
},
Callers {
name: String,
#[arg(long)]
cross_project: bool,
#[command(flatten)]
output: TextJsonArgs,
},
Callees {
name: String,
#[arg(long)]
cross_project: bool,
#[command(flatten)]
output: TextJsonArgs,
},
Onboard {
query: String,
#[arg(short = 'd', long, default_value = "3")]
depth: usize,
#[command(flatten)]
output: TextJsonArgs,
#[arg(long, value_parser = parse_nonzero_usize)]
tokens: Option<usize>,
},
Neighbors {
name: String,
#[arg(short = 'n', long, default_value = "5")]
limit: usize,
#[command(flatten)]
output: TextJsonArgs,
},
Notes {
#[command(subcommand)]
subcmd: NotesCommand,
},
Ref {
#[command(subcommand)]
subcmd: RefCommand,
},
Diff {
source: String,
target: Option<String>,
#[arg(short = 't', long, default_value = "0.95")]
threshold: f32,
#[arg(short = 'l', long)]
lang: Option<String>,
#[command(flatten)]
output: TextJsonArgs,
},
Drift {
reference: String,
#[arg(short = 't', long, default_value = "0.95")]
threshold: f32,
#[arg(long, default_value = "0.0")]
min_drift: f32,
#[arg(short = 'l', long)]
lang: Option<String>,
#[arg(short = 'n', long)]
limit: Option<usize>,
#[command(flatten)]
output: TextJsonArgs,
},
Explain {
name: String,
#[command(flatten)]
output: TextJsonArgs,
#[arg(long, value_parser = parse_nonzero_usize)]
tokens: Option<usize>,
},
Similar {
#[command(flatten)]
args: args::SimilarArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Impact {
#[command(flatten)]
args: args::ImpactArgs,
#[command(flatten)]
output: OutputArgs,
},
#[command(name = "impact-diff")]
ImpactDiff {
#[arg(long)]
base: Option<String>,
#[arg(long)]
stdin: bool,
#[command(flatten)]
output: TextJsonArgs,
},
Review {
#[arg(long)]
base: Option<String>,
#[arg(long)]
stdin: bool,
#[command(flatten)]
output: TextJsonArgs,
#[arg(long, value_parser = parse_nonzero_usize)]
tokens: Option<usize>,
},
Ci {
#[arg(long)]
base: Option<String>,
#[arg(long)]
stdin: bool,
#[command(flatten)]
output: TextJsonArgs,
#[arg(long, default_value = "high")]
gate: GateThreshold,
#[arg(long, value_parser = parse_nonzero_usize)]
tokens: Option<usize>,
},
Trace {
#[command(flatten)]
args: args::TraceArgs,
#[command(flatten)]
output: OutputArgs,
},
TestMap {
name: String,
#[arg(long, default_value = "5")]
depth: usize,
#[arg(long)]
cross_project: bool,
#[command(flatten)]
output: TextJsonArgs,
},
Context {
#[command(flatten)]
args: args::ContextArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Dead {
#[command(flatten)]
args: args::DeadArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Gather {
#[command(flatten)]
args: args::GatherArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Project {
#[command(subcommand)]
subcmd: ProjectCommand,
},
Gc {
#[command(flatten)]
output: TextJsonArgs,
},
Health {
#[command(flatten)]
output: TextJsonArgs,
},
#[command(name = "audit-mode")]
AuditMode {
state: Option<AuditModeState>,
#[arg(long, default_value = "30m")]
expires: String,
#[command(flatten)]
output: TextJsonArgs,
},
Telemetry {
#[arg(long)]
reset: bool,
#[arg(long, requires = "reset")]
reason: Option<String>,
#[arg(long)]
all: bool,
#[command(flatten)]
output: TextJsonArgs,
},
Stale {
#[command(flatten)]
output: TextJsonArgs,
#[arg(long)]
count_only: bool,
},
Suggest {
#[command(flatten)]
output: TextJsonArgs,
#[arg(long)]
apply: bool,
},
Read {
path: String,
#[arg(long)]
focus: Option<String>,
#[command(flatten)]
output: TextJsonArgs,
},
Reconstruct {
path: String,
#[command(flatten)]
output: TextJsonArgs,
},
Related {
name: String,
#[arg(short = 'n', long, default_value = "5")]
limit: usize,
#[command(flatten)]
output: TextJsonArgs,
},
Where {
description: String,
#[arg(short = 'n', long, default_value = "3")]
limit: usize,
#[command(flatten)]
output: TextJsonArgs,
},
Scout {
#[command(flatten)]
args: args::ScoutArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Plan {
description: String,
#[arg(short = 'n', long, default_value = "5")]
limit: usize,
#[command(flatten)]
output: TextJsonArgs,
#[arg(long, value_parser = parse_nonzero_usize)]
tokens: Option<usize>,
},
Task {
description: String,
#[arg(short = 'n', long, default_value = "5")]
limit: usize,
#[command(flatten)]
output: TextJsonArgs,
#[arg(long, value_parser = parse_nonzero_usize)]
tokens: Option<usize>,
#[arg(long)]
brief: bool,
},
#[cfg(feature = "convert")]
Convert {
path: String,
#[arg(short = 'o', long)]
output: Option<String>,
#[arg(long)]
overwrite: bool,
#[arg(long)]
dry_run: bool,
#[arg(long)]
clean_tags: Option<String>,
},
ExportModel {
#[arg(long)]
repo: String,
#[arg(long, default_value = ".")]
output: std::path::PathBuf,
#[arg(long)]
dim: Option<u64>,
},
TrainData {
#[arg(long, required = true, num_args = 1..)]
repos: Vec<std::path::PathBuf>,
#[arg(long)]
output: std::path::PathBuf,
#[arg(long, default_value = "0")]
max_commits: usize,
#[arg(long, default_value = "15")]
min_msg_len: usize,
#[arg(long, default_value = "20")]
max_files: usize,
#[arg(long, default_value = "5")]
dedup_cap: usize,
#[arg(long)]
resume: bool,
#[arg(long)]
verbose: bool,
},
TrainPairs {
#[arg(long)]
output: String,
#[arg(long)]
limit: Option<usize>,
#[arg(long)]
language: Option<String>,
#[arg(long)]
contrastive: bool,
},
Cache {
#[command(subcommand)]
subcmd: CacheCommand,
},
}
pub(super) use super::commands::{CacheCommand, NotesCommand, ProjectCommand, RefCommand};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_finite_f32_normal_values() {
assert!(validate_finite_f32(0.0, "test").is_ok());
assert!(validate_finite_f32(1.0, "test").is_ok());
assert!(validate_finite_f32(-1.0, "test").is_ok());
assert!(validate_finite_f32(0.5, "test").is_ok());
}
#[test]
fn validate_finite_f32_rejects_nan() {
let result = validate_finite_f32(f32::NAN, "threshold");
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("threshold"));
}
#[test]
fn validate_finite_f32_rejects_infinity() {
assert!(validate_finite_f32(f32::INFINITY, "test").is_err());
assert!(validate_finite_f32(f32::NEG_INFINITY, "test").is_err());
}
#[test]
fn validate_finite_f32_returns_value_on_success() {
assert_eq!(validate_finite_f32(0.42, "x").unwrap(), 0.42);
}
#[test]
fn clap_defaults_match_constants() {
use crate::cli::config::{DEFAULT_LIMIT, DEFAULT_NAME_BOOST, DEFAULT_THRESHOLD};
assert_eq!(DEFAULT_LIMIT, 5);
assert!((DEFAULT_THRESHOLD - 0.3).abs() < f32::EPSILON);
assert!((DEFAULT_NAME_BOOST - 0.2).abs() < f32::EPSILON);
}
}