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 Analysis,
25}
26
27impl std::fmt::Display for AgentRole {
28 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29 match self {
30 Self::Developer => write!(f, "developer"),
31 Self::Reviewer => write!(f, "reviewer"),
32 Self::Commit => write!(f, "commit"),
33 Self::Analysis => write!(f, "analysis"),
34 }
35 }
36}
37
38#[derive(Debug, Clone, Deserialize)]
69pub struct FallbackConfig {
70 #[serde(default)]
72 pub developer: Vec<String>,
73 #[serde(default)]
75 pub reviewer: Vec<String>,
76 #[serde(default)]
78 pub commit: Vec<String>,
79 #[serde(default)]
83 pub analysis: Vec<String>,
84 #[serde(default)]
87 pub provider_fallback: HashMap<String, Vec<String>>,
88 #[serde(default = "default_max_retries")]
90 pub max_retries: u32,
91 #[serde(default = "default_retry_delay_ms")]
93 pub retry_delay_ms: u64,
94 #[serde(default = "default_backoff_multiplier")]
96 pub backoff_multiplier: f64,
97 #[serde(default = "default_max_backoff_ms")]
99 pub max_backoff_ms: u64,
100 #[serde(default = "default_max_cycles")]
102 pub max_cycles: u32,
103}
104
105const fn default_max_retries() -> u32 {
106 3
107}
108
109const fn default_retry_delay_ms() -> u64 {
110 1000
111}
112
113const fn default_backoff_multiplier() -> f64 {
114 2.0
115}
116
117const fn default_max_backoff_ms() -> u64 {
118 60000 }
120
121const fn default_max_cycles() -> u32 {
122 3
123}
124
125const IEEE_754_EXP_BIAS: i32 = 1023;
127const IEEE_754_EXP_MASK: u64 = 0x7FF;
128const IEEE_754_MANTISSA_MASK: u64 = 0x000F_FFFF_FFFF_FFFF;
129const IEEE_754_IMPLICIT_ONE: u64 = 1u64 << 52;
130
131fn f64_to_u64_via_bits(value: f64) -> u64 {
137 if !value.is_finite() || value < 0.0 {
139 return 0;
140 }
141
142 let bits = value.to_bits();
144
145 let exp_biased = ((bits >> 52) & IEEE_754_EXP_MASK) as i32;
150 let mantissa = bits & IEEE_754_MANTISSA_MASK;
151
152 if exp_biased == 0 {
154 return 0;
157 }
158
159 let exp = exp_biased - IEEE_754_EXP_BIAS;
161
162 if exp < 0 {
165 return 0;
166 }
167
168 let full_mantissa = mantissa | IEEE_754_IMPLICIT_ONE;
171
172 let shift = 52i32 - exp;
175
176 if shift <= 0 {
177 u64::MAX
180 } else if shift < 64 {
181 full_mantissa >> shift
182 } else {
183 0
184 }
185}
186
187impl Default for FallbackConfig {
188 fn default() -> Self {
189 Self {
190 developer: Vec::new(),
191 reviewer: Vec::new(),
192 commit: Vec::new(),
193 analysis: Vec::new(),
194 provider_fallback: HashMap::new(),
195 max_retries: default_max_retries(),
196 retry_delay_ms: default_retry_delay_ms(),
197 backoff_multiplier: default_backoff_multiplier(),
198 max_backoff_ms: default_max_backoff_ms(),
199 max_cycles: default_max_cycles(),
200 }
201 }
202}
203
204impl FallbackConfig {
205 pub fn calculate_backoff(&self, cycle: u32) -> u64 {
211 let multiplier_hundredths = self.get_multiplier_hundredths();
214 let base_hundredths = self.retry_delay_ms.saturating_mul(100);
215
216 let mut delay_hundredths = base_hundredths;
219 for _ in 0..cycle {
220 delay_hundredths = delay_hundredths.saturating_mul(multiplier_hundredths);
221 delay_hundredths = delay_hundredths.saturating_div(100);
222 }
223
224 delay_hundredths.div_euclid(100).min(self.max_backoff_ms)
226 }
227
228 fn get_multiplier_hundredths(&self) -> u64 {
233 const EPSILON: f64 = 0.0001;
234
235 let m = self.backoff_multiplier;
238 if (m - 1.0).abs() < EPSILON {
239 return 100;
240 } else if (m - 1.5).abs() < EPSILON {
241 return 150;
242 } else if (m - 2.0).abs() < EPSILON {
243 return 200;
244 } else if (m - 2.5).abs() < EPSILON {
245 return 250;
246 } else if (m - 3.0).abs() < EPSILON {
247 return 300;
248 } else if (m - 4.0).abs() < EPSILON {
249 return 400;
250 } else if (m - 5.0).abs() < EPSILON {
251 return 500;
252 } else if (m - 10.0).abs() < EPSILON {
253 return 1000;
254 }
255
256 let clamped = m.clamp(0.0, 1000.0);
260 let multiplied = clamped * 100.0;
261 let rounded = multiplied.round();
262
263 f64_to_u64_via_bits(rounded)
265 }
266
267 pub fn get_fallbacks(&self, role: AgentRole) -> &[String] {
269 match role {
270 AgentRole::Developer => &self.developer,
271 AgentRole::Reviewer => &self.reviewer,
272 AgentRole::Commit => self.get_effective_commit_fallbacks(),
273 AgentRole::Analysis => self.get_effective_analysis_fallbacks(),
274 }
275 }
276
277 fn get_effective_analysis_fallbacks(&self) -> &[String] {
281 if self.analysis.is_empty() {
282 &self.developer
283 } else {
284 &self.analysis
285 }
286 }
287
288 fn get_effective_commit_fallbacks(&self) -> &[String] {
294 if self.commit.is_empty() {
295 &self.reviewer
296 } else {
297 &self.commit
298 }
299 }
300
301 pub fn has_fallbacks(&self, role: AgentRole) -> bool {
303 !self.get_fallbacks(role).is_empty()
304 }
305
306 pub fn get_provider_fallbacks(&self, agent_name: &str) -> &[String] {
311 self.provider_fallback
312 .get(agent_name)
313 .map_or(&[], std::vec::Vec::as_slice)
314 }
315
316 pub fn has_provider_fallbacks(&self, agent_name: &str) -> bool {
318 self.provider_fallback
319 .get(agent_name)
320 .is_some_and(|v| !v.is_empty())
321 }
322}
323
324#[cfg(test)]
325mod tests {
326 use super::*;
327
328 #[test]
329 fn test_agent_role_display() {
330 assert_eq!(format!("{}", AgentRole::Developer), "developer");
331 assert_eq!(format!("{}", AgentRole::Reviewer), "reviewer");
332 assert_eq!(format!("{}", AgentRole::Commit), "commit");
333 assert_eq!(format!("{}", AgentRole::Analysis), "analysis");
334 }
335
336 #[test]
337 fn test_fallback_config_defaults() {
338 let config = FallbackConfig::default();
339 assert!(config.developer.is_empty());
340 assert!(config.reviewer.is_empty());
341 assert!(config.commit.is_empty());
342 assert!(config.analysis.is_empty());
343 assert_eq!(config.max_retries, 3);
344 assert_eq!(config.retry_delay_ms, 1000);
345 assert!((config.backoff_multiplier - 2.0).abs() < f64::EPSILON);
347 assert_eq!(config.max_backoff_ms, 60000);
348 assert_eq!(config.max_cycles, 3);
349 }
350
351 #[test]
352 fn test_fallback_config_calculate_backoff() {
353 let config = FallbackConfig {
354 retry_delay_ms: 1000,
355 backoff_multiplier: 2.0,
356 max_backoff_ms: 60000,
357 ..Default::default()
358 };
359
360 assert_eq!(config.calculate_backoff(0), 1000);
361 assert_eq!(config.calculate_backoff(1), 2000);
362 assert_eq!(config.calculate_backoff(2), 4000);
363 assert_eq!(config.calculate_backoff(3), 8000);
364
365 assert_eq!(config.calculate_backoff(10), 60000);
367 }
368
369 #[test]
370 fn test_fallback_config_get_fallbacks() {
371 let config = FallbackConfig {
372 developer: vec!["claude".to_string(), "codex".to_string()],
373 reviewer: vec!["codex".to_string()],
374 ..Default::default()
375 };
376
377 assert_eq!(
378 config.get_fallbacks(AgentRole::Developer),
379 &["claude", "codex"]
380 );
381 assert_eq!(config.get_fallbacks(AgentRole::Reviewer), &["codex"]);
382
383 assert_eq!(
385 config.get_fallbacks(AgentRole::Analysis),
386 &["claude", "codex"]
387 );
388 }
389
390 #[test]
391 fn test_fallback_config_has_fallbacks() {
392 let config = FallbackConfig {
393 developer: vec!["claude".to_string()],
394 reviewer: vec![],
395 ..Default::default()
396 };
397
398 assert!(config.has_fallbacks(AgentRole::Developer));
399 assert!(config.has_fallbacks(AgentRole::Analysis));
400 assert!(!config.has_fallbacks(AgentRole::Reviewer));
401 }
402
403 #[test]
404 fn test_fallback_config_defaults_provider_fallback() {
405 let config = FallbackConfig::default();
406 assert!(config.get_provider_fallbacks("opencode").is_empty());
407 assert!(!config.has_provider_fallbacks("opencode"));
408 }
409
410 #[test]
411 fn test_provider_fallback_config() {
412 let mut provider_fallback = HashMap::new();
413 provider_fallback.insert(
414 "opencode".to_string(),
415 vec![
416 "-m opencode/glm-4.7-free".to_string(),
417 "-m opencode/claude-sonnet-4".to_string(),
418 ],
419 );
420
421 let config = FallbackConfig {
422 provider_fallback,
423 ..Default::default()
424 };
425
426 let fallbacks = config.get_provider_fallbacks("opencode");
427 assert_eq!(fallbacks.len(), 2);
428 assert_eq!(fallbacks[0], "-m opencode/glm-4.7-free");
429 assert_eq!(fallbacks[1], "-m opencode/claude-sonnet-4");
430
431 assert!(config.has_provider_fallbacks("opencode"));
432 assert!(!config.has_provider_fallbacks("claude"));
433 }
434
435 #[test]
436 fn test_fallback_config_from_toml() {
437 let toml_str = r#"
438 developer = ["claude", "codex"]
439 reviewer = ["codex", "claude"]
440 max_retries = 5
441 retry_delay_ms = 2000
442
443 [provider_fallback]
444 opencode = ["-m opencode/glm-4.7-free", "-m zai/glm-4.7"]
445 "#;
446
447 let config: FallbackConfig = toml::from_str(toml_str).unwrap();
448 assert_eq!(config.developer, vec!["claude", "codex"]);
449 assert_eq!(config.reviewer, vec!["codex", "claude"]);
450 assert_eq!(config.max_retries, 5);
451 assert_eq!(config.retry_delay_ms, 2000);
452 assert_eq!(config.get_provider_fallbacks("opencode").len(), 2);
453 }
454
455 #[test]
456 fn test_commit_uses_reviewer_chain_when_empty() {
457 let config = FallbackConfig {
459 commit: vec![],
460 reviewer: vec!["agent1".to_string(), "agent2".to_string()],
461 ..Default::default()
462 };
463
464 assert_eq!(
466 config.get_fallbacks(AgentRole::Commit),
467 &["agent1", "agent2"]
468 );
469 assert!(config.has_fallbacks(AgentRole::Commit));
470 }
471
472 #[test]
473 fn test_commit_uses_own_chain_when_configured() {
474 let config = FallbackConfig {
476 commit: vec!["commit-agent".to_string()],
477 reviewer: vec!["reviewer-agent".to_string()],
478 ..Default::default()
479 };
480
481 assert_eq!(config.get_fallbacks(AgentRole::Commit), &["commit-agent"]);
483 }
484}