Skip to main content

utils/
reasoning.rs

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