ryo_suggest/suggest.rs
1//! Suggest trait and core types for continuous refactoring
2//!
3//! This module defines the domain model for detecting improvement opportunities
4//! and generating MutationSpecs for execution.
5//!
6//! # Architecture: Suggest and the Two-Layer Mutation System
7//!
8//! Ryo has two mutation specification layers:
9//! - [`Intent`](ryo_app::intent::Intent): Public DSL for CLI users (pattern-based)
10//! - [`MutationSpec`]: Execution-level specification (concrete targets)
11//!
12//! The `Suggest` system **bypasses Intent** and generates `MutationSpec` directly:
13//!
14//! ```text
15//! ┌─────────────────────────────────────────────────────────────────┐
16//! │ Suggest::detect() │
17//! │ - Analyzes code for improvement opportunities │
18//! │ - Returns SuggestOpportunity with concrete SymbolIds │
19//! └───────────────────────────┬─────────────────────────────────────┘
20//! ↓
21//! ┌─────────────────────────────────────────────────────────────────┐
22//! │ Suggest::to_mutation_specs() │
23//! │ - Converts opportunity to MutationSpec(s) │
24//! │ - Skips Intent layer (no pattern resolution needed) │
25//! └───────────────────────────┬─────────────────────────────────────┘
26//! ↓
27//! ┌─────────────────────────────────────────────────────────────────┐
28//! │ Executor │
29//! │ - Applies MutationSpecs to AST │
30//! └─────────────────────────────────────────────────────────────────┘
31//! ```
32//!
33//! ## Why Bypass Intent?
34//!
35//! - Suggest already has resolved symbols from `AnalysisContext`
36//! - No need for pattern matching (targets are concrete)
37//! - More efficient: direct path to execution
38//!
39//! ## Re-export of MutationSpec
40//!
41//! `MutationSpec` is re-exported here for convenience. Users of the Suggest
42//! system can import both `Suggest` trait and `MutationSpec` from this module
43//! without needing to depend on `ryo-executor` directly.
44
45use std::collections::HashMap;
46use std::fmt;
47
48use ryo_analysis::context::AnalysisContext;
49use ryo_analysis::{SymbolId, SymbolPath};
50use serde::{Deserialize, Serialize};
51use thiserror::Error;
52
53// =============================================================================
54// Error Types
55// =============================================================================
56
57/// Error type for `to_mutation_specs()` operations
58#[derive(Debug, Error)]
59pub enum SuggestError {
60 #[error("Failed to resolve module path for spec at {path}")]
61 ModulePathResolution { path: String },
62
63 #[error("Invalid symbol path: {reason}")]
64 InvalidSymbolPath { reason: String },
65
66 #[error("Missing required context: {context}")]
67 MissingContext { context: String },
68}
69
70/// Result type for Suggest operations
71pub type SuggestResult<T> = Result<T, SuggestError>;
72
73// =============================================================================
74// Parameterized Suggest Types
75// =============================================================================
76
77/// Parameters for parameterized suggestions.
78///
79/// Used to pass variables like `{ "name": "Order" }` to generate
80/// context-specific code (e.g., `OrderAPI`, `OrderService`).
81pub type SuggestParams = HashMap<String, String>;
82
83/// Definition of a parameter for parameterized suggestions.
84///
85/// Used by LLMs to understand what parameters a suggestion accepts.
86#[derive(Debug, Clone, Serialize, Deserialize)]
87pub struct ParamDef {
88 /// Parameter name (e.g., "name")
89 pub name: String,
90 /// Human-readable description (e.g., "Name of the API to generate")
91 pub description: String,
92 /// Whether this parameter is required
93 pub required: bool,
94}
95
96impl ParamDef {
97 /// Create a required parameter definition
98 pub fn required(name: impl Into<String>, description: impl Into<String>) -> Self {
99 Self {
100 name: name.into(),
101 description: description.into(),
102 required: true,
103 }
104 }
105
106 /// Create an optional parameter definition
107 pub fn optional(name: impl Into<String>, description: impl Into<String>) -> Self {
108 Self {
109 name: name.into(),
110 description: description.into(),
111 required: false,
112 }
113 }
114}
115
116// Re-export MutationSpec and related types from ryo-executor for convenience.
117// Suggest implementations generate MutationSpecs directly, bypassing Intent.
118pub use ryo_executor::executor::{EnumToTraitStrategy, MatchHandling, MutationSpec};
119
120/// Category for suggestion classification
121#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
122pub enum SuggestCategory {
123 /// Derive macros (Default, Clone, Debug, etc.)
124 Derive,
125 /// Design patterns (Builder, Factory, etc.)
126 Pattern,
127 /// Performance improvements (Atomic, RwLock, etc.)
128 Performance,
129 /// Safety improvements (LockScope, etc.)
130 Safety,
131 /// Idiomatic transformations (match→if-let, etc.)
132 Idiom,
133 /// Code organization (extract, inline, etc.)
134 Refactor,
135 /// Lint rules (code quality checks, test coverage, etc.)
136 Lint,
137}
138
139impl fmt::Display for SuggestCategory {
140 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
141 match self {
142 Self::Derive => write!(f, "Derive"),
143 Self::Pattern => write!(f, "Pattern"),
144 Self::Performance => write!(f, "Performance"),
145 Self::Safety => write!(f, "Safety"),
146 Self::Idiom => write!(f, "Idiom"),
147 Self::Refactor => write!(f, "Refactor"),
148 Self::Lint => write!(f, "Lint"),
149 }
150 }
151}
152
153/// Safety classification for auto-application decisions
154#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
155pub enum SafetyLevel {
156 /// Fully automatic - no side effects, guaranteed safe
157 /// Examples: Add #[derive(Default)] to struct with all-defaultable fields
158 Auto,
159
160 /// Confirmation recommended - standard refactoring
161 /// Examples: Generate Builder pattern, extract function
162 Confirm,
163
164 /// Manual only - potential breaking changes
165 /// Examples: Change public API, remove unused code
166 Manual,
167}
168
169impl fmt::Display for SafetyLevel {
170 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171 match self {
172 Self::Auto => write!(f, "AUTO"),
173 Self::Confirm => write!(f, "CONFIRM"),
174 Self::Manual => write!(f, "MANUAL"),
175 }
176 }
177}
178
179// =============================================================================
180// Priority Computation
181// =============================================================================
182
183/// Compute priority score from confidence, safety level, and pattern weight.
184///
185/// Returns 0-255 (higher = more important).
186///
187/// # Formula
188/// ```text
189/// priority = confidence × safety_weight × normalized_pattern_weight × 255
190/// ```
191///
192/// Where:
193/// - `confidence`: 0.0-1.0 from the opportunity
194/// - `safety_weight`: Auto=1.0, Confirm=0.7, Manual=0.4
195/// - `normalized_pattern_weight`: pattern_weight normalized to 0.4-1.0 range
196/// (input range 0.5-2.5 maps to 0.4-1.0)
197///
198/// # Examples
199/// ```ignore
200/// // High priority: high confidence + auto safety + critical pattern
201/// compute_priority(0.95, SafetyLevel::Auto, 2.5) // → ~242
202///
203/// // Low priority: medium confidence + manual safety + low pattern weight
204/// compute_priority(0.7, SafetyLevel::Manual, 0.8) // → ~29
205/// ```
206pub fn compute_priority(confidence: f32, safety: SafetyLevel, pattern_weight: f32) -> u8 {
207 let safety_weight = match safety {
208 SafetyLevel::Auto => 1.0,
209 SafetyLevel::Confirm => 0.7,
210 SafetyLevel::Manual => 0.4,
211 };
212 // Normalize pattern_weight: 0.5-2.5 → 0.4-1.0
213 // This ensures pattern weight affects but doesn't dominate priority
214 let normalized_pattern = (pattern_weight / 2.5).clamp(0.4, 1.0);
215 (confidence * safety_weight * normalized_pattern * 255.0).clamp(0.0, 255.0) as u8
216}
217
218/// Newtype for opportunity IDs (unique within a detection run)
219#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
220pub struct OpportunityId(pub u32);
221
222impl OpportunityId {
223 pub fn new(id: u32) -> Self {
224 Self(id)
225 }
226
227 pub fn as_u32(self) -> u32 {
228 self.0
229 }
230}
231
232impl fmt::Display for OpportunityId {
233 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
234 write!(f, "O{:04}", self.0)
235 }
236}
237
238/// Location information for a suggestion
239///
240/// Uses SymbolId/SymbolPath for precise symbol reference.
241/// The `file` field is derived from the symbol's span when available.
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
243pub struct SuggestLocation {
244 /// Symbol ID for precise reference
245 pub symbol_id: SymbolId,
246
247 /// Symbol path (e.g., "test_crate::module::SymbolName")
248 pub symbol_path: SymbolPath,
249
250 /// File path (derived from symbol span, for display)
251 pub file: String,
252}
253
254impl SuggestLocation {
255 /// Create a new SuggestLocation from symbol information
256 pub fn new(symbol_id: SymbolId, symbol_path: SymbolPath, file: impl Into<String>) -> Self {
257 Self {
258 symbol_id,
259 symbol_path,
260 file: file.into(),
261 }
262 }
263
264 /// Create from AnalysisContext by looking up the symbol
265 pub fn from_context(ctx: &AnalysisContext, symbol_id: SymbolId) -> Option<Self> {
266 let symbol_path = ctx.registry().resolve(symbol_id)?;
267 let file = ctx
268 .registry()
269 .span(symbol_id)
270 .map(|span| span.file.to_string())
271 .unwrap_or_else(|| symbol_path.crate_name().to_string());
272 Some(Self::new(symbol_id, symbol_path.clone(), file))
273 }
274
275 /// Get the symbol name (last component of path)
276 pub fn symbol_name(&self) -> &str {
277 self.symbol_path.name()
278 }
279
280 /// Create a test location with dummy SymbolId
281 #[cfg(any(test, feature = "testing"))]
282 pub fn for_test(file: impl Into<String>, symbol_name: impl Into<String>) -> Self {
283 let name = symbol_name.into();
284 // Use a fixed test SymbolId (0v1 format)
285 let symbol_id = SymbolId::parse("0v1").expect("test SymbolId");
286 let symbol_path = SymbolPath::builder("test")
287 .push(&name)
288 .build()
289 .expect("test path");
290 Self {
291 symbol_id,
292 symbol_path,
293 file: file.into(),
294 }
295 }
296}
297
298impl fmt::Display for SuggestLocation {
299 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
300 write!(f, "{} ({})", self.symbol_path, self.file)
301 }
302}
303
304/// Severity level for lint violations (maps to SafetyLevel)
305#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
306#[serde(rename_all = "lowercase")]
307pub enum LintSeverity {
308 /// Informational message
309 Info,
310 /// Warning that should be addressed
311 Warning,
312 /// Error that must be fixed
313 Error,
314}
315
316impl fmt::Display for LintSeverity {
317 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
318 match self {
319 Self::Info => write!(f, "info"),
320 Self::Warning => write!(f, "warning"),
321 Self::Error => write!(f, "error"),
322 }
323 }
324}
325
326impl std::str::FromStr for LintSeverity {
327 type Err = String;
328
329 fn from_str(s: &str) -> Result<Self, Self::Err> {
330 match s.to_lowercase().as_str() {
331 "info" | "hint" => Ok(Self::Info),
332 "warning" | "warn" => Ok(Self::Warning),
333 "error" | "err" => Ok(Self::Error),
334 _ => Err(format!("Unknown severity: {}", s)),
335 }
336 }
337}
338
339impl From<LintSeverity> for SafetyLevel {
340 fn from(severity: LintSeverity) -> Self {
341 match severity {
342 LintSeverity::Info => SafetyLevel::Auto,
343 LintSeverity::Warning => SafetyLevel::Confirm,
344 LintSeverity::Error => SafetyLevel::Manual,
345 }
346 }
347}
348
349impl From<SafetyLevel> for LintSeverity {
350 fn from(level: SafetyLevel) -> Self {
351 match level {
352 SafetyLevel::Auto => LintSeverity::Info,
353 SafetyLevel::Confirm => LintSeverity::Warning,
354 SafetyLevel::Manual => LintSeverity::Error,
355 }
356 }
357}
358
359/// Type-safe context for different suggestion types
360#[derive(Debug, Clone, Serialize, Deserialize)]
361#[serde(tag = "type")]
362pub enum OpportunityContext {
363 /// Default derive opportunity
364 Derive {
365 derive_name: String,
366 #[serde(default)]
367 missing_impls: Vec<String>,
368 },
369
370 /// Builder pattern opportunity
371 Builder {
372 struct_name: String,
373 field_count: usize,
374 has_required_fields: bool,
375 },
376
377 /// Atomic replacement opportunity
378 Atomic {
379 current_type: String,
380 suggested_atomic: String,
381 },
382
383 /// From/Into implementation opportunity
384 FromInto {
385 source_type: String,
386 target_type: String,
387 },
388
389 /// Lint violation context
390 Lint {
391 /// Rule code (e.g., "RL001")
392 code: String,
393 /// Rule name (e.g., "require-test-for-mutation")
394 rule: String,
395 /// Severity level
396 severity: LintSeverity,
397 /// Optional suggestion for fixing
398 #[serde(default)]
399 suggestion: Option<String>,
400 /// Expected pattern (what should exist)
401 #[serde(default)]
402 expected: Option<String>,
403 /// Actual pattern (what was found)
404 #[serde(default)]
405 actual: Option<String>,
406 },
407
408 /// Spec (domain specification) opportunity
409 Spec {
410 /// Rule code (e.g., "RS001")
411 code: String,
412 /// Spec alias name (e.g., "TaskSpec")
413 #[serde(default)]
414 alias_name: Option<String>,
415 /// Base type name (e.g., "Task")
416 #[serde(default)]
417 base_type: Option<String>,
418 /// Group name (e.g., "DomainGroup")
419 #[serde(default)]
420 group: Option<String>,
421 /// Related types (for relation suggestions)
422 #[serde(default)]
423 related_types: Vec<String>,
424 /// Optional suggestion for fixing
425 #[serde(default)]
426 suggestion: Option<String>,
427 },
428
429 /// Generic context for extensibility
430 Custom {
431 #[serde(flatten)]
432 data: serde_json::Value,
433 },
434
435 /// Parameterized code generation context.
436 ///
437 /// Used when generating code from a pattern with user-provided parameters.
438 /// Example: API pattern with `{ "name": "Order" }` generates `OrderAPI`.
439 Generation {
440 /// Pattern name (e.g., "api", "domain", "service")
441 pattern: String,
442 /// User-provided parameters (e.g., `{ "name": "Order" }`)
443 params: HashMap<String, String>,
444 },
445}
446
447// =============================================================================
448// Symbol Scope
449// =============================================================================
450
451/// The code scope where a symbol is defined.
452///
453/// Used to classify suggestions by their origin context, enabling
454/// consumers to filter suggestions (e.g., show only library code issues).
455#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
456pub enum SymbolScope {
457 /// Library code (src/lib.rs and its modules)
458 #[default]
459 Lib,
460 /// Binary crate code (src/main.rs, src/bin/*.rs and their modules)
461 Bin,
462 /// Test code (#[cfg(test)] modules, tests/ directory)
463 Test,
464}
465
466impl SymbolScope {
467 /// Check if this scope represents production code (Lib or Bin)
468 pub fn is_production(&self) -> bool {
469 matches!(self, Self::Lib | Self::Bin)
470 }
471}
472
473impl fmt::Display for SymbolScope {
474 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
475 match self {
476 Self::Lib => write!(f, "lib"),
477 Self::Bin => write!(f, "bin"),
478 Self::Test => write!(f, "test"),
479 }
480 }
481}
482
483impl std::str::FromStr for SymbolScope {
484 type Err = String;
485
486 fn from_str(s: &str) -> Result<Self, Self::Err> {
487 match s.to_lowercase().as_str() {
488 "lib" => Ok(Self::Lib),
489 "bin" => Ok(Self::Bin),
490 "test" => Ok(Self::Test),
491 other => Err(format!(
492 "unknown scope: '{}' (expected: lib, bin, test)",
493 other
494 )),
495 }
496 }
497}
498
499impl SymbolScope {
500 /// Resolve the scope for a symbol from AnalysisContext.
501 ///
502 /// Resolution order:
503 /// 1. Check if symbol path contains a "tests" segment → Test
504 /// 2. Check if symbol's file path is in tests/ directory → Test
505 /// 3. Check if symbol's crate has a binary entry point → Bin
506 /// 4. Otherwise → Lib
507 ///
508 /// The `binary_crates` set should be pre-computed via
509 /// `SymbolScope::binary_crate_names(ctx)` for efficiency.
510 pub fn resolve(
511 ctx: &AnalysisContext,
512 symbol_id: SymbolId,
513 binary_crates: &std::collections::HashSet<String>,
514 ) -> Self {
515 // 1. Check symbol path for test module segments.
516 // Matches:
517 // - "tests" / "tests_*" — any segment (standard #[cfg(test)] names)
518 // - "test_*" — non-root segments only (module names like
519 // "test_utils", "test_harness"; excludes crate names like "test_crate")
520 if let Some(path) = ctx.registry.path(symbol_id) {
521 for (i, segment) in path.segments().enumerate() {
522 if segment == "tests" || segment.starts_with("tests_") {
523 return Self::Test;
524 }
525 // test_ prefix only for module segments (skip crate name at index 0)
526 if i > 0 && segment.starts_with("test_") {
527 return Self::Test;
528 }
529 }
530 }
531
532 // 2. Check file path for tests/ directory
533 if let Some(span) = ctx.registry.span(symbol_id) {
534 let relative = span.file.as_relative().to_string_lossy();
535 if relative.contains("/tests/") || relative.starts_with("tests/") {
536 return Self::Test;
537 }
538
539 // 3. Check if this symbol's crate is a binary crate
540 let crate_name = span.file.crate_name().as_str();
541 if binary_crates.contains(crate_name) {
542 return Self::Bin;
543 }
544 }
545
546 // 4. Default: library code
547 Self::Lib
548 }
549
550 /// Pre-compute the set of binary crate names from AnalysisContext.
551 ///
552 /// A crate is binary if any of its files is a binary entry point
553 /// (main.rs or src/bin/*.rs).
554 pub fn binary_crate_names(ctx: &AnalysisContext) -> std::collections::HashSet<String> {
555 ctx.files()
556 .keys()
557 .filter(|f| f.is_binary_entry())
558 .map(|f| f.crate_name().as_str().to_string())
559 .collect()
560 }
561}
562
563// =============================================================================
564// Suggest Opportunity
565// =============================================================================
566
567/// A detected opportunity for improvement
568#[derive(Debug, Clone, Serialize, Deserialize)]
569pub struct SuggestOpportunity {
570 /// Unique ID within this detection run
571 pub id: OpportunityId,
572
573 /// Target symbol(s) for this opportunity
574 pub targets: Vec<SymbolId>,
575
576 /// Primary location for display
577 pub location: SuggestLocation,
578
579 /// Human-readable suggestion message
580 pub message: String,
581
582 /// Confidence score (0.0 - 1.0)
583 pub confidence: f32,
584
585 /// Pattern-specific context (type-safe via enum)
586 pub context: OpportunityContext,
587
588 /// Code scope where this opportunity was detected
589 #[serde(default)]
590 pub scope: SymbolScope,
591}
592
593impl SuggestOpportunity {
594 /// Create a new opportunity
595 ///
596 /// Scope defaults to `Lib`. The pipeline (detect_with_config) resolves
597 /// and overwrites the scope based on symbol context.
598 pub fn new(
599 id: OpportunityId,
600 targets: Vec<SymbolId>,
601 location: SuggestLocation,
602 message: impl Into<String>,
603 confidence: f32,
604 context: OpportunityContext,
605 ) -> Self {
606 Self {
607 id,
608 targets,
609 location,
610 message: message.into(),
611 confidence: confidence.clamp(0.0, 1.0),
612 context,
613 scope: SymbolScope::default(),
614 }
615 }
616
617 /// Set the scope for this opportunity
618 pub fn with_scope(mut self, scope: SymbolScope) -> Self {
619 self.scope = scope;
620 self
621 }
622
623 /// Get the primary target symbol (first in the list)
624 pub fn primary_target(&self) -> Option<SymbolId> {
625 self.targets.first().copied()
626 }
627
628 /// Override severity for Lint context.
629 ///
630 /// Only affects Lint context - other contexts are unchanged.
631 /// Returns self for chaining.
632 pub fn with_severity_override(mut self, new_severity: LintSeverity) -> Self {
633 if let OpportunityContext::Lint {
634 ref mut severity, ..
635 } = self.context
636 {
637 *severity = new_severity;
638 }
639 self
640 }
641
642 /// Get the lint severity if this is a Lint context
643 pub fn lint_severity(&self) -> Option<LintSeverity> {
644 if let OpportunityContext::Lint { severity, .. } = &self.context {
645 Some(*severity)
646 } else {
647 None
648 }
649 }
650
651 /// Get the lint rule code if this is a Lint context
652 pub fn lint_code(&self) -> Option<&str> {
653 if let OpportunityContext::Lint { code, .. } = &self.context {
654 Some(code)
655 } else {
656 None
657 }
658 }
659
660 /// Get the spec rule code if this is a Spec context
661 pub fn spec_code(&self) -> Option<&str> {
662 if let OpportunityContext::Spec { code, .. } = &self.context {
663 Some(code)
664 } else {
665 None
666 }
667 }
668
669 // ========== Builder Methods ==========
670
671 /// Set suggestion text for Lint or Spec context.
672 /// Returns self for chaining.
673 pub fn with_suggestion(mut self, suggestion: impl Into<String>) -> Self {
674 match &mut self.context {
675 OpportunityContext::Lint {
676 suggestion: ref mut s,
677 ..
678 } => {
679 *s = Some(suggestion.into());
680 }
681 OpportunityContext::Spec {
682 suggestion: ref mut s,
683 ..
684 } => {
685 *s = Some(suggestion.into());
686 }
687 _ => {}
688 }
689 self
690 }
691
692 /// Set expected/actual for Lint context.
693 /// Returns self for chaining.
694 pub fn with_expected_actual(
695 mut self,
696 expected: impl Into<String>,
697 actual: impl Into<String>,
698 ) -> Self {
699 if let OpportunityContext::Lint {
700 expected: ref mut e,
701 actual: ref mut a,
702 ..
703 } = &mut self.context
704 {
705 *e = Some(expected.into());
706 *a = Some(actual.into());
707 }
708 self
709 }
710
711 /// Set related types for Spec context.
712 /// Returns self for chaining.
713 pub fn with_related_types(mut self, types: Vec<String>) -> Self {
714 if let OpportunityContext::Spec {
715 related_types: ref mut rt,
716 ..
717 } = &mut self.context
718 {
719 *rt = types;
720 }
721 self
722 }
723
724 /// Override confidence score.
725 /// Returns self for chaining.
726 pub fn with_confidence(mut self, confidence: f32) -> Self {
727 self.confidence = confidence.clamp(0.0, 1.0);
728 self
729 }
730}
731
732/// Detection + MutationSpec generation capability
733///
734/// Each implementor:
735/// 1. Detects opportunities from AnalysisContext for given symbols
736/// 2. Generates MutationSpecs (NOT Mutations) for each opportunity
737///
738/// This separation ensures:
739/// - Detect logic stays with Suggest implementations
740/// - Execution flows through standard MutationSpec → Executor pipeline
741/// - No special handling needed in Executor for Suggest-originated mutations
742///
743/// # Parameterized Suggestions
744///
745/// Suggestions can accept external parameters for code generation.
746/// Override `accepts_params()`, `param_schema()`, and `detect_with_params()`
747/// to enable parameterized generation.
748///
749/// ```ignore
750/// impl Suggest for ApiPatternSuggest {
751/// fn accepts_params(&self) -> bool { true }
752///
753/// fn param_schema(&self) -> Vec<ParamDef> {
754/// vec![ParamDef::required("name", "API name (e.g., Order)")]
755/// }
756///
757/// fn detect_with_params(&self, ctx, symbols, params) -> Vec<SuggestOpportunity> {
758/// let name = params.get("name").unwrap();
759/// // Generate OrderAPI opportunities...
760/// }
761/// }
762/// ```
763pub trait Suggest: Send + Sync {
764 /// Name of this suggestion pattern (e.g., "Builder", "Default")
765 fn name(&self) -> &'static str;
766
767 /// Human-readable description
768 fn description(&self) -> &str;
769
770 /// Category for filtering/grouping
771 fn category(&self) -> SuggestCategory;
772
773 /// Safety level for auto-application decisions
774 fn safety_level(&self) -> SafetyLevel;
775
776 /// Priority weight for ranking (higher = more important)
777 fn priority_weight(&self) -> f32 {
778 1.0
779 }
780
781 /// Optional rule ID for pattern-based rules (e.g., "RL021").
782 /// Returns None for non-pattern suggestions.
783 fn rule_id(&self) -> Option<&str> {
784 None
785 }
786
787 /// Target scopes where this suggest applies.
788 ///
789 /// When non-empty, opportunities are filtered to only those in the specified scopes.
790 /// When empty (default), the suggest applies to all scopes.
791 ///
792 /// Example: `vec![SymbolScope::Lib, SymbolScope::Bin]` excludes test code.
793 fn target_scopes(&self) -> Vec<SymbolScope> {
794 vec![]
795 }
796
797 // ========== Parameterized Suggest Support ==========
798
799 /// Whether this suggestion accepts external parameters.
800 ///
801 /// If true, `detect_with_params` should be overridden to handle parameters.
802 /// LLMs can query this to determine if a suggestion supports generation mode.
803 fn accepts_params(&self) -> bool {
804 false
805 }
806
807 /// Schema of accepted parameters (for LLM consumption).
808 ///
809 /// Returns parameter definitions that LLMs can use to construct
810 /// valid parameter sets for `detect_with_params`.
811 fn param_schema(&self) -> Vec<ParamDef> {
812 vec![]
813 }
814
815 /// Detect opportunities with external parameters.
816 ///
817 /// Override this method for parameterized code generation.
818 /// The `params` map contains user-provided values like `{ "name": "Order" }`.
819 ///
820 /// Default implementation ignores params and delegates to `detect`.
821 fn detect_with_params(
822 &self,
823 ctx: &AnalysisContext,
824 symbols: &[SymbolId],
825 _params: &SuggestParams,
826 ) -> Vec<SuggestOpportunity> {
827 self.detect(ctx, symbols)
828 }
829
830 // ========== Core Detection ==========
831
832 /// Detect opportunities for the given symbols
833 ///
834 /// Takes:
835 /// - ctx: Full AnalysisContext for graph queries, type info, etc.
836 /// - symbols: Target symbols to check (batch processing)
837 ///
838 /// Returns: Opportunities found (may be empty)
839 fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity>;
840
841 /// Convert a detected opportunity to executable MutationSpecs
842 ///
843 /// Takes:
844 /// - ctx: AnalysisContext for resolving SymbolPath, type info, etc.
845 /// - opportunity: The detected opportunity
846 ///
847 /// Returns Vec because one opportunity may require multiple specs
848 /// (e.g., Builder pattern needs struct + impl + methods)
849 ///
850 /// # Errors
851 /// Returns `SuggestError` if mutation spec generation fails
852 fn to_mutation_specs(
853 &self,
854 ctx: &AnalysisContext,
855 opportunity: &SuggestOpportunity,
856 ) -> SuggestResult<Vec<MutationSpec>>;
857}
858
859/// Boxed Suggest trait object
860pub type SuggestBox = Box<dyn Suggest>;
861
862#[cfg(test)]
863mod tests {
864 use super::*;
865
866 #[test]
867 fn test_safety_level_ordering() {
868 assert!(SafetyLevel::Auto < SafetyLevel::Confirm);
869 assert!(SafetyLevel::Confirm < SafetyLevel::Manual);
870 }
871
872 #[test]
873 fn test_opportunity_id_display() {
874 let id = OpportunityId::new(42);
875 assert_eq!(id.to_string(), "O0042");
876 }
877
878 #[test]
879 fn test_suggest_location_display() {
880 let loc = SuggestLocation::for_test("src/lib.rs", "MyStruct");
881 // for_test creates path as "test::MyStruct"
882 assert_eq!(loc.to_string(), "test::MyStruct (src/lib.rs)");
883 }
884
885 #[test]
886 fn test_opportunity_context_serde() {
887 let ctx = OpportunityContext::Builder {
888 struct_name: "Config".into(),
889 field_count: 5,
890 has_required_fields: true,
891 };
892
893 let json = serde_json::to_string(&ctx).unwrap();
894 let parsed: OpportunityContext = serde_json::from_str(&json).unwrap();
895
896 match parsed {
897 OpportunityContext::Builder {
898 struct_name,
899 field_count,
900 ..
901 } => {
902 assert_eq!(struct_name, "Config");
903 assert_eq!(field_count, 5);
904 }
905 _ => panic!("Wrong variant"),
906 }
907 }
908
909 #[test]
910 fn test_confidence_clamping() {
911 let opp = SuggestOpportunity::new(
912 OpportunityId::new(1),
913 vec![],
914 SuggestLocation::for_test("test.rs", "Test"),
915 "Test message",
916 1.5, // Over 1.0
917 OpportunityContext::Derive {
918 derive_name: "Default".into(),
919 missing_impls: vec![],
920 },
921 );
922 assert_eq!(opp.confidence, 1.0);
923
924 let opp2 = SuggestOpportunity::new(
925 OpportunityId::new(2),
926 vec![],
927 SuggestLocation::for_test("test.rs", "Test"),
928 "Test message",
929 -0.5, // Below 0.0
930 OpportunityContext::Derive {
931 derive_name: "Default".into(),
932 missing_impls: vec![],
933 },
934 );
935 assert_eq!(opp2.confidence, 0.0);
936 }
937
938 #[test]
939 fn test_lint_severity_from_str() {
940 use std::str::FromStr;
941
942 // Lowercase
943 assert_eq!(LintSeverity::from_str("info").unwrap(), LintSeverity::Info);
944 assert_eq!(
945 LintSeverity::from_str("warning").unwrap(),
946 LintSeverity::Warning
947 );
948 assert_eq!(
949 LintSeverity::from_str("error").unwrap(),
950 LintSeverity::Error
951 );
952
953 // Titlecase (from YAML/TOML)
954 assert_eq!(LintSeverity::from_str("Info").unwrap(), LintSeverity::Info);
955 assert_eq!(
956 LintSeverity::from_str("Warning").unwrap(),
957 LintSeverity::Warning
958 );
959 assert_eq!(
960 LintSeverity::from_str("Error").unwrap(),
961 LintSeverity::Error
962 );
963
964 // Aliases
965 assert_eq!(LintSeverity::from_str("hint").unwrap(), LintSeverity::Info);
966 assert_eq!(
967 LintSeverity::from_str("warn").unwrap(),
968 LintSeverity::Warning
969 );
970 assert_eq!(LintSeverity::from_str("err").unwrap(), LintSeverity::Error);
971
972 // Invalid
973 assert!(LintSeverity::from_str("invalid").is_err());
974 }
975
976 #[test]
977 fn test_opportunity_severity_override() {
978 let opp = SuggestOpportunity::new(
979 OpportunityId::new(1),
980 vec![],
981 SuggestLocation::for_test("test.rs", "Test"),
982 "Test message",
983 0.8,
984 OpportunityContext::Lint {
985 code: "RL001".into(),
986 rule: "no-unwrap".into(),
987 severity: LintSeverity::Warning,
988 suggestion: None,
989 expected: None,
990 actual: None,
991 },
992 );
993
994 // Original severity
995 assert_eq!(opp.lint_severity(), Some(LintSeverity::Warning));
996
997 // Override severity
998 let opp = opp.with_severity_override(LintSeverity::Error);
999 assert_eq!(opp.lint_severity(), Some(LintSeverity::Error));
1000
1001 // Non-lint context is unchanged
1002 let opp2 = SuggestOpportunity::new(
1003 OpportunityId::new(2),
1004 vec![],
1005 SuggestLocation::for_test("test.rs", "Test"),
1006 "Test message",
1007 0.8,
1008 OpportunityContext::Derive {
1009 derive_name: "Default".into(),
1010 missing_impls: vec![],
1011 },
1012 );
1013 let opp2 = opp2.with_severity_override(LintSeverity::Error);
1014 assert_eq!(opp2.lint_severity(), None); // Still None, not Lint context
1015 }
1016}