Skip to main content

utils/
reasoning.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::str::FromStr;
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, schemars::JsonSchema)]
6#[serde(rename_all = "lowercase")]
7pub enum ReasoningEffort {
8    Minimal,
9    Low,
10    Medium,
11    High,
12    Xhigh,
13    Max,
14}
15
16impl ReasoningEffort {
17    pub fn as_str(self) -> &'static str {
18        match self {
19            Self::Minimal => "minimal",
20            Self::Low => "low",
21            Self::Medium => "medium",
22            Self::High => "high",
23            Self::Xhigh => "xhigh",
24            Self::Max => "max",
25        }
26    }
27
28    pub fn all() -> &'static [ReasoningEffort] {
29        &[Self::Minimal, Self::Low, Self::Medium, Self::High, Self::Xhigh, Self::Max]
30    }
31
32    /// Cycles through only the given `levels`, wrapping to `None` after the last.
33    /// Returns `None` when `levels` is empty.
34    pub fn cycle_within(current: Option<Self>, levels: &[Self]) -> Option<Self> {
35        if levels.is_empty() {
36            return None;
37        }
38        match current {
39            None => Some(levels[0]),
40            Some(effort) => levels.iter().position(|&l| l == effort).and_then(|i| levels.get(i + 1)).copied(),
41        }
42    }
43
44    /// Cycles backwards through only the given `levels`, wrapping to `None` after the first.
45    /// Returns `None` when `levels` is empty.
46    pub fn cycle_within_back(current: Option<Self>, levels: &[Self]) -> Option<Self> {
47        if levels.is_empty() {
48            return None;
49        }
50        match current {
51            None => Some(*levels.last().expect("levels is non-empty")),
52            Some(effort) => levels
53                .iter()
54                .position(|&l| l == effort)
55                .and_then(|i| i.checked_sub(1))
56                .and_then(|i| levels.get(i))
57                .copied(),
58        }
59    }
60
61    /// Returns `self` if it's in `levels`, otherwise the highest level ≤ self.
62    /// Falls back to the first element of `levels`. Panics if `levels` is empty.
63    pub fn clamp_to(self, levels: &[Self]) -> Self {
64        levels.iter().rev().find(|&&l| l <= self).copied().unwrap_or(*levels.first().expect("levels must not be empty"))
65    }
66
67    /// Converts `Option<ReasoningEffort>` to a config string value.
68    pub fn config_str(effort: Option<Self>) -> &'static str {
69        effort.map_or("none", Self::as_str)
70    }
71
72    /// Parse a string into an optional effort level.
73    /// Accepts "none" / "" as `None`, and supported effort names as `Some`.
74    pub fn parse(s: &str) -> Result<Option<Self>, String> {
75        match s {
76            "none" | "" => Ok(None),
77            other => other.parse().map(Some),
78        }
79    }
80}
81
82impl fmt::Display for ReasoningEffort {
83    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84        f.write_str(self.as_str())
85    }
86}
87
88impl FromStr for ReasoningEffort {
89    type Err = String;
90
91    fn from_str(s: &str) -> Result<Self, Self::Err> {
92        match s {
93            "minimal" => Ok(Self::Minimal),
94            "low" => Ok(Self::Low),
95            "medium" => Ok(Self::Medium),
96            "high" => Ok(Self::High),
97            "xhigh" => Ok(Self::Xhigh),
98            "max" => Ok(Self::Max),
99            _ => Err(format!("Unknown reasoning effort: '{s}'")),
100        }
101    }
102}
103
104#[cfg(test)]
105mod tests {
106    use super::*;
107
108    #[test]
109    fn display_roundtrip() {
110        for effort in ReasoningEffort::all() {
111            let s = effort.to_string();
112            let parsed: ReasoningEffort = s.parse().unwrap();
113            assert_eq!(*effort, parsed);
114        }
115    }
116
117    #[test]
118    fn as_str_matches_display() {
119        for effort in ReasoningEffort::all() {
120            assert_eq!(effort.as_str(), effort.to_string());
121        }
122    }
123
124    #[test]
125    fn from_str_rejects_unknown() {
126        assert!("extreme".parse::<ReasoningEffort>().is_err());
127    }
128
129    #[test]
130    fn all_returns_six_variants() {
131        assert_eq!(ReasoningEffort::all().len(), 6);
132    }
133
134    #[test]
135    fn parse_none_and_empty() {
136        assert_eq!(ReasoningEffort::parse("none").unwrap(), None);
137        assert_eq!(ReasoningEffort::parse("").unwrap(), None);
138    }
139
140    #[test]
141    fn parse_valid_levels() {
142        assert_eq!(ReasoningEffort::parse("high").unwrap(), Some(ReasoningEffort::High));
143        assert_eq!(ReasoningEffort::parse("low").unwrap(), Some(ReasoningEffort::Low));
144    }
145
146    #[test]
147    fn parse_rejects_unknown() {
148        assert!(ReasoningEffort::parse("extreme").is_err());
149    }
150
151    #[test]
152    fn config_str_values() {
153        assert_eq!(ReasoningEffort::config_str(None), "none");
154        assert_eq!(ReasoningEffort::config_str(Some(ReasoningEffort::Low)), "low");
155        assert_eq!(ReasoningEffort::config_str(Some(ReasoningEffort::High)), "high");
156    }
157
158    #[test]
159    fn serialize_produces_lowercase() {
160        for effort in ReasoningEffort::all() {
161            let json = serde_json::to_value(effort).unwrap();
162            assert_eq!(json.as_str().unwrap(), effort.as_str());
163        }
164    }
165
166    #[test]
167    fn variants_are_ordered_by_effort() {
168        let mut sorted = ReasoningEffort::all().to_vec();
169        sorted.sort();
170        assert_eq!(sorted, ReasoningEffort::all());
171        assert!(ReasoningEffort::Minimal < ReasoningEffort::Low);
172        assert!(ReasoningEffort::Xhigh < ReasoningEffort::Max);
173    }
174
175    #[test]
176    fn cycle_within_three_levels() {
177        use ReasoningEffort::*;
178        let levels = &[Low, Medium, High];
179        assert_eq!(ReasoningEffort::cycle_within(None, levels), Some(Low));
180        assert_eq!(ReasoningEffort::cycle_within(Some(Low), levels), Some(Medium));
181        assert_eq!(ReasoningEffort::cycle_within(Some(Medium), levels), Some(High));
182        assert_eq!(ReasoningEffort::cycle_within(Some(High), levels), None);
183    }
184
185    #[test]
186    fn cycle_within_five_levels() {
187        use ReasoningEffort::*;
188        let levels = &[Low, Medium, High, Xhigh, Max];
189        assert_eq!(ReasoningEffort::cycle_within(None, levels), Some(Low));
190        assert_eq!(ReasoningEffort::cycle_within(Some(High), levels), Some(Xhigh));
191        assert_eq!(ReasoningEffort::cycle_within(Some(Xhigh), levels), Some(Max));
192        assert_eq!(ReasoningEffort::cycle_within(Some(Max), levels), None);
193    }
194
195    #[test]
196    fn cycle_within_empty_returns_none() {
197        assert_eq!(ReasoningEffort::cycle_within(None, &[]), None);
198        assert_eq!(ReasoningEffort::cycle_within(Some(ReasoningEffort::Low), &[]), None);
199    }
200
201    #[test]
202    fn cycle_within_unknown_current_wraps_to_none() {
203        use ReasoningEffort::*;
204        // Current is Xhigh but levels only have Low/Medium/High
205        assert_eq!(ReasoningEffort::cycle_within(Some(Xhigh), &[Low, Medium, High]), None);
206    }
207
208    #[test]
209    fn cycle_within_back_three_levels() {
210        use ReasoningEffort::*;
211        let levels = &[Low, Medium, High];
212        assert_eq!(ReasoningEffort::cycle_within_back(None, levels), Some(High));
213        assert_eq!(ReasoningEffort::cycle_within_back(Some(High), levels), Some(Medium));
214        assert_eq!(ReasoningEffort::cycle_within_back(Some(Medium), levels), Some(Low));
215        assert_eq!(ReasoningEffort::cycle_within_back(Some(Low), levels), None);
216    }
217
218    #[test]
219    fn cycle_within_back_empty_returns_none() {
220        assert_eq!(ReasoningEffort::cycle_within_back(None, &[]), None);
221        assert_eq!(ReasoningEffort::cycle_within_back(Some(ReasoningEffort::Low), &[]), None);
222    }
223
224    #[test]
225    fn clamp_to_self_in_levels() {
226        use ReasoningEffort::*;
227        assert_eq!(High.clamp_to(&[Low, Medium, High]), High);
228        assert_eq!(Xhigh.clamp_to(&[Low, Medium, High, Xhigh]), Xhigh);
229        assert_eq!(Max.clamp_to(&[Low, Medium, High, Xhigh, Max]), Max);
230    }
231
232    #[test]
233    fn clamp_to_highest_le() {
234        use ReasoningEffort::*;
235        // Max not in [Low, Medium, High, Xhigh] -> clamp to Xhigh
236        assert_eq!(Max.clamp_to(&[Low, Medium, High, Xhigh]), Xhigh);
237    }
238
239    #[test]
240    fn clamp_to_fallback_first() {
241        use ReasoningEffort::*;
242        // Low not in [Medium, High] and no level ≤ Low → fallback to first (Medium)
243        assert_eq!(Low.clamp_to(&[Medium, High]), Medium);
244    }
245
246    #[test]
247    fn parse_extended_levels() {
248        assert_eq!(ReasoningEffort::parse("minimal").unwrap(), Some(ReasoningEffort::Minimal));
249        assert_eq!(ReasoningEffort::parse("xhigh").unwrap(), Some(ReasoningEffort::Xhigh));
250        assert_eq!(ReasoningEffort::parse("max").unwrap(), Some(ReasoningEffort::Max));
251        assert!(ReasoningEffort::parse("ultra").is_err());
252    }
253}