use clap::{ArgAction, ArgGroup, Parser, Subcommand};
use gobby_code::{commands, config, freshness, output, setup};
#[derive(Parser)]
#[command(name = "gcode", version, about = "Fast code index CLI for Gobby")]
struct Cli {
#[arg(long, global = true)]
project: Option<String>,
#[arg(long, global = true)]
format: Option<output::Format>,
#[arg(long, global = true)]
quiet: bool,
#[arg(long, global = true)]
verbose: bool,
#[arg(long, global = true)]
no_freshness: bool,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Init,
Setup {
#[arg(long, required = true)]
standalone: bool,
#[arg(long)]
database_url: Option<String>,
#[arg(long)]
no_services: bool,
#[arg(long)]
overwrite_code_index: bool,
#[arg(long, default_value = "public")]
schema: String,
#[arg(long)]
embedding_provider: Option<String>,
#[arg(long)]
embedding_api_base: Option<String>,
#[arg(long)]
embedding_model: Option<String>,
#[arg(long)]
embedding_vector_dim: Option<usize>,
#[arg(long)]
embedding_api_key_env: Option<String>,
#[arg(long)]
falkordb_host: Option<String>,
#[arg(long)]
falkordb_port: Option<u16>,
#[arg(long)]
falkordb_password: Option<String>,
#[arg(long)]
qdrant_url: Option<String>,
},
Index {
path: Option<String>,
#[arg(long, num_args = 1..)]
files: Option<Vec<String>>,
#[arg(long)]
full: bool,
#[arg(long)]
require_cpp_semantics: bool,
#[arg(long)]
sync_projections: bool,
},
Status,
Invalidate {
#[arg(long)]
force: bool,
},
Graph {
#[command(subcommand)]
command: GraphCommand,
},
Vector {
#[command(subcommand)]
command: VectorCommand,
},
Search {
query: String,
#[arg(value_name = "PATH")]
paths: Vec<String>,
#[arg(long, default_value = "10")]
limit: usize,
#[arg(long, default_value = "0")]
offset: usize,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
language: Option<String>,
},
SearchSymbol {
query: String,
#[arg(value_name = "PATH")]
paths: Vec<String>,
#[arg(long, default_value = "10")]
limit: usize,
#[arg(long, default_value = "0")]
offset: usize,
#[arg(long)]
kind: Option<String>,
#[arg(long)]
language: Option<String>,
#[arg(long)]
with_graph: bool,
},
SearchText {
query: String,
#[arg(value_name = "PATH")]
paths: Vec<String>,
#[arg(long, default_value = "10")]
limit: usize,
#[arg(long, default_value = "0")]
offset: usize,
#[arg(long)]
language: Option<String>,
},
SearchContent {
query: String,
#[arg(value_name = "PATH")]
paths: Vec<String>,
#[arg(long, default_value = "10")]
limit: usize,
#[arg(long, default_value = "0")]
offset: usize,
#[arg(long)]
language: Option<String>,
},
#[command(
after_help = "gcode grep is indexed search over code_content_chunks. Unsupported grep/rg flags are intentionally rejected; use raw `rg` for filesystem grep."
)]
Grep {
pattern: String,
#[arg(value_name = "PATH")]
paths: Vec<String>,
#[arg(short = 'F', long)]
fixed_strings: bool,
#[arg(short = 'i', long)]
ignore_case: bool,
#[arg(short = 'B', long)]
before_context: Option<usize>,
#[arg(short = 'A', long)]
after_context: Option<usize>,
#[arg(short = 'C', long)]
context: Option<usize>,
#[arg(short = 'g', long)]
glob: Vec<String>,
#[arg(short = 'm', long)]
max_count: Option<usize>,
#[arg(short = 'n', long)]
line_number: bool,
#[arg(long = "limit", hide = true, value_parser = reject_grep_limit)]
_unsupported_limit: Option<String>,
#[arg(short = 'l', long = "files-with-matches", hide = true, action = ArgAction::SetTrue)]
unsupported_files_with_matches: bool,
#[arg(short = 'L', long = "files-without-match", hide = true, action = ArgAction::SetTrue)]
unsupported_files_without_match: bool,
#[arg(short = 'c', long = "count", hide = true, action = ArgAction::SetTrue)]
unsupported_count: bool,
#[arg(short = 'o', long = "only-matching", hide = true, action = ArgAction::SetTrue)]
unsupported_only_matching: bool,
#[arg(short = 'v', long = "invert-match", hide = true, action = ArgAction::SetTrue)]
unsupported_invert_match: bool,
#[arg(short = 'w', long = "word-regexp", hide = true, action = ArgAction::SetTrue)]
unsupported_word_regexp: bool,
#[arg(short = 'e', long = "regexp", hide = true)]
unsupported_regexp: Option<String>,
#[arg(short = 'r', long = "recursive", hide = true, action = ArgAction::SetTrue)]
unsupported_recursive: bool,
#[arg(short = 't', long = "type", hide = true)]
unsupported_type: Option<String>,
#[arg(short = 'T', long = "type-not", hide = true)]
unsupported_type_not: Option<String>,
#[arg(short = 'P', long = "pcre2", hide = true, action = ArgAction::SetTrue)]
unsupported_pcre2: bool,
#[arg(short = 'U', long = "multiline", hide = true, action = ArgAction::SetTrue)]
unsupported_multiline: bool,
#[arg(long = "json", hide = true, action = ArgAction::SetTrue)]
unsupported_json: bool,
},
Outline { file: String },
Symbol { id: String },
Symbols { ids: Vec<String> },
Kinds,
Tree,
Callers {
symbol_name: String,
#[arg(long, default_value = "10")]
limit: usize,
#[arg(long, default_value = "0")]
offset: usize,
},
Usages {
symbol_name: String,
#[arg(long, default_value = "10")]
limit: usize,
#[arg(long, default_value = "0")]
offset: usize,
},
Imports { file: String },
BlastRadius {
target: String,
#[arg(long, default_value = "3")]
depth: usize,
},
RepoOutline,
Projects,
Prune {
#[arg(long)]
force: bool,
},
}
#[derive(Subcommand)]
enum GraphCommand {
SyncFile {
#[arg(long)]
file: String,
#[arg(long)]
allow_missing_indexed_file: bool,
},
Clear {
#[arg(long)]
project_id: Option<String>,
},
Rebuild,
Report {
#[arg(long, default_value = "10")]
top_n: usize,
},
Overview {
#[arg(long, default_value = "100")]
limit: usize,
},
File {
#[arg(long)]
file: String,
},
Neighbors {
#[arg(long)]
symbol_id: String,
#[arg(long, default_value = "100")]
limit: usize,
},
#[command(group(
ArgGroup::new("target")
.required(true)
.args(["symbol_id", "file"])
))]
BlastRadius {
#[arg(long)]
symbol_id: Option<String>,
#[arg(long)]
file: Option<String>,
#[arg(long, default_value = "3")]
depth: usize,
#[arg(long, default_value = "100")]
limit: usize,
},
}
#[derive(Subcommand)]
enum VectorCommand {
SyncFile {
#[arg(long)]
file: String,
},
Clear,
Rebuild,
}
fn ensure_project_fresh(ctx: &config::Context, disabled: bool) -> anyhow::Result<()> {
if !disabled {
freshness::ensure_fresh(ctx, freshness::FreshnessScope::Project)?;
}
Ok(())
}
fn ensure_files_fresh(
ctx: &config::Context,
disabled: bool,
files: Vec<std::path::PathBuf>,
) -> anyhow::Result<()> {
if !disabled {
freshness::ensure_fresh(ctx, freshness::FreshnessScope::Files(files))?;
}
Ok(())
}
fn ensure_symbol_fresh(ctx: &config::Context, disabled: bool, id: &str) -> anyhow::Result<()> {
if !disabled {
freshness::ensure_symbol_fresh(ctx, id)?;
}
Ok(())
}
fn reject_grep_limit(_value: &str) -> Result<String, String> {
Err(
"gcode grep is indexed search; --limit is unsupported. Use -m/--max-count, or run raw `rg` for filesystem grep."
.to_string(),
)
}
fn effective_format(explicit_format: Option<output::Format>, command: &Command) -> output::Format {
explicit_format.unwrap_or(match command {
Command::Grep { .. } => output::Format::Text,
_ => output::Format::Json,
})
}
fn reject_unsupported_grep_flags(command: &Command) -> anyhow::Result<()> {
let Command::Grep {
unsupported_files_with_matches,
unsupported_files_without_match,
unsupported_count,
unsupported_only_matching,
unsupported_invert_match,
unsupported_word_regexp,
unsupported_regexp,
unsupported_recursive,
unsupported_type,
unsupported_type_not,
unsupported_pcre2,
unsupported_multiline,
unsupported_json,
..
} = command
else {
return Ok(());
};
let flag = [
(*unsupported_files_with_matches).then_some("--files-with-matches"),
(*unsupported_files_without_match).then_some("--files-without-match"),
(*unsupported_count).then_some("--count"),
(*unsupported_only_matching).then_some("--only-matching"),
(*unsupported_invert_match).then_some("--invert-match"),
(*unsupported_word_regexp).then_some("--word-regexp"),
unsupported_regexp.as_ref().map(|_| "--regexp"),
(*unsupported_recursive).then_some("--recursive"),
unsupported_type.as_ref().map(|_| "--type"),
unsupported_type_not.as_ref().map(|_| "--type-not"),
(*unsupported_pcre2).then_some("--pcre2"),
(*unsupported_multiline).then_some("--multiline"),
(*unsupported_json).then_some("--json"),
]
.into_iter()
.flatten()
.next();
if let Some(flag) = flag {
anyhow::bail!(
"gcode grep is indexed search; unsupported grep/rg flag `{flag}`. Use raw `rg` for filesystem grep."
);
}
Ok(())
}
fn dispatch_early_command<F>(
cli: &Cli,
format: output::Format,
setup_runner: F,
) -> anyhow::Result<bool>
where
F: FnOnce(setup::StandaloneSetupRequest, output::Format, bool) -> anyhow::Result<()>,
{
match &cli.command {
Command::Init => {
let root = match &cli.project {
Some(p) => std::path::PathBuf::from(p).canonicalize()?,
None => config::detect_project_root()?,
};
commands::init::run(&root, format, cli.quiet)?;
Ok(true)
}
Command::Setup {
standalone,
database_url,
no_services,
overwrite_code_index,
schema,
embedding_provider,
embedding_api_base,
embedding_model,
embedding_vector_dim,
embedding_api_key_env,
falkordb_host,
falkordb_port,
falkordb_password,
qdrant_url,
} => {
let mut request = setup::StandaloneSetupRequest::new(
*standalone,
database_url.clone(),
Some(schema.clone()),
);
request.no_services = *no_services;
request.overwrite_code_index = *overwrite_code_index;
request.embedding_provider = embedding_provider.clone();
request.embedding_api_base = embedding_api_base.clone();
request.embedding_model = embedding_model.clone();
request.embedding_vector_dim = *embedding_vector_dim;
request.embedding_api_key_env = embedding_api_key_env.clone();
request.falkordb_host = falkordb_host.clone();
request.falkordb_port = *falkordb_port;
request.falkordb_password = falkordb_password.clone();
request.qdrant_url = qdrant_url.clone();
setup_runner(request, format, cli.quiet)?;
Ok(true)
}
Command::Projects => {
commands::status::projects(format)?;
Ok(true)
}
Command::Prune { force } => {
commands::status::prune(*force)?;
Ok(true)
}
Command::Graph {
command:
GraphCommand::Clear {
project_id: Some(project_id),
},
} => {
let ctx = config::Context::resolve_for_project_id(project_id, cli.quiet)?;
commands::graph::clear(&ctx, format)?;
Ok(true)
}
_ => Ok(false),
}
}
fn main() -> std::process::ExitCode {
match run() {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(error) => {
if let Some(contract_error) =
error.downcast_ref::<commands::graph::GraphSyncContractError>()
{
if let Err(print_error) = contract_error.print() {
eprintln!("Error: {print_error:?}");
return std::process::ExitCode::FAILURE;
}
return std::process::ExitCode::from(contract_error.exit_code());
}
eprintln!("Error: {error:?}");
std::process::ExitCode::FAILURE
}
}
}
fn run() -> anyhow::Result<()> {
let cli = Cli::parse();
let format = effective_format(cli.format, &cli.command);
reject_unsupported_grep_flags(&cli.command)?;
if dispatch_early_command(&cli, format, commands::setup::run)? {
return Ok(());
}
let ctx = config::Context::resolve(cli.project.as_deref(), cli.quiet)?;
match cli.command {
Command::Init | Command::Setup { .. } | Command::Projects | Command::Prune { .. } => {
unreachable!()
}
Command::Index {
path,
files,
full,
require_cpp_semantics,
sync_projections,
} => commands::index::run(
&ctx,
path,
files,
full,
require_cpp_semantics,
sync_projections,
format,
),
Command::Status => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::status::run(&ctx, format)
}
Command::Invalidate { force } => commands::status::invalidate(&ctx, force),
Command::Graph {
command:
GraphCommand::SyncFile {
file,
allow_missing_indexed_file,
},
} => commands::graph::sync_file(&ctx, &file, allow_missing_indexed_file, format),
Command::Graph {
command: GraphCommand::Clear { project_id: None },
} => commands::graph::clear(&ctx, format),
Command::Graph {
command: GraphCommand::Clear {
project_id: Some(_),
},
} => unreachable!(),
Command::Graph {
command: GraphCommand::Rebuild,
} => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::graph::rebuild(&ctx, format)
}
Command::Graph {
command: GraphCommand::Report { top_n },
} => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::graph::report(&ctx, top_n, format)
}
Command::Vector {
command: VectorCommand::SyncFile { file },
} => {
ensure_files_fresh(
&ctx,
cli.no_freshness,
vec![std::path::PathBuf::from(&file)],
)?;
commands::vector::sync_file(&ctx, &file, format)
}
Command::Vector {
command: VectorCommand::Clear,
} => commands::vector::clear(&ctx, format),
Command::Vector {
command: VectorCommand::Rebuild,
} => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::vector::rebuild(&ctx, format)
}
Command::Graph {
command: GraphCommand::Overview { limit },
} => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::graph::overview(&ctx, limit, format)
}
Command::Graph {
command: GraphCommand::File { file },
} => {
ensure_files_fresh(
&ctx,
cli.no_freshness,
vec![std::path::PathBuf::from(&file)],
)?;
commands::graph::file(&ctx, &file, format)
}
Command::Graph {
command: GraphCommand::Neighbors { symbol_id, limit },
} => {
ensure_symbol_fresh(&ctx, cli.no_freshness, &symbol_id)?;
commands::graph::neighbors(&ctx, &symbol_id, limit, format)
}
Command::Graph {
command:
GraphCommand::BlastRadius {
symbol_id,
file,
depth,
limit,
},
} => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::graph::graph_blast_radius(
&ctx,
symbol_id.as_deref(),
file.as_deref(),
depth,
limit,
format,
)
}
Command::Search {
query,
paths,
limit,
offset,
kind,
language,
} => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::search::search(
&ctx,
&query,
commands::search::SearchOptions {
limit,
offset,
kind: kind.as_deref(),
language: language.as_deref(),
paths: &paths,
format,
with_graph: true,
},
)
}
Command::SearchSymbol {
query,
paths,
limit,
offset,
kind,
language,
with_graph,
} => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::search::search_symbol(
&ctx,
&query,
commands::search::SearchOptions {
limit,
offset,
kind: kind.as_deref(),
language: language.as_deref(),
paths: &paths,
format,
with_graph,
},
)
}
Command::SearchText {
query,
paths,
limit,
offset,
language,
} => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::search::search_text(
&ctx,
&query,
limit,
offset,
language.as_deref(),
&paths,
format,
)
}
Command::SearchContent {
query,
paths,
limit,
offset,
language,
} => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::search::search_content(
&ctx,
&query,
limit,
offset,
language.as_deref(),
&paths,
format,
)
}
Command::Grep {
pattern,
paths,
fixed_strings,
ignore_case,
before_context,
after_context,
context,
glob,
max_count,
line_number: _,
_unsupported_limit: _,
unsupported_files_with_matches: _,
unsupported_files_without_match: _,
unsupported_count: _,
unsupported_only_matching: _,
unsupported_invert_match: _,
unsupported_word_regexp: _,
unsupported_regexp: _,
unsupported_recursive: _,
unsupported_type: _,
unsupported_type_not: _,
unsupported_pcre2: _,
unsupported_multiline: _,
unsupported_json: _,
} => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::grep::run(
&ctx,
commands::grep::GrepOptions {
pattern: &pattern,
paths: &paths,
globs: &glob,
fixed_strings,
ignore_case,
context,
before_context,
after_context,
max_count,
format,
},
)
}
Command::Outline { file } => {
ensure_files_fresh(
&ctx,
cli.no_freshness,
vec![std::path::PathBuf::from(&file)],
)?;
commands::symbols::outline(&ctx, &file, format, cli.verbose)
}
Command::Symbol { id } => {
ensure_symbol_fresh(&ctx, cli.no_freshness, &id)?;
commands::symbols::symbol(&ctx, &id, format)
}
Command::Symbols { ids } => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::symbols::symbols(&ctx, &ids, format)
}
Command::Kinds => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::symbols::kinds(&ctx, format)
}
Command::Tree => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::symbols::tree(&ctx, format)
}
Command::Callers {
symbol_name,
limit,
offset,
} => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::graph::callers(&ctx, &symbol_name, limit, offset, format)
}
Command::Usages {
symbol_name,
limit,
offset,
} => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::graph::usages(&ctx, &symbol_name, limit, offset, format)
}
Command::Imports { file } => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::graph::imports(&ctx, &file, format)
}
Command::BlastRadius { target, depth } => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::graph::blast_radius(&ctx, &target, depth, format)
}
Command::RepoOutline => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::status::repo_outline(&ctx, format)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn parse_projection_lifecycle_commands() {
let cli = Cli::try_parse_from([
"gcode",
"--format",
"text",
"graph",
"sync-file",
"--file",
"src/lib.rs",
])
.expect("graph sync-file parses");
assert!(matches!(cli.format, Some(output::Format::Text)));
match cli.command {
Command::Graph {
command:
GraphCommand::SyncFile {
file,
allow_missing_indexed_file,
},
} => {
assert_eq!(file, "src/lib.rs");
assert!(!allow_missing_indexed_file);
}
_ => panic!("expected graph sync-file command"),
}
let cli = Cli::try_parse_from([
"gcode",
"--format",
"text",
"vector",
"sync-file",
"--file",
"src/lib.rs",
])
.expect("vector sync-file parses");
assert!(matches!(cli.format, Some(output::Format::Text)));
match cli.command {
Command::Vector {
command: VectorCommand::SyncFile { file },
} => assert_eq!(file, "src/lib.rs"),
_ => panic!("expected vector sync-file command"),
}
let cli = Cli::try_parse_from(["gcode", "graph", "clear"]).expect("graph clear parses");
assert!(matches!(
cli.command,
Command::Graph {
command: GraphCommand::Clear { project_id: None }
}
));
let cli = Cli::try_parse_from(["gcode", "graph", "clear", "--project-id", "project-1"])
.expect("graph clear --project-id parses");
assert!(matches!(
cli.command,
Command::Graph {
command: GraphCommand::Clear {
project_id: Some(project_id)
}
} if project_id == "project-1"
));
let cli = Cli::try_parse_from(["gcode", "graph", "rebuild"]).expect("graph rebuild parses");
assert!(matches!(
cli.command,
Command::Graph {
command: GraphCommand::Rebuild
}
));
let cli = Cli::try_parse_from(["gcode", "graph", "report"]).expect("graph report parses");
assert!(matches!(
cli.command,
Command::Graph {
command: GraphCommand::Report { top_n: 10 }
}
));
let cli =
Cli::try_parse_from(["gcode", "graph", "overview"]).expect("graph overview parses");
assert!(matches!(
cli.command,
Command::Graph {
command: GraphCommand::Overview { limit: 100 }
}
));
let cli = Cli::try_parse_from(["gcode", "graph", "overview", "--limit", "25"])
.expect("graph overview limit parses");
assert!(matches!(
cli.command,
Command::Graph {
command: GraphCommand::Overview { limit: 25 }
}
));
let cli = Cli::try_parse_from(["gcode", "graph", "file", "--file", "src/main.rs"])
.expect("graph file parses");
match cli.command {
Command::Graph {
command: GraphCommand::File { file },
} => assert_eq!(file, "src/main.rs"),
_ => panic!("expected graph file command"),
}
let cli = Cli::try_parse_from([
"gcode",
"graph",
"neighbors",
"--symbol-id",
"sym-1",
"--limit",
"7",
])
.expect("graph neighbors parses");
match cli.command {
Command::Graph {
command: GraphCommand::Neighbors { symbol_id, limit },
} => {
assert_eq!(symbol_id, "sym-1");
assert_eq!(limit, 7);
}
_ => panic!("expected graph neighbors command"),
}
let cli = Cli::try_parse_from([
"gcode",
"graph",
"blast-radius",
"--symbol-id",
"sym-1",
"--depth",
"2",
"--limit",
"9",
])
.expect("graph blast-radius symbol parses");
match cli.command {
Command::Graph {
command:
GraphCommand::BlastRadius {
symbol_id,
file,
depth,
limit,
},
} => {
assert_eq!(symbol_id.as_deref(), Some("sym-1"));
assert_eq!(file, None);
assert_eq!(depth, 2);
assert_eq!(limit, 9);
}
_ => panic!("expected graph blast-radius command"),
}
let cli = Cli::try_parse_from([
"gcode",
"graph",
"blast-radius",
"--file",
"src/lib.rs",
"--depth",
"2",
"--limit",
"9",
])
.expect("graph blast-radius file parses");
match cli.command {
Command::Graph {
command:
GraphCommand::BlastRadius {
symbol_id,
file,
depth,
limit,
},
} => {
assert_eq!(symbol_id, None);
assert_eq!(file.as_deref(), Some("src/lib.rs"));
assert_eq!(depth, 2);
assert_eq!(limit, 9);
}
_ => panic!("expected graph blast-radius command"),
}
let cli = Cli::try_parse_from(["gcode", "vector", "clear"]).expect("vector clear parses");
assert!(matches!(
cli.command,
Command::Vector {
command: VectorCommand::Clear
}
));
let cli =
Cli::try_parse_from(["gcode", "vector", "rebuild"]).expect("vector rebuild parses");
assert!(matches!(
cli.command,
Command::Vector {
command: VectorCommand::Rebuild
}
));
let cli =
Cli::try_parse_from(["gcode", "index", "--sync-projections"]).expect("index parses");
match cli.command {
Command::Index {
sync_projections, ..
} => assert!(sync_projections),
_ => panic!("expected index command"),
}
}
#[test]
fn parse_graph_report_global_format() {
let cli = Cli::try_parse_from([
"gcode", "graph", "report", "--top-n", "5", "--format", "text",
])
.expect("graph report parses");
assert!(matches!(cli.format, Some(output::Format::Text)));
match cli.command {
Command::Graph {
command: GraphCommand::Report { top_n },
} => assert_eq!(top_n, 5),
_ => panic!("expected graph report command"),
}
let err = match Cli::try_parse_from(["gcode", "graph", "report", "--limit", "5"]) {
Ok(_) => panic!("report keeps minimal args"),
Err(err) => err,
};
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
}
#[test]
fn test_parse_index_require_cpp_semantics() {
let cli = Cli::try_parse_from(["gcode", "index", "--require-cpp-semantics"])
.expect("index parses");
match cli.command {
Command::Index {
require_cpp_semantics,
sync_projections,
..
} => {
assert!(require_cpp_semantics);
assert!(!sync_projections);
}
_ => panic!("expected index command"),
}
}
#[test]
fn test_parse_callers_remains_top_level() {
let cli = Cli::try_parse_from(["gcode", "callers", "handleAuth"]).expect("callers parses");
match cli.command {
Command::Callers {
symbol_name,
limit,
offset,
} => {
assert_eq!(symbol_name, "handleAuth");
assert_eq!(limit, 10);
assert_eq!(offset, 0);
}
_ => panic!("expected top-level callers command"),
}
}
#[test]
fn test_parse_usages_remains_top_level() {
let cli = Cli::try_parse_from(["gcode", "usages", "DatabasePool"]).expect("usages parses");
match cli.command {
Command::Usages {
symbol_name,
limit,
offset,
} => {
assert_eq!(symbol_name, "DatabasePool");
assert_eq!(limit, 10);
assert_eq!(offset, 0);
}
_ => panic!("expected top-level usages command"),
}
}
#[test]
fn test_parse_imports_remains_top_level() {
let cli = Cli::try_parse_from(["gcode", "imports", "src/auth.ts"]).expect("imports parses");
match cli.command {
Command::Imports { file } => assert_eq!(file, "src/auth.ts"),
_ => panic!("expected top-level imports command"),
}
}
#[test]
fn test_parse_blast_radius_remains_top_level() {
let cli = Cli::try_parse_from(["gcode", "blast-radius", "handleAuth"])
.expect("blast-radius parses");
match cli.command {
Command::BlastRadius { target, depth } => {
assert_eq!(target, "handleAuth");
assert_eq!(depth, 3);
}
_ => panic!("expected top-level blast-radius command"),
}
}
#[test]
fn test_parse_search_symbol_filters() {
let cli = Cli::try_parse_from([
"gcode",
"search-symbol",
"outline",
"crates/gcode/src",
"--kind",
"function",
"--language",
"rust",
])
.expect("search-symbol parses");
match cli.command {
Command::SearchSymbol {
query,
paths,
limit,
offset,
kind,
language,
with_graph,
} => {
assert_eq!(query, "outline");
assert_eq!(paths, vec!["crates/gcode/src"]);
assert_eq!(limit, 10);
assert_eq!(offset, 0);
assert_eq!(kind.as_deref(), Some("function"));
assert_eq!(language.as_deref(), Some("rust"));
assert!(!with_graph);
}
_ => panic!("expected search-symbol command"),
}
}
#[test]
fn test_parse_search_symbol_with_graph() {
let cli = Cli::try_parse_from(["gcode", "search-symbol", "outline", "--with-graph"])
.expect("search-symbol --with-graph parses");
match cli.command {
Command::SearchSymbol { with_graph, .. } => assert!(with_graph),
_ => panic!("expected search-symbol command"),
}
}
#[test]
fn test_parse_search_language_filters() {
let cli = Cli::try_parse_from(["gcode", "search", "outline", "--language", "rust"])
.expect("search parses");
match cli.command {
Command::Search { language, .. } => {
assert_eq!(language.as_deref(), Some("rust"));
}
_ => panic!("expected search command"),
}
}
#[test]
fn test_parse_search_positional_paths() {
let cli = Cli::try_parse_from([
"gcode",
"search",
"outline",
"src/gobby",
"tests",
"--limit",
"20",
])
.expect("search parses");
match cli.command {
Command::Search { paths, limit, .. } => {
assert_eq!(paths, vec!["src/gobby", "tests"]);
assert_eq!(limit, 20);
}
_ => panic!("expected search command"),
}
}
#[test]
fn test_parse_search_text_positional_path_after_option() {
let cli = Cli::try_parse_from([
"gcode",
"search-text",
"outline",
"--limit",
"5",
"src/gobby",
])
.expect("search-text parses");
match cli.command {
Command::SearchText { paths, limit, .. } => {
assert_eq!(paths, vec!["src/gobby"]);
assert_eq!(limit, 5);
}
_ => panic!("expected search-text command"),
}
}
#[test]
fn test_parse_search_content_positional_paths_and_format() {
let cli = Cli::try_parse_from([
"gcode",
"search-content",
"QUERY",
"src/gobby",
"tests",
"--limit",
"20",
"--format",
"text",
])
.expect("search-content parses");
assert!(matches!(cli.format, Some(output::Format::Text)));
match cli.command {
Command::SearchContent { paths, limit, .. } => {
assert_eq!(paths, vec!["src/gobby", "tests"]);
assert_eq!(limit, 20);
}
_ => panic!("expected search-content command"),
}
}
#[test]
fn test_parse_search_content_positional_path_after_option() {
let cli = Cli::try_parse_from([
"gcode",
"search-content",
"QUERY",
"--limit",
"5",
"src/gobby",
])
.expect("search-content parses");
match cli.command {
Command::SearchContent { paths, limit, .. } => {
assert_eq!(paths, vec!["src/gobby"]);
assert_eq!(limit, 5);
}
_ => panic!("expected search-content command"),
}
}
#[test]
fn test_parse_search_path_flag_rejected() {
for command in ["search", "search-symbol", "search-text", "search-content"] {
let err = match Cli::try_parse_from([
"gcode",
command,
"QUERY",
"--path",
"crates/gcode/src",
]) {
Ok(_) => panic!("--path should be rejected for {command}"),
Err(err) => err,
};
assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
assert!(
err.to_string().contains("--path"),
"unexpected error for {command}: {err}"
);
}
}
#[test]
fn test_parse_no_freshness_global_flag() {
let cli = Cli::try_parse_from(["gcode", "--no-freshness", "tree"]).expect("tree parses");
assert!(cli.no_freshness);
assert!(matches!(cli.command, Command::Tree));
}
#[test]
fn parse_setup_standalone() {
let cli = Cli::try_parse_from([
"gcode",
"setup",
"--standalone",
"--database-url",
"postgresql://localhost/gcode",
"--no-services",
"--overwrite-code-index",
"--embedding-provider",
"ollama",
"--embedding-vector-dim",
"768",
"--falkordb-password",
"secret-pass",
])
.expect("setup parses");
match cli.command {
Command::Setup {
standalone,
database_url,
no_services,
overwrite_code_index,
schema,
embedding_provider,
embedding_vector_dim,
falkordb_password,
..
} => {
assert!(standalone);
assert_eq!(
database_url.as_deref(),
Some("postgresql://localhost/gcode")
);
assert!(no_services);
assert!(overwrite_code_index);
assert_eq!(schema, "public");
assert_eq!(embedding_provider.as_deref(), Some("ollama"));
assert_eq!(embedding_vector_dim, Some(768));
assert_eq!(falkordb_password.as_deref(), Some("secret-pass"));
}
_ => panic!("expected setup command"),
}
}
#[test]
fn setup_runs_before_context_resolve() {
let project = tempfile::tempdir().expect("temp project");
let cli = Cli::try_parse_from([
"gcode",
"--project",
project.path().to_str().expect("utf8 temp path"),
"setup",
"--standalone",
"--database-url",
"postgresql://localhost/gcode",
"--overwrite-code-index",
"--embedding-api-base",
"https://embeddings.example/v1",
])
.expect("setup parses");
let mut called = false;
let dispatched = dispatch_early_command(
&cli,
effective_format(cli.format, &cli.command),
|request, _format, _quiet| {
called = true;
assert!(request.standalone);
assert_eq!(
request.database_url.as_deref(),
Some("postgresql://localhost/gcode")
);
assert_eq!(request.schema, "public");
assert!(request.overwrite_code_index);
assert_eq!(
request.embedding_api_base.as_deref(),
Some("https://embeddings.example/v1")
);
Ok(())
},
)
.expect("early dispatch succeeds without resolving project context");
assert!(dispatched);
assert!(called);
}
#[test]
fn parse_grep_basic() {
let cli =
Cli::try_parse_from(["gcode", "grep", "needle", "src"]).expect("grep basic parses");
match cli.command {
Command::Grep {
pattern,
paths,
fixed_strings,
ignore_case,
..
} => {
assert_eq!(pattern, "needle");
assert_eq!(paths, vec!["src"]);
assert!(!fixed_strings);
assert!(!ignore_case);
}
_ => panic!("expected grep command"),
}
}
#[test]
fn parse_grep_ignore_case() {
let cli = Cli::try_parse_from(["gcode", "grep", "needle", "--ignore-case"])
.expect("grep ignore-case parses");
match cli.command {
Command::Grep { ignore_case, .. } => assert!(ignore_case),
_ => panic!("expected grep command"),
}
}
#[test]
fn parse_grep_with_flags() {
let cli = Cli::try_parse_from([
"gcode",
"grep",
"needle",
"-F",
"-C",
"2",
"-g",
"*.py",
"src/gobby",
])
.expect("grep with flags parses");
match cli.command {
Command::Grep {
pattern,
paths,
fixed_strings,
context,
glob,
..
} => {
assert_eq!(pattern, "needle");
assert_eq!(paths, vec!["src/gobby"]);
assert!(fixed_strings);
assert_eq!(context, Some(2));
assert_eq!(glob, vec!["*.py"]);
}
_ => panic!("expected grep command"),
}
}
#[test]
fn parse_grep_max_count() {
let cli = Cli::try_parse_from(["gcode", "grep", "needle", "-m", "5", "src"])
.expect("grep with -m parses");
match cli.command {
Command::Grep {
paths, max_count, ..
} => {
assert_eq!(paths, vec!["src"]);
assert_eq!(max_count, Some(5));
}
_ => panic!("expected grep command"),
}
let cli = Cli::try_parse_from(["gcode", "grep", "needle", "--max-count", "5", "src"])
.expect("grep with --max-count parses");
match cli.command {
Command::Grep {
paths, max_count, ..
} => {
assert_eq!(paths, vec!["src"]);
assert_eq!(max_count, Some(5));
}
_ => panic!("expected grep command"),
}
let cli = Cli::try_parse_from(["gcode", "grep", "needle", "src", "-m", "5"])
.expect("grep with -m after path parses");
match cli.command {
Command::Grep {
paths, max_count, ..
} => {
assert_eq!(paths, vec!["src"]);
assert_eq!(max_count, Some(5));
}
_ => panic!("expected grep command"),
}
let cli = Cli::try_parse_from(["gcode", "grep", "needle", "src", "--max-count", "5"])
.expect("grep with --max-count after path parses");
match cli.command {
Command::Grep {
paths, max_count, ..
} => {
assert_eq!(paths, vec!["src"]);
assert_eq!(max_count, Some(5));
}
_ => panic!("expected grep command"),
}
}
#[test]
fn parse_grep_rejects_limit() {
let err = match Cli::try_parse_from(["gcode", "grep", "needle", "src", "--limit", "5"]) {
Ok(_) => panic!("--limit should be rejected"),
Err(err) => err,
};
assert!(
err.to_string().contains("gcode grep is indexed search"),
"unexpected error: {err}"
);
assert!(
err.to_string().contains("-m/--max-count"),
"unexpected error: {err}"
);
}
#[test]
fn parse_grep_unsupported_flag_fails_before_context_resolution() {
let cli = Cli::try_parse_from(["gcode", "grep", "needle", "--files-with-matches"])
.expect("unsupported grep flag parses for contract rejection");
let err = reject_unsupported_grep_flags(&cli.command)
.expect_err("unsupported grep flag should fail before context resolution");
assert!(
err.to_string().contains("gcode grep is indexed search"),
"unexpected error: {err}"
);
assert!(
err.to_string().contains("--files-with-matches"),
"unexpected error: {err}"
);
assert!(
err.to_string().contains("raw `rg`"),
"unexpected error: {err}"
);
}
#[test]
fn parse_grep_unsupported_flag_after_path_fails_with_indexed_search_message() {
let cli = Cli::try_parse_from(["gcode", "grep", "needle", "src", "--files-with-matches"])
.expect("unsupported grep flag after path parses for contract rejection");
let err = reject_unsupported_grep_flags(&cli.command)
.expect_err("unsupported grep flag after path should fail");
assert!(
err.to_string().contains("gcode grep is indexed search"),
"unexpected error: {err}"
);
}
#[test]
fn parse_graph_sync_file_with_flag() {
let cli = Cli::try_parse_from([
"gcode",
"graph",
"sync-file",
"--file",
"src/lib.rs",
"--allow-missing-indexed-file",
])
.expect("graph sync-file with flag parses");
match cli.command {
Command::Graph {
command:
GraphCommand::SyncFile {
file,
allow_missing_indexed_file,
},
} => {
assert_eq!(file, "src/lib.rs");
assert!(allow_missing_indexed_file);
}
_ => panic!("expected graph sync-file command"),
}
}
#[test]
fn parse_grep_with_global_format() {
let cli = Cli::try_parse_from(["gcode", "--format", "text", "grep", "needle", "src"])
.expect("grep with global format parses");
assert!(matches!(cli.format, Some(output::Format::Text)));
match cli.command {
Command::Grep { pattern, .. } => assert_eq!(pattern, "needle"),
_ => panic!("expected grep command"),
}
}
#[test]
fn effective_format_defaults_grep_to_text() {
let cli = Cli::try_parse_from(["gcode", "grep", "needle", "src", "-m", "50"])
.expect("grep parses");
assert!(cli.format.is_none());
assert!(matches!(
effective_format(cli.format, &cli.command),
output::Format::Text
));
}
#[test]
fn effective_format_honors_explicit_grep_json() {
let cli = Cli::try_parse_from([
"gcode", "grep", "needle", "src", "-m", "50", "--format", "json",
])
.expect("grep parses with explicit json format");
assert!(matches!(cli.format, Some(output::Format::Json)));
assert!(matches!(
effective_format(cli.format, &cli.command),
output::Format::Json
));
}
#[test]
fn effective_format_keeps_other_commands_json_by_default() {
let cli =
Cli::try_parse_from(["gcode", "search-content", "needle"]).expect("search parses");
assert!(cli.format.is_none());
assert!(matches!(
effective_format(cli.format, &cli.command),
output::Format::Json
));
}
}