use std::collections::HashMap;
use std::fmt;
use ryo_analysis::context::AnalysisContext;
use ryo_analysis::{SymbolId, SymbolPath};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum SuggestError {
#[error("Failed to resolve module path for spec at {path}")]
ModulePathResolution { path: String },
#[error("Invalid symbol path: {reason}")]
InvalidSymbolPath { reason: String },
#[error("Missing required context: {context}")]
MissingContext { context: String },
}
pub type SuggestResult<T> = Result<T, SuggestError>;
pub type SuggestParams = HashMap<String, String>;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParamDef {
pub name: String,
pub description: String,
pub required: bool,
}
impl ParamDef {
pub fn required(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
required: true,
}
}
pub fn optional(name: impl Into<String>, description: impl Into<String>) -> Self {
Self {
name: name.into(),
description: description.into(),
required: false,
}
}
}
pub use ryo_executor::executor::{EnumToTraitStrategy, MatchHandling, MutationSpec};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SuggestCategory {
Derive,
Pattern,
Performance,
Safety,
Idiom,
Refactor,
Lint,
}
impl fmt::Display for SuggestCategory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Derive => write!(f, "Derive"),
Self::Pattern => write!(f, "Pattern"),
Self::Performance => write!(f, "Performance"),
Self::Safety => write!(f, "Safety"),
Self::Idiom => write!(f, "Idiom"),
Self::Refactor => write!(f, "Refactor"),
Self::Lint => write!(f, "Lint"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum SafetyLevel {
Auto,
Confirm,
Manual,
}
impl fmt::Display for SafetyLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Auto => write!(f, "AUTO"),
Self::Confirm => write!(f, "CONFIRM"),
Self::Manual => write!(f, "MANUAL"),
}
}
}
pub fn compute_priority(confidence: f32, safety: SafetyLevel, pattern_weight: f32) -> u8 {
let safety_weight = match safety {
SafetyLevel::Auto => 1.0,
SafetyLevel::Confirm => 0.7,
SafetyLevel::Manual => 0.4,
};
let normalized_pattern = (pattern_weight / 2.5).clamp(0.4, 1.0);
(confidence * safety_weight * normalized_pattern * 255.0).clamp(0.0, 255.0) as u8
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct OpportunityId(pub u32);
impl OpportunityId {
pub fn new(id: u32) -> Self {
Self(id)
}
pub fn as_u32(self) -> u32 {
self.0
}
}
impl fmt::Display for OpportunityId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "O{:04}", self.0)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SuggestLocation {
pub symbol_id: SymbolId,
pub symbol_path: SymbolPath,
pub file: String,
}
impl SuggestLocation {
pub fn new(symbol_id: SymbolId, symbol_path: SymbolPath, file: impl Into<String>) -> Self {
Self {
symbol_id,
symbol_path,
file: file.into(),
}
}
pub fn from_context(ctx: &AnalysisContext, symbol_id: SymbolId) -> Option<Self> {
let symbol_path = ctx.registry().resolve(symbol_id)?;
let file = ctx
.registry()
.span(symbol_id)
.map(|span| span.file.to_string())
.unwrap_or_else(|| symbol_path.crate_name().to_string());
Some(Self::new(symbol_id, symbol_path.clone(), file))
}
pub fn symbol_name(&self) -> &str {
self.symbol_path.name()
}
#[cfg(any(test, feature = "testing"))]
pub fn for_test(file: impl Into<String>, symbol_name: impl Into<String>) -> Self {
let name = symbol_name.into();
let symbol_id = SymbolId::parse("0v1").expect("test SymbolId");
let symbol_path = SymbolPath::builder("test")
.push(&name)
.build()
.expect("test path");
Self {
symbol_id,
symbol_path,
file: file.into(),
}
}
}
impl fmt::Display for SuggestLocation {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} ({})", self.symbol_path, self.file)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LintSeverity {
Info,
Warning,
Error,
}
impl fmt::Display for LintSeverity {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Info => write!(f, "info"),
Self::Warning => write!(f, "warning"),
Self::Error => write!(f, "error"),
}
}
}
impl std::str::FromStr for LintSeverity {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"info" | "hint" => Ok(Self::Info),
"warning" | "warn" => Ok(Self::Warning),
"error" | "err" => Ok(Self::Error),
_ => Err(format!("Unknown severity: {}", s)),
}
}
}
impl From<LintSeverity> for SafetyLevel {
fn from(severity: LintSeverity) -> Self {
match severity {
LintSeverity::Info => SafetyLevel::Auto,
LintSeverity::Warning => SafetyLevel::Confirm,
LintSeverity::Error => SafetyLevel::Manual,
}
}
}
impl From<SafetyLevel> for LintSeverity {
fn from(level: SafetyLevel) -> Self {
match level {
SafetyLevel::Auto => LintSeverity::Info,
SafetyLevel::Confirm => LintSeverity::Warning,
SafetyLevel::Manual => LintSeverity::Error,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum OpportunityContext {
Derive {
derive_name: String,
#[serde(default)]
missing_impls: Vec<String>,
},
Builder {
struct_name: String,
field_count: usize,
has_required_fields: bool,
},
Atomic {
current_type: String,
suggested_atomic: String,
},
FromInto {
source_type: String,
target_type: String,
},
Lint {
code: String,
rule: String,
severity: LintSeverity,
#[serde(default)]
suggestion: Option<String>,
#[serde(default)]
expected: Option<String>,
#[serde(default)]
actual: Option<String>,
},
Spec {
code: String,
#[serde(default)]
alias_name: Option<String>,
#[serde(default)]
base_type: Option<String>,
#[serde(default)]
group: Option<String>,
#[serde(default)]
related_types: Vec<String>,
#[serde(default)]
suggestion: Option<String>,
},
Custom {
#[serde(flatten)]
data: serde_json::Value,
},
Generation {
pattern: String,
params: HashMap<String, String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
pub enum SymbolScope {
#[default]
Lib,
Bin,
Test,
}
impl SymbolScope {
pub fn is_production(&self) -> bool {
matches!(self, Self::Lib | Self::Bin)
}
}
impl fmt::Display for SymbolScope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Lib => write!(f, "lib"),
Self::Bin => write!(f, "bin"),
Self::Test => write!(f, "test"),
}
}
}
impl std::str::FromStr for SymbolScope {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"lib" => Ok(Self::Lib),
"bin" => Ok(Self::Bin),
"test" => Ok(Self::Test),
other => Err(format!(
"unknown scope: '{}' (expected: lib, bin, test)",
other
)),
}
}
}
impl SymbolScope {
pub fn resolve(
ctx: &AnalysisContext,
symbol_id: SymbolId,
binary_crates: &std::collections::HashSet<String>,
) -> Self {
if let Some(path) = ctx.registry.path(symbol_id) {
for (i, segment) in path.segments().enumerate() {
if segment == "tests" || segment.starts_with("tests_") {
return Self::Test;
}
if i > 0 && segment.starts_with("test_") {
return Self::Test;
}
}
}
if let Some(span) = ctx.registry.span(symbol_id) {
let relative = span.file.as_relative().to_string_lossy();
if relative.contains("/tests/") || relative.starts_with("tests/") {
return Self::Test;
}
let crate_name = span.file.crate_name().as_str();
if binary_crates.contains(crate_name) {
return Self::Bin;
}
}
Self::Lib
}
pub fn binary_crate_names(ctx: &AnalysisContext) -> std::collections::HashSet<String> {
ctx.files()
.keys()
.filter(|f| f.is_binary_entry())
.map(|f| f.crate_name().as_str().to_string())
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SuggestOpportunity {
pub id: OpportunityId,
pub targets: Vec<SymbolId>,
pub location: SuggestLocation,
pub message: String,
pub confidence: f32,
pub context: OpportunityContext,
#[serde(default)]
pub scope: SymbolScope,
}
impl SuggestOpportunity {
pub fn new(
id: OpportunityId,
targets: Vec<SymbolId>,
location: SuggestLocation,
message: impl Into<String>,
confidence: f32,
context: OpportunityContext,
) -> Self {
Self {
id,
targets,
location,
message: message.into(),
confidence: confidence.clamp(0.0, 1.0),
context,
scope: SymbolScope::default(),
}
}
pub fn with_scope(mut self, scope: SymbolScope) -> Self {
self.scope = scope;
self
}
pub fn primary_target(&self) -> Option<SymbolId> {
self.targets.first().copied()
}
pub fn with_severity_override(mut self, new_severity: LintSeverity) -> Self {
if let OpportunityContext::Lint {
ref mut severity, ..
} = self.context
{
*severity = new_severity;
}
self
}
pub fn lint_severity(&self) -> Option<LintSeverity> {
if let OpportunityContext::Lint { severity, .. } = &self.context {
Some(*severity)
} else {
None
}
}
pub fn lint_code(&self) -> Option<&str> {
if let OpportunityContext::Lint { code, .. } = &self.context {
Some(code)
} else {
None
}
}
pub fn spec_code(&self) -> Option<&str> {
if let OpportunityContext::Spec { code, .. } = &self.context {
Some(code)
} else {
None
}
}
pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
match &mut self.context {
OpportunityContext::Lint {
suggestion: ref mut s,
..
} => {
*s = Some(suggestion.into());
}
OpportunityContext::Spec {
suggestion: ref mut s,
..
} => {
*s = Some(suggestion.into());
}
_ => {}
}
self
}
pub fn with_expected_actual(
mut self,
expected: impl Into<String>,
actual: impl Into<String>,
) -> Self {
if let OpportunityContext::Lint {
expected: ref mut e,
actual: ref mut a,
..
} = &mut self.context
{
*e = Some(expected.into());
*a = Some(actual.into());
}
self
}
pub fn with_related_types(mut self, types: Vec<String>) -> Self {
if let OpportunityContext::Spec {
related_types: ref mut rt,
..
} = &mut self.context
{
*rt = types;
}
self
}
pub fn with_confidence(mut self, confidence: f32) -> Self {
self.confidence = confidence.clamp(0.0, 1.0);
self
}
}
pub trait Suggest: Send + Sync {
fn name(&self) -> &'static str;
fn description(&self) -> &str;
fn category(&self) -> SuggestCategory;
fn safety_level(&self) -> SafetyLevel;
fn priority_weight(&self) -> f32 {
1.0
}
fn rule_id(&self) -> Option<&str> {
None
}
fn target_scopes(&self) -> Vec<SymbolScope> {
vec![]
}
fn accepts_params(&self) -> bool {
false
}
fn param_schema(&self) -> Vec<ParamDef> {
vec![]
}
fn detect_with_params(
&self,
ctx: &AnalysisContext,
symbols: &[SymbolId],
_params: &SuggestParams,
) -> Vec<SuggestOpportunity> {
self.detect(ctx, symbols)
}
fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity>;
fn to_mutation_specs(
&self,
ctx: &AnalysisContext,
opportunity: &SuggestOpportunity,
) -> SuggestResult<Vec<MutationSpec>>;
}
pub type SuggestBox = Box<dyn Suggest>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_safety_level_ordering() {
assert!(SafetyLevel::Auto < SafetyLevel::Confirm);
assert!(SafetyLevel::Confirm < SafetyLevel::Manual);
}
#[test]
fn test_opportunity_id_display() {
let id = OpportunityId::new(42);
assert_eq!(id.to_string(), "O0042");
}
#[test]
fn test_suggest_location_display() {
let loc = SuggestLocation::for_test("src/lib.rs", "MyStruct");
assert_eq!(loc.to_string(), "test::MyStruct (src/lib.rs)");
}
#[test]
fn test_opportunity_context_serde() {
let ctx = OpportunityContext::Builder {
struct_name: "Config".into(),
field_count: 5,
has_required_fields: true,
};
let json = serde_json::to_string(&ctx).unwrap();
let parsed: OpportunityContext = serde_json::from_str(&json).unwrap();
match parsed {
OpportunityContext::Builder {
struct_name,
field_count,
..
} => {
assert_eq!(struct_name, "Config");
assert_eq!(field_count, 5);
}
_ => panic!("Wrong variant"),
}
}
#[test]
fn test_confidence_clamping() {
let opp = SuggestOpportunity::new(
OpportunityId::new(1),
vec![],
SuggestLocation::for_test("test.rs", "Test"),
"Test message",
1.5, OpportunityContext::Derive {
derive_name: "Default".into(),
missing_impls: vec![],
},
);
assert_eq!(opp.confidence, 1.0);
let opp2 = SuggestOpportunity::new(
OpportunityId::new(2),
vec![],
SuggestLocation::for_test("test.rs", "Test"),
"Test message",
-0.5, OpportunityContext::Derive {
derive_name: "Default".into(),
missing_impls: vec![],
},
);
assert_eq!(opp2.confidence, 0.0);
}
#[test]
fn test_lint_severity_from_str() {
use std::str::FromStr;
assert_eq!(LintSeverity::from_str("info").unwrap(), LintSeverity::Info);
assert_eq!(
LintSeverity::from_str("warning").unwrap(),
LintSeverity::Warning
);
assert_eq!(
LintSeverity::from_str("error").unwrap(),
LintSeverity::Error
);
assert_eq!(LintSeverity::from_str("Info").unwrap(), LintSeverity::Info);
assert_eq!(
LintSeverity::from_str("Warning").unwrap(),
LintSeverity::Warning
);
assert_eq!(
LintSeverity::from_str("Error").unwrap(),
LintSeverity::Error
);
assert_eq!(LintSeverity::from_str("hint").unwrap(), LintSeverity::Info);
assert_eq!(
LintSeverity::from_str("warn").unwrap(),
LintSeverity::Warning
);
assert_eq!(LintSeverity::from_str("err").unwrap(), LintSeverity::Error);
assert!(LintSeverity::from_str("invalid").is_err());
}
#[test]
fn test_opportunity_severity_override() {
let opp = SuggestOpportunity::new(
OpportunityId::new(1),
vec![],
SuggestLocation::for_test("test.rs", "Test"),
"Test message",
0.8,
OpportunityContext::Lint {
code: "RL001".into(),
rule: "no-unwrap".into(),
severity: LintSeverity::Warning,
suggestion: None,
expected: None,
actual: None,
},
);
assert_eq!(opp.lint_severity(), Some(LintSeverity::Warning));
let opp = opp.with_severity_override(LintSeverity::Error);
assert_eq!(opp.lint_severity(), Some(LintSeverity::Error));
let opp2 = SuggestOpportunity::new(
OpportunityId::new(2),
vec![],
SuggestLocation::for_test("test.rs", "Test"),
"Test message",
0.8,
OpportunityContext::Derive {
derive_name: "Default".into(),
missing_impls: vec![],
},
);
let opp2 = opp2.with_severity_override(LintSeverity::Error);
assert_eq!(opp2.lint_severity(), None); }
}