1use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16pub enum AgentRole {
17 Developer,
19 Reviewer,
21 Commit,
23}
24
25impl std::fmt::Display for AgentRole {
26 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27 match self {
28 Self::Developer => write!(f, "developer"),
29 Self::Reviewer => write!(f, "reviewer"),
30 Self::Commit => write!(f, "commit"),
31 }
32 }
33}
34
35#[derive(Debug, Clone, Deserialize)]
66pub struct FallbackConfig {
67 #[serde(default)]
69 pub developer: Vec<String>,
70 #[serde(default)]
72 pub reviewer: Vec<String>,
73 #[serde(default)]
75 pub commit: Vec<String>,
76 #[serde(default)]
79 pub provider_fallback: HashMap<String, Vec<String>>,
80 #[serde(default = "default_max_retries")]
82 pub max_retries: u32,
83 #[serde(default = "default_retry_delay_ms")]
85 pub retry_delay_ms: u64,
86 #[serde(default = "default_backoff_multiplier")]
88 pub backoff_multiplier: f64,
89 #[serde(default = "default_max_backoff_ms")]
91 pub max_backoff_ms: u64,
92 #[serde(default = "default_max_cycles")]
94 pub max_cycles: u32,
95}
96
97const fn default_max_retries() -> u32 {
98 3
99}
100
101const fn default_retry_delay_ms() -> u64 {
102 1000
103}
104
105const fn default_backoff_multiplier() -> f64 {
106 2.0
107}
108
109const fn default_max_backoff_ms() -> u64 {
110 60000 }
112
113const fn default_max_cycles() -> u32 {
114 3
115}
116
117const IEEE_754_EXP_BIAS: i32 = 1023;
119const IEEE_754_EXP_MASK: u64 = 0x7FF;
120const IEEE_754_MANTISSA_MASK: u64 = 0x000F_FFFF_FFFF_FFFF;
121const IEEE_754_IMPLICIT_ONE: u64 = 1u64 << 52;
122
123fn f64_to_u64_via_bits(value: f64) -> u64 {
129 if !value.is_finite() || value < 0.0 {
131 return 0;
132 }
133
134 let bits = value.to_bits();
136
137 let exp_biased = ((bits >> 52) & IEEE_754_EXP_MASK) as i32;
142 let mantissa = bits & IEEE_754_MANTISSA_MASK;
143
144 if exp_biased == 0 {
146 return 0;
149 }
150
151 let exp = exp_biased - IEEE_754_EXP_BIAS;
153
154 if exp < 0 {
157 return 0;
158 }
159
160 let full_mantissa = mantissa | IEEE_754_IMPLICIT_ONE;
163
164 let shift = 52i32 - exp;
167
168 if shift <= 0 {
169 u64::MAX
172 } else if shift < 64 {
173 full_mantissa >> shift
174 } else {
175 0
176 }
177}
178
179impl Default for FallbackConfig {
180 fn default() -> Self {
181 Self {
182 developer: Vec::new(),
183 reviewer: Vec::new(),
184 commit: Vec::new(),
185 provider_fallback: HashMap::new(),
186 max_retries: default_max_retries(),
187 retry_delay_ms: default_retry_delay_ms(),
188 backoff_multiplier: default_backoff_multiplier(),
189 max_backoff_ms: default_max_backoff_ms(),
190 max_cycles: default_max_cycles(),
191 }
192 }
193}
194
195impl FallbackConfig {
196 pub fn calculate_backoff(&self, cycle: u32) -> u64 {
202 let multiplier_hundredths = self.get_multiplier_hundredths();
205 let base_hundredths = self.retry_delay_ms.saturating_mul(100);
206
207 let mut delay_hundredths = base_hundredths;
210 for _ in 0..cycle {
211 delay_hundredths = delay_hundredths.saturating_mul(multiplier_hundredths);
212 delay_hundredths = delay_hundredths.saturating_div(100);
213 }
214
215 delay_hundredths.div_euclid(100).min(self.max_backoff_ms)
217 }
218
219 fn get_multiplier_hundredths(&self) -> u64 {
224 const EPSILON: f64 = 0.0001;
225
226 let m = self.backoff_multiplier;
229 if (m - 1.0).abs() < EPSILON {
230 return 100;
231 } else if (m - 1.5).abs() < EPSILON {
232 return 150;
233 } else if (m - 2.0).abs() < EPSILON {
234 return 200;
235 } else if (m - 2.5).abs() < EPSILON {
236 return 250;
237 } else if (m - 3.0).abs() < EPSILON {
238 return 300;
239 } else if (m - 4.0).abs() < EPSILON {
240 return 400;
241 } else if (m - 5.0).abs() < EPSILON {
242 return 500;
243 } else if (m - 10.0).abs() < EPSILON {
244 return 1000;
245 }
246
247 let clamped = m.clamp(0.0, 1000.0);
251 let multiplied = clamped * 100.0;
252 let rounded = multiplied.round();
253
254 f64_to_u64_via_bits(rounded)
256 }
257
258 pub fn get_fallbacks(&self, role: AgentRole) -> &[String] {
260 match role {
261 AgentRole::Developer => &self.developer,
262 AgentRole::Reviewer => &self.reviewer,
263 AgentRole::Commit => self.get_effective_commit_fallbacks(),
264 }
265 }
266
267 fn get_effective_commit_fallbacks(&self) -> &[String] {
273 if self.commit.is_empty() {
274 &self.reviewer
275 } else {
276 &self.commit
277 }
278 }
279
280 pub fn has_fallbacks(&self, role: AgentRole) -> bool {
282 !self.get_fallbacks(role).is_empty()
283 }
284
285 pub fn get_provider_fallbacks(&self, agent_name: &str) -> &[String] {
290 self.provider_fallback
291 .get(agent_name)
292 .map_or(&[], std::vec::Vec::as_slice)
293 }
294
295 pub fn has_provider_fallbacks(&self, agent_name: &str) -> bool {
297 self.provider_fallback
298 .get(agent_name)
299 .is_some_and(|v| !v.is_empty())
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306
307 #[test]
308 fn test_agent_role_display() {
309 assert_eq!(format!("{}", AgentRole::Developer), "developer");
310 assert_eq!(format!("{}", AgentRole::Reviewer), "reviewer");
311 assert_eq!(format!("{}", AgentRole::Commit), "commit");
312 }
313
314 #[test]
315 fn test_fallback_config_defaults() {
316 let config = FallbackConfig::default();
317 assert!(config.developer.is_empty());
318 assert!(config.reviewer.is_empty());
319 assert!(config.commit.is_empty());
320 assert_eq!(config.max_retries, 3);
321 assert_eq!(config.retry_delay_ms, 1000);
322 assert!((config.backoff_multiplier - 2.0).abs() < f64::EPSILON);
324 assert_eq!(config.max_backoff_ms, 60000);
325 assert_eq!(config.max_cycles, 3);
326 }
327
328 #[test]
329 fn test_fallback_config_calculate_backoff() {
330 let config = FallbackConfig {
331 retry_delay_ms: 1000,
332 backoff_multiplier: 2.0,
333 max_backoff_ms: 60000,
334 ..Default::default()
335 };
336
337 assert_eq!(config.calculate_backoff(0), 1000);
338 assert_eq!(config.calculate_backoff(1), 2000);
339 assert_eq!(config.calculate_backoff(2), 4000);
340 assert_eq!(config.calculate_backoff(3), 8000);
341
342 assert_eq!(config.calculate_backoff(10), 60000);
344 }
345
346 #[test]
347 fn test_fallback_config_get_fallbacks() {
348 let config = FallbackConfig {
349 developer: vec!["claude".to_string(), "codex".to_string()],
350 reviewer: vec!["codex".to_string()],
351 ..Default::default()
352 };
353
354 assert_eq!(
355 config.get_fallbacks(AgentRole::Developer),
356 &["claude", "codex"]
357 );
358 assert_eq!(config.get_fallbacks(AgentRole::Reviewer), &["codex"]);
359 }
360
361 #[test]
362 fn test_fallback_config_has_fallbacks() {
363 let config = FallbackConfig {
364 developer: vec!["claude".to_string()],
365 reviewer: vec![],
366 ..Default::default()
367 };
368
369 assert!(config.has_fallbacks(AgentRole::Developer));
370 assert!(!config.has_fallbacks(AgentRole::Reviewer));
371 }
372
373 #[test]
374 fn test_fallback_config_defaults_provider_fallback() {
375 let config = FallbackConfig::default();
376 assert!(config.get_provider_fallbacks("opencode").is_empty());
377 assert!(!config.has_provider_fallbacks("opencode"));
378 }
379
380 #[test]
381 fn test_provider_fallback_config() {
382 let mut provider_fallback = HashMap::new();
383 provider_fallback.insert(
384 "opencode".to_string(),
385 vec![
386 "-m opencode/glm-4.7-free".to_string(),
387 "-m opencode/claude-sonnet-4".to_string(),
388 ],
389 );
390
391 let config = FallbackConfig {
392 provider_fallback,
393 ..Default::default()
394 };
395
396 let fallbacks = config.get_provider_fallbacks("opencode");
397 assert_eq!(fallbacks.len(), 2);
398 assert_eq!(fallbacks[0], "-m opencode/glm-4.7-free");
399 assert_eq!(fallbacks[1], "-m opencode/claude-sonnet-4");
400
401 assert!(config.has_provider_fallbacks("opencode"));
402 assert!(!config.has_provider_fallbacks("claude"));
403 }
404
405 #[test]
406 fn test_fallback_config_from_toml() {
407 let toml_str = r#"
408 developer = ["claude", "codex"]
409 reviewer = ["codex", "claude"]
410 max_retries = 5
411 retry_delay_ms = 2000
412
413 [provider_fallback]
414 opencode = ["-m opencode/glm-4.7-free", "-m zai/glm-4.7"]
415 "#;
416
417 let config: FallbackConfig = toml::from_str(toml_str).unwrap();
418 assert_eq!(config.developer, vec!["claude", "codex"]);
419 assert_eq!(config.reviewer, vec!["codex", "claude"]);
420 assert_eq!(config.max_retries, 5);
421 assert_eq!(config.retry_delay_ms, 2000);
422 assert_eq!(config.get_provider_fallbacks("opencode").len(), 2);
423 }
424
425 #[test]
426 fn test_commit_uses_reviewer_chain_when_empty() {
427 let config = FallbackConfig {
429 commit: vec![],
430 reviewer: vec!["agent1".to_string(), "agent2".to_string()],
431 ..Default::default()
432 };
433
434 assert_eq!(
436 config.get_fallbacks(AgentRole::Commit),
437 &["agent1", "agent2"]
438 );
439 assert!(config.has_fallbacks(AgentRole::Commit));
440 }
441
442 #[test]
443 fn test_commit_uses_own_chain_when_configured() {
444 let config = FallbackConfig {
446 commit: vec!["commit-agent".to_string()],
447 reviewer: vec!["reviewer-agent".to_string()],
448 ..Default::default()
449 };
450
451 assert_eq!(config.get_fallbacks(AgentRole::Commit), &["commit-agent"]);
453 }
454}