use anyhow::Result;
use clap::{Parser, Subcommand};
use super::BatchContext;
use crate::cli::args::{
BlameArgs, ContextArgs, DeadArgs, GatherArgs, ImpactArgs, ScoutArgs, SimilarArgs, TraceArgs,
};
use crate::cli::parse_nonzero_usize;
use crate::cli::GateThreshold;
use super::handlers;
#[derive(Parser, Debug)]
#[command(
no_binary_name = true,
disable_help_subcommand = true,
disable_help_flag = true
)]
pub(crate) struct BatchInput {
#[command(subcommand)]
pub cmd: BatchCmd,
}
#[derive(Subcommand, Debug)]
pub(crate) enum BatchCmd {
Search {
query: String,
#[arg(short = 'n', long, default_value = "5")]
limit: usize,
#[arg(long)]
name_only: bool,
#[arg(long)]
rrf: bool,
#[arg(long)]
rerank: bool,
#[arg(long)]
splade: bool,
#[arg(long, default_value = "0.7")]
splade_alpha: f32,
#[arg(short = 'l', long)]
lang: Option<String>,
#[arg(short = 'p', long)]
path: Option<String>,
#[arg(long)]
include_type: Option<Vec<String>>,
#[arg(long)]
exclude_type: Option<Vec<String>>,
#[arg(long, value_parser = parse_nonzero_usize)]
tokens: Option<usize>,
#[arg(long)]
no_demote: bool,
#[arg(long, default_value = "0.2")]
name_boost: f32,
#[arg(long = "ref")]
ref_name: Option<String>,
#[arg(long)]
include_refs: bool,
#[arg(long)]
no_content: bool,
#[arg(short = 'C', long)]
context: Option<usize>,
#[arg(long)]
expand: bool,
#[arg(long)]
no_stale_check: bool,
},
Blame {
#[command(flatten)]
args: BlameArgs,
},
Deps {
name: String,
#[arg(long)]
reverse: bool,
#[arg(long)]
cross_project: bool,
},
Callers {
name: String,
#[arg(long)]
cross_project: bool,
},
Callees {
name: String,
#[arg(long)]
cross_project: bool,
},
Explain {
name: String,
#[arg(long, value_parser = parse_nonzero_usize)]
tokens: Option<usize>,
},
Similar {
#[command(flatten)]
args: SimilarArgs,
},
Gather {
#[command(flatten)]
args: GatherArgs,
},
Impact {
#[command(flatten)]
args: ImpactArgs,
},
#[command(name = "test-map")]
TestMap {
name: String,
#[arg(long, default_value = "5")]
depth: usize,
#[arg(long)]
cross_project: bool,
},
Trace {
#[command(flatten)]
args: TraceArgs,
},
Dead {
#[command(flatten)]
args: DeadArgs,
},
Related {
name: String,
#[arg(short = 'n', long, default_value = "5")]
limit: usize,
},
Context {
#[command(flatten)]
args: ContextArgs,
},
Stats,
Onboard {
query: String,
#[arg(short = 'd', long, default_value = "3")]
depth: usize,
#[arg(long, value_parser = parse_nonzero_usize)]
tokens: Option<usize>,
},
Scout {
#[command(flatten)]
args: ScoutArgs,
},
Where {
description: String,
#[arg(short = 'n', long, default_value = "3")]
limit: usize,
},
Read {
path: String,
#[arg(long)]
focus: Option<String>,
},
Stale,
Health,
Drift {
reference: String,
#[arg(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>,
},
Notes {
#[arg(long)]
warnings: bool,
#[arg(long)]
patterns: bool,
},
Task {
description: String,
#[arg(short = 'n', long, default_value = "5")]
limit: usize,
#[arg(long, value_parser = parse_nonzero_usize)]
tokens: Option<usize>,
},
Review {
#[arg(long)]
base: Option<String>,
#[arg(long, value_parser = parse_nonzero_usize)]
tokens: Option<usize>,
},
Ci {
#[arg(long)]
base: Option<String>,
#[arg(long, default_value = "off")]
gate: GateThreshold,
#[arg(long, value_parser = parse_nonzero_usize)]
tokens: Option<usize>,
},
Diff {
source: String,
target: Option<String>,
#[arg(long, default_value = "0.95")]
threshold: f32,
#[arg(short = 'l', long)]
lang: Option<String>,
},
#[command(name = "impact-diff")]
ImpactDiff {
#[arg(long)]
base: Option<String>,
},
Plan {
description: String,
#[arg(short = 'n', long, default_value = "5")]
limit: usize,
#[arg(long, value_parser = parse_nonzero_usize)]
tokens: Option<usize>,
},
Suggest {
#[arg(long)]
apply: bool,
},
Gc,
#[command(visible_alias = "invalidate")]
Refresh,
Help,
}
impl BatchCmd {
pub(crate) fn is_pipeable(&self) -> bool {
matches!(
self,
BatchCmd::Blame { .. }
| BatchCmd::Callers { .. }
| BatchCmd::Callees { .. }
| BatchCmd::Deps { .. }
| BatchCmd::Explain { .. }
| BatchCmd::Similar { .. }
| BatchCmd::Impact { .. }
| BatchCmd::TestMap { .. }
| BatchCmd::Related { .. }
| BatchCmd::Scout { .. }
)
}
}
fn log_query(command: &str, query: &str) {
use std::io::Write;
let Some(home) = dirs::home_dir() else {
return;
};
let log_path = home.join(".cache/cqs/query_log.jsonl");
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let Ok(mut f) = opts.open(&log_path) else {
tracing::debug!(path = %log_path.display(), "Query log open failed, skipping");
return;
};
let ts = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let _ = writeln!(
f,
"{{\"ts\":{},\"cmd\":\"{}\",\"query\":{}}}",
ts,
command,
serde_json::to_string(query).unwrap_or_else(|_| "\"\"".to_string())
);
}
pub(crate) fn dispatch(ctx: &BatchContext, cmd: BatchCmd) -> Result<serde_json::Value> {
let _span = tracing::debug_span!("batch_dispatch").entered();
match cmd {
BatchCmd::Blame { args } => {
handlers::dispatch_blame(ctx, &args.name, args.depth, args.callers)
}
BatchCmd::Search {
query,
limit,
name_only,
rrf,
rerank,
splade,
splade_alpha,
lang,
path,
include_type,
exclude_type,
tokens,
no_demote,
name_boost,
ref_name,
include_refs,
no_content,
context,
expand,
no_stale_check,
} => {
log_query("search", &query);
handlers::dispatch_search(
ctx,
&handlers::SearchParams {
query,
limit,
name_only,
rrf,
rerank,
splade,
splade_alpha,
lang,
path,
include_type,
exclude_type,
tokens,
no_demote,
name_boost,
ref_name,
include_refs,
no_content,
context,
expand,
no_stale_check,
},
)
}
BatchCmd::Deps {
name,
reverse,
cross_project,
} => handlers::dispatch_deps(ctx, &name, reverse, cross_project),
BatchCmd::Callers {
name,
cross_project,
} => handlers::dispatch_callers(ctx, &name, cross_project),
BatchCmd::Callees {
name,
cross_project,
} => handlers::dispatch_callees(ctx, &name, cross_project),
BatchCmd::Explain { name, tokens } => handlers::dispatch_explain(ctx, &name, tokens),
BatchCmd::Similar { args } => {
handlers::dispatch_similar(ctx, &args.name, args.limit, args.threshold)
}
BatchCmd::Gather { args } => {
log_query("gather", &args.query);
handlers::dispatch_gather(
ctx,
&handlers::GatherParams {
query: &args.query,
expand: args.expand,
direction: args.direction,
limit: args.limit,
tokens: args.tokens,
ref_name: args.ref_name.as_deref(),
},
)
}
BatchCmd::Impact { args } => handlers::dispatch_impact(
ctx,
&args.name,
args.depth,
args.suggest_tests,
args.type_impact,
args.cross_project,
),
BatchCmd::TestMap {
name,
depth,
cross_project,
} => handlers::dispatch_test_map(ctx, &name, depth, cross_project),
BatchCmd::Trace { args } => handlers::dispatch_trace(
ctx,
&args.source,
&args.target,
args.max_depth as usize,
args.cross_project,
),
BatchCmd::Dead { args } => {
handlers::dispatch_dead(ctx, args.include_pub, &args.min_confidence)
}
BatchCmd::Related { name, limit } => handlers::dispatch_related(ctx, &name, limit),
BatchCmd::Context { args } => {
handlers::dispatch_context(ctx, &args.path, args.summary, args.compact, args.tokens)
}
BatchCmd::Stats => handlers::dispatch_stats(ctx),
BatchCmd::Onboard {
query,
depth,
tokens,
} => {
log_query("onboard", &query);
handlers::dispatch_onboard(ctx, &query, depth, tokens)
}
BatchCmd::Scout { args } => {
log_query("scout", &args.query);
handlers::dispatch_scout(ctx, &args.query, args.limit, args.tokens)
}
BatchCmd::Where { description, limit } => {
log_query("where", &description);
handlers::dispatch_where(ctx, &description, limit)
}
BatchCmd::Read { path, focus } => handlers::dispatch_read(ctx, &path, focus.as_deref()),
BatchCmd::Stale => handlers::dispatch_stale(ctx),
BatchCmd::Health => handlers::dispatch_health(ctx),
BatchCmd::Drift {
reference,
threshold,
min_drift,
lang,
limit,
} => handlers::dispatch_drift(
ctx,
&reference,
threshold,
min_drift,
lang.as_deref(),
limit,
),
BatchCmd::Notes { warnings, patterns } => handlers::dispatch_notes(ctx, warnings, patterns),
BatchCmd::Task {
description,
limit,
tokens,
} => {
log_query("task", &description);
handlers::dispatch_task(ctx, &description, limit, tokens)
}
BatchCmd::Review { base, tokens } => {
handlers::dispatch_review(ctx, base.as_deref(), tokens)
}
BatchCmd::Ci { base, gate, tokens } => {
handlers::dispatch_ci(ctx, base.as_deref(), &gate, tokens)
}
BatchCmd::Diff {
source,
target,
threshold,
lang,
} => handlers::dispatch_diff(ctx, &source, target.as_deref(), threshold, lang.as_deref()),
BatchCmd::ImpactDiff { base } => handlers::dispatch_impact_diff(ctx, base.as_deref()),
BatchCmd::Plan {
description,
limit,
tokens,
} => handlers::dispatch_plan(ctx, &description, limit, tokens),
BatchCmd::Suggest { apply } => handlers::dispatch_suggest(ctx, apply),
BatchCmd::Gc => handlers::dispatch_gc(ctx),
BatchCmd::Refresh => handlers::dispatch_refresh(ctx),
BatchCmd::Help => handlers::dispatch_help(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn test_parse_search() {
let input = BatchInput::try_parse_from(["search", "hello"]).unwrap();
match input.cmd {
BatchCmd::Search {
ref query, limit, ..
} => {
assert_eq!(query, "hello");
assert_eq!(limit, 5); }
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_parse_search_with_flags() {
let input =
BatchInput::try_parse_from(["search", "hello", "--limit", "3", "--name-only"]).unwrap();
match input.cmd {
BatchCmd::Search {
ref query,
limit,
name_only,
..
} => {
assert_eq!(query, "hello");
assert_eq!(limit, 3);
assert!(name_only);
}
_ => panic!("Expected Search command"),
}
}
#[test]
fn test_parse_callers() {
let input = BatchInput::try_parse_from(["callers", "my_func"]).unwrap();
match input.cmd {
BatchCmd::Callers { ref name, .. } => assert_eq!(name, "my_func"),
_ => panic!("Expected Callers command"),
}
}
#[test]
fn test_parse_gather_with_ref() {
let input =
BatchInput::try_parse_from(["gather", "alarm config", "--ref", "aveva"]).unwrap();
match input.cmd {
BatchCmd::Gather { ref args } => {
assert_eq!(args.query, "alarm config");
assert_eq!(args.ref_name.as_deref(), Some("aveva"));
}
_ => panic!("Expected Gather command"),
}
}
#[test]
fn test_parse_dead_with_confidence() {
let input =
BatchInput::try_parse_from(["dead", "--min-confidence", "high", "--include-pub"])
.unwrap();
match input.cmd {
BatchCmd::Dead { ref args } => {
assert!(args.include_pub);
assert!(matches!(
args.min_confidence,
cqs::store::DeadConfidence::High
));
}
_ => panic!("Expected Dead command"),
}
}
#[test]
fn test_parse_unknown_command() {
let result = BatchInput::try_parse_from(["bogus"]);
assert!(result.is_err());
}
#[test]
fn test_parse_trace() {
let input = BatchInput::try_parse_from(["trace", "main", "validate"]).unwrap();
match input.cmd {
BatchCmd::Trace { ref args } => {
assert_eq!(args.source, "main");
assert_eq!(args.target, "validate");
assert_eq!(args.max_depth, 10); }
_ => panic!("Expected Trace command"),
}
}
#[test]
fn test_parse_context() {
let input = BatchInput::try_parse_from(["context", "src/lib.rs", "--compact"]).unwrap();
match input.cmd {
BatchCmd::Context { ref args } => {
assert_eq!(args.path, "src/lib.rs");
assert!(args.compact);
assert!(!args.summary);
}
_ => panic!("Expected Context command"),
}
}
#[test]
fn test_parse_stats() {
let input = BatchInput::try_parse_from(["stats"]).unwrap();
assert!(matches!(input.cmd, BatchCmd::Stats));
}
#[test]
fn test_parse_impact_with_suggest() {
let input =
BatchInput::try_parse_from(["impact", "foo", "--depth", "3", "--suggest-tests"])
.unwrap();
match input.cmd {
BatchCmd::Impact { ref args } => {
assert_eq!(args.name, "foo");
assert_eq!(args.depth, 3);
assert!(args.suggest_tests);
assert!(!args.type_impact);
}
_ => panic!("Expected Impact command"),
}
}
#[test]
fn test_parse_scout() {
let input = BatchInput::try_parse_from(["scout", "error handling"]).unwrap();
match input.cmd {
BatchCmd::Scout { ref args } => {
assert_eq!(args.query, "error handling");
assert_eq!(args.limit, 5); }
_ => panic!("Expected Scout command"),
}
}
#[test]
fn test_parse_scout_with_flags() {
let input = BatchInput::try_parse_from([
"scout",
"error handling",
"--limit",
"20",
"--tokens",
"2000",
])
.unwrap();
match input.cmd {
BatchCmd::Scout { ref args } => {
assert_eq!(args.query, "error handling");
assert_eq!(args.limit, 20);
assert_eq!(args.tokens, Some(2000));
}
_ => panic!("Expected Scout command"),
}
}
#[test]
fn test_parse_where() {
let input = BatchInput::try_parse_from(["where", "new CLI command"]).unwrap();
match input.cmd {
BatchCmd::Where {
ref description,
limit,
} => {
assert_eq!(description, "new CLI command");
assert_eq!(limit, 3); }
_ => panic!("Expected Where command"),
}
}
#[test]
fn test_parse_read() {
let input = BatchInput::try_parse_from(["read", "src/lib.rs"]).unwrap();
match input.cmd {
BatchCmd::Read {
ref path,
ref focus,
} => {
assert_eq!(path, "src/lib.rs");
assert!(focus.is_none());
}
_ => panic!("Expected Read command"),
}
}
#[test]
fn test_parse_read_focused() {
let input =
BatchInput::try_parse_from(["read", "src/lib.rs", "--focus", "enumerate_files"])
.unwrap();
match input.cmd {
BatchCmd::Read {
ref path,
ref focus,
} => {
assert_eq!(path, "src/lib.rs");
assert_eq!(focus.as_deref(), Some("enumerate_files"));
}
_ => panic!("Expected Read command"),
}
}
#[test]
fn test_parse_stale() {
let input = BatchInput::try_parse_from(["stale"]).unwrap();
assert!(matches!(input.cmd, BatchCmd::Stale));
}
#[test]
fn test_parse_health() {
let input = BatchInput::try_parse_from(["health"]).unwrap();
assert!(matches!(input.cmd, BatchCmd::Health));
}
#[test]
fn test_parse_notes() {
let input = BatchInput::try_parse_from(["notes"]).unwrap();
match input.cmd {
BatchCmd::Notes { warnings, patterns } => {
assert!(!warnings);
assert!(!patterns);
}
_ => panic!("Expected Notes command"),
}
}
#[test]
fn test_parse_notes_warnings() {
let input = BatchInput::try_parse_from(["notes", "--warnings"]).unwrap();
match input.cmd {
BatchCmd::Notes { warnings, patterns } => {
assert!(warnings);
assert!(!patterns);
}
_ => panic!("Expected Notes command"),
}
}
#[test]
fn test_parse_notes_patterns() {
let input = BatchInput::try_parse_from(["notes", "--patterns"]).unwrap();
match input.cmd {
BatchCmd::Notes { warnings, patterns } => {
assert!(!warnings);
assert!(patterns);
}
_ => panic!("Expected Notes command"),
}
}
#[test]
fn test_parse_blame() {
let input = BatchInput::try_parse_from(["blame", "my_func"]).unwrap();
match input.cmd {
BatchCmd::Blame { ref args } => {
assert_eq!(args.name, "my_func");
assert_eq!(args.depth, 10); assert!(!args.callers);
}
_ => panic!("Expected Blame command"),
}
}
#[test]
fn test_parse_blame_with_flags() {
let input =
BatchInput::try_parse_from(["blame", "my_func", "-d", "5", "--callers"]).unwrap();
match input.cmd {
BatchCmd::Blame { ref args } => {
assert_eq!(args.name, "my_func");
assert_eq!(args.depth, 5);
assert!(args.callers);
}
_ => panic!("Expected Blame command"),
}
}
}