use std::path::PathBuf;
use clap::{Parser, Subcommand, ValueEnum};
use srcsearch::{
SearchHit, SearchScope, index_project, index_target, search_tantivy_index,
update_tantivy_index, write_json, write_tantivy_index,
};
#[derive(Clone, Debug, ValueEnum, PartialEq, Eq)]
enum SearchScopeArg {
All,
Doc,
}
impl From<SearchScopeArg> for SearchScope {
fn from(value: SearchScopeArg) -> Self {
match value {
SearchScopeArg::All => SearchScope::All,
SearchScopeArg::Doc => SearchScope::Doc,
}
}
}
const CLI_USAGE_HELP: &str = concat!(
"Usage:\n",
" srcsearch json --project-root . --output index.json\n",
" srcsearch json --project-dir . -o index.json\n",
" srcsearch index --project-root . --output-dir index\n",
" srcsearch index -p . -o index\n",
" srcsearch update --project-root . --index-dir index --changed-file src/lib.rs\n",
" srcsearch search --index-dir index --query quickstart --scope doc\n",
" srcsearch search -i index -q quickstart -s doc",
);
#[derive(Debug, Parser)]
#[command(
name = "srcsearch",
version,
about = "Index Rust and Markdown documentation",
after_help = CLI_USAGE_HELP
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Debug, Subcommand)]
enum Commands {
Json {
#[arg(
long,
short = 'p',
visible_alias = "project-dir",
default_value = ".",
value_name = "PATH"
)]
project_root: PathBuf,
#[arg(long, short = 'o', value_name = "FILE")]
output: PathBuf,
},
Index {
#[arg(
long,
short = 'p',
visible_alias = "project-dir",
default_value = ".",
value_name = "PATH"
)]
project_root: PathBuf,
#[arg(long, short = 'o', value_name = "DIR")]
output_dir: PathBuf,
},
Update {
#[arg(
long,
short = 'p',
visible_alias = "project-dir",
default_value = ".",
value_name = "PATH"
)]
project_root: PathBuf,
#[arg(long, short = 'i', value_name = "DIR")]
index_dir: PathBuf,
#[arg(long = "changed-file", required = true, num_args = 1.., value_name = "PATH")]
changed_files: Vec<String>,
},
#[command(
about = "Search indexed content (stemming matches inflected forms in title/body/doc fields)"
)]
Search {
#[arg(long, short = 'i', value_name = "DIR")]
index_dir: PathBuf,
#[arg(long, short = 'q', value_name = "QUERY")]
query: String,
#[arg(long, short = 'l', default_value_t = 10, value_name = "N")]
limit: i64,
#[arg(long, short = 's', value_enum, default_value_t = SearchScopeArg::All)]
scope: SearchScopeArg,
#[arg(long)]
json: bool,
},
}
fn non_empty(value: Option<&str>) -> Option<&str> {
value.filter(|value| !value.trim().is_empty())
}
fn format_hit_label(hit: &SearchHit) -> String {
match hit.record_type.as_str() {
"markdown" => non_empty(hit.title.as_deref())
.or(non_empty(hit.name.as_deref()))
.or(non_empty(hit.signature.as_deref()))
.unwrap_or("(untitled)")
.to_string(),
"rust" => non_empty(hit.name.as_deref())
.or(non_empty(hit.signature.as_deref()))
.or(non_empty(hit.title.as_deref()))
.unwrap_or("(untitled)")
.to_string(),
_ => non_empty(hit.title.as_deref())
.or(non_empty(hit.name.as_deref()))
.or(non_empty(hit.signature.as_deref()))
.unwrap_or("(untitled)")
.to_string(),
}
}
fn format_search_hits(hits: &[SearchHit]) -> String {
if hits.is_empty() {
return "No results found.\n".to_string();
}
let mut output = String::new();
for hit in hits {
let line_start = hit.line_start.unwrap_or(1);
output.push_str(&format!(
"{}:{}:1: {}\n",
hit.file_path,
line_start,
format_hit_label(hit)
));
}
output
}
fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
let args = Cli::parse();
match args.command {
Commands::Json {
project_root,
output,
} => {
let records = index_project(&project_root)?;
write_json(&records, &output)?;
}
Commands::Index {
project_root,
output_dir,
} => {
let records = index_project(&project_root)?;
write_tantivy_index(&records, &output_dir, Some(&project_root))?;
}
Commands::Update {
project_root,
index_dir,
changed_files,
} => {
let mut changed_records = Vec::new();
for changed_file in &changed_files {
let path = project_root.join(changed_file);
if !path.exists() {
continue;
}
let mut file_records = index_target(&path, &project_root)?;
changed_records.append(&mut file_records);
}
update_tantivy_index(
&changed_records,
&index_dir,
Some(&project_root),
&changed_files,
)?;
}
Commands::Search {
index_dir,
query,
limit,
scope,
json,
} => {
let hits = search_tantivy_index(&index_dir, &query, limit, scope.into())?;
if json {
println!("{}", serde_json::to_string_pretty(&hits)?);
} else {
print!("{}", format_search_hits(&hits));
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{Cli, Commands, SearchScopeArg, format_search_hits};
use clap::{CommandFactory, Parser, error::ErrorKind};
use srcsearch::SearchHit;
use std::path::PathBuf;
#[test]
fn parses_json_subcommand() {
let cli = Cli::parse_from([
"srcsearch",
"json",
"--project-root",
".",
"--output",
"index.json",
]);
match cli.command {
Commands::Json {
project_root,
output,
} => {
assert_eq!(project_root, PathBuf::from("."));
assert_eq!(output, PathBuf::from("index.json"));
}
Commands::Index { .. } | Commands::Update { .. } | Commands::Search { .. } => {
panic!("expected json subcommand")
}
}
}
#[test]
fn parses_index_subcommand() {
let cli = Cli::parse_from([
"srcsearch",
"index",
"--project-root",
".",
"--output-dir",
"index",
]);
match cli.command {
Commands::Index {
project_root,
output_dir,
} => {
assert_eq!(project_root, PathBuf::from("."));
assert_eq!(output_dir, PathBuf::from("index"));
}
Commands::Json { .. } | Commands::Update { .. } | Commands::Search { .. } => {
panic!("expected index subcommand")
}
}
}
#[test]
fn parses_short_aliases() {
let cli = Cli::parse_from(["srcsearch", "json", "-p", ".", "-o", "index.json"]);
match cli.command {
Commands::Json {
project_root,
output,
} => {
assert_eq!(project_root, PathBuf::from("."));
assert_eq!(output, PathBuf::from("index.json"));
}
Commands::Index { .. } | Commands::Update { .. } | Commands::Search { .. } => {
panic!("expected json subcommand")
}
}
}
#[test]
fn defaults_project_dir_to_current_directory() {
let cli = Cli::parse_from(["srcsearch", "index", "-o", "index"]);
match cli.command {
Commands::Index {
project_root,
output_dir,
} => {
assert_eq!(project_root, PathBuf::from("."));
assert_eq!(output_dir, PathBuf::from("index"));
}
Commands::Json { .. } | Commands::Update { .. } | Commands::Search { .. } => {
panic!("expected index subcommand")
}
}
}
#[test]
fn parses_project_dir_alias() {
let cli = Cli::parse_from([
"srcsearch",
"index",
"--project-dir",
".",
"--output-dir",
"index",
]);
match cli.command {
Commands::Index {
project_root,
output_dir,
} => {
assert_eq!(project_root, PathBuf::from("."));
assert_eq!(output_dir, PathBuf::from("index"));
}
Commands::Json { .. } | Commands::Update { .. } | Commands::Search { .. } => {
panic!("expected index subcommand")
}
}
}
#[test]
fn parses_update_subcommand() {
let cli = Cli::parse_from([
"srcsearch",
"update",
"--project-root",
".",
"--index-dir",
"index",
"--changed-file",
"src/lib.rs",
"--changed-file",
"README.md",
]);
match cli.command {
Commands::Update {
project_root,
index_dir,
changed_files,
} => {
assert_eq!(project_root, PathBuf::from("."));
assert_eq!(index_dir, PathBuf::from("index"));
assert_eq!(
changed_files,
vec!["src/lib.rs".to_string(), "README.md".to_string()]
);
}
Commands::Json { .. } | Commands::Index { .. } | Commands::Search { .. } => {
panic!("expected update subcommand")
}
}
}
#[test]
fn parses_search_subcommand_with_defaults() {
let cli = Cli::parse_from([
"srcsearch",
"search",
"--index-dir",
"index",
"--query",
"quickstart",
]);
match cli.command {
Commands::Search {
index_dir,
query,
limit,
scope,
json,
} => {
assert_eq!(index_dir, PathBuf::from("index"));
assert_eq!(query, "quickstart");
assert_eq!(limit, 10);
assert_eq!(scope, SearchScopeArg::All);
assert!(!json);
}
Commands::Json { .. } | Commands::Index { .. } | Commands::Update { .. } => {
panic!("expected search subcommand")
}
}
}
#[test]
fn parses_search_subcommand_short_flags() {
let cli = Cli::parse_from([
"srcsearch",
"search",
"-i",
"index",
"-q",
"tantivy",
"-l",
"5",
]);
match cli.command {
Commands::Search {
index_dir,
query,
limit,
scope,
json,
} => {
assert_eq!(index_dir, PathBuf::from("index"));
assert_eq!(query, "tantivy");
assert_eq!(limit, 5);
assert_eq!(scope, SearchScopeArg::All);
assert!(!json);
}
Commands::Json { .. } | Commands::Index { .. } | Commands::Update { .. } => {
panic!("expected search subcommand")
}
}
}
#[test]
fn parses_search_subcommand_large_limit() {
let cli = Cli::parse_from([
"srcsearch",
"search",
"--index-dir",
"index",
"--query",
"tantivy",
"--limit",
"9223372036854775807",
]);
match cli.command {
Commands::Search { limit, json, .. } => {
assert_eq!(limit, i64::MAX);
assert!(!json);
}
Commands::Json { .. } | Commands::Index { .. } | Commands::Update { .. } => {
panic!("expected search subcommand")
}
}
}
#[test]
fn parses_search_scope_doc() {
let cli = Cli::parse_from([
"srcsearch",
"search",
"--index-dir",
"index",
"--query",
"quickstart",
"--scope",
"doc",
]);
match cli.command {
Commands::Search { scope, .. } => assert_eq!(scope, SearchScopeArg::Doc),
Commands::Json { .. } | Commands::Index { .. } | Commands::Update { .. } => {
panic!("expected search subcommand")
}
}
}
#[test]
fn parses_search_scope_short_alias() {
let cli = Cli::parse_from([
"srcsearch",
"search",
"-i",
"index",
"-q",
"quickstart",
"-s",
"doc",
]);
match cli.command {
Commands::Search { scope, .. } => assert_eq!(scope, SearchScopeArg::Doc),
Commands::Json { .. } | Commands::Index { .. } | Commands::Update { .. } => {
panic!("expected search subcommand")
}
}
}
#[test]
fn rejects_invalid_search_scope() {
let err = Cli::try_parse_from([
"srcsearch",
"search",
"--index-dir",
"index",
"--query",
"quickstart",
"--scope",
"invalid",
])
.expect_err("invalid scope should fail");
assert_eq!(err.kind(), ErrorKind::InvalidValue);
}
#[test]
fn parses_search_json_flag() {
let cli = Cli::parse_from([
"srcsearch",
"search",
"--index-dir",
"index",
"--query",
"quickstart",
"--json",
]);
match cli.command {
Commands::Search { json, .. } => assert!(json),
Commands::Json { .. } | Commands::Index { .. } | Commands::Update { .. } => {
panic!("expected search subcommand")
}
}
}
#[test]
fn formats_empty_search_results() {
assert_eq!(format_search_hits(&[]), "No results found.\n");
}
#[test]
fn formats_search_result_lines_for_editor_links() {
let hits = vec![SearchHit {
score: 1.2345,
record_type: "rust".to_string(),
file_path: "src/lib.rs".to_string(),
title: None,
name: Some("search_tantivy_index".to_string()),
kind: Some("fn".to_string()),
signature: Some("pub fn search_tantivy_index(...)".to_string()),
line_start: Some(42),
line_end: Some(47),
heading_line: None,
}];
let output = format_search_hits(&hits);
assert_eq!(output, "src/lib.rs:42:1: search_tantivy_index\n");
}
#[test]
fn formats_search_result_lines_with_default_line_number() {
let hits = vec![SearchHit {
score: 2.0,
record_type: "markdown".to_string(),
file_path: "README.md".to_string(),
title: Some("semantic search over code and docs".to_string()),
name: None,
kind: None,
signature: None,
line_start: None,
line_end: None,
heading_line: None,
}];
let output = format_search_hits(&hits);
assert_eq!(
output,
"README.md:1:1: semantic search over code and docs\n"
);
}
#[test]
fn search_help_mentions_stemming_behavior() {
let mut command = Cli::command();
let help = command
.find_subcommand_mut("search")
.expect("search subcommand should exist")
.render_long_help()
.to_string();
assert!(help.contains("stemming matches inflected forms in title/body/doc fields"));
}
#[test]
fn help_usage_mentions_search_and_aliases() {
let mut command = Cli::command();
let help = command.render_help().to_string();
assert!(help.contains("srcsearch json --project-root . --output index.json"));
assert!(help.contains("srcsearch json --project-dir . -o index.json"));
assert!(help.contains("srcsearch index --project-root . --output-dir index"));
assert!(help.contains("srcsearch index -p . -o index"));
assert!(help.contains("srcsearch search --index-dir index --query quickstart --scope doc"));
assert!(help.contains("srcsearch search -i index -q quickstart -s doc"));
}
}