use std::path::Path;
use serde::{Deserialize, Serialize};
use super::error::OutputError;
pub struct OutputContext {
pub hostname: String,
pub solver: String,
pub started_at: String,
pub completed_at: String,
pub mpi_world_size: u32,
pub mpi_ranks_participated: u32,
}
#[must_use]
pub fn get_hostname() -> String {
std::fs::read_to_string("/proc/sys/kernel/hostname")
.map(|s| s.trim().to_string())
.or_else(|_| std::env::var("HOSTNAME"))
.unwrap_or_else(|_| "unknown".to_string())
}
#[must_use]
pub fn now_iso8601() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MpiInfo {
pub world_size: u32,
pub ranks_participated: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataConfiguration {
pub seed: Option<i64>,
pub max_iterations: Option<u32>,
pub forward_passes: Option<u32>,
pub stopping_mode: String,
pub policy_mode: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataProblemDimensions {
pub num_stages: u32,
pub num_hydros: u32,
pub num_thermals: u32,
pub num_buses: u32,
pub num_lines: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataIterations {
pub completed: u32,
pub converged_at: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataConvergence {
pub achieved: bool,
pub final_gap_percent: Option<f64>,
pub termination_reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataCuts {
pub total_generated: u64,
pub total_active: u64,
pub peak_active: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataScenarios {
pub total: u32,
pub completed: u32,
pub failed: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrainingMetadata {
pub cobre_version: String,
pub hostname: String,
pub solver: String,
pub started_at: String,
pub completed_at: String,
pub duration_seconds: f64,
pub status: String,
pub configuration: MetadataConfiguration,
pub problem_dimensions: MetadataProblemDimensions,
pub iterations: MetadataIterations,
pub convergence: MetadataConvergence,
pub cuts: MetadataCuts,
pub mpi: MpiInfo,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SimulationMetadata {
pub cobre_version: String,
pub hostname: String,
pub solver: String,
pub started_at: String,
pub completed_at: String,
pub duration_seconds: f64,
pub status: String,
pub scenarios: MetadataScenarios,
pub mpi: MpiInfo,
}
pub fn write_training_metadata(
path: &Path,
metadata: &TrainingMetadata,
) -> Result<(), OutputError> {
write_json_atomic(path, metadata, "training_metadata")
}
pub fn write_simulation_metadata(
path: &Path,
metadata: &SimulationMetadata,
) -> Result<(), OutputError> {
write_json_atomic(path, metadata, "simulation_metadata")
}
pub fn read_training_metadata(path: &Path) -> Result<TrainingMetadata, OutputError> {
read_json(path, "training_metadata")
}
pub fn read_simulation_metadata(path: &Path) -> Result<SimulationMetadata, OutputError> {
read_json(path, "simulation_metadata")
}
fn read_json<T>(path: &Path, manifest_type: &str) -> Result<T, OutputError>
where
T: serde::de::DeserializeOwned,
{
let content = std::fs::read_to_string(path).map_err(|e| OutputError::io(path, e))?;
serde_json::from_str(&content).map_err(|e| OutputError::ManifestError {
manifest_type: manifest_type.to_string(),
message: e.to_string(),
})
}
fn write_json_atomic<T: Serialize>(
path: &Path,
value: &T,
manifest_type: &str,
) -> Result<(), OutputError> {
let json = serde_json::to_string_pretty(value).map_err(|e| OutputError::ManifestError {
manifest_type: manifest_type.to_string(),
message: e.to_string(),
})?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, &json).map_err(|e| OutputError::io(&tmp, e))?;
std::fs::rename(&tmp, path).map_err(|e| OutputError::io(path, e))?;
Ok(())
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::float_cmp,
clippy::cast_possible_truncation
)]
mod tests {
use super::*;
use tempfile::tempdir;
fn make_training_metadata() -> TrainingMetadata {
TrainingMetadata {
cobre_version: env!("CARGO_PKG_VERSION").to_string(),
hostname: "test-host".to_string(),
solver: "highs".to_string(),
started_at: "2026-01-17T08:00:00Z".to_string(),
completed_at: "2026-01-17T12:30:00Z".to_string(),
duration_seconds: 16_200.0,
status: "complete".to_string(),
configuration: MetadataConfiguration {
seed: Some(42),
max_iterations: Some(100),
forward_passes: Some(192),
stopping_mode: "any".to_string(),
policy_mode: "fresh".to_string(),
},
problem_dimensions: MetadataProblemDimensions {
num_stages: 12,
num_hydros: 160,
num_thermals: 200,
num_buses: 5,
num_lines: 8,
},
iterations: MetadataIterations {
completed: 100,
converged_at: Some(95),
},
convergence: MetadataConvergence {
achieved: true,
final_gap_percent: Some(0.45),
termination_reason: "bound_stalling".to_string(),
},
cuts: MetadataCuts {
total_generated: 1_250_000,
total_active: 980_000,
peak_active: 1_100_000,
},
mpi: MpiInfo {
world_size: 1,
ranks_participated: 1,
},
}
}
fn make_simulation_metadata() -> SimulationMetadata {
SimulationMetadata {
cobre_version: env!("CARGO_PKG_VERSION").to_string(),
hostname: "test-host".to_string(),
solver: "highs".to_string(),
started_at: "2026-01-17T13:00:00Z".to_string(),
completed_at: "2026-01-17T13:15:00Z".to_string(),
duration_seconds: 900.0,
status: "complete".to_string(),
scenarios: MetadataScenarios {
total: 100,
completed: 100,
failed: 0,
},
mpi: MpiInfo {
world_size: 1,
ranks_participated: 1,
},
}
}
#[test]
fn training_metadata_roundtrip() {
let original = make_training_metadata();
let json = serde_json::to_string_pretty(&original).unwrap();
let decoded: TrainingMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.cobre_version, original.cobre_version);
assert_eq!(decoded.hostname, original.hostname);
assert_eq!(decoded.solver, original.solver);
assert_eq!(decoded.started_at, original.started_at);
assert_eq!(decoded.completed_at, original.completed_at);
assert_eq!(decoded.duration_seconds, original.duration_seconds);
assert_eq!(decoded.status, original.status);
assert_eq!(decoded.iterations.completed, original.iterations.completed);
assert_eq!(
decoded.iterations.converged_at,
original.iterations.converged_at
);
assert_eq!(decoded.convergence.achieved, original.convergence.achieved);
assert_eq!(
decoded.convergence.final_gap_percent,
original.convergence.final_gap_percent
);
assert_eq!(decoded.cuts.total_generated, original.cuts.total_generated);
assert_eq!(decoded.cuts.total_active, original.cuts.total_active);
assert_eq!(decoded.cuts.peak_active, original.cuts.peak_active);
assert_eq!(decoded.mpi.world_size, original.mpi.world_size);
}
#[test]
fn simulation_metadata_roundtrip() {
let original = make_simulation_metadata();
let json = serde_json::to_string_pretty(&original).unwrap();
let decoded: SimulationMetadata = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.cobre_version, original.cobre_version);
assert_eq!(decoded.status, original.status);
assert_eq!(decoded.scenarios.total, original.scenarios.total);
assert_eq!(decoded.scenarios.completed, original.scenarios.completed);
assert_eq!(decoded.scenarios.failed, original.scenarios.failed);
assert_eq!(decoded.mpi.world_size, original.mpi.world_size);
}
#[test]
fn write_training_metadata_creates_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("metadata.json");
let metadata = make_training_metadata();
write_training_metadata(&path, &metadata).expect("write must succeed");
assert!(path.exists(), "metadata file must exist after write");
let content = std::fs::read_to_string(&path).unwrap();
let _parsed: serde_json::Value =
serde_json::from_str(&content).expect("file must contain valid JSON");
}
#[test]
fn write_simulation_metadata_creates_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("metadata.json");
let metadata = make_simulation_metadata();
write_simulation_metadata(&path, &metadata).expect("write must succeed");
assert!(path.exists(), "metadata file must exist after write");
let content = std::fs::read_to_string(&path).unwrap();
let _parsed: serde_json::Value =
serde_json::from_str(&content).expect("file must contain valid JSON");
}
#[test]
fn write_training_metadata_fields_survive_write_read_cycle() {
let dir = tempdir().unwrap();
let path = dir.path().join("metadata.json");
let original = make_training_metadata();
write_training_metadata(&path, &original).expect("write must succeed");
let decoded = read_training_metadata(&path).expect("read must succeed");
assert_eq!(decoded.iterations.completed, 100);
assert!(decoded.convergence.achieved);
assert_eq!(decoded.cuts.total_generated, 1_250_000);
}
#[test]
fn write_simulation_metadata_fields_survive_write_read_cycle() {
let dir = tempdir().unwrap();
let path = dir.path().join("metadata.json");
let original = make_simulation_metadata();
write_simulation_metadata(&path, &original).expect("write must succeed");
let decoded = read_simulation_metadata(&path).expect("read must succeed");
assert_eq!(decoded.scenarios.total, 100);
assert_eq!(decoded.scenarios.completed, 100);
}
#[test]
fn write_training_metadata_missing_parent_returns_io_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("nonexistent_subdir").join("metadata.json");
let metadata = make_training_metadata();
let result = write_training_metadata(&path, &metadata);
assert!(
matches!(result, Err(OutputError::IoError { .. })),
"error must be IoError when parent directory is missing, got: {result:?}"
);
}
#[test]
fn write_simulation_metadata_missing_parent_returns_io_error() {
let dir = tempdir().unwrap();
let path = dir.path().join("nonexistent_subdir").join("metadata.json");
let metadata = make_simulation_metadata();
let result = write_simulation_metadata(&path, &metadata);
assert!(
matches!(result, Err(OutputError::IoError { .. })),
"error must be IoError when parent directory is missing"
);
}
#[test]
fn read_training_metadata_missing_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("nonexistent.json");
let result = read_training_metadata(&path);
assert!(
matches!(result, Err(OutputError::IoError { .. })),
"missing file must return OutputError::IoError, got: {result:?}"
);
}
#[test]
fn read_training_metadata_malformed_json() {
use std::io::Write;
let dir = tempdir().unwrap();
let path = dir.path().join("metadata.json");
let mut file = std::fs::File::create(&path).unwrap();
writeln!(file, "{{not valid json at all").unwrap();
let result = read_training_metadata(&path);
assert!(
matches!(result, Err(OutputError::ManifestError { .. })),
"malformed JSON must return OutputError::ManifestError, got: {result:?}"
);
}
#[test]
fn write_metadata_atomic_no_tmp_remains() {
let dir = tempdir().unwrap();
let path = dir.path().join("metadata.json");
let metadata = make_training_metadata();
write_training_metadata(&path, &metadata).expect("write must succeed");
let tmp = path.with_extension("json.tmp");
assert!(
!tmp.exists(),
"no .tmp file must remain after a successful write"
);
assert!(path.exists(), "the target file must exist");
}
#[test]
fn training_metadata_cobre_version_matches_cargo_pkg_version() {
let dir = tempdir().unwrap();
let path = dir.path().join("metadata.json");
let metadata = make_training_metadata();
write_training_metadata(&path, &metadata).expect("write must succeed");
let content = std::fs::read_to_string(&path).unwrap();
let value: serde_json::Value = serde_json::from_str(&content).unwrap();
let version = value["cobre_version"]
.as_str()
.expect("cobre_version must be a string");
assert_eq!(version, env!("CARGO_PKG_VERSION"));
}
#[test]
fn now_iso8601_returns_valid_format() {
let ts = now_iso8601();
assert!(ts.ends_with('Z'), "timestamp must end with Z: {ts}");
assert!(ts.contains('T'), "timestamp must contain T separator: {ts}");
}
}