use crate::commands::run::PhaseType;
use crate::contracts::{Model, Runner};
use crate::progress::ExecutionPhase;
use std::collections::HashMap;
use std::time::Duration;
#[derive(Debug, Default)]
pub(crate) struct RunExecutionTimings {
canonical: Option<(String, String)>,
mixed_runner_model: bool,
phase_durations: HashMap<ExecutionPhase, Duration>,
}
#[derive(Debug)]
pub(crate) struct ExecutionHistoryPayload {
pub runner: String,
pub model: String,
pub phase_durations: HashMap<ExecutionPhase, Duration>,
pub total_duration: Duration,
}
impl RunExecutionTimings {
pub(crate) fn record_runner_duration(
&mut self,
phase_type: PhaseType,
runner: &Runner,
model: &Model,
duration: Duration,
) {
let phase = match phase_type {
PhaseType::Planning => ExecutionPhase::Planning,
PhaseType::Implementation => ExecutionPhase::Implementation,
PhaseType::Review => ExecutionPhase::Review,
PhaseType::SinglePhase => ExecutionPhase::Planning,
};
let pair = (runner.as_str().to_string(), model.as_str().to_string());
if let Some(existing) = self.canonical.as_ref() {
if existing != &pair {
self.mixed_runner_model = true;
}
} else {
self.canonical = Some(pair);
}
let entry = self.phase_durations.entry(phase).or_insert(Duration::ZERO);
*entry += duration;
}
pub(crate) fn build_payload(&self, phase_count: u8) -> Option<ExecutionHistoryPayload> {
if self.mixed_runner_model {
return None;
}
let (runner, model) = self.canonical.clone()?;
let mut phase_durations = HashMap::new();
phase_durations.extend(self.phase_durations.iter().map(|(k, v)| (*k, *v)));
match phase_count {
1 => {
phase_durations.retain(|phase, _| matches!(phase, ExecutionPhase::Planning));
}
2 => {
phase_durations.retain(|phase, _| {
matches!(
phase,
ExecutionPhase::Planning | ExecutionPhase::Implementation
)
});
}
_ => {
phase_durations.retain(|phase, _| {
matches!(
phase,
ExecutionPhase::Planning
| ExecutionPhase::Implementation
| ExecutionPhase::Review
)
});
}
}
let total_duration = phase_durations
.values()
.copied()
.fold(Duration::ZERO, |acc, d| acc + d);
Some(ExecutionHistoryPayload {
runner,
model,
phase_durations,
total_duration,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_record_single_phase() {
let mut timings = RunExecutionTimings::default();
timings.record_runner_duration(
PhaseType::Planning,
&Runner::Codex,
&Model::Gpt53,
Duration::from_secs(60),
);
let payload = timings.build_payload(2).unwrap();
assert_eq!(payload.runner, "codex");
assert_eq!(payload.model, "gpt-5.3");
assert_eq!(
payload.phase_durations.get(&ExecutionPhase::Planning),
Some(&Duration::from_secs(60))
);
assert_eq!(payload.total_duration, Duration::from_secs(60));
}
#[test]
fn test_accumulate_multiple_phases() {
let mut timings = RunExecutionTimings::default();
timings.record_runner_duration(
PhaseType::Planning,
&Runner::Codex,
&Model::Gpt53,
Duration::from_secs(60),
);
timings.record_runner_duration(
PhaseType::Implementation,
&Runner::Codex,
&Model::Gpt53,
Duration::from_secs(120),
);
let payload = timings.build_payload(2).unwrap();
assert_eq!(payload.total_duration, Duration::from_secs(180));
assert_eq!(
payload.phase_durations.get(&ExecutionPhase::Planning),
Some(&Duration::from_secs(60))
);
assert_eq!(
payload.phase_durations.get(&ExecutionPhase::Implementation),
Some(&Duration::from_secs(120))
);
}
#[test]
fn test_accumulate_same_phase_multiple_times() {
let mut timings = RunExecutionTimings::default();
timings.record_runner_duration(
PhaseType::Implementation,
&Runner::Codex,
&Model::Gpt53,
Duration::from_secs(60),
);
timings.record_runner_duration(
PhaseType::Implementation,
&Runner::Codex,
&Model::Gpt53,
Duration::from_secs(30),
);
let payload = timings.build_payload(2).unwrap();
assert_eq!(
payload.phase_durations.get(&ExecutionPhase::Implementation),
Some(&Duration::from_secs(90))
);
}
#[test]
fn test_single_phase_maps_to_planning() {
let mut timings = RunExecutionTimings::default();
timings.record_runner_duration(
PhaseType::SinglePhase,
&Runner::Claude,
&Model::Gpt53,
Duration::from_secs(45),
);
let payload = timings.build_payload(1).unwrap();
assert_eq!(
payload.phase_durations.get(&ExecutionPhase::Planning),
Some(&Duration::from_secs(45))
);
}
#[test]
fn test_mixed_runner_model_returns_none() {
let mut timings = RunExecutionTimings::default();
timings.record_runner_duration(
PhaseType::Planning,
&Runner::Codex,
&Model::Gpt53,
Duration::from_secs(60),
);
timings.record_runner_duration(
PhaseType::Implementation,
&Runner::Claude, &Model::Gpt53,
Duration::from_secs(120),
);
assert!(timings.build_payload(2).is_none());
}
#[test]
fn test_mixed_model_returns_none() {
let mut timings = RunExecutionTimings::default();
timings.record_runner_duration(
PhaseType::Planning,
&Runner::Codex,
&Model::Gpt53,
Duration::from_secs(60),
);
timings.record_runner_duration(
PhaseType::Implementation,
&Runner::Codex,
&Model::Gpt54, Duration::from_secs(120),
);
assert!(timings.build_payload(2).is_none());
}
#[test]
fn test_empty_timings_returns_none() {
let timings = RunExecutionTimings::default();
assert!(timings.build_payload(2).is_none());
}
#[test]
fn test_phase_count_filtering() {
let mut timings = RunExecutionTimings::default();
timings.record_runner_duration(
PhaseType::Planning,
&Runner::Codex,
&Model::Gpt53,
Duration::from_secs(60),
);
timings.record_runner_duration(
PhaseType::Implementation,
&Runner::Codex,
&Model::Gpt53,
Duration::from_secs(120),
);
timings.record_runner_duration(
PhaseType::Review,
&Runner::Codex,
&Model::Gpt53,
Duration::from_secs(30),
);
let payload1 = timings.build_payload(1).unwrap();
assert_eq!(payload1.phase_durations.len(), 1);
assert!(
payload1
.phase_durations
.contains_key(&ExecutionPhase::Planning)
);
let payload2 = timings.build_payload(2).unwrap();
assert_eq!(payload2.phase_durations.len(), 2);
assert!(
payload2
.phase_durations
.contains_key(&ExecutionPhase::Planning)
);
assert!(
payload2
.phase_durations
.contains_key(&ExecutionPhase::Implementation)
);
let payload3 = timings.build_payload(3).unwrap();
assert_eq!(payload3.phase_durations.len(), 3);
assert!(
payload3
.phase_durations
.contains_key(&ExecutionPhase::Planning)
);
assert!(
payload3
.phase_durations
.contains_key(&ExecutionPhase::Implementation)
);
assert!(
payload3
.phase_durations
.contains_key(&ExecutionPhase::Review)
);
}
}