use anyhow::Result;
use clap::{Parser, Subcommand};
use std::path::{Component, Path, PathBuf};
use tokio;
use loregrep::cli_main::{AnalyzeArgs, CliApp, CliConfig, ExecToolArgs, ScanArgs, SearchArgs};
#[derive(Parser)]
#[command(name = "loregrep")]
#[command(about = "Lightweight Code Analysis Tool with AI-powered search")]
#[command(
long_about = "Loregrep analyzes codebases using tree-sitter parsing and provides AI-powered natural language search capabilities"
)]
#[command(version)]
pub struct Cli {
#[command(subcommand)]
pub command: Commands,
#[arg(short, long, global = true, default_value = ".")]
pub directory: PathBuf,
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(long, global = true)]
pub no_color: bool,
#[arg(short, long, global = true)]
pub config: Option<PathBuf>,
}
#[derive(Subcommand)]
pub enum Commands {
Scan(ScanArgs),
Search(SearchArgs),
Analyze(AnalyzeArgs),
ExecTool(ExecToolArgs),
Config,
}
fn effective_path(directory: &Path, arg: &Path) -> PathBuf {
if arg.is_absolute() {
return normalize_cur_dirs(arg);
}
let joined = directory.join(arg);
let normalized = normalize_cur_dirs(&joined);
if normalized.as_os_str().is_empty() {
PathBuf::from(".")
} else {
normalized
}
}
fn normalize_cur_dirs(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for component in path.components() {
match component {
Component::CurDir => {}
other => out.push(other.as_os_str()),
}
}
out
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::fmt::init();
let cli = Cli::parse();
let config = CliConfig::load(cli.config.as_deref())?;
let mut app = CliApp::new(config, cli.verbose, !cli.no_color).await?;
match cli.command {
Commands::Scan(mut args) => {
args.path = effective_path(&cli.directory, &args.path);
app.scan(args).await
}
Commands::Search(mut args) => {
args.path = effective_path(&cli.directory, &args.path);
app.search(args).await
}
Commands::Analyze(mut args) => {
args.file = effective_path(&cli.directory, &args.file);
app.analyze(args).await
}
Commands::ExecTool(mut args) => {
args.path = effective_path(&cli.directory, &args.path);
app.exec_tool(args).await
}
Commands::Config => app.show_config().await,
}
}
#[cfg(test)]
mod tests {
use super::*;
use clap::CommandFactory;
fn eff(dir: &str, arg: &str) -> String {
effective_path(Path::new(dir), Path::new(arg))
.display()
.to_string()
}
#[test]
fn every_spelling_of_the_current_directory_yields_the_base_directory() {
for spelling in [".", "./", ".//", "./."] {
assert_eq!(eff("/repo", spelling), "/repo", "spelling {spelling:?}");
}
}
#[test]
fn an_explicitly_written_dot_is_not_special_cased_away() {
assert_eq!(eff(".", "."), ".");
assert_eq!(eff(".", "./"), ".");
}
#[test]
fn a_relative_argument_resolves_against_the_base_directory() {
assert_eq!(eff("/repo", "src"), "/repo/src");
assert_eq!(eff("/repo", "./src/lib.rs"), "/repo/src/lib.rs");
assert_eq!(eff("/repo/", "src/"), "/repo/src");
}
#[test]
fn an_absolute_argument_wins_over_the_base_directory() {
assert_eq!(eff("/repo", "/elsewhere/src"), "/elsewhere/src");
assert_eq!(eff("/repo", "/elsewhere/./src"), "/elsewhere/src");
}
#[test]
fn a_relative_base_directory_is_kept_relative() {
assert_eq!(eff("sub", "src"), "sub/src");
assert_eq!(eff("./sub", "./src"), "sub/src");
}
#[test]
fn parent_components_are_preserved_for_the_layers_below_to_judge() {
assert_eq!(eff("/repo", "../other"), "/repo/../other");
assert_eq!(eff("/repo", "./../other"), "/repo/../other");
}
#[test]
fn the_function_is_idempotent_on_its_own_output() {
let once = effective_path(Path::new("/repo"), Path::new("./src/"));
let twice = effective_path(Path::new("/repo"), &once);
assert_eq!(once, twice);
}
#[test]
fn analyze_and_exec_tool_agree_on_the_same_relative_argument() {
let cli =
Cli::try_parse_from(["loregrep", "-d", "/repo", "analyze", "src/lib.rs"]).unwrap();
let analyzed = match cli.command {
Commands::Analyze(a) => effective_path(&cli.directory, &a.file),
_ => unreachable!(),
};
let cli = Cli::try_parse_from([
"loregrep",
"-d",
"/repo",
"exec-tool",
"analyze_file",
"--path",
"src",
])
.unwrap();
let root = match cli.command {
Commands::ExecTool(a) => effective_path(&cli.directory, &a.path),
_ => unreachable!(),
};
assert_eq!(root, PathBuf::from("/repo/src"));
assert_eq!(analyzed, root.join("lib.rs"));
}
#[test]
fn scan_search_analyze_and_exec_tool_share_one_rule() {
let base = "/repo";
let cases: Vec<(&str, PathBuf)> = vec![
(
"scan",
match Cli::try_parse_from(["loregrep", "-d", base, "scan", "./src"])
.unwrap()
.command
{
Commands::Scan(a) => effective_path(Path::new(base), &a.path),
_ => unreachable!(),
},
),
(
"search",
match Cli::try_parse_from(["loregrep", "-d", base, "search", "q", "-p", "./src"])
.unwrap()
.command
{
Commands::Search(a) => effective_path(Path::new(base), &a.path),
_ => unreachable!(),
},
),
(
"analyze",
match Cli::try_parse_from(["loregrep", "-d", base, "analyze", "./src"])
.unwrap()
.command
{
Commands::Analyze(a) => effective_path(Path::new(base), &a.file),
_ => unreachable!(),
},
),
(
"exec-tool",
match Cli::try_parse_from([
"loregrep",
"-d",
base,
"exec-tool",
"t",
"--path",
"./src",
])
.unwrap()
.command
{
Commands::ExecTool(a) => effective_path(Path::new(base), &a.path),
_ => unreachable!(),
},
),
];
for (name, resolved) in cases {
assert_eq!(resolved, PathBuf::from("/repo/src"), "subcommand {name}");
}
}
#[test]
fn scan_no_longer_accepts_flags_it_would_discard() {
for flag in [
vec!["loregrep", "scan", "--include", "*.rs"],
vec!["loregrep", "scan", "--exclude", "target"],
vec!["loregrep", "scan", "--follow-symlinks"],
] {
assert!(
Cli::try_parse_from(&flag).is_err(),
"expected {flag:?} to be rejected"
);
}
assert!(Cli::try_parse_from(["loregrep", "scan", "--cache"]).is_ok());
}
#[test]
fn the_directory_help_text_describes_what_the_flag_does() {
let cmd = Cli::command();
let arg = cmd
.get_arguments()
.find(|a| a.get_id() == "directory")
.expect("global --directory flag");
let help = arg
.get_help()
.map(|h| h.to_string())
.unwrap_or_else(|| arg.get_long_help().map(|h| h.to_string()).unwrap());
assert!(help.contains("Relative paths"), "help was: {help}");
assert!(help.contains("absolute"), "help was: {help}");
assert!(
help.contains("does not change the process working directory"),
"help was: {help}"
);
}
}