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, Serialize)]
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 #[must_use]
211 pub fn calculate_backoff(&self, cycle: u32) -> u64 {
212 let multiplier_hundredths = self.get_multiplier_hundredths();
215 let base_hundredths = self.retry_delay_ms.saturating_mul(100);
216
217 let mut delay_hundredths = base_hundredths;
220 for _ in 0..cycle {
221 delay_hundredths = delay_hundredths.saturating_mul(multiplier_hundredths);
222 delay_hundredths = delay_hundredths.saturating_div(100);
223 }
224
225 delay_hundredths.div_euclid(100).min(self.max_backoff_ms)
227 }
228
229 fn get_multiplier_hundredths(&self) -> u64 {
234 const EPSILON: f64 = 0.0001;
235
236 let m = self.backoff_multiplier;
239 if (m - 1.0).abs() < EPSILON {
240 return 100;
241 } else if (m - 1.5).abs() < EPSILON {
242 return 150;
243 } else if (m - 2.0).abs() < EPSILON {
244 return 200;
245 } else if (m - 2.5).abs() < EPSILON {
246 return 250;
247 } else if (m - 3.0).abs() < EPSILON {
248 return 300;
249 } else if (m - 4.0).abs() < EPSILON {
250 return 400;
251 } else if (m - 5.0).abs() < EPSILON {
252 return 500;
253 } else if (m - 10.0).abs() < EPSILON {
254 return 1000;
255 }
256
257 let clamped = m.clamp(0.0, 1000.0);
261 let multiplied = clamped * 100.0;
262 let rounded = multiplied.round();
263
264 f64_to_u64_via_bits(rounded)
266 }
267
268 #[must_use]
270 pub fn get_fallbacks(&self, role: AgentRole) -> &[String] {
271 match role {
272 AgentRole::Developer => &self.developer,
273 AgentRole::Reviewer => &self.reviewer,
274 AgentRole::Commit => self.get_effective_commit_fallbacks(),
275 AgentRole::Analysis => self.get_effective_analysis_fallbacks(),
276 }
277 }
278
279 fn get_effective_analysis_fallbacks(&self) -> &[String] {
283 if self.analysis.is_empty() {
284 &self.developer
285 } else {
286 &self.analysis
287 }
288 }
289
290 fn get_effective_commit_fallbacks(&self) -> &[String] {
296 if self.commit.is_empty() {
297 &self.reviewer
298 } else {
299 &self.commit
300 }
301 }
302
303 #[must_use]
305 pub fn has_fallbacks(&self, role: AgentRole) -> bool {
306 !self.get_fallbacks(role).is_empty()
307 }
308
309 pub fn get_provider_fallbacks(&self, agent_name: &str) -> &[String] {
314 self.provider_fallback
315 .get(agent_name)
316 .map_or(&[], std::vec::Vec::as_slice)
317 }
318
319 #[must_use]
321 pub fn has_provider_fallbacks(&self, agent_name: &str) -> bool {
322 self.provider_fallback
323 .get(agent_name)
324 .is_some_and(|v| !v.is_empty())
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn test_agent_role_display() {
334 assert_eq!(format!("{}", AgentRole::Developer), "developer");
335 assert_eq!(format!("{}", AgentRole::Reviewer), "reviewer");
336 assert_eq!(format!("{}", AgentRole::Commit), "commit");
337 assert_eq!(format!("{}", AgentRole::Analysis), "analysis");
338 }
339
340 #[test]
341 fn test_fallback_config_defaults() {
342 let config = FallbackConfig::default();
343 assert!(config.developer.is_empty());
344 assert!(config.reviewer.is_empty());
345 assert!(config.commit.is_empty());
346 assert!(config.analysis.is_empty());
347 assert_eq!(config.max_retries, 3);
348 assert_eq!(config.retry_delay_ms, 1000);
349 assert!((config.backoff_multiplier - 2.0).abs() < f64::EPSILON);
351 assert_eq!(config.max_backoff_ms, 60000);
352 assert_eq!(config.max_cycles, 3);
353 }
354
355 #[test]
356 fn test_fallback_config_calculate_backoff() {
357 let config = FallbackConfig {
358 retry_delay_ms: 1000,
359 backoff_multiplier: 2.0,
360 max_backoff_ms: 60000,
361 ..Default::default()
362 };
363
364 assert_eq!(config.calculate_backoff(0), 1000);
365 assert_eq!(config.calculate_backoff(1), 2000);
366 assert_eq!(config.calculate_backoff(2), 4000);
367 assert_eq!(config.calculate_backoff(3), 8000);
368
369 assert_eq!(config.calculate_backoff(10), 60000);
371 }
372
373 #[test]
374 fn test_fallback_config_get_fallbacks() {
375 let config = FallbackConfig {
376 developer: vec!["claude".to_string(), "codex".to_string()],
377 reviewer: vec!["codex".to_string()],
378 ..Default::default()
379 };
380
381 assert_eq!(
382 config.get_fallbacks(AgentRole::Developer),
383 &["claude", "codex"]
384 );
385 assert_eq!(config.get_fallbacks(AgentRole::Reviewer), &["codex"]);
386
387 assert_eq!(
389 config.get_fallbacks(AgentRole::Analysis),
390 &["claude", "codex"]
391 );
392 }
393
394 #[test]
395 fn test_fallback_config_has_fallbacks() {
396 let config = FallbackConfig {
397 developer: vec!["claude".to_string()],
398 reviewer: vec![],
399 ..Default::default()
400 };
401
402 assert!(config.has_fallbacks(AgentRole::Developer));
403 assert!(config.has_fallbacks(AgentRole::Analysis));
404 assert!(!config.has_fallbacks(AgentRole::Reviewer));
405 }
406
407 #[test]
408 fn test_fallback_config_defaults_provider_fallback() {
409 let config = FallbackConfig::default();
410 assert!(config.get_provider_fallbacks("opencode").is_empty());
411 assert!(!config.has_provider_fallbacks("opencode"));
412 }
413
414 #[test]
415 fn test_provider_fallback_config() {
416 let mut provider_fallback = HashMap::new();
417 provider_fallback.insert(
418 "opencode".to_string(),
419 vec![
420 "-m opencode/glm-4.7-free".to_string(),
421 "-m opencode/claude-sonnet-4".to_string(),
422 ],
423 );
424
425 let config = FallbackConfig {
426 provider_fallback,
427 ..Default::default()
428 };
429
430 let fallbacks = config.get_provider_fallbacks("opencode");
431 assert_eq!(fallbacks.len(), 2);
432 assert_eq!(fallbacks[0], "-m opencode/glm-4.7-free");
433 assert_eq!(fallbacks[1], "-m opencode/claude-sonnet-4");
434
435 assert!(config.has_provider_fallbacks("opencode"));
436 assert!(!config.has_provider_fallbacks("claude"));
437 }
438
439 #[test]
440 fn test_fallback_config_from_toml() {
441 let toml_str = r#"
442 developer = ["claude", "codex"]
443 reviewer = ["codex", "claude"]
444 max_retries = 5
445 retry_delay_ms = 2000
446
447 [provider_fallback]
448 opencode = ["-m opencode/glm-4.7-free", "-m zai/glm-4.7"]
449 "#;
450
451 let config: FallbackConfig = toml::from_str(toml_str).unwrap();
452 assert_eq!(config.developer, vec!["claude", "codex"]);
453 assert_eq!(config.reviewer, vec!["codex", "claude"]);
454 assert_eq!(config.max_retries, 5);
455 assert_eq!(config.retry_delay_ms, 2000);
456 assert_eq!(config.get_provider_fallbacks("opencode").len(), 2);
457 }
458
459 #[test]
460 fn test_commit_uses_reviewer_chain_when_empty() {
461 let config = FallbackConfig {
463 commit: vec![],
464 reviewer: vec!["agent1".to_string(), "agent2".to_string()],
465 ..Default::default()
466 };
467
468 assert_eq!(
470 config.get_fallbacks(AgentRole::Commit),
471 &["agent1", "agent2"]
472 );
473 assert!(config.has_fallbacks(AgentRole::Commit));
474 }
475
476 #[test]
477 fn test_commit_uses_own_chain_when_configured() {
478 let config = FallbackConfig {
480 commit: vec!["commit-agent".to_string()],
481 reviewer: vec!["reviewer-agent".to_string()],
482 ..Default::default()
483 };
484
485 assert_eq!(config.get_fallbacks(AgentRole::Commit), &["commit-agent"]);
487 }
488}