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