mod cache;
pub mod cli;
mod docgen;
pub mod error;
mod external;
mod index_builder;
mod query;
mod reexport;
mod render;
mod resolve;
mod search;
mod signature;
mod source;
mod stdlib;
#[cfg(test)]
mod test_utils;
mod types;
use std::io::{self, Write};
use std::path::Path;
use std::time::Instant;
use cli::{Cli, CrateSpec, FeatureFlags, OutputMode, QueryPath};
use error::{GroxError, Result};
use render::dispatch::RenderContext;
use resolve::{CrateSource, ProjectContext};
use types::{DocIndex, ItemKind, QueryResult};
pub fn run(cli: &Cli) -> Result<()> {
if cli.clear_cache {
if let Some(path) = cache::clear_global_cache() {
eprintln!("[grox] Cleared cache at {}", path.display());
} else {
eprintln!("[grox] No cache directory found");
}
return Ok(());
}
let stdout = io::stdout();
let mut out = stdout.lock();
let ctx = if cli.manifest_path.is_some() {
Some(ProjectContext::discover(cli.manifest_path.as_deref())?)
} else {
ProjectContext::discover(None).ok()
};
if cli.path.is_none()
&& cli.search.is_none()
&& !cli.readme
&& ctx
.as_ref()
.is_some_and(ProjectContext::is_virtual_workspace)
{
let ctx = ctx.as_ref().expect("invariant: checked is_some");
return handle_workspace(&mut out, ctx, cli);
}
let query_path = QueryPath::parse(cli.path.as_deref().unwrap_or(""))?;
let (source, query_path) = resolve_crate_source(ctx.as_ref(), query_path)?;
let features = FeatureFlags::from_cli(cli);
let feature_suffix = cache::cache_suffix(&features, cli.private);
let (mut index, source) = load_or_build_index(
source,
&features,
&feature_suffix,
cli.private,
false,
ctx.as_ref(),
)?;
if cli.readme {
handle_readme(&mut out, &source, ctx.as_ref())?;
} else if let Some(search_query) = &cli.search {
handle_search(
&mut out,
&index,
search_query,
cli,
Some(&source),
&features,
ctx.as_ref(),
)?;
} else {
let kind_filter = if cli.recursive {
None
} else {
cli.kind.map(ItemKind::from)
};
let result = resolve_with_reexport_fallbacks(
&query_path,
&mut index,
kind_filter,
ctx.as_ref(),
&features,
&feature_suffix,
cli.private,
);
let render_ctx = RenderContext::from_cli(&index, cli);
if cli.recursive && cli.source {
source::handle_recursive_source(
&mut out,
&result,
&render_ctx,
&source,
cli,
ctx.as_ref(),
&features,
&feature_suffix,
)?;
} else if cli.source {
source::handle_source(
&mut out,
&result,
&index,
&source,
cli.docs,
render_ctx.kind_filter,
)?;
} else {
render::dispatch::handle_output(
&mut out,
&result,
&render_ctx,
cli,
ctx.as_ref(),
&features,
&feature_suffix,
)?;
}
}
Ok(())
}
pub(crate) fn resolve_crate_source(
ctx: Option<&ProjectContext>,
query: QueryPath,
) -> Result<(CrateSource, QueryPath)> {
match &query.crate_spec {
CrateSpec::CurrentCrate => {
if let Some(ctx) = ctx {
Ok((ctx.resolve_crate(&CrateSpec::CurrentCrate), query))
} else {
Err(GroxError::ManifestNotFound)
}
}
CrateSpec::Versioned { name, version } => Ok((
CrateSource::External {
name: name.clone(),
version: Some(version.clone()),
},
query,
)),
CrateSpec::Named(name) => {
if let Some(ctx) = ctx {
let source = ctx.resolve_crate(&CrateSpec::Named(name.clone()));
if matches!(&source, CrateSource::External { .. }) {
if query.item_segments.is_empty() && query::looks_like_item_name(name) {
let current_source = ctx.resolve_crate(&CrateSpec::CurrentCrate);
let reinterpreted = QueryPath {
crate_spec: CrateSpec::CurrentCrate,
item_segments: vec![name.clone()],
};
return Ok((current_source, reinterpreted));
}
let version = match &source {
CrateSource::External { version, .. } => version.clone(),
CrateSource::CurrentCrate { .. }
| CrateSource::Dependency { .. }
| CrateSource::Stdlib { .. } => None,
};
print_auto_fetch_message(name, version.as_deref());
}
Ok((source, query))
} else {
let source =
resolve::resolve_crate_without_context(&CrateSpec::Named(name.clone()))?;
if matches!(&source, CrateSource::External { .. }) {
print_auto_fetch_message(name, None);
}
Ok((source, query))
}
}
}
}
fn print_auto_fetch_message(name: &str, version: Option<&str>) {
match version {
Some(v) => eprintln!("[grox] {name} not in project deps, fetching {v} from crates.io..."),
None => eprintln!("[grox] {name} not in project deps, fetching latest from crates.io..."),
}
}
pub(crate) fn load_or_build_index(
source: CrateSource,
features: &FeatureFlags,
feature_suffix: &str,
private: bool,
quiet: bool,
ctx: Option<&ProjectContext>,
) -> Result<(DocIndex, CrateSource)> {
let cache_file = cache::cache_path(&source, feature_suffix, ctx);
if let Some(ref path) = cache_file {
if let Some(index) = cache::load_cached(path, &source, ctx) {
return Ok((index, source));
}
}
let start = Instant::now();
let name = source.name().to_string();
let version = source.version().unwrap_or("").to_string();
if !quiet {
if version.is_empty() {
eprintln!("[grox] Building index for {name}");
} else {
eprintln!("[grox] Building index for {name} {version}");
}
}
let (json_str, source) = if let CrateSource::External {
name: ext_name,
version: version_opt,
} = source
{
let (json_path, canonical_name, resolved_version) =
external::fetch_external_crate(&ext_name, version_opt.as_deref(), features, private)?;
let source = CrateSource::External {
name: canonical_name,
version: Some(resolved_version),
};
let json_str =
std::fs::read_to_string(&json_path).map_err(|e| GroxError::JsonReadFailed {
path: json_path,
source: e,
})?;
(json_str, source)
} else {
let json_str = docgen::generate_rustdoc_json(&source, features, private)?;
(json_str, source)
};
let krate = index_builder::parse_rustdoc_json(&json_str)?;
let crate_name = resolve::normalize_crate_name(source.name());
let crate_version = source.version().unwrap_or("");
let index = index_builder::build_index(&krate, &crate_name, crate_version);
if let Some(ref path) = cache_file {
cache::save_to_cache(path, &index, &source, ctx);
} else {
if let Some(path) = cache::cache_path(&source, feature_suffix, ctx) {
cache::save_to_cache(&path, &index, &source, ctx);
}
}
let elapsed = start.elapsed().as_secs_f64();
if !quiet {
eprintln!("[grox] Built index for {name} ({elapsed:.1}s)");
}
Ok((index, source))
}
fn resolve_with_reexport_fallbacks(
query_path: &QueryPath,
index: &mut DocIndex,
kind_filter: Option<ItemKind>,
ctx: Option<&ProjectContext>,
features: &FeatureFlags,
feature_suffix: &str,
private: bool,
) -> QueryResult {
let mut result = resolve_item(query_path, index, kind_filter);
if matches!(result, QueryResult::NotFound { .. }) {
if let Some(resolved) =
reexport::try_resolve_reexport_on_not_found(query_path, index, kind_filter)
{
result = resolved;
}
}
let unresolved = matches!(result, QueryResult::NotFound { .. })
|| matches!(&result, QueryResult::Ambiguous { indices, .. } if indices.iter().all(|&i| {
index.items[i].reexport_source.is_some()
}));
if unresolved {
if let Some((source_index, idx)) = reexport::try_resolve_via_prefix_reexport(
query_path,
index,
ctx,
features,
feature_suffix,
private,
) {
*index = source_index;
result = QueryResult::Found { index: idx };
}
}
if matches!(result, QueryResult::NotFound { .. }) {
if let Some((source_index, idx)) = reexport::try_resolve_via_glob_reexport(
query_path,
index,
ctx,
features,
feature_suffix,
private,
) {
*index = source_index;
result = QueryResult::Found { index: idx };
}
}
result
}
pub(crate) fn resolve_item(
query: &QueryPath,
index: &DocIndex,
kind_filter: Option<ItemKind>,
) -> QueryResult {
let crate_name = &index.crate_name;
if query.item_segments.is_empty() {
let result = query::lookup(index, crate_name, None);
if !matches!(result, QueryResult::NotFound { .. }) {
return result;
}
let normalized = crate_name.replace('-', "_");
if normalized != *crate_name {
let result = query::lookup(index, &normalized, None);
if !matches!(result, QueryResult::NotFound { .. }) {
return result;
}
}
return result;
}
let item_path = query.item_segments.join("::");
let full_path = format!("{crate_name}::{item_path}");
let first_result = query::lookup(index, &full_path, kind_filter);
if !matches!(first_result, QueryResult::NotFound { .. }) {
return first_result;
}
let preserved_suggestions = match &first_result {
QueryResult::NotFound { suggestions, .. } => suggestions.clone(),
_ => Vec::new(),
};
let result = query::lookup(index, &item_path, kind_filter);
if !matches!(result, QueryResult::NotFound { .. }) {
return result;
}
if kind_filter.is_some() {
let result = query::lookup(index, &full_path, None);
if !matches!(result, QueryResult::NotFound { .. }) {
return result;
}
let result = query::lookup(index, &item_path, None);
if !matches!(result, QueryResult::NotFound { .. }) {
return result;
}
}
if query.item_segments.len() >= 2 {
let parent_segments: Vec<&str> = query.item_segments[..query.item_segments.len() - 1]
.iter()
.map(String::as_str)
.collect();
let method_name = &query.item_segments[query.item_segments.len() - 1];
let mut full_parent: Vec<&str> = vec![crate_name.as_str()];
full_parent.extend(&parent_segments);
let result = query::lookup_method(index, &full_parent, method_name, kind_filter);
if !matches!(result, QueryResult::NotFound { .. }) {
return result;
}
let result = query::lookup_method(index, &parent_segments, method_name, kind_filter);
if !matches!(result, QueryResult::NotFound { .. }) {
return result;
}
}
QueryResult::NotFound {
query: item_path,
suggestions: preserved_suggestions,
}
}
fn handle_readme(
w: &mut impl Write,
source: &CrateSource,
ctx: Option<&ProjectContext>,
) -> Result<()> {
match source {
CrateSource::Stdlib { name } => {
eprintln!("README not available for standard library crate '{name}'");
Err(GroxError::ItemNotFound {
query: format!("{name} README"),
crate_name: Some(name.clone()),
suggestions: Vec::new(),
})
}
CrateSource::CurrentCrate { manifest_path, .. } => {
let dir = manifest_path
.parent()
.expect("invariant: manifest_path has a parent");
let search_dir = ctx.map_or_else(
|| dir.to_path_buf(),
|c| {
c.current_manifest_path()
.parent()
.map_or_else(|| dir.to_path_buf(), Path::to_path_buf)
},
);
find_and_print_readme(w, &search_dir, source.name())
}
CrateSource::Dependency { manifest_path, .. } => {
let dir = manifest_path
.parent()
.expect("invariant: manifest_path has a parent");
find_and_print_readme(w, dir, source.name())
}
CrateSource::External { name, version } => {
let cache_dir = dirs::cache_dir().ok_or(GroxError::ItemNotFound {
query: format!("{name} README"),
crate_name: Some(name.clone()),
suggestions: Vec::new(),
})?;
let ver = version.as_deref().unwrap_or("latest");
let dir = cache_dir.join("groxide").join(format!("{name}-{ver}"));
find_and_print_readme(w, &dir, name)
}
}
}
fn find_and_print_readme(w: &mut impl Write, dir: &Path, crate_name: &str) -> Result<()> {
const README_NAMES: &[&str] = &[
"README.md",
"README.MD",
"Readme.md",
"readme.md",
"README",
"README.txt",
];
for name in README_NAMES {
let path = dir.join(name);
if path.exists() {
let content = std::fs::read_to_string(&path)?;
write!(w, "{content}").map_err(GroxError::Io)?;
return Ok(());
}
}
eprintln!("No README found for {crate_name}");
Err(GroxError::ItemNotFound {
query: format!("{crate_name} README"),
crate_name: Some(crate_name.to_string()),
suggestions: Vec::new(),
})
}
fn handle_search(
w: &mut impl Write,
index: &DocIndex,
search_query: &str,
cli: &Cli,
source: Option<&CrateSource>,
features: &FeatureFlags,
ctx: Option<&ProjectContext>,
) -> Result<()> {
let kind_filter = cli.kind.map(ItemKind::from);
let results = search::search(index, search_query, kind_filter)?;
let total = results.len();
match cli.output_mode() {
OutputMode::Json => {
for r in &results {
let item = index.get(r.index);
let obj = serde_json::json!({
"path": item.path,
"kind": item.kind.short_name(),
"signature": item.signature,
"summary": item.summary,
"score": r.score,
});
writeln!(
w,
"{}",
serde_json::to_string(&obj).expect("invariant: json serializes")
)
.map_err(GroxError::Io)?;
}
}
OutputMode::Brief | OutputMode::Text => {
if total == 0 {
writeln!(w, "0 results for \"{search_query}\":").map_err(GroxError::Io)?;
} else {
writeln!(w, "{total} results for \"{search_query}\":").map_err(GroxError::Io)?;
writeln!(w).map_err(GroxError::Io)?;
let max_kind_width = results
.iter()
.map(|r| index.get(r.index).kind.short_name().len())
.max()
.unwrap_or(0);
let max_path_width = results
.iter()
.map(|r| index.get(r.index).path.len())
.max()
.unwrap_or(0);
for r in &results {
let item = index.get(r.index);
let kind = item.kind.short_name();
let path = &item.path;
let summary = &item.summary;
if summary.is_empty() {
writeln!(w, "{kind:<max_kind_width$} {path:<max_path_width$}")
.map_err(GroxError::Io)?;
} else {
writeln!(
w,
"{kind:<max_kind_width$} {path:<max_path_width$} {summary}"
)
.map_err(GroxError::Io)?;
}
}
}
}
}
if total == 0 && !features.all_features {
if let Some(src) = source {
if !matches!(src, CrateSource::External { .. }) {
let all_features = FeatureFlags {
all_features: true,
no_default_features: false,
features: Vec::new(),
};
let all_suffix = cache::cache_suffix(&all_features, cli.private);
if let Ok((all_index, _)) = load_or_build_index(
src.clone(),
&all_features,
&all_suffix,
cli.private,
false,
ctx,
) {
let all_results = search::search(&all_index, search_query, kind_filter)?;
if !all_results.is_empty() {
eprintln!(
"hint: {} items found with --all-features",
all_results.len()
);
}
}
}
}
}
Ok(())
}
fn handle_workspace(w: &mut impl Write, ctx: &ProjectContext, cli: &Cli) -> Result<()> {
let members = ctx.workspace_member_packages();
let features = FeatureFlags::from_cli(cli);
let feature_suffix = cache::cache_suffix(&features, cli.private);
let lib_members: Vec<_> = members
.into_iter()
.filter(|pkg| pkg.targets.iter().any(cargo_metadata::Target::is_lib))
.collect();
let start = Instant::now();
eprintln!("[grox] Building workspace indices...");
let mut built: Vec<(&cargo_metadata::Package, DocIndex, CrateSource)> = Vec::new();
let mut errors: Vec<(String, GroxError)> = Vec::new();
for pkg in &lib_members {
let source = CrateSource::CurrentCrate {
manifest_path: pkg.manifest_path.clone().into_std_path_buf(),
name: pkg.name.to_string(),
version: pkg.version.to_string(),
};
match load_or_build_index(
source,
&features,
&feature_suffix,
cli.private,
true,
Some(ctx),
) {
Ok((index, source)) => built.push((pkg, index, source)),
Err(e) => errors.push((pkg.name.to_string(), e)),
}
}
let elapsed = start.elapsed().as_secs_f64();
eprintln!("[grox] Built workspace indices ({elapsed:.1}s)");
for (name, e) in &errors {
eprintln!("[grox] Failed to build index for {name}: {e}");
}
let mut first = true;
for (pkg, index, source) in &built {
let query_path = QueryPath {
crate_spec: CrateSpec::CurrentCrate,
item_segments: Vec::new(),
};
let result = resolve_item(&query_path, index, None);
if !first {
write!(w, "\n\n").map_err(GroxError::Io)?;
}
first = false;
match result {
QueryResult::Found { index: idx } => {
let render_ctx = RenderContext::from_cli(index, cli);
if cli.recursive && cli.source {
render::dispatch::render_recursive_source(
w,
&render_ctx,
idx,
source,
cli.docs,
)?;
} else if cli.recursive {
render::dispatch::render_recursive(w, &render_ctx, idx, cli.docs)?;
} else {
let display = render::build_display_item(
index,
idx,
render_ctx.include_private,
render_ctx.kind_filter,
);
let output = match render_ctx.mode {
OutputMode::Json => render::json::render_json(&display),
OutputMode::Brief => render::brief::render_brief(&display),
OutputMode::Text => {
render::text::render_text(&display, &render_ctx.limits, None)
}
};
writeln!(w, "{output}").map_err(GroxError::Io)?;
}
}
QueryResult::NotFound { .. } | QueryResult::Ambiguous { .. } => {
eprintln!("[grox] Could not resolve crate root for {}", pkg.name);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn load_fixture_index() -> DocIndex {
let json_path = concat!(
env!("CARGO_MANIFEST_DIR"),
"/test-fixtures/groxide_test_api.json"
);
let json = std::fs::read_to_string(json_path).expect("fixture JSON exists");
let krate = index_builder::parse_rustdoc_json(&json).expect("fixture JSON parses");
index_builder::build_index(&krate, "groxide_test_api", "0.1.0")
}
fn query_fixture(
index: &DocIndex,
path: &str,
cli_args: &[&str],
) -> (std::result::Result<String, GroxError>, String) {
let full_args: Vec<&str> = std::iter::once("grox")
.chain(cli_args.iter().copied())
.collect();
let cli = Cli::try_parse_from(&full_args).expect("args parse");
let query_path = if path.is_empty() {
QueryPath {
crate_spec: CrateSpec::CurrentCrate,
item_segments: Vec::new(),
}
} else {
QueryPath {
crate_spec: CrateSpec::CurrentCrate,
item_segments: path.split("::").map(String::from).collect(),
}
};
let kind_filter = cli.kind.map(ItemKind::from);
let features = FeatureFlags::from_cli(&cli);
let feature_suffix = features.cache_suffix();
let mut result = resolve_item(&query_path, index, kind_filter);
if matches!(result, QueryResult::NotFound { .. }) {
if let Some(resolved) =
reexport::try_resolve_reexport_on_not_found(&query_path, index, kind_filter)
{
result = resolved;
}
}
let mut stdout_buf = Vec::new();
if let Some(search_query) = cli.search.as_deref() {
match handle_search(
&mut stdout_buf,
index,
search_query,
&cli,
None,
&features,
None,
) {
Ok(()) => {
let output = String::from_utf8(stdout_buf).expect("valid utf8");
return (Ok(output), String::new());
}
Err(e) => return (Err(e), String::new()),
}
}
if cli.source {
let source = CrateSource::CurrentCrate {
manifest_path: std::path::PathBuf::from("/tmp/Cargo.toml"),
name: "groxide_test_api".to_string(),
version: "0.1.0".to_string(),
};
let kind_filter = cli.kind.map(ItemKind::from);
match source::handle_source(
&mut stdout_buf,
&result,
index,
&source,
cli.docs,
kind_filter,
) {
Ok(()) => {
let output = String::from_utf8(stdout_buf).expect("valid utf8");
return (Ok(output), String::new());
}
Err(e) => return (Err(e), String::new()),
}
}
let render_ctx = RenderContext::from_cli(index, &cli);
match render::dispatch::handle_output(
&mut stdout_buf,
&result,
&render_ctx,
&cli,
None,
&features,
&feature_suffix,
) {
Ok(()) => {
let output = String::from_utf8(stdout_buf).expect("valid utf8");
(Ok(output), String::new())
}
Err(e) => (Err(e), String::new()),
}
}
use clap::Parser;
#[test]
fn basic_query_returns_expected_output() {
let index = load_fixture_index();
let (result, _) = query_fixture(&index, "GenericStruct", &["GenericStruct"]);
let output = result.expect("query should succeed");
assert!(
output.contains("GenericStruct"),
"output should contain GenericStruct: {output}"
);
assert!(
output.contains("struct"),
"output should mention struct: {output}"
);
}
#[test]
fn unknown_item_returns_not_found() {
let index = load_fixture_index();
let (result, _) = query_fixture(&index, "NonexistentItem", &["NonexistentItem"]);
match result {
Err(GroxError::ItemNotFound { query, .. }) => {
assert!(
query.contains("NonexistentItem"),
"error should contain the query: {query}"
);
}
other => panic!("expected ItemNotFound, got {other:?}"),
}
}
#[test]
fn crate_root_query_returns_module_listing() {
let index = load_fixture_index();
let query_path = QueryPath::parse("").expect("parses");
let features = FeatureFlags {
all_features: false,
no_default_features: false,
features: Vec::new(),
};
let feature_suffix = features.cache_suffix();
let result = resolve_item(&query_path, &index, None);
let mut buf = Vec::new();
let cli = Cli::try_parse_from(["grox"]).expect("parses");
let render_ctx = RenderContext::from_cli(&index, &cli);
let r = render::dispatch::handle_output(
&mut buf,
&result,
&render_ctx,
&cli,
None,
&features,
&feature_suffix,
);
assert!(r.is_ok(), "crate root query should succeed");
let output = String::from_utf8(buf).expect("valid utf8");
assert!(
output.contains("groxide_test_api"),
"should contain crate name: {output}"
);
}
#[test]
fn search_query_returns_results() {
let index = load_fixture_index();
let cli = Cli::try_parse_from(["grox", "-S", "add"]).expect("parses");
let mut buf = Vec::new();
let features = FeatureFlags::from_cli(&cli);
let result = handle_search(&mut buf, &index, "add", &cli, None, &features, None);
assert!(result.is_ok(), "search should succeed");
let output = String::from_utf8(buf).expect("valid utf8");
assert!(
output.contains("add"),
"search results should contain 'add': {output}"
);
assert!(
output.contains("results for"),
"should have result count header: {output}"
);
}
#[test]
fn json_mode_produces_valid_json() {
let index = load_fixture_index();
let (result, _) = query_fixture(&index, "GenericStruct", &["--json", "GenericStruct"]);
let output = result.expect("json mode should succeed");
let parsed: serde_json::Value =
serde_json::from_str(&output).expect("output should be valid JSON");
assert_eq!(parsed["kind"], "struct");
assert!(
parsed["path"]
.as_str()
.unwrap_or("")
.contains("GenericStruct"),
"JSON should contain GenericStruct path"
);
}
#[test]
fn resolve_crate_source_returns_error_without_context() {
let query = QueryPath::parse("").expect("parses");
let result = resolve_crate_source(None, query);
assert!(result.is_err(), "should fail without project context");
}
#[test]
fn resolve_crate_source_reinterprets_item_like_name() {
let ctx = ProjectContext::discover(None).ok();
if ctx.is_none() {
return; }
let query = QueryPath::parse("Mutex").expect("parses");
let (source, new_query) = resolve_crate_source(ctx.as_ref(), query).expect("resolves");
assert!(
matches!(source, CrateSource::CurrentCrate { .. }),
"should reinterpret as current crate: {source:?}"
);
assert_eq!(new_query.item_segments, vec!["Mutex"]);
}
#[test]
fn function_query_returns_leaf_output() {
let index = load_fixture_index();
let (result, _) = query_fixture(&index, "add", &["add"]);
let output = result.expect("function query should succeed");
assert!(output.contains("fn"), "should be a function: {output}");
assert!(
output.contains("add"),
"should contain function name: {output}"
);
}
#[test]
fn enum_query_shows_variants() {
let index = load_fixture_index();
let (result, _) = query_fixture(&index, "Direction", &["Direction"]);
let output = result.expect("enum query should succeed");
assert!(
output.contains("variants:") || output.contains("North"),
"enum should show variants: {output}"
);
}
#[test]
fn module_query_shows_children() {
let index = load_fixture_index();
let (result, _) = query_fixture(&index, "containers", &["containers"]);
let output = result.expect("module query should succeed");
assert!(!output.is_empty(), "module query should produce output");
}
#[test]
fn constant_query_returns_output() {
let index = load_fixture_index();
let (result, _) = query_fixture(&index, "MAX_BUFFER_SIZE", &["MAX_BUFFER_SIZE"]);
let output = result.expect("constant query should succeed");
assert!(output.contains("const"), "should show const: {output}");
}
#[test]
fn search_json_mode_produces_valid_json_lines() {
let index = load_fixture_index();
let cli = Cli::try_parse_from(["grox", "-S", "add", "--json"]).expect("parses");
let mut buf = Vec::new();
let features = FeatureFlags::from_cli(&cli);
let result = handle_search(&mut buf, &index, "add", &cli, None, &features, None);
assert!(result.is_ok(), "search should succeed");
let output = String::from_utf8(buf).expect("valid utf8");
for line in output.lines() {
let parsed: serde_json::Value =
serde_json::from_str(line).expect("each line should be valid JSON");
assert!(parsed.get("score").is_some(), "should have score field");
}
}
#[test]
fn empty_search_query_returns_error() {
let index = load_fixture_index();
let cli = Cli::try_parse_from(["grox", "-S", ""]).expect("parses");
let mut buf = Vec::new();
let features = FeatureFlags::from_cli(&cli);
let result = handle_search(&mut buf, &index, "", &cli, None, &features, None);
assert!(result.is_err(), "empty search should fail");
}
#[test]
fn resolve_reexport_finds_item_by_name_when_path_not_found() {
let index = load_fixture_index();
let query_path = QueryPath {
crate_spec: CrateSpec::CurrentCrate,
item_segments: vec!["nonexistent_mod".to_string(), "Helper".to_string()],
};
let result = reexport::try_resolve_reexport_on_not_found(&query_path, &index, None);
assert!(
result.is_some(),
"should find Helper via re-export fallback"
);
let idx = match result.unwrap() {
QueryResult::Found { index: idx } => idx,
QueryResult::Ambiguous { indices, .. } => indices[0],
other @ QueryResult::NotFound { .. } => {
panic!("expected Found or Ambiguous, got {other:?}")
}
};
assert_eq!(index.items[idx].name, "Helper");
}
#[test]
fn resolve_reexport_returns_none_for_truly_missing_item() {
let index = load_fixture_index();
let query_path = QueryPath {
crate_spec: CrateSpec::CurrentCrate,
item_segments: vec!["TotallyFakeItem99".to_string()],
};
let result = reexport::try_resolve_reexport_on_not_found(&query_path, &index, None);
assert!(
result.is_none(),
"should return None for truly missing item"
);
}
}