Skip to main content

ryo_suggest/
design_choice.rs

1//! Design choice types for multiple alternative suggestions.
2//!
3//! This module provides types for representing design choices when a suggestion
4//! has multiple valid implementation approaches. For example, when converting
5//! an enum to a trait, the user might choose between:
6//! - Full dynamic dispatch (Box<dyn Trait>)
7//! - Static dispatch with generics
8//! - Enum-based wrapper
9//!
10//! # Example
11//!
12//! ```ignore
13//! let choices = DesignChoiceSet {
14//!     suggestion_id: suggestion.id,
15//!     pattern_name: "EnumToTrait".to_string(),
16//!     choices: vec![
17//!         DesignChoice::new("A", "Full Dynamic", "Use Box<dyn Trait> for maximum flexibility"),
18//!         DesignChoice::new("B", "Static Dispatch", "Use generics for better performance"),
19//!     ],
20//!     recommended: Some(ChoiceId::new("A")),
21//! };
22//! ```
23
24use crate::SuggestId;
25use ryo_executor::executor::MutationSpec;
26use serde::{Deserialize, Serialize};
27use std::fmt;
28use std::path::PathBuf;
29
30/// Unique identifier for a design choice within a set.
31#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
32pub struct ChoiceId(String);
33
34impl ChoiceId {
35    /// Create a new choice ID.
36    pub fn new(id: impl Into<String>) -> Self {
37        Self(id.into())
38    }
39
40    /// Get the ID as a string reference.
41    pub fn as_str(&self) -> &str {
42        &self.0
43    }
44}
45
46impl fmt::Display for ChoiceId {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        write!(f, "{}", self.0)
49    }
50}
51
52impl From<&str> for ChoiceId {
53    fn from(s: &str) -> Self {
54        Self::new(s)
55    }
56}
57
58/// Rating level for trade-off dimensions (1-3 stars).
59#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
60#[repr(u8)]
61#[derive(Default)]
62pub enum Rating {
63    /// Low (★☆☆)
64    Low = 1,
65    /// Medium (★★☆)
66    #[default]
67    Medium = 2,
68    /// High (★★★)
69    High = 3,
70}
71
72impl Rating {
73    /// Convert rating to star representation.
74    pub fn stars(&self) -> &'static str {
75        match self {
76            Rating::Low => "★☆☆",
77            Rating::Medium => "★★☆",
78            Rating::High => "★★★",
79        }
80    }
81
82    /// Get numeric value (1-3).
83    pub fn value(&self) -> u8 {
84        *self as u8
85    }
86}
87
88impl fmt::Display for Rating {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        write!(f, "{}", self.stars())
91    }
92}
93
94/// Trade-off analysis for a design choice.
95///
96/// Provides a structured view of the pros/cons of each choice
97/// to help users make informed decisions.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct TradeOffs {
100    /// How easy it is to extend this design later.
101    pub extensibility: Rating,
102
103    /// Runtime performance characteristics.
104    pub performance: Rating,
105
106    /// Implementation complexity.
107    pub complexity: Rating,
108
109    /// Whether this change breaks existing API.
110    pub breaking_change: bool,
111
112    /// Files that will be affected by this choice.
113    pub affected_files: Vec<PathBuf>,
114}
115
116impl TradeOffs {
117    /// Create trade-offs with all medium ratings and no breaking change.
118    pub fn default_medium() -> Self {
119        Self {
120            extensibility: Rating::Medium,
121            performance: Rating::Medium,
122            complexity: Rating::Medium,
123            breaking_change: false,
124            affected_files: Vec::new(),
125        }
126    }
127
128    /// Builder method to set extensibility.
129    pub fn with_extensibility(mut self, rating: Rating) -> Self {
130        self.extensibility = rating;
131        self
132    }
133
134    /// Builder method to set performance.
135    pub fn with_performance(mut self, rating: Rating) -> Self {
136        self.performance = rating;
137        self
138    }
139
140    /// Builder method to set complexity.
141    pub fn with_complexity(mut self, rating: Rating) -> Self {
142        self.complexity = rating;
143        self
144    }
145
146    /// Builder method to set breaking change flag.
147    pub fn with_breaking_change(mut self, breaking: bool) -> Self {
148        self.breaking_change = breaking;
149        self
150    }
151
152    /// Builder method to add affected files.
153    pub fn with_affected_files(mut self, files: Vec<PathBuf>) -> Self {
154        self.affected_files = files;
155        self
156    }
157
158    /// Calculate a simple score based on ratings.
159    /// Higher is better. Penalizes complexity and breaking changes.
160    pub fn score(&self) -> f32 {
161        let ext = self.extensibility.value() as f32;
162        let perf = self.performance.value() as f32;
163        let comp = self.complexity.value() as f32;
164
165        // Score formula: extensibility + performance - complexity/2
166        // Breaking changes reduce score by 1
167        let base_score = ext + perf - comp / 2.0;
168        if self.breaking_change {
169            base_score - 1.0
170        } else {
171            base_score
172        }
173    }
174}
175
176impl Default for TradeOffs {
177    fn default() -> Self {
178        Self::default_medium()
179    }
180}
181
182/// A single design choice representing one possible implementation approach.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct DesignChoice {
185    /// Unique identifier within the choice set (e.g., "A", "B", "C").
186    pub id: ChoiceId,
187
188    /// Short label for display (e.g., "A").
189    pub label: String,
190
191    /// Human-readable title (e.g., "Full Dynamic (Box<dyn Trait>)").
192    pub title: String,
193
194    /// Detailed description of this approach.
195    pub description: String,
196
197    /// Trade-off analysis.
198    pub trade_offs: TradeOffs,
199
200    /// Intent/MutationSpec group to execute for this choice.
201    /// Already converted from Intents for direct execution.
202    pub specs: Vec<MutationSpec>,
203}
204
205impl DesignChoice {
206    /// Create a new design choice with minimal information.
207    pub fn new(
208        id: impl Into<ChoiceId>,
209        label: impl Into<String>,
210        title: impl Into<String>,
211        description: impl Into<String>,
212    ) -> Self {
213        Self {
214            id: id.into(),
215            label: label.into(),
216            title: title.into(),
217            description: description.into(),
218            trade_offs: TradeOffs::default(),
219            specs: Vec::new(),
220        }
221    }
222
223    /// Builder method to set trade-offs.
224    pub fn with_trade_offs(mut self, trade_offs: TradeOffs) -> Self {
225        self.trade_offs = trade_offs;
226        self
227    }
228
229    /// Builder method to set specs.
230    pub fn with_specs(mut self, specs: Vec<MutationSpec>) -> Self {
231        self.specs = specs;
232        self
233    }
234
235    /// Get the number of specs in this choice.
236    pub fn spec_count(&self) -> usize {
237        self.specs.len()
238    }
239
240    /// Check if this is a breaking change.
241    pub fn is_breaking(&self) -> bool {
242        self.trade_offs.breaking_change
243    }
244}
245
246/// A set of design choices for a single suggestion.
247///
248/// When a suggestion has multiple valid approaches, this type
249/// groups them together with a recommended default.
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct DesignChoiceSet {
252    /// ID of the parent suggestion.
253    pub suggestion_id: SuggestId,
254
255    /// Name of the pattern that generated these choices (e.g., "EnumToTrait").
256    pub pattern_name: String,
257
258    /// Available choices.
259    pub choices: Vec<DesignChoice>,
260
261    /// Recommended choice ID (if any).
262    pub recommended: Option<ChoiceId>,
263}
264
265impl DesignChoiceSet {
266    /// Create a new choice set.
267    pub fn new(suggestion_id: SuggestId, pattern_name: impl Into<String>) -> Self {
268        Self {
269            suggestion_id,
270            pattern_name: pattern_name.into(),
271            choices: Vec::new(),
272            recommended: None,
273        }
274    }
275
276    /// Add a choice to the set.
277    pub fn add_choice(mut self, choice: DesignChoice) -> Self {
278        self.choices.push(choice);
279        self
280    }
281
282    /// Set the recommended choice.
283    pub fn with_recommended(mut self, id: impl Into<ChoiceId>) -> Self {
284        self.recommended = Some(id.into());
285        self
286    }
287
288    /// Get a choice by ID.
289    pub fn get_choice(&self, id: &ChoiceId) -> Option<&DesignChoice> {
290        self.choices.iter().find(|c| &c.id == id)
291    }
292
293    /// Get the recommended choice (if set and exists).
294    pub fn get_recommended(&self) -> Option<&DesignChoice> {
295        self.recommended.as_ref().and_then(|id| self.get_choice(id))
296    }
297
298    /// Check if this set has multiple choices.
299    pub fn has_alternatives(&self) -> bool {
300        self.choices.len() > 1
301    }
302
303    /// Get the number of choices.
304    pub fn choice_count(&self) -> usize {
305        self.choices.len()
306    }
307
308    /// Get choices sorted by trade-off score (highest first).
309    pub fn choices_by_score(&self) -> Vec<&DesignChoice> {
310        let mut sorted: Vec<_> = self.choices.iter().collect();
311        sorted.sort_by(|a, b| {
312            b.trade_offs
313                .score()
314                .partial_cmp(&a.trade_offs.score())
315                .unwrap_or(std::cmp::Ordering::Equal)
316        });
317        sorted
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324    use crate::SuggestIdGenerator;
325
326    #[test]
327    fn test_choice_id() {
328        let id = ChoiceId::new("A");
329        assert_eq!(id.as_str(), "A");
330        assert_eq!(id.to_string(), "A");
331
332        let id2: ChoiceId = "B".into();
333        assert_eq!(id2.as_str(), "B");
334    }
335
336    #[test]
337    fn test_rating_stars() {
338        assert_eq!(Rating::Low.stars(), "★☆☆");
339        assert_eq!(Rating::Medium.stars(), "★★☆");
340        assert_eq!(Rating::High.stars(), "★★★");
341    }
342
343    #[test]
344    fn test_rating_ordering() {
345        assert!(Rating::Low < Rating::Medium);
346        assert!(Rating::Medium < Rating::High);
347    }
348
349    #[test]
350    fn test_trade_offs_score() {
351        // High extensibility, high performance, low complexity = best score
352        let good = TradeOffs::default_medium()
353            .with_extensibility(Rating::High)
354            .with_performance(Rating::High)
355            .with_complexity(Rating::Low);
356        assert!(good.score() > 4.0);
357
358        // Low extensibility, low performance, high complexity = worst score
359        let bad = TradeOffs::default_medium()
360            .with_extensibility(Rating::Low)
361            .with_performance(Rating::Low)
362            .with_complexity(Rating::High);
363        assert!(bad.score() < 2.0);
364
365        // Breaking change penalty
366        let breaking = TradeOffs::default_medium().with_breaking_change(true);
367        let non_breaking = TradeOffs::default_medium().with_breaking_change(false);
368        assert!(breaking.score() < non_breaking.score());
369    }
370
371    #[test]
372    fn test_design_choice_builder() {
373        let choice = DesignChoice::new(
374            ChoiceId::new("A"),
375            "A",
376            "Full Dynamic",
377            "Use Box<dyn Trait>",
378        )
379        .with_trade_offs(
380            TradeOffs::default_medium()
381                .with_extensibility(Rating::High)
382                .with_performance(Rating::Low),
383        );
384
385        assert_eq!(choice.id.as_str(), "A");
386        assert_eq!(choice.title, "Full Dynamic");
387        assert_eq!(choice.trade_offs.extensibility, Rating::High);
388        assert_eq!(choice.trade_offs.performance, Rating::Low);
389    }
390
391    #[test]
392    fn test_design_choice_set() {
393        let mut gen = SuggestIdGenerator::new();
394        let suggestion_id = gen.next_id();
395
396        let set = DesignChoiceSet::new(suggestion_id, "EnumToTrait")
397            .add_choice(DesignChoice::new("A", "A", "Dynamic", "Box<dyn>"))
398            .add_choice(DesignChoice::new("B", "B", "Static", "Generics"))
399            .with_recommended("A");
400
401        assert_eq!(set.choice_count(), 2);
402        assert!(set.has_alternatives());
403        assert!(set.get_choice(&ChoiceId::new("A")).is_some());
404        assert!(set.get_choice(&ChoiceId::new("B")).is_some());
405        assert!(set.get_choice(&ChoiceId::new("C")).is_none());
406
407        let recommended = set.get_recommended();
408        assert!(recommended.is_some());
409        assert_eq!(recommended.unwrap().title, "Dynamic");
410    }
411
412    #[test]
413    fn test_choices_by_score() {
414        let mut gen = SuggestIdGenerator::new();
415        let suggestion_id = gen.next_id();
416
417        let set = DesignChoiceSet::new(suggestion_id, "Test")
418            .add_choice(
419                DesignChoice::new("A", "A", "Low Score", "desc").with_trade_offs(
420                    TradeOffs::default_medium()
421                        .with_extensibility(Rating::Low)
422                        .with_performance(Rating::Low),
423                ),
424            )
425            .add_choice(
426                DesignChoice::new("B", "B", "High Score", "desc").with_trade_offs(
427                    TradeOffs::default_medium()
428                        .with_extensibility(Rating::High)
429                        .with_performance(Rating::High),
430                ),
431            );
432
433        let sorted = set.choices_by_score();
434        assert_eq!(sorted[0].id.as_str(), "B"); // High score first
435        assert_eq!(sorted[1].id.as_str(), "A");
436    }
437
438    #[test]
439    fn test_trade_offs_serde() {
440        let trade_offs = TradeOffs::default_medium()
441            .with_extensibility(Rating::High)
442            .with_breaking_change(true)
443            .with_affected_files(vec![PathBuf::from("src/lib.rs")]);
444
445        let json = serde_json::to_string(&trade_offs).unwrap();
446        let parsed: TradeOffs = serde_json::from_str(&json).unwrap();
447
448        assert_eq!(parsed.extensibility, Rating::High);
449        assert!(parsed.breaking_change);
450        assert_eq!(parsed.affected_files.len(), 1);
451    }
452}