use ryo_analysis::context::AnalysisContext;
use ryo_analysis::{SymbolId, SymbolKind, SymbolPath};
use ryo_executor::{SpecRelation, SpecRelationKind};
use super::{create_spec_opportunity, is_framework_type, SpecDetails, SpecSuggest};
use crate::{
MutationSpec, OpportunityId, SafetyLevel, Suggest, SuggestCategory, SuggestError,
SuggestLocation, SuggestOpportunity, SuggestResult,
};
pub struct MissingRelation {
suffix: String,
default_group: String,
}
impl MissingRelation {
pub fn new() -> Self {
Self {
suffix: "Spec".to_string(),
default_group: "DomainGroup".to_string(),
}
}
pub fn with_suffix(suffix: impl Into<String>) -> Self {
Self {
suffix: suffix.into(),
default_group: "DomainGroup".to_string(),
}
}
pub fn with_group(mut self, group: impl Into<String>) -> Self {
self.default_group = group.into();
self
}
fn find_spec_for_type(
&self,
ctx: &AnalysisContext,
type_name: &str,
) -> Option<(SymbolId, SymbolPath)> {
let spec_name = format!("{}{}", type_name, self.suffix);
for symbol_id in ctx.registry.iter_by_kind(SymbolKind::TypeAlias) {
if let Some(path) = ctx.registry.path(symbol_id) {
if path.name() == spec_name {
return Some((symbol_id, path.clone()));
}
}
}
None
}
fn find_struct_for_type(&self, ctx: &AnalysisContext, type_name: &str) -> Option<SymbolId> {
for symbol_id in ctx.registry.iter_by_kind(SymbolKind::Struct) {
if let Some(path) = ctx.registry.path(symbol_id) {
if path.name() == type_name {
return Some(symbol_id);
}
}
}
None
}
fn analyze_struct_relations(
&self,
ctx: &AnalysisContext,
struct_id: SymbolId,
struct_name: &str,
) -> Vec<String> {
let mut relations = Vec::new();
let typeflow = ctx.typeflow_graph();
for used_id in typeflow.types_used_by(struct_id) {
if let Some(path) = ctx.registry.path(used_id) {
let kind = ctx.registry.kind(used_id);
if !matches!(kind, Some(SymbolKind::Struct) | Some(SymbolKind::Enum)) {
continue;
}
let used_name = path.name();
if is_framework_type(used_name) || used_name == struct_name {
continue;
}
if self.find_spec_for_type(ctx, used_name).is_some() {
relations.push(used_name.to_string());
}
}
}
relations
}
fn spec_has_relations(&self, ctx: &AnalysisContext, spec_id: SymbolId) -> bool {
let typeflow = ctx.typeflow_graph();
let used_count = typeflow.types_used_by(spec_id).count();
used_count > 2
}
}
impl Default for MissingRelation {
fn default() -> Self {
Self::new()
}
}
const RULE_CODE: &str = "RS006";
impl SpecSuggest for MissingRelation {
fn spec_suffix(&self) -> &str {
&self.suffix
}
}
impl Suggest for MissingRelation {
fn name(&self) -> &'static str {
"missing-relation"
}
fn description(&self) -> &str {
"Suggests Relations based on struct field types"
}
fn category(&self) -> SuggestCategory {
SuggestCategory::Pattern
}
fn safety_level(&self) -> SafetyLevel {
SafetyLevel::Confirm }
fn priority_weight(&self) -> f32 {
0.8 }
fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity> {
let mut opportunities = Vec::new();
let mut next_id = 0u32;
let symbols_to_check: Vec<SymbolId> = if symbols.is_empty() {
ctx.registry.iter_by_kind(SymbolKind::TypeAlias).collect()
} else {
symbols.to_vec()
};
for spec_id in symbols_to_check {
let path = match ctx.registry.path(spec_id) {
Some(p) => p,
None => continue,
};
let alias_name = path.name();
if !self.is_spec_alias(alias_name) {
continue;
}
let base_type = match self.extract_base_type(alias_name) {
Some(bt) => bt,
None => continue,
};
if self.spec_has_relations(ctx, spec_id) {
continue;
}
let struct_id = match self.find_struct_for_type(ctx, base_type) {
Some(id) => id,
None => continue,
};
let relations = self.analyze_struct_relations(ctx, struct_id, base_type);
if relations.is_empty() {
continue;
}
let Some(location) = SuggestLocation::from_context(ctx, spec_id) else {
continue;
};
let relation_str = relations.join(", ");
let suggested_relations = relations
.iter()
.map(|name| format!("RelatedTo({})", name))
.collect::<Vec<_>>()
.join(", ");
let opp = create_spec_opportunity(
RULE_CODE,
OpportunityId::new(next_id),
vec![spec_id, struct_id],
location,
format!(
"Spec `{}` could define relations to: {}",
alias_name, relation_str
),
0.7, SpecDetails {
alias_name: Some(alias_name.to_string()),
base_type: Some(base_type.to_string()),
group: Some(self.default_group.clone()),
related_types: relations.clone(),
suggestion: Some(format!("Add Relations![{}]", suggested_relations)),
},
)
.with_related_types(relations);
opportunities.push(opp);
next_id += 1;
}
opportunities
}
fn to_mutation_specs(
&self,
ctx: &AnalysisContext,
opportunity: &SuggestOpportunity,
) -> SuggestResult<Vec<MutationSpec>> {
let spec_id = match opportunity.targets.first() {
Some(id) => *id,
None => return Ok(Vec::new()),
};
let path = match ctx.registry.path(spec_id) {
Some(p) => p,
None => return Ok(Vec::new()),
};
let alias_name = path.name();
let base_type = match self.extract_base_type(alias_name) {
Some(bt) => bt.to_string(),
None => return Ok(Vec::new()),
};
let module_path =
self.get_module_path(path)
.ok_or_else(|| SuggestError::ModulePathResolution {
path: path.to_string(),
})?;
let module_id = match ctx.registry.lookup(&module_path) {
Some(id) => id,
None => return Ok(Vec::new()),
};
let struct_id = match self.find_struct_for_type(ctx, &base_type) {
Some(id) => id,
None => return Ok(Vec::new()),
};
let relations = self.analyze_struct_relations(ctx, struct_id, &base_type);
let spec_relations: Vec<SpecRelation> = relations
.into_iter()
.map(|target| SpecRelation::new(SpecRelationKind::RelatedTo, target))
.collect();
Ok(vec![MutationSpec::AddSpec {
type_id: struct_id,
module_id,
group: self.default_group.clone(),
alias_name: Some(alias_name.to_string()),
relations: spec_relations,
}])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_spec_alias() {
let rule = MissingRelation::new();
assert!(rule.is_spec_alias("TaskSpec"));
assert!(rule.is_spec_alias("UserSpec"));
assert!(!rule.is_spec_alias("Spec"));
assert!(!rule.is_spec_alias("Task"));
}
#[test]
fn test_extract_base_type() {
let rule = MissingRelation::new();
assert_eq!(rule.extract_base_type("TaskSpec"), Some("Task"));
assert_eq!(rule.extract_base_type("OrderSpec"), Some("Order"));
assert_eq!(rule.extract_base_type("Spec"), None);
}
#[test]
fn test_is_framework_type() {
assert!(is_framework_type("String"));
assert!(is_framework_type("Vec"));
assert!(is_framework_type("u64"));
assert!(is_framework_type("Option"));
assert!(!is_framework_type("Task"));
assert!(!is_framework_type("User"));
assert!(!is_framework_type("Order"));
}
#[test]
fn test_with_group() {
let rule = MissingRelation::new().with_group("CustomGroup");
assert_eq!(rule.default_group, "CustomGroup");
}
}