Skip to main content

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}