Skip to main content

ryo_suggest/
service.rs

1//! SuggestService - ReadOnly interface for concurrent access
2//!
3//! Provides thread-safe access to suggestions for LLM and UI queries.
4//! Uses parking_lot::RwLock for concurrent read access with exclusive writes.
5
6use parking_lot::{MappedRwLockReadGuard, RwLock, RwLockReadGuard};
7use rayon::prelude::*;
8
9use ryo_analysis::context::AnalysisContext;
10use ryo_analysis::SymbolId;
11
12use crate::allow::AllowStore;
13use crate::id::SuggestId;
14use crate::store::{GcConfig, StoredSuggestion, SuggestIndex, SuggestStore};
15use crate::suggest::{
16    LintSeverity, MutationSpec, ParamDef, SafetyLevel, SuggestCategory, SuggestOpportunity,
17    SuggestParams, SymbolScope,
18};
19use crate::suggest_registry::SuggestRegistry;
20use crate::trigger::{AcChanges, PendingChanges, SuggestStrategy, SuggestTrigger};
21
22/// Information about a parameterized suggestion.
23///
24/// Used by LLMs to discover available generation patterns
25/// and their parameter schemas.
26#[derive(Debug, Clone)]
27pub struct ParameterizedSuggestInfo {
28    /// Suggestion name (rule ID)
29    pub name: &'static str,
30    /// Human-readable description
31    pub description: String,
32    /// Category for filtering
33    pub category: SuggestCategory,
34    /// Parameter schema
35    pub param_schema: Vec<ParamDef>,
36}
37
38/// Query filter for suggestions
39#[derive(Debug, Clone, Default)]
40pub struct SuggestQuery {
41    /// Filter by category
42    pub category: Option<SuggestCategory>,
43
44    /// Filter by safety level (max level to include)
45    pub max_safety: Option<SafetyLevel>,
46
47    /// Filter by minimum confidence
48    pub min_confidence: Option<f32>,
49
50    /// Filter by target symbols (any of these)
51    pub target_symbols: Option<Vec<SymbolId>>,
52
53    /// Limit results
54    pub limit: Option<usize>,
55}
56
57impl SuggestQuery {
58    /// Create an empty query (matches all)
59    pub fn all() -> Self {
60        Self::default()
61    }
62
63    /// Filter by category
64    pub fn with_category(mut self, category: SuggestCategory) -> Self {
65        self.category = Some(category);
66        self
67    }
68
69    /// Filter by maximum safety level
70    pub fn with_max_safety(mut self, safety: SafetyLevel) -> Self {
71        self.max_safety = Some(safety);
72        self
73    }
74
75    /// Filter by minimum confidence
76    pub fn with_min_confidence(mut self, confidence: f32) -> Self {
77        self.min_confidence = Some(confidence);
78        self
79    }
80
81    /// Filter by target symbols
82    pub fn with_targets(mut self, symbols: Vec<SymbolId>) -> Self {
83        self.target_symbols = Some(symbols);
84        self
85    }
86
87    /// Limit number of results
88    pub fn with_limit(mut self, limit: usize) -> Self {
89        self.limit = Some(limit);
90        self
91    }
92
93    /// Check if a suggestion matches this query
94    fn matches(&self, stored: &StoredSuggestion, registry: &SuggestRegistry) -> bool {
95        // Category filter
96        if let Some(cat) = self.category {
97            if let Some(suggest) = registry.get(stored.suggest_idx) {
98                if suggest.category() != cat {
99                    return false;
100                }
101            }
102        }
103
104        // Safety filter
105        if let Some(max_safety) = self.max_safety {
106            if stored.safety > max_safety {
107                return false;
108            }
109        }
110
111        // Confidence filter
112        if let Some(min_conf) = self.min_confidence {
113            if stored.opportunity.confidence < min_conf {
114                return false;
115            }
116        }
117
118        // Target symbol filter
119        if let Some(ref targets) = self.target_symbols {
120            let has_match = stored
121                .opportunity
122                .targets
123                .iter()
124                .any(|t| targets.contains(t));
125            if !has_match {
126                return false;
127            }
128        }
129
130        true
131    }
132}
133
134/// View of a suggestion returned by queries
135#[derive(Debug)]
136pub struct SuggestView<'a> {
137    /// The suggestion ID
138    pub id: SuggestId,
139
140    /// The stored suggestion data
141    pub stored: &'a StoredSuggestion,
142
143    /// Pattern name
144    pub pattern_name: &'static str,
145
146    /// Pattern category
147    pub category: SuggestCategory,
148}
149
150/// Thread-safe service for accessing suggestions
151///
152/// Provides read-only queries for LLM and UI consumers.
153/// Write operations go through SuggestEngine (not this service).
154pub struct SuggestService {
155    /// Thread-safe store access
156    store: RwLock<SuggestStore>,
157
158    /// Pattern registry (immutable after initialization)
159    registry: SuggestRegistry,
160
161    /// Evaluation strategy
162    strategy: SuggestStrategy,
163
164    /// Pending changes tracker
165    pending: RwLock<PendingChanges>,
166
167    /// GC configuration
168    gc_config: GcConfig,
169}
170
171impl SuggestService {
172    /// Create a new service with the given registry
173    pub fn new(registry: SuggestRegistry) -> Self {
174        Self {
175            store: RwLock::new(SuggestStore::new()),
176            registry,
177            strategy: SuggestStrategy::default(),
178            pending: RwLock::new(PendingChanges::new()),
179            gc_config: GcConfig::default(),
180        }
181    }
182
183    /// Create a service with custom strategy
184    pub fn with_strategy(registry: SuggestRegistry, strategy: SuggestStrategy) -> Self {
185        Self {
186            store: RwLock::new(SuggestStore::new()),
187            registry,
188            strategy,
189            pending: RwLock::new(PendingChanges::new()),
190            gc_config: GcConfig::default(),
191        }
192    }
193
194    /// Create a service with custom GC configuration
195    pub fn with_gc_config(registry: SuggestRegistry, gc_config: GcConfig) -> Self {
196        Self {
197            store: RwLock::new(SuggestStore::new()),
198            registry,
199            strategy: SuggestStrategy::default(),
200            pending: RwLock::new(PendingChanges::new()),
201            gc_config,
202        }
203    }
204
205    // ========== Read Operations (concurrent safe) ==========
206
207    /// Get a suggestion by ID
208    ///
209    /// Returns a mapped guard that holds the read lock while providing
210    /// access to the stored suggestion.
211    pub fn get(&self, id: SuggestId) -> Option<MappedRwLockReadGuard<'_, StoredSuggestion>> {
212        let guard = self.store.read();
213        RwLockReadGuard::try_map(guard, |store| store.get(id)).ok()
214    }
215
216    /// Check if a suggestion ID is still valid
217    pub fn is_valid(&self, id: SuggestId) -> bool {
218        self.store.read().is_valid(id)
219    }
220
221    /// Query suggestions with filter
222    pub fn query(&self, query: &SuggestQuery) -> Vec<(SuggestId, SuggestCategory, SafetyLevel)> {
223        let store = self.store.read();
224
225        let mut results: Vec<_> = store
226            .iter()
227            .filter(|(_, stored)| query.matches(stored, &self.registry))
228            .map(|(id, stored)| {
229                let category = self
230                    .registry
231                    .get(stored.suggest_idx)
232                    .map(|s| s.category())
233                    .unwrap_or(SuggestCategory::Refactor);
234                (id, category, stored.safety)
235            })
236            .collect();
237
238        // Apply limit
239        if let Some(limit) = query.limit {
240            results.truncate(limit);
241        }
242
243        results
244    }
245
246    /// Get all suggestions for auto-application (safety=Auto)
247    pub fn auto_applicable(&self) -> Vec<SuggestId> {
248        self.query(&SuggestQuery::all().with_max_safety(SafetyLevel::Auto))
249            .into_iter()
250            .map(|(id, _, _)| id)
251            .collect()
252    }
253
254    /// Get suggestions by category
255    pub fn by_category(&self, category: SuggestCategory) -> Vec<SuggestId> {
256        self.query(&SuggestQuery::all().with_category(category))
257            .into_iter()
258            .map(|(id, _, _)| id)
259            .collect()
260    }
261
262    /// Get suggestion count
263    pub fn count(&self) -> usize {
264        self.store.read().len()
265    }
266
267    /// Check if there are any suggestions
268    pub fn is_empty(&self) -> bool {
269        self.count() == 0
270    }
271
272    /// Get pattern names for a suggestion
273    pub fn pattern_name(&self, id: SuggestId) -> Option<&'static str> {
274        let store = self.store.read();
275        let stored = store.get(id)?;
276        self.registry.get(stored.suggest_idx).map(|s| s.name())
277    }
278
279    /// Get rule ID for a suggestion (e.g., "RL021").
280    /// Returns None for non-pattern-based suggestions.
281    pub fn rule_id(&self, id: SuggestId) -> Option<&str> {
282        let store = self.store.read();
283        let stored = store.get(id)?;
284        self.registry
285            .get(stored.suggest_idx)
286            .and_then(|s| s.rule_id())
287    }
288
289    /// Get registry access (for pattern lookup)
290    pub fn registry(&self) -> &SuggestRegistry {
291        &self.registry
292    }
293
294    // ========== MutationSpec Generation ==========
295
296    /// Generate MutationSpecs for a suggestion
297    ///
298    /// Takes AnalysisContext to resolve types and generate accurate specs.
299    pub fn to_mutation_specs(
300        &self,
301        id: SuggestId,
302        ctx: &AnalysisContext,
303    ) -> Option<Vec<MutationSpec>> {
304        let store = self.store.read();
305        let stored = store.get(id)?;
306        let suggest = self.registry.get(stored.suggest_idx)?;
307
308        suggest.to_mutation_specs(ctx, &stored.opportunity).ok()
309    }
310
311    // ========== Parameterized Generation ==========
312
313    /// Generate suggestions with external parameters.
314    ///
315    /// This method enables code generation from patterns with user-provided
316    /// parameters. For example, generating `OrderAPI` from an API pattern
317    /// with `{ "name": "Order" }`.
318    ///
319    /// # Arguments
320    /// * `ctx` - Analysis context for code graph queries
321    /// * `rule_id` - Rule ID of the parameterized suggestion (e.g., "api-pattern")
322    /// * `params` - User-provided parameters (e.g., `{ "name": "Order" }`)
323    ///
324    /// # Returns
325    /// * `Some(opportunities)` - Generated opportunities if rule exists and accepts params
326    /// * `None` - If rule not found or doesn't accept params
327    ///
328    /// # Example
329    /// ```ignore
330    /// let params = [("name".to_string(), "Order".to_string())].into_iter().collect();
331    /// let opps = service.generate_with_params(&ctx, "api-pattern", &params);
332    /// ```
333    pub fn generate_with_params(
334        &self,
335        ctx: &AnalysisContext,
336        rule_id: &str,
337        params: &SuggestParams,
338    ) -> Option<Vec<SuggestOpportunity>> {
339        let (_, suggest) = self.registry.get_by_name(rule_id)?;
340
341        if !suggest.accepts_params() {
342            return None;
343        }
344
345        Some(suggest.detect_with_params(ctx, &[], params))
346    }
347
348    /// Generate suggestions and store them.
349    ///
350    /// Like `generate_with_params`, but also stores the generated opportunities
351    /// in the service for later retrieval and application.
352    ///
353    /// Returns the number of suggestions stored.
354    pub fn generate_and_store(
355        &self,
356        ctx: &AnalysisContext,
357        rule_id: &str,
358        params: &SuggestParams,
359    ) -> usize {
360        let Some((idx, suggest)) = self.registry.get_by_name(rule_id) else {
361            return 0;
362        };
363
364        if !suggest.accepts_params() {
365            return 0;
366        }
367
368        let opportunities = suggest.detect_with_params(ctx, &[], params);
369        let mut count = 0;
370
371        for opportunity in opportunities {
372            let stored = StoredSuggestion::new(
373                opportunity,
374                idx,
375                suggest.safety_level(),
376                suggest.priority_weight(),
377            );
378            if self.insert(stored).is_some() {
379                count += 1;
380            }
381        }
382
383        count
384    }
385
386    /// List all parameterized suggestions with their schemas.
387    ///
388    /// Returns suggestions that accept external parameters, along with
389    /// their parameter schemas. Useful for LLMs to discover available
390    /// generation patterns.
391    pub fn list_parameterized(&self) -> Vec<ParameterizedSuggestInfo> {
392        self.registry
393            .iter()
394            .filter_map(|(_, suggest)| {
395                if suggest.accepts_params() {
396                    Some(ParameterizedSuggestInfo {
397                        name: suggest.name(),
398                        description: suggest.description().to_string(),
399                        category: suggest.category(),
400                        param_schema: suggest.param_schema(),
401                    })
402                } else {
403                    None
404                }
405            })
406            .collect()
407    }
408
409    // ========== Write Operations (exclusive access) ==========
410
411    /// Record changes and check if evaluation should trigger
412    pub fn record_changes(&self, trigger: SuggestTrigger) -> bool {
413        let mut pending = self.pending.write();
414
415        // Accumulate changes
416        if let Some(changes) = trigger.changes() {
417            pending.record_goal(changes.clone());
418        }
419
420        // Check if we should evaluate
421        self.strategy.should_evaluate(&trigger, &pending)
422    }
423
424    /// Take pending changes (for evaluation)
425    pub fn take_pending(&self) -> (usize, AcChanges) {
426        self.pending.write().take()
427    }
428
429    /// Insert a new suggestion (write operation).
430    ///
431    /// Returns `None` if an active suggestion with the same identity
432    /// (pattern + opportunity_id) already exists.
433    pub fn insert(&self, suggestion: StoredSuggestion) -> Option<SuggestId> {
434        self.store.write().insert(suggestion)
435    }
436
437    /// Close a suggestion (write operation)
438    pub fn close(&self, id: SuggestId, reason: impl Into<String>) -> bool {
439        self.store.write().close(id, reason)
440    }
441
442    /// Invalidate suggestions for a modified symbol
443    pub fn invalidate_for_symbol(&self, symbol: &SymbolId) {
444        self.store.write().invalidate_for_symbol(symbol);
445    }
446
447    /// Remove suggestions for a deleted symbol
448    pub fn remove_for_symbol(&self, symbol: &SymbolId) {
449        self.store.write().remove_for_symbol(symbol);
450    }
451
452    /// Run garbage collection
453    pub fn gc(&self, valid_symbols: impl Fn(&SymbolId) -> bool) {
454        self.store.write().gc(&self.gc_config, &valid_symbols);
455    }
456
457    /// Clear all suggestions
458    pub fn clear(&self) {
459        self.store.write().clear();
460    }
461
462    // ========== Detection (batch operation) ==========
463
464    /// Detect suggestions for given symbols using registered patterns.
465    ///
466    /// This is the main entry point for suggestion detection.
467    /// Automatically filters out suggestions for symbols with `@spec:allow(...)` directives.
468    ///
469    /// Returns the number of new suggestions found.
470    pub fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> usize {
471        // Build allow store from context (extracts @spec:allow directives)
472        let allow_store = AllowStore::from_context(ctx);
473        self.detect_with_allow(ctx, symbols, &allow_store)
474    }
475
476    /// Detect suggestions with custom AllowStore.
477    ///
478    /// Use this when you want to reuse an AllowStore across multiple detect calls
479    /// or when you have a pre-built AllowStore.
480    pub fn detect_with_allow(
481        &self,
482        ctx: &AnalysisContext,
483        symbols: &[SymbolId],
484        allow_store: &AllowStore,
485    ) -> usize {
486        let mut count = 0;
487
488        for (idx, suggest) in self.registry.iter() {
489            let rule_id = suggest.rule_id();
490            let opportunities = suggest.detect(ctx, symbols);
491
492            for opportunity in opportunities {
493                // Check if this opportunity should be skipped due to @spec:allow
494                if let Some(rule_id) = rule_id {
495                    if allow_store.is_allowed_for_symbols(ctx, &opportunity.targets, rule_id) {
496                        continue; // Skip allowed suggestions
497                    }
498                }
499
500                let stored = StoredSuggestion::new(
501                    opportunity,
502                    idx,
503                    suggest.safety_level(),
504                    suggest.priority_weight(),
505                );
506                if self.insert(stored).is_some() {
507                    count += 1;
508                }
509            }
510        }
511
512        count
513    }
514
515    /// Detect suggestions with rule filter.
516    ///
517    /// The `is_rule_enabled` closure takes (rule_id, file_path) to check if a rule should be processed.
518    /// Rules returning `false` are skipped. This enables project and module-level rule configuration.
519    pub fn detect_with_rule_filter<F>(
520        &self,
521        ctx: &AnalysisContext,
522        symbols: &[SymbolId],
523        is_rule_enabled: F,
524    ) -> usize
525    where
526        F: Fn(&str, &str) -> bool,
527    {
528        let allow_store = AllowStore::from_context(ctx);
529        self.detect_with_allow_and_rule_filter(ctx, symbols, &allow_store, is_rule_enabled)
530    }
531
532    /// Detect suggestions with custom AllowStore and rule filter.
533    ///
534    /// Combines symbol-level allow directives with project/module-level rule filtering.
535    /// The `is_rule_enabled` closure takes (rule_id, file_path) to support module configs.
536    pub fn detect_with_allow_and_rule_filter<F>(
537        &self,
538        ctx: &AnalysisContext,
539        symbols: &[SymbolId],
540        allow_store: &AllowStore,
541        is_rule_enabled: F,
542    ) -> usize
543    where
544        F: Fn(&str, &str) -> bool,
545    {
546        self.detect_with_config(ctx, symbols, allow_store, is_rule_enabled, |_| None)
547    }
548
549    /// Detect suggestions with full configuration support.
550    ///
551    /// This is the most complete variant that supports:
552    /// - Custom AllowStore for symbol-level @spec:allow directives
553    /// - Rule filter closure for project-level rule configuration
554    /// - Severity override closure for per-rule severity changes
555    ///
556    /// The `severity_override` closure takes a rule_id and returns an optional
557    /// new severity. If None, the default severity is used.
558    ///
559    /// The `is_rule_enabled` closure takes (rule_id, file_path) to support
560    /// module-level rule configuration.
561    pub fn detect_with_config<F, S>(
562        &self,
563        ctx: &AnalysisContext,
564        symbols: &[SymbolId],
565        allow_store: &AllowStore,
566        is_rule_enabled: F,
567        severity_override: S,
568    ) -> usize
569    where
570        F: Fn(&str, &str) -> bool,
571        S: Fn(&str) -> Option<LintSeverity>,
572    {
573        self.detect_with_config_and_scope(
574            ctx,
575            symbols,
576            allow_store,
577            is_rule_enabled,
578            severity_override,
579            &[],
580        )
581    }
582
583    /// Detect suggestions with full configuration and scope filtering.
584    ///
585    /// Extends `detect_with_config` with scope-based filtering.
586    /// Each opportunity is tagged with its `SymbolScope` (Lib/Bin/Test)
587    /// based on symbol context. If `scope_filter` is non-empty, only
588    /// opportunities matching one of the specified scopes are stored.
589    pub fn detect_with_config_and_scope<F, S>(
590        &self,
591        ctx: &AnalysisContext,
592        symbols: &[SymbolId],
593        allow_store: &AllowStore,
594        is_rule_enabled: F,
595        severity_override: S,
596        scope_filter: &[SymbolScope],
597    ) -> usize
598    where
599        F: Fn(&str, &str) -> bool,
600        S: Fn(&str) -> Option<LintSeverity>,
601    {
602        let mut count = 0;
603
604        // Pre-compute binary crate names once for scope resolution
605        let binary_crates = SymbolScope::binary_crate_names(ctx);
606
607        for (idx, suggest) in self.registry.iter() {
608            let rule_id = suggest.rule_id();
609
610            let opportunities = suggest.detect(ctx, symbols);
611
612            for opportunity in opportunities {
613                // Check module-level and project-level rule filter
614                if let Some(rule_id) = rule_id {
615                    let file_path = &opportunity.location.file;
616                    if !is_rule_enabled(rule_id, file_path) {
617                        continue;
618                    }
619                }
620
621                // Check symbol-level @spec:allow
622                if let Some(rule_id) = rule_id {
623                    if allow_store.is_allowed_for_symbols(ctx, &opportunity.targets, rule_id) {
624                        continue;
625                    }
626                }
627
628                // Resolve scope from primary target symbol
629                let scope = opportunity
630                    .primary_target()
631                    .map(|sid| SymbolScope::resolve(ctx, sid, &binary_crates))
632                    .unwrap_or_default();
633
634                // Apply CLI scope filter (empty = allow all)
635                if !scope_filter.is_empty() && !scope_filter.contains(&scope) {
636                    continue;
637                }
638
639                // Apply suggest-level scope constraint (e.g., Safety rules → lib+bin only)
640                let target_scopes = suggest.target_scopes();
641                if !target_scopes.is_empty() && !target_scopes.contains(&scope) {
642                    continue;
643                }
644
645                // Tag opportunity with resolved scope
646                let opportunity = opportunity.with_scope(scope);
647
648                // Apply severity override if configured
649                let opportunity = if let Some(rule_id) = rule_id {
650                    if let Some(new_severity) = severity_override(rule_id) {
651                        opportunity.with_severity_override(new_severity)
652                    } else {
653                        opportunity
654                    }
655                } else {
656                    opportunity
657                };
658
659                // Determine safety level:
660                // - For Lint context, use lint_severity (which may be overridden)
661                // - For other contexts, use the pattern's default safety_level
662                let safety = opportunity
663                    .lint_severity()
664                    .map(SafetyLevel::from)
665                    .unwrap_or_else(|| suggest.safety_level());
666
667                let stored =
668                    StoredSuggestion::new(opportunity, idx, safety, suggest.priority_weight());
669                if self.insert(stored).is_some() {
670                    count += 1;
671                }
672            }
673        }
674
675        count
676    }
677
678    /// Detect suggestions for specific patterns only.
679    ///
680    /// Automatically filters out suggestions for symbols with `@spec:allow(...)` directives.
681    pub fn detect_patterns(
682        &self,
683        ctx: &AnalysisContext,
684        symbols: &[SymbolId],
685        pattern_names: &[&str],
686    ) -> usize {
687        let allow_store = AllowStore::from_context(ctx);
688        self.detect_patterns_with_allow(ctx, symbols, pattern_names, &allow_store)
689    }
690
691    /// Detect suggestions for specific patterns with custom AllowStore.
692    pub fn detect_patterns_with_allow(
693        &self,
694        ctx: &AnalysisContext,
695        symbols: &[SymbolId],
696        pattern_names: &[&str],
697        allow_store: &AllowStore,
698    ) -> usize {
699        let mut count = 0;
700
701        for name in pattern_names {
702            if let Some((idx, suggest)) = self.registry.get_by_name(name) {
703                let rule_id = suggest.rule_id();
704                let opportunities = suggest.detect(ctx, symbols);
705
706                for opportunity in opportunities {
707                    // Check if this opportunity should be skipped due to @spec:allow
708                    if let Some(rule_id) = rule_id {
709                        if allow_store.is_allowed_for_symbols(ctx, &opportunity.targets, rule_id) {
710                            continue;
711                        }
712                    }
713
714                    let stored = StoredSuggestion::new(
715                        opportunity,
716                        idx,
717                        suggest.safety_level(),
718                        suggest.priority_weight(),
719                    );
720                    if self.insert(stored).is_some() {
721                        count += 1;
722                    }
723                }
724            }
725        }
726
727        count
728    }
729
730    /// Detect suggestions with pre-check filter.
731    ///
732    /// The `precheck` callback is called for each opportunity with:
733    /// - The opportunity itself
734    /// - The generated MutationSpecs
735    ///
736    /// Only opportunities where `precheck` returns `true` are stored.
737    /// This enables scan-time verification to ensure Apply will succeed.
738    ///
739    /// # Example
740    ///
741    /// ```ignore
742    /// let count = service.detect_with_precheck(ctx, symbols, |opp, specs, ctx| {
743    ///     // Run GraphVerifier on specs
744    ///     verify_specs(specs, ctx)
745    /// });
746    /// ```
747    pub fn detect_with_precheck<F>(
748        &self,
749        ctx: &AnalysisContext,
750        symbols: &[SymbolId],
751        precheck: F,
752    ) -> DetectWithPrecheckResult
753    where
754        F: Fn(&SuggestOpportunity, &[MutationSpec], &AnalysisContext) -> bool,
755    {
756        let allow_store = AllowStore::from_context(ctx);
757        self.detect_with_precheck_and_allow(ctx, symbols, &allow_store, precheck)
758    }
759
760    /// Detect suggestions with pre-check filter and custom AllowStore.
761    pub fn detect_with_precheck_and_allow<F>(
762        &self,
763        ctx: &AnalysisContext,
764        symbols: &[SymbolId],
765        allow_store: &AllowStore,
766        precheck: F,
767    ) -> DetectWithPrecheckResult
768    where
769        F: Fn(&SuggestOpportunity, &[MutationSpec], &AnalysisContext) -> bool,
770    {
771        let mut result = DetectWithPrecheckResult::default();
772
773        for (idx, suggest) in self.registry.iter() {
774            let rule_id = suggest.rule_id();
775            let opportunities = suggest.detect(ctx, symbols);
776
777            for opportunity in opportunities {
778                // Check if this opportunity should be skipped due to @spec:allow
779                if let Some(rule_id) = rule_id {
780                    if allow_store.is_allowed_for_symbols(ctx, &opportunity.targets, rule_id) {
781                        continue; // Skip allowed suggestions
782                    }
783                }
784
785                // Generate MutationSpecs for pre-check
786                let specs = match suggest.to_mutation_specs(ctx, &opportunity) {
787                    Ok(s) => s,
788                    Err(_) => {
789                        result.skipped_no_specs += 1;
790                        continue;
791                    }
792                };
793
794                if specs.is_empty() {
795                    result.skipped_no_specs += 1;
796                    continue;
797                }
798
799                // Run pre-check
800                if precheck(&opportunity, &specs, ctx) {
801                    let stored = StoredSuggestion::new(
802                        opportunity,
803                        idx,
804                        suggest.safety_level(),
805                        suggest.priority_weight(),
806                    );
807                    if self.insert(stored).is_some() {
808                        result.passed += 1;
809                    }
810                } else {
811                    result.failed_precheck += 1;
812                }
813            }
814        }
815
816        result
817    }
818
819    /// Detect suggestions with parallel pre-check execution.
820    ///
821    /// This method parallelizes the expensive precheck phase using rayon:
822    /// 1. Phase 1 (sequential): Detect all opportunities and generate specs
823    /// 2. Phase 2 (parallel): Run precheck on each candidate
824    /// 3. Phase 3 (sequential): Insert passed suggestions
825    ///
826    /// This significantly reduces wall-clock time when precheck involves
827    /// expensive operations like fork_clone() (~100ms each).
828    ///
829    /// # Example
830    ///
831    /// ```ignore
832    /// let result = service.detect_with_parallel_precheck(ctx, symbols, |opp, specs, ctx| {
833    ///     // This closure runs in parallel - must be Sync
834    ///     let mut forked = ctx.fork_clone();
835    ///     executor.execute_v2(&blueprint, &mut forked).success
836    /// });
837    /// ```
838    pub fn detect_with_parallel_precheck<F>(
839        &self,
840        ctx: &AnalysisContext,
841        symbols: &[SymbolId],
842        precheck: F,
843    ) -> DetectWithPrecheckResult
844    where
845        F: Fn(&SuggestOpportunity, &[MutationSpec], &AnalysisContext) -> bool + Sync,
846    {
847        self.detect_with_parallel_precheck_limited(ctx, symbols, None, precheck)
848    }
849
850    /// Detect suggestions with parallel pre-check and optional limit.
851    ///
852    /// When `limit` is Some(N), only the top N candidates by priority are checked.
853    /// Candidates are sorted by priority (confidence * safety_weight) before precheck.
854    pub fn detect_with_parallel_precheck_limited<F>(
855        &self,
856        ctx: &AnalysisContext,
857        symbols: &[SymbolId],
858        limit: Option<usize>,
859        precheck: F,
860    ) -> DetectWithPrecheckResult
861    where
862        F: Fn(&SuggestOpportunity, &[MutationSpec], &AnalysisContext) -> bool + Sync,
863    {
864        let allow_store = AllowStore::from_context(ctx);
865        self.detect_with_parallel_precheck_and_allow_limited(
866            ctx,
867            symbols,
868            &allow_store,
869            limit,
870            precheck,
871        )
872    }
873
874    /// Detect suggestions with parallel pre-check, custom AllowStore, and optional limit.
875    pub fn detect_with_parallel_precheck_and_allow<F>(
876        &self,
877        ctx: &AnalysisContext,
878        symbols: &[SymbolId],
879        allow_store: &AllowStore,
880        precheck: F,
881    ) -> DetectWithPrecheckResult
882    where
883        F: Fn(&SuggestOpportunity, &[MutationSpec], &AnalysisContext) -> bool + Sync,
884    {
885        self.detect_with_parallel_precheck_and_allow_limited(
886            ctx,
887            symbols,
888            allow_store,
889            None,
890            precheck,
891        )
892    }
893
894    /// Detect suggestions with parallel pre-check, custom AllowStore, and optional limit.
895    pub fn detect_with_parallel_precheck_and_allow_limited<F>(
896        &self,
897        ctx: &AnalysisContext,
898        symbols: &[SymbolId],
899        allow_store: &AllowStore,
900        limit: Option<usize>,
901        precheck: F,
902    ) -> DetectWithPrecheckResult
903    where
904        F: Fn(&SuggestOpportunity, &[MutationSpec], &AnalysisContext) -> bool + Sync,
905    {
906        self.detect_with_parallel_precheck_full(
907            ctx,
908            symbols,
909            allow_store,
910            limit,
911            |_| true,
912            precheck,
913        )
914    }
915
916    /// Detect suggestions with parallel pre-check, rule filter, and optional limit.
917    ///
918    /// This is the most complete variant that supports:
919    /// - Custom AllowStore for symbol-level @spec:allow directives
920    /// - Rule filter closure for project-level rule configuration
921    /// - Optional limit on candidates to check
922    /// - Parallel precheck execution
923    pub fn detect_with_parallel_precheck_and_rule_filter<F, R>(
924        &self,
925        ctx: &AnalysisContext,
926        symbols: &[SymbolId],
927        limit: Option<usize>,
928        is_rule_enabled: R,
929        precheck: F,
930    ) -> DetectWithPrecheckResult
931    where
932        F: Fn(&SuggestOpportunity, &[MutationSpec], &AnalysisContext) -> bool + Sync,
933        R: Fn(&str) -> bool,
934    {
935        let allow_store = AllowStore::from_context(ctx);
936        self.detect_with_parallel_precheck_full(
937            ctx,
938            symbols,
939            &allow_store,
940            limit,
941            is_rule_enabled,
942            precheck,
943        )
944    }
945
946    /// Full-featured parallel precheck detection with all options.
947    ///
948    /// This is the internal implementation that other parallel precheck methods delegate to.
949    pub fn detect_with_parallel_precheck_full<F, R>(
950        &self,
951        ctx: &AnalysisContext,
952        symbols: &[SymbolId],
953        allow_store: &AllowStore,
954        limit: Option<usize>,
955        is_rule_enabled: R,
956        precheck: F,
957    ) -> DetectWithPrecheckResult
958    where
959        F: Fn(&SuggestOpportunity, &[MutationSpec], &AnalysisContext) -> bool + Sync,
960        R: Fn(&str) -> bool,
961    {
962        // Phase 1: Collect all candidates (sequential, lightweight)
963        // Each candidate contains: (opportunity, specs, suggest_idx, safety_level, priority)
964        let mut candidates: Vec<(
965            SuggestOpportunity,
966            Vec<MutationSpec>,
967            SuggestIndex,
968            SafetyLevel,
969            u8,
970        )> = Vec::new();
971        let mut skipped_no_specs = 0;
972
973        for (idx, suggest) in self.registry.iter() {
974            let rule_id = suggest.rule_id();
975
976            // Check project-level rule filter first (skip entire pattern if disabled)
977            if let Some(rule_id) = rule_id {
978                if !is_rule_enabled(rule_id) {
979                    continue;
980                }
981            }
982
983            let opportunities = suggest.detect(ctx, symbols);
984
985            for opportunity in opportunities {
986                // Check symbol-level @spec:allow
987                if let Some(rule_id) = rule_id {
988                    if allow_store.is_allowed_for_symbols(ctx, &opportunity.targets, rule_id) {
989                        continue;
990                    }
991                }
992
993                // Generate MutationSpecs
994                let specs = match suggest.to_mutation_specs(ctx, &opportunity) {
995                    Ok(s) => s,
996                    Err(_) => {
997                        skipped_no_specs += 1;
998                        continue;
999                    }
1000                };
1001
1002                if specs.is_empty() {
1003                    skipped_no_specs += 1;
1004                    continue;
1005                }
1006
1007                let safety = suggest.safety_level();
1008                let priority = crate::suggest::compute_priority(
1009                    opportunity.confidence,
1010                    safety,
1011                    suggest.priority_weight(),
1012                );
1013                candidates.push((opportunity, specs, idx, safety, priority));
1014            }
1015        }
1016
1017        // Phase 1.5: Sort by priority (descending) and limit if requested
1018        candidates.sort_by_key(|b| std::cmp::Reverse(b.4)); // Higher priority first
1019        let total_candidates = candidates.len();
1020        let skipped_by_limit = if let Some(limit) = limit {
1021            if candidates.len() > limit {
1022                let skipped = candidates.len() - limit;
1023                candidates.truncate(limit);
1024                skipped
1025            } else {
1026                0
1027            }
1028        } else {
1029            0
1030        };
1031
1032        // Phase 2: Run precheck in parallel (expensive, CPU-bound)
1033        // Each task gets its own forked context via the precheck closure
1034        let precheck_results: Vec<Option<StoredSuggestion>> = candidates
1035            .into_par_iter()
1036            .map(|(opportunity, specs, suggest_idx, safety, priority)| {
1037                if precheck(&opportunity, &specs, ctx) {
1038                    let mut stored = StoredSuggestion::new_with_priority(
1039                        opportunity,
1040                        suggest_idx,
1041                        safety,
1042                        priority,
1043                    );
1044                    stored.precheck_status = crate::store::PrecheckStatus::Passed;
1045                    Some(stored)
1046                } else {
1047                    None
1048                }
1049            })
1050            .collect();
1051
1052        // Phase 3: Insert passed suggestions (sequential, needs write lock)
1053        let mut passed = 0;
1054        let mut failed_precheck = 0;
1055
1056        for result in precheck_results {
1057            match result {
1058                Some(stored) => {
1059                    if self.insert(stored).is_some() {
1060                        passed += 1;
1061                    }
1062                }
1063                None => {
1064                    failed_precheck += 1;
1065                }
1066            }
1067        }
1068
1069        DetectWithPrecheckResult {
1070            passed,
1071            failed_precheck,
1072            skipped_no_specs,
1073            skipped_by_limit,
1074            total_candidates,
1075        }
1076    }
1077}
1078
1079/// Result of detection with pre-check.
1080#[derive(Debug, Clone, Default)]
1081pub struct DetectWithPrecheckResult {
1082    /// Number of suggestions that passed pre-check and were stored
1083    pub passed: usize,
1084    /// Number of suggestions that failed pre-check
1085    pub failed_precheck: usize,
1086    /// Number of suggestions skipped due to no MutationSpecs generated
1087    pub skipped_no_specs: usize,
1088    /// Number of suggestions skipped due to limit
1089    pub skipped_by_limit: usize,
1090    /// Total candidates before limiting (for logging)
1091    pub total_candidates: usize,
1092}
1093
1094impl DetectWithPrecheckResult {
1095    /// Total suggestions detected (before pre-check filtering)
1096    pub fn total_detected(&self) -> usize {
1097        self.passed + self.failed_precheck + self.skipped_no_specs
1098    }
1099
1100    /// Number of suggestions that were prechecked
1101    pub fn prechecked(&self) -> usize {
1102        self.passed + self.failed_precheck
1103    }
1104}
1105
1106/// Statistics about service state
1107#[derive(Debug, Clone, Default)]
1108pub struct SuggestStats {
1109    /// Total active suggestions
1110    pub active_count: usize,
1111
1112    /// Suggestions by category
1113    pub by_category: std::collections::HashMap<SuggestCategory, usize>,
1114
1115    /// Suggestions by safety level
1116    pub by_safety: std::collections::HashMap<SafetyLevel, usize>,
1117
1118    /// Number of registered patterns
1119    pub pattern_count: usize,
1120}
1121
1122impl SuggestService {
1123    /// Get statistics about current state
1124    pub fn stats(&self) -> SuggestStats {
1125        let mut stats = SuggestStats {
1126            pattern_count: self.registry.len(),
1127            ..SuggestStats::default()
1128        };
1129
1130        let store = self.store.read();
1131
1132        for (_, stored) in store.iter() {
1133            stats.active_count += 1;
1134
1135            // Count by category
1136            if let Some(suggest) = self.registry.get(stored.suggest_idx) {
1137                *stats.by_category.entry(suggest.category()).or_default() += 1;
1138            }
1139
1140            // Count by safety
1141            *stats.by_safety.entry(stored.safety).or_default() += 1;
1142        }
1143
1144        stats
1145    }
1146
1147    /// Take the store contents, leaving an empty store.
1148    ///
1149    /// Used for preserving suggestions across API reload.
1150    pub fn take_store(&self) -> SuggestStore {
1151        std::mem::take(&mut *self.store.write())
1152    }
1153
1154    /// Restore store contents from a previous backup.
1155    ///
1156    /// Used for preserving suggestions across API reload.
1157    pub fn restore_store(&self, store: SuggestStore) {
1158        *self.store.write() = store;
1159    }
1160}
1161
1162#[cfg(test)]
1163mod tests {
1164    use super::*;
1165
1166    #[test]
1167    fn test_suggest_query_builder() {
1168        let query = SuggestQuery::all()
1169            .with_category(SuggestCategory::Derive)
1170            .with_max_safety(SafetyLevel::Confirm)
1171            .with_min_confidence(0.8)
1172            .with_limit(10);
1173
1174        assert_eq!(query.category, Some(SuggestCategory::Derive));
1175        assert_eq!(query.max_safety, Some(SafetyLevel::Confirm));
1176        assert_eq!(query.min_confidence, Some(0.8));
1177        assert_eq!(query.limit, Some(10));
1178    }
1179
1180    #[test]
1181    fn test_service_new() {
1182        let registry = SuggestRegistry::new();
1183        let service = SuggestService::new(registry);
1184
1185        assert!(service.is_empty());
1186        assert_eq!(service.count(), 0);
1187    }
1188
1189    #[test]
1190    fn test_service_with_strategy() {
1191        let registry = SuggestRegistry::new();
1192        let strategy = SuggestStrategy::high_perf();
1193        let service = SuggestService::with_strategy(registry, strategy);
1194
1195        assert!(service.is_empty());
1196    }
1197}