mod commands;
mod config;
mod db;
mod falkor;
mod freshness;
mod git;
mod index;
mod models;
mod output;
mod progress;
mod project;
mod savings;
mod schema;
mod search;
mod secrets;
mod skill;
use clap::{Parser, Subcommand};
#[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, default_value = "json")]
format: 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,
Index {
path: Option<String>,
#[arg(long, num_args = 1..)]
files: Option<Vec<String>>,
#[arg(long)]
full: bool,
},
Status,
Invalidate {
#[arg(long)]
force: bool,
},
Graph {
#[command(subcommand)]
command: GraphCommand,
},
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>,
},
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>,
},
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 {
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 main() -> anyhow::Result<()> {
let cli = Cli::parse();
match &cli.command {
Command::Init => {
let root = match &cli.project {
Some(p) => std::path::PathBuf::from(p).canonicalize()?,
None => config::detect_project_root()?,
};
return commands::init::run(&root, cli.format, cli.quiet);
}
Command::Projects => {
return commands::status::projects(cli.format);
}
Command::Prune { force } => {
return commands::status::prune(*force);
}
_ => {}
}
let ctx = config::Context::resolve(cli.project.as_deref(), cli.quiet)?;
match cli.command {
Command::Init | Command::Projects | Command::Prune { .. } => unreachable!(),
Command::Index { path, files, full } => commands::index::run(&ctx, path, files, full),
Command::Status => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::status::run(&ctx, cli.format)
}
Command::Invalidate { force } => commands::status::invalidate(&ctx, force),
Command::Graph {
command: GraphCommand::Clear,
} => commands::graph::clear(&ctx, cli.format),
Command::Graph {
command: GraphCommand::Rebuild,
} => commands::graph::rebuild(&ctx, cli.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: cli.format,
},
)
}
Command::SearchSymbol {
query,
paths,
limit,
offset,
kind,
language,
} => {
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: cli.format,
},
)
}
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,
cli.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,
cli.format,
)
}
Command::Outline { file } => {
ensure_files_fresh(
&ctx,
cli.no_freshness,
vec![std::path::PathBuf::from(&file)],
)?;
commands::symbols::outline(&ctx, &file, cli.format, cli.verbose)
}
Command::Symbol { id } => {
ensure_symbol_fresh(&ctx, cli.no_freshness, &id)?;
commands::symbols::symbol(&ctx, &id, cli.format)
}
Command::Symbols { ids } => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::symbols::symbols(&ctx, &ids, cli.format)
}
Command::Kinds => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::symbols::kinds(&ctx, cli.format)
}
Command::Tree => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::symbols::tree(&ctx, cli.format)
}
Command::Callers {
symbol_name,
limit,
offset,
} => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::graph::callers(&ctx, &symbol_name, limit, offset, cli.format)
}
Command::Usages {
symbol_name,
limit,
offset,
} => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::graph::usages(&ctx, &symbol_name, limit, offset, cli.format)
}
Command::Imports { file } => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::graph::imports(&ctx, &file, cli.format)
}
Command::BlastRadius { target, depth } => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::graph::blast_radius(&ctx, &target, depth, cli.format)
}
Command::RepoOutline => {
ensure_project_fresh(&ctx, cli.no_freshness)?;
commands::status::repo_outline(&ctx, cli.format)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::Parser;
#[test]
fn test_parse_graph_clear() {
let cli = Cli::try_parse_from(["gcode", "graph", "clear"]).expect("graph clear parses");
assert!(matches!(
cli.command,
Command::Graph {
command: GraphCommand::Clear
}
));
}
#[test]
fn test_parse_graph_rebuild() {
let cli = Cli::try_parse_from(["gcode", "graph", "rebuild"]).expect("graph rebuild parses");
assert!(matches!(
cli.command,
Command::Graph {
command: GraphCommand::Rebuild
}
));
}
#[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,
} => {
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"));
}
_ => 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, 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));
}
}