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    /// Returns `self` if it's in `levels`, otherwise the highest level ≤ self.
45    /// Falls back to the first element of `levels`. Panics if `levels` is empty.
46    pub fn clamp_to(self, levels: &[Self]) -> Self {
47        levels.iter().rev().find(|&&l| l <= self).copied().unwrap_or(*levels.first().expect("levels must not be empty"))
48    }
49
50    /// Converts `Option<ReasoningEffort>` to a config string value.
51    pub fn config_str(effort: Option<Self>) -> &'static str {
52        effort.map_or("none", Self::as_str)
53    }
54
55    /// Parse a string into an optional effort level.
56    /// Accepts "none" / "" as `None`, and supported effort names as `Some`.
57    pub fn parse(s: &str) -> Result<Option<Self>, String> {
58        match s {
59            "none" | "" => Ok(None),
60            other => other.parse().map(Some),
61        }
62    }
63}
64
65impl fmt::Display for ReasoningEffort {
66    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
67        f.write_str(self.as_str())
68    }
69}
70
71impl FromStr for ReasoningEffort {
72    type Err = String;
73
74    fn from_str(s: &str) -> Result<Self, Self::Err> {
75        match s {
76            "minimal" => Ok(Self::Minimal),
77            "low" => Ok(Self::Low),
78            "medium" => Ok(Self::Medium),
79            "high" => Ok(Self::High),
80            "xhigh" => Ok(Self::Xhigh),
81            "max" => Ok(Self::Max),
82            _ => Err(format!("Unknown reasoning effort: '{s}'")),
83        }
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90
91    #[test]
92    fn display_roundtrip() {
93        for effort in ReasoningEffort::all() {
94            let s = effort.to_string();
95            let parsed: ReasoningEffort = s.parse().unwrap();
96            assert_eq!(*effort, parsed);
97        }
98    }
99
100    #[test]
101    fn as_str_matches_display() {
102        for effort in ReasoningEffort::all() {
103            assert_eq!(effort.as_str(), effort.to_string());
104        }
105    }
106
107    #[test]
108    fn from_str_rejects_unknown() {
109        assert!("extreme".parse::<ReasoningEffort>().is_err());
110    }
111
112    #[test]
113    fn all_returns_six_variants() {
114        assert_eq!(ReasoningEffort::all().len(), 6);
115    }
116
117    #[test]
118    fn parse_none_and_empty() {
119        assert_eq!(ReasoningEffort::parse("none").unwrap(), None);
120        assert_eq!(ReasoningEffort::parse("").unwrap(), None);
121    }
122
123    #[test]
124    fn parse_valid_levels() {
125        assert_eq!(ReasoningEffort::parse("high").unwrap(), Some(ReasoningEffort::High));
126        assert_eq!(ReasoningEffort::parse("low").unwrap(), Some(ReasoningEffort::Low));
127    }
128
129    #[test]
130    fn parse_rejects_unknown() {
131        assert!(ReasoningEffort::parse("extreme").is_err());
132    }
133
134    #[test]
135    fn config_str_values() {
136        assert_eq!(ReasoningEffort::config_str(None), "none");
137        assert_eq!(ReasoningEffort::config_str(Some(ReasoningEffort::Low)), "low");
138        assert_eq!(ReasoningEffort::config_str(Some(ReasoningEffort::High)), "high");
139    }
140
141    #[test]
142    fn serialize_produces_lowercase() {
143        for effort in ReasoningEffort::all() {
144            let json = serde_json::to_value(effort).unwrap();
145            assert_eq!(json.as_str().unwrap(), effort.as_str());
146        }
147    }
148
149    #[test]
150    fn variants_are_ordered_by_effort() {
151        let mut sorted = ReasoningEffort::all().to_vec();
152        sorted.sort();
153        assert_eq!(sorted, ReasoningEffort::all());
154        assert!(ReasoningEffort::Minimal < ReasoningEffort::Low);
155        assert!(ReasoningEffort::Xhigh < ReasoningEffort::Max);
156    }
157
158    #[test]
159    fn cycle_within_three_levels() {
160        use ReasoningEffort::*;
161        let levels = &[Low, Medium, High];
162        assert_eq!(ReasoningEffort::cycle_within(None, levels), Some(Low));
163        assert_eq!(ReasoningEffort::cycle_within(Some(Low), levels), Some(Medium));
164        assert_eq!(ReasoningEffort::cycle_within(Some(Medium), levels), Some(High));
165        assert_eq!(ReasoningEffort::cycle_within(Some(High), levels), None);
166    }
167
168    #[test]
169    fn cycle_within_five_levels() {
170        use ReasoningEffort::*;
171        let levels = &[Low, Medium, High, Xhigh, Max];
172        assert_eq!(ReasoningEffort::cycle_within(None, levels), Some(Low));
173        assert_eq!(ReasoningEffort::cycle_within(Some(High), levels), Some(Xhigh));
174        assert_eq!(ReasoningEffort::cycle_within(Some(Xhigh), levels), Some(Max));
175        assert_eq!(ReasoningEffort::cycle_within(Some(Max), levels), None);
176    }
177
178    #[test]
179    fn cycle_within_empty_returns_none() {
180        assert_eq!(ReasoningEffort::cycle_within(None, &[]), None);
181        assert_eq!(ReasoningEffort::cycle_within(Some(ReasoningEffort::Low), &[]), None);
182    }
183
184    #[test]
185    fn cycle_within_unknown_current_wraps_to_none() {
186        use ReasoningEffort::*;
187        // Current is Xhigh but levels only have Low/Medium/High
188        assert_eq!(ReasoningEffort::cycle_within(Some(Xhigh), &[Low, Medium, High]), None);
189    }
190
191    #[test]
192    fn clamp_to_self_in_levels() {
193        use ReasoningEffort::*;
194        assert_eq!(High.clamp_to(&[Low, Medium, High]), High);
195        assert_eq!(Xhigh.clamp_to(&[Low, Medium, High, Xhigh]), Xhigh);
196        assert_eq!(Max.clamp_to(&[Low, Medium, High, Xhigh, Max]), Max);
197    }
198
199    #[test]
200    fn clamp_to_highest_le() {
201        use ReasoningEffort::*;
202        // Max not in [Low, Medium, High, Xhigh] -> clamp to Xhigh
203        assert_eq!(Max.clamp_to(&[Low, Medium, High, Xhigh]), Xhigh);
204    }
205
206    #[test]
207    fn clamp_to_fallback_first() {
208        use ReasoningEffort::*;
209        // Low not in [Medium, High] and no level ≤ Low → fallback to first (Medium)
210        assert_eq!(Low.clamp_to(&[Medium, High]), Medium);
211    }
212
213    #[test]
214    fn parse_extended_levels() {
215        assert_eq!(ReasoningEffort::parse("minimal").unwrap(), Some(ReasoningEffort::Minimal));
216        assert_eq!(ReasoningEffort::parse("xhigh").unwrap(), Some(ReasoningEffort::Xhigh));
217        assert_eq!(ReasoningEffort::parse("max").unwrap(), Some(ReasoningEffort::Max));
218        assert!(ReasoningEffort::parse("ultra").is_err());
219    }
220}