use std::collections::HashSet;
use std::fmt::Write;
use std::io;
use std::path::PathBuf;
pub const EXIT_SUCCESS: i32 = 0;
pub const EXIT_NOT_FOUND: i32 = 1;
pub const EXIT_ERROR: i32 = 2;
pub(crate) fn format_suggestions(suggestions: &[String]) -> String {
if suggestions.is_empty() {
return String::new();
}
let mut seen = HashSet::new();
let unique: Vec<_> = suggestions.iter().filter(|s| seen.insert(*s)).collect();
let total = unique.len();
let display_count = total.min(5);
let mut result = String::from("\n\nDid you mean:");
for s in &unique[..display_count] {
result.push_str("\n ");
result.push_str(s);
}
if total > 5 {
let _ = write!(result, "\n ... {} more", total - 5);
}
result
}
#[derive(Debug, thiserror::Error)]
pub enum GroxError {
#[error("not in a Rust project\n\nRun grox in a Rust project directory, or specify --manifest-path.\nTo query external crates: grox <crate>\nTo query stdlib: grox std, grox core, grox alloc")]
ManifestNotFound,
#[error("failed to read cargo metadata\n\n{details}")]
CargoMetadataFailed {
details: String,
},
#[error("crate '{name}' not found{}", format_suggestions(suggestions))]
CrateNotFound {
name: String,
suggestions: Vec<String>,
},
#[error("nightly toolchain required\n\nRun: rustup toolchain install nightly")]
NightlyNotAvailable,
#[error("no item matching \"{query}\"{}{}", format_crate_ctx(crate_name.as_deref()), format_suggestions(suggestions))]
ItemNotFound {
query: String,
crate_name: Option<String>,
suggestions: Vec<String>,
},
#[error("rustdoc generation failed\n\n{stderr}")]
RustdocFailed {
stderr: String,
},
#[error("'{name}' has no library target — grox indexes library APIs only.\n\nIf this crate has dependencies you want to query, try `grox <dep>`.")]
NoLibraryTarget {
name: String,
},
#[error("standard library source not available\n\nRun: rustup component add rust-src")]
StdLibSourceMissing,
#[error("failed to fetch '{name}' from crates.io\n\n{details}")]
ExternalFetchFailed {
name: String,
details: String,
},
#[error("{message}")]
InvalidQuery {
message: String,
},
#[error("failed to read {}: {source}", path.display())]
JsonReadFailed {
path: PathBuf,
source: io::Error,
},
#[error("failed to parse rustdoc JSON: {details}")]
JsonParseFailed {
details: String,
},
#[error("cache error: {message}")]
CacheSerializationFailed {
message: String,
},
#[error("I/O error: {0}")]
Io(#[from] io::Error),
}
fn format_crate_ctx(crate_name: Option<&str>) -> String {
match crate_name {
Some(name) => format!(" in crate '{name}'"),
None => String::new(),
}
}
impl GroxError {
#[must_use]
pub fn exit_code(&self) -> i32 {
match self {
Self::CrateNotFound { .. } | Self::ItemNotFound { .. } => EXIT_NOT_FOUND,
Self::ManifestNotFound
| Self::CargoMetadataFailed { .. }
| Self::NightlyNotAvailable
| Self::RustdocFailed { .. }
| Self::NoLibraryTarget { .. }
| Self::StdLibSourceMissing
| Self::ExternalFetchFailed { .. }
| Self::InvalidQuery { .. }
| Self::JsonReadFailed { .. }
| Self::JsonParseFailed { .. }
| Self::CacheSerializationFailed { .. }
| Self::Io(_) => EXIT_ERROR,
}
}
}
pub type Result<T> = std::result::Result<T, GroxError>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn error_display_manifest_not_found_contains_expected_text() {
let err = GroxError::ManifestNotFound;
let msg = err.to_string();
assert!(msg.contains("not in a Rust project"));
assert!(msg.contains("--manifest-path"));
}
#[test]
fn error_display_cargo_metadata_failed_contains_details() {
let err = GroxError::CargoMetadataFailed {
details: "could not find Cargo.toml".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("failed to read cargo metadata"));
assert!(msg.contains("could not find Cargo.toml"));
}
#[test]
fn error_display_crate_not_found_with_suggestions() {
let err = GroxError::CrateNotFound {
name: "tokoi".to_string(),
suggestions: vec!["tokio".to_string()],
};
let msg = err.to_string();
assert!(msg.contains("crate 'tokoi' not found"));
assert!(msg.contains("Did you mean:"));
assert!(msg.contains("tokio"));
}
#[test]
fn error_display_crate_not_found_no_suggestions() {
let err = GroxError::CrateNotFound {
name: "zzz".to_string(),
suggestions: vec![],
};
let msg = err.to_string();
assert!(msg.contains("crate 'zzz' not found"));
assert!(!msg.contains("Did you mean"));
}
#[test]
fn error_display_item_not_found_with_crate_context() {
let err = GroxError::ItemNotFound {
query: "Mutx".to_string(),
crate_name: Some("tokio".to_string()),
suggestions: vec!["Mutex".to_string()],
};
let msg = err.to_string();
assert!(msg.contains("no item matching \"Mutx\""));
assert!(msg.contains("in crate 'tokio'"));
assert!(msg.contains("Did you mean:"));
assert!(msg.contains("Mutex"));
}
#[test]
fn error_display_item_not_found_without_crate_context() {
let err = GroxError::ItemNotFound {
query: "Foo".to_string(),
crate_name: None,
suggestions: vec![],
};
let msg = err.to_string();
assert!(msg.contains("no item matching \"Foo\""));
assert!(!msg.contains("in crate"));
}
#[test]
fn error_display_nightly_not_available() {
let err = GroxError::NightlyNotAvailable;
let msg = err.to_string();
assert!(msg.contains("nightly toolchain required"));
assert!(msg.contains("rustup toolchain install nightly"));
}
#[test]
fn error_display_rustdoc_failed() {
let err = GroxError::RustdocFailed {
stderr: "error[E0001]: some error".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("rustdoc generation failed"));
assert!(msg.contains("error[E0001]"));
}
#[test]
fn error_display_stdlib_source_missing() {
let err = GroxError::StdLibSourceMissing;
let msg = err.to_string();
assert!(msg.contains("standard library source not available"));
assert!(msg.contains("rustup component add rust-src"));
}
#[test]
fn error_display_invalid_query() {
let err = GroxError::InvalidQuery {
message: "missing version after @".to_string(),
};
assert_eq!(err.to_string(), "missing version after @");
}
#[test]
fn error_display_json_read_failed() {
let err = GroxError::JsonReadFailed {
path: PathBuf::from("/tmp/test.json"),
source: io::Error::new(io::ErrorKind::NotFound, "file not found"),
};
let msg = err.to_string();
assert!(msg.contains("failed to read /tmp/test.json"));
assert!(msg.contains("file not found"));
}
#[test]
fn exit_code_crate_not_found_returns_1() {
let err = GroxError::CrateNotFound {
name: "x".to_string(),
suggestions: vec![],
};
assert_eq!(err.exit_code(), EXIT_NOT_FOUND);
}
#[test]
fn exit_code_item_not_found_returns_1() {
let err = GroxError::ItemNotFound {
query: "x".to_string(),
crate_name: None,
suggestions: vec![],
};
assert_eq!(err.exit_code(), EXIT_NOT_FOUND);
}
#[test]
fn exit_code_manifest_not_found_returns_2() {
assert_eq!(GroxError::ManifestNotFound.exit_code(), EXIT_ERROR);
}
#[test]
fn exit_code_nightly_not_available_returns_2() {
assert_eq!(GroxError::NightlyNotAvailable.exit_code(), EXIT_ERROR);
}
#[test]
fn exit_code_io_error_returns_2() {
let err = GroxError::Io(io::Error::other("boom"));
assert_eq!(err.exit_code(), EXIT_ERROR);
}
#[test]
fn exit_code_rustdoc_failed_returns_2() {
let err = GroxError::RustdocFailed {
stderr: String::new(),
};
assert_eq!(err.exit_code(), EXIT_ERROR);
}
#[test]
fn format_suggestions_empty_returns_empty_string() {
assert_eq!(format_suggestions(&[]), "");
}
#[test]
fn format_suggestions_deduplicates() {
let suggestions = vec!["a".to_string(), "b".to_string(), "a".to_string()];
let result = format_suggestions(&suggestions);
let count = result.matches("\n a").count();
assert_eq!(count, 1);
}
#[test]
fn format_suggestions_caps_at_5_with_ellipsis() {
let suggestions: Vec<String> = (0..8).map(|i| format!("item{i}")).collect();
let result = format_suggestions(&suggestions);
assert!(result.contains("Did you mean:"));
assert!(result.contains("item0"));
assert!(result.contains("item4"));
assert!(!result.contains("item5"));
assert!(result.contains("... 3 more"));
}
#[test]
fn format_suggestions_exactly_5_no_ellipsis() {
let suggestions: Vec<String> = (0..5).map(|i| format!("item{i}")).collect();
let result = format_suggestions(&suggestions);
assert!(result.contains("item4"));
assert!(!result.contains("..."));
}
#[test]
fn format_suggestions_single_item() {
let suggestions = vec!["Mutex".to_string()];
let result = format_suggestions(&suggestions);
assert!(result.contains("Did you mean:"));
assert!(result.contains("Mutex"));
}
}