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