use jiff::Timestamp;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct RunContext {
pub observation: Timestamp,
pub git: GitInfo,
pub env: EnvironmentInfo,
pub toolchain: ToolchainInfo,
pub tool_version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub machine: Option<MachineInfo>,
}
impl RunContext {
#[must_use]
pub fn new(
observation: Timestamp,
git: GitInfo,
env: EnvironmentInfo,
toolchain: ToolchainInfo,
tool_version: String,
) -> Self {
Self {
observation,
git,
env,
toolchain,
tool_version,
machine: None,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct MachineInfo {
pub processors: usize,
pub memory_regions: usize,
pub cpu_brand: Option<String>,
pub fingerprint: String,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct GitInfo {
pub commit: Option<String>,
pub branch: Option<String>,
pub dirty: bool,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct EnvironmentInfo {
pub provider: EnvironmentProvider,
pub run_id: Option<String>,
pub pull_request: Option<String>,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum EnvironmentProvider {
#[default]
Local,
#[serde(rename = "github_actions")]
GitHubActions,
#[serde(rename = "azure_devops")]
AzureDevOps,
}
#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
pub struct ToolchainInfo {
pub target_triple: String,
pub rustc_version: Option<String>,
}
#[must_use]
pub fn detect_environment(get: impl Fn(&str) -> Option<String>) -> EnvironmentInfo {
if get("GITHUB_ACTIONS").as_deref() == Some("true") {
return EnvironmentInfo {
provider: EnvironmentProvider::GitHubActions,
run_id: get("GITHUB_RUN_ID"),
pull_request: None,
};
}
if get("TF_BUILD").is_some() {
return EnvironmentInfo {
provider: EnvironmentProvider::AzureDevOps,
run_id: get("BUILD_BUILDID"),
pull_request: get("SYSTEM_PULLREQUEST_PULLREQUESTID"),
};
}
EnvironmentInfo::default()
}
#[cfg(test)]
#[cfg_attr(coverage_nightly, coverage(off))]
mod tests {
use std::collections::HashMap;
use super::*;
fn env_from(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
let map: HashMap<String, String> = pairs
.iter()
.map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
.collect();
move |name: &str| map.get(name).cloned()
}
#[test]
fn detect_environment_recognizes_github_actions() {
let env = env_from(&[("GITHUB_ACTIONS", "true"), ("GITHUB_RUN_ID", "42")]);
let detected = detect_environment(env);
assert_eq!(detected.provider, EnvironmentProvider::GitHubActions);
assert_eq!(detected.run_id.as_deref(), Some("42"));
}
#[test]
fn detect_environment_recognizes_azure_devops() {
let env = env_from(&[
("TF_BUILD", "True"),
("BUILD_BUILDID", "7"),
("SYSTEM_PULLREQUEST_PULLREQUESTID", "99"),
]);
let detected = detect_environment(env);
assert_eq!(detected.provider, EnvironmentProvider::AzureDevOps);
assert_eq!(detected.run_id.as_deref(), Some("7"));
assert_eq!(detected.pull_request.as_deref(), Some("99"));
}
#[test]
fn detect_environment_defaults_to_local() {
let detected = detect_environment(env_from(&[]));
assert_eq!(detected.provider, EnvironmentProvider::Local);
}
#[test]
fn machine_info_is_omitted_when_absent_and_restored_when_present() {
let epoch = "2024-01-01T00:00:00Z".parse().unwrap();
let context = RunContext::new(
epoch,
GitInfo::default(),
EnvironmentInfo::default(),
ToolchainInfo::default(),
"0.0.1".to_owned(),
);
let json = serde_json::to_string(&context).unwrap();
assert!(!json.contains("machine"), "{json}");
assert_eq!(serde_json::from_str::<RunContext>(&json).unwrap(), context);
let mut with_machine = context;
with_machine.machine = Some(MachineInfo {
processors: 8,
memory_regions: 1,
cpu_brand: Some("Test CPU 3000".to_owned()),
fingerprint: "d3ddd69dcf3b84ea".to_owned(),
});
let json = serde_json::to_string(&with_machine).unwrap();
assert!(
json.contains("\"fingerprint\":\"d3ddd69dcf3b84ea\""),
"{json}"
);
assert_eq!(
serde_json::from_str::<RunContext>(&json).unwrap(),
with_machine
);
}
#[test]
fn run_context_without_machine_field_deserializes_to_none() {
let json = r#"{
"observation": "2024-01-01T00:00:00Z",
"git": {"commit": null, "branch": null, "dirty": false},
"env": {"provider": "local", "run_id": null, "pull_request": null},
"toolchain": {"target_triple": "", "rustc_version": null},
"tool_version": "0.0.1"
}"#;
let context: RunContext = serde_json::from_str(json).unwrap();
assert_eq!(context.machine, None);
}
#[test]
fn provider_serializes_brand_names_without_mangling() {
let cases = [
(EnvironmentProvider::Local, "\"local\""),
(EnvironmentProvider::GitHubActions, "\"github_actions\""),
(EnvironmentProvider::AzureDevOps, "\"azure_devops\""),
];
for (provider, wire) in cases {
let serialized = serde_json::to_string(&provider).unwrap();
assert_eq!(serialized, wire);
let parsed: EnvironmentProvider = serde_json::from_str(wire).unwrap();
assert_eq!(parsed, provider);
}
}
}