use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
const MAX_OUTPUT_BYTES: usize = 512 * 1024;
#[derive(Debug, Clone)]
pub struct MatrixResult {
pub success_count: usize,
pub failure_count: usize,
pub all_exit_codes: String,
}
impl MatrixResult {
pub fn from_envelopes(envelopes: &[StepEnvelope]) -> Self {
let success_count = envelopes.iter().filter(|e| e.exit_code == 0).count();
let failure_count = envelopes.len() - success_count;
let all_exit_codes = envelopes
.iter()
.map(|e| e.exit_code.to_string())
.collect::<Vec<_>>()
.join(",");
Self {
success_count,
failure_count,
all_exit_codes,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StepEnvelope {
pub step_name: String,
pub started_at: DateTime<Utc>,
pub finished_at: DateTime<Utc>,
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
#[serde(default)]
pub cost_usd: Option<f64>,
#[serde(default)]
pub session_id: Option<String>,
#[serde(default)]
pub input_tokens: Option<u64>,
#[serde(default)]
pub output_tokens: Option<u64>,
#[serde(default)]
pub stdout_file: Option<String>,
}
impl StepEnvelope {
pub fn truncate_stdout(&mut self) {
if self.stdout.len() > MAX_OUTPUT_BYTES {
self.stdout.truncate(MAX_OUTPUT_BYTES);
self.stdout.push_str("\n... [truncated at 512KB]");
}
}
pub fn truncate_stderr(&mut self) {
if self.stderr.len() > MAX_OUTPUT_BYTES {
self.stderr.truncate(MAX_OUTPUT_BYTES);
self.stderr.push_str("\n... [truncated at 512KB]");
}
}
pub fn truncate_output(&mut self) {
self.truncate_stdout();
self.truncate_stderr();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn make_envelope(step_name: &str, exit_code: i32) -> StepEnvelope {
StepEnvelope {
step_name: step_name.to_string(),
started_at: Utc::now(),
finished_at: Utc::now(),
exit_code,
stdout: String::new(),
stderr: String::new(),
cost_usd: None,
session_id: None,
input_tokens: None,
output_tokens: None,
stdout_file: None,
}
}
#[test]
fn test_matrix_result_all_success() {
let envelopes = vec![
make_envelope("run-model-0", 0),
make_envelope("run-model-1", 0),
make_envelope("run-model-2", 0),
];
let result = MatrixResult::from_envelopes(&envelopes);
assert_eq!(result.success_count, 3);
assert_eq!(result.failure_count, 0);
assert_eq!(result.all_exit_codes, "0,0,0");
}
#[test]
fn test_matrix_result_mixed() {
let envelopes = vec![
make_envelope("run-model-0", 0),
make_envelope("run-model-1", 1),
make_envelope("run-model-2", 0),
];
let result = MatrixResult::from_envelopes(&envelopes);
assert_eq!(result.success_count, 2);
assert_eq!(result.failure_count, 1);
assert_eq!(result.all_exit_codes, "0,1,0");
}
#[test]
fn test_matrix_result_all_failed() {
let envelopes = vec![
make_envelope("run-model-0", 1),
make_envelope("run-model-1", 2),
];
let result = MatrixResult::from_envelopes(&envelopes);
assert_eq!(result.success_count, 0);
assert_eq!(result.failure_count, 2);
assert_eq!(result.all_exit_codes, "1,2");
}
#[test]
fn test_matrix_result_empty() {
let result = MatrixResult::from_envelopes(&[]);
assert_eq!(result.success_count, 0);
assert_eq!(result.failure_count, 0);
assert_eq!(result.all_exit_codes, "");
}
fn create_test_envelope() -> StepEnvelope {
StepEnvelope {
step_name: "test-step".to_string(),
started_at: Utc::now(),
finished_at: Utc::now(),
exit_code: 0,
stdout: "test output".to_string(),
stderr: "".to_string(),
cost_usd: Some(0.05),
session_id: Some("sess-123".to_string()),
input_tokens: Some(100),
output_tokens: Some(200),
stdout_file: Some("/tmp/stdout-123.txt".to_string()),
}
}
#[test]
fn test_envelope_serde_roundtrip() {
let envelope = create_test_envelope();
let json = serde_json::to_string(&envelope).unwrap();
let deserialized: StepEnvelope = serde_json::from_str(&json).unwrap();
assert_eq!(deserialized.step_name, envelope.step_name);
assert_eq!(deserialized.exit_code, envelope.exit_code);
assert_eq!(deserialized.stdout, envelope.stdout);
assert_eq!(deserialized.stderr, envelope.stderr);
assert_eq!(deserialized.cost_usd, envelope.cost_usd);
assert_eq!(deserialized.session_id, envelope.session_id);
assert_eq!(deserialized.input_tokens, envelope.input_tokens);
assert_eq!(deserialized.output_tokens, envelope.output_tokens);
assert_eq!(deserialized.stdout_file, envelope.stdout_file);
}
#[test]
fn test_envelope_stdout_truncation() {
let mut envelope = StepEnvelope {
step_name: "big-output".to_string(),
started_at: Utc::now(),
finished_at: Utc::now(),
exit_code: 0,
stdout: "x".repeat(MAX_OUTPUT_BYTES + 1000),
stderr: "".to_string(),
cost_usd: None,
session_id: None,
input_tokens: None,
output_tokens: None,
stdout_file: None,
};
assert_eq!(envelope.stdout.len(), MAX_OUTPUT_BYTES + 1000);
envelope.truncate_stdout();
assert_eq!(
envelope.stdout.len(),
MAX_OUTPUT_BYTES + "\n... [truncated at 512KB]".len()
);
assert!(envelope.stdout.ends_with("\n... [truncated at 512KB]"));
}
#[test]
fn test_envelope_stderr_truncation() {
let mut envelope = StepEnvelope {
step_name: "stderr-test".to_string(),
started_at: Utc::now(),
finished_at: Utc::now(),
exit_code: 0,
stdout: String::new(),
stderr: "x".repeat(MAX_OUTPUT_BYTES + 1000),
cost_usd: None,
session_id: None,
input_tokens: None,
output_tokens: None,
stdout_file: None,
};
assert_eq!(envelope.stderr.len(), MAX_OUTPUT_BYTES + 1000);
envelope.truncate_stderr();
assert_eq!(
envelope.stderr.len(),
MAX_OUTPUT_BYTES + "\n... [truncated at 512KB]".len()
);
assert!(envelope.stderr.ends_with("\n... [truncated at 512KB]"));
}
#[test]
fn test_envelope_no_truncation_when_small() {
let mut envelope = StepEnvelope {
step_name: "small-output".to_string(),
started_at: Utc::now(),
finished_at: Utc::now(),
exit_code: 0,
stdout: "small output".to_string(),
stderr: "".to_string(),
cost_usd: None,
session_id: None,
input_tokens: None,
output_tokens: None,
stdout_file: None,
};
let original_len = envelope.stdout.len();
envelope.truncate_stdout();
assert_eq!(envelope.stdout.len(), original_len);
assert_eq!(envelope.stdout, "small output");
}
#[test]
fn test_envelope_exactly_at_limit() {
let mut envelope = StepEnvelope {
step_name: "exact-limit".to_string(),
started_at: Utc::now(),
finished_at: Utc::now(),
exit_code: 0,
stdout: "x".repeat(MAX_OUTPUT_BYTES),
stderr: "".to_string(),
cost_usd: None,
session_id: None,
input_tokens: None,
output_tokens: None,
stdout_file: None,
};
let original_len = envelope.stdout.len();
envelope.truncate_stdout();
assert_eq!(envelope.stdout.len(), original_len);
}
#[test]
fn test_envelope_optional_fields() {
let envelope = StepEnvelope {
step_name: "minimal".to_string(),
started_at: Utc::now(),
finished_at: Utc::now(),
exit_code: 0,
stdout: "output".to_string(),
stderr: "error".to_string(),
cost_usd: None,
session_id: None,
input_tokens: None,
output_tokens: None,
stdout_file: None,
};
let json = serde_json::to_string(&envelope).unwrap();
let deserialized: StepEnvelope = serde_json::from_str(&json).unwrap();
assert!(deserialized.cost_usd.is_none());
assert!(deserialized.session_id.is_none());
assert!(deserialized.input_tokens.is_none());
assert!(deserialized.output_tokens.is_none());
assert!(deserialized.stdout_file.is_none());
}
#[test]
fn test_envelope_default_fields_in_json() {
let json = r#"{
"step_name": "test",
"started_at": "2024-01-15T10:30:00Z",
"finished_at": "2024-01-15T10:31:00Z",
"exit_code": 0,
"stdout": "output",
"stderr": ""
}"#;
let envelope: StepEnvelope = serde_json::from_str(json).unwrap();
assert_eq!(envelope.step_name, "test");
assert_eq!(envelope.exit_code, 0);
assert!(envelope.cost_usd.is_none());
assert!(envelope.session_id.is_none());
assert!(envelope.input_tokens.is_none());
assert!(envelope.output_tokens.is_none());
assert!(envelope.stdout_file.is_none());
}
}