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)")
}
}
pub(crate) fn parse_finite_f32(s: &str) -> std::result::Result<f32, String> {
let val: f32 = s.parse().map_err(|e| format!("{e}"))?;
if val.is_finite() {
Ok(val)
} else {
Err(format!(
"value must be a finite number, got {val} (NaN/Infinity rejected)"
))
}
}
#[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", value_parser = parse_finite_f32)]
pub threshold: f32,
#[arg(long, default_value = "0.2", value_parser = parse_finite_f32)]
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, value_parser = parse_finite_f32)]
pub splade_alpha: Option<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 {
#[command(flatten)]
args: args::DepsArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Callers {
#[command(flatten)]
args: args::CallersArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Callees {
#[command(flatten)]
args: args::CallersArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Onboard {
#[command(flatten)]
args: args::OnboardArgs,
#[command(flatten)]
output: TextJsonArgs,
},
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 {
#[command(flatten)]
args: args::DiffArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Drift {
#[command(flatten)]
args: args::DriftArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Explain {
#[command(flatten)]
args: args::ExplainArgs,
#[command(flatten)]
output: TextJsonArgs,
},
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 {
#[command(flatten)]
args: args::ImpactDiffArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Review {
#[command(flatten)]
args: args::ReviewArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Ci {
#[command(flatten)]
args: args::CiArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Trace {
#[command(flatten)]
args: args::TraceArgs,
#[command(flatten)]
output: OutputArgs,
},
TestMap {
#[command(flatten)]
args: args::TestMapArgs,
#[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)]
args: args::StaleArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Suggest {
#[command(flatten)]
args: args::SuggestArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Read {
#[command(flatten)]
args: args::ReadArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Reconstruct {
path: String,
#[command(flatten)]
output: TextJsonArgs,
},
Related {
#[command(flatten)]
args: args::RelatedArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Where {
#[command(flatten)]
args: args::WhereArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Scout {
#[command(flatten)]
args: args::ScoutArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Plan {
#[command(flatten)]
args: args::PlanArgs,
#[command(flatten)]
output: TextJsonArgs,
},
Task {
#[command(flatten)]
args: args::TaskArgs,
#[command(flatten)]
output: TextJsonArgs,
},
#[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};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum BatchSupport {
Cli,
Daemon,
}
impl Commands {
pub(crate) fn batch_support(&self) -> BatchSupport {
match self {
Commands::Init
| Commands::Index { .. }
| Commands::Watch { .. }
| Commands::Batch
| Commands::Chat
| Commands::Completions { .. }
| Commands::Doctor { .. }
| Commands::AuditMode { .. }
| Commands::Telemetry { .. }
| Commands::TrainData { .. }
| Commands::TrainPairs { .. }
| Commands::Cache { .. }
| Commands::Ref { .. }
| Commands::Project { .. }
| Commands::ExportModel { .. }
| Commands::Affected { .. }
| Commands::Brief { .. }
| Commands::Neighbors { .. }
| Commands::Reconstruct { .. } => BatchSupport::Cli,
#[cfg(feature = "convert")]
Commands::Convert { .. } => BatchSupport::Cli,
Commands::Notes { subcmd } => match subcmd {
NotesCommand::List { .. } => BatchSupport::Daemon,
_ => BatchSupport::Cli,
},
Commands::Stats { .. }
| Commands::Blame { .. }
| Commands::Deps { .. }
| Commands::Callers { .. }
| Commands::Callees { .. }
| Commands::Onboard { .. }
| Commands::Diff { .. }
| Commands::Drift { .. }
| Commands::Explain { .. }
| Commands::Similar { .. }
| Commands::Impact { .. }
| Commands::ImpactDiff { .. }
| Commands::Review { .. }
| Commands::Ci { .. }
| Commands::Trace { .. }
| Commands::TestMap { .. }
| Commands::Context { .. }
| Commands::Dead { .. }
| Commands::Gather { .. }
| Commands::Health { .. }
| Commands::Stale { .. }
| Commands::Read { .. }
| Commands::Related { .. }
| Commands::Where { .. }
| Commands::Scout { .. }
| Commands::Plan { .. }
| Commands::Task { .. } => BatchSupport::Daemon,
Commands::Gc { .. } => BatchSupport::Cli,
Commands::Suggest { ref args, .. } => {
if args.apply {
BatchSupport::Cli
} else {
BatchSupport::Daemon
}
}
}
}
}
#[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);
}
#[test]
fn parse_finite_f32_accepts_finite() {
assert_eq!(parse_finite_f32("0.0").unwrap(), 0.0);
assert_eq!(parse_finite_f32("0.5").unwrap(), 0.5);
assert_eq!(parse_finite_f32("-1.0").unwrap(), -1.0);
assert_eq!(parse_finite_f32("1e10").unwrap(), 1e10);
}
#[test]
fn parse_finite_f32_rejects_nan() {
let r = parse_finite_f32("NaN");
assert!(r.is_err());
assert!(r.unwrap_err().contains("NaN"));
}
#[test]
fn parse_finite_f32_rejects_infinity() {
assert!(parse_finite_f32("inf").is_err());
assert!(parse_finite_f32("Infinity").is_err());
assert!(parse_finite_f32("-inf").is_err());
}
#[test]
fn parse_finite_f32_rejects_garbage() {
assert!(parse_finite_f32("not a number").is_err());
assert!(parse_finite_f32("").is_err());
}
#[test]
fn batch_support_lifecycle_commands_are_cli_only() {
use clap::Parser;
let cli = Cli::try_parse_from(["cqs", "init"]).unwrap();
assert_eq!(cli.command.unwrap().batch_support(), BatchSupport::Cli);
let cli = Cli::try_parse_from(["cqs", "chat"]).unwrap();
assert_eq!(cli.command.unwrap().batch_support(), BatchSupport::Cli);
let cli = Cli::try_parse_from(["cqs", "index"]).unwrap();
assert_eq!(cli.command.unwrap().batch_support(), BatchSupport::Cli);
}
#[test]
fn batch_support_notes_mutations_are_cli_only() {
use clap::Parser;
let cli = Cli::try_parse_from(["cqs", "notes", "list"]).unwrap();
assert_eq!(cli.command.unwrap().batch_support(), BatchSupport::Daemon);
let cli = Cli::try_parse_from(["cqs", "notes", "add", "foo"]).unwrap();
assert_eq!(cli.command.unwrap().batch_support(), BatchSupport::Cli);
let cli = Cli::try_parse_from(["cqs", "notes", "remove", "foo"]).unwrap();
assert_eq!(cli.command.unwrap().batch_support(), BatchSupport::Cli);
}
#[test]
fn batch_support_search_commands_daemon_dispatchable() {
use clap::Parser;
let cli = Cli::try_parse_from(["cqs", "scout", "foo"]).unwrap();
assert_eq!(cli.command.unwrap().batch_support(), BatchSupport::Daemon);
let cli = Cli::try_parse_from(["cqs", "impact", "foo"]).unwrap();
assert_eq!(cli.command.unwrap().batch_support(), BatchSupport::Daemon);
let cli = Cli::try_parse_from(["cqs", "stale"]).unwrap();
assert_eq!(cli.command.unwrap().batch_support(), BatchSupport::Daemon);
}
}