1use std::path::{Path, PathBuf};
8
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::error::{Error, Result};
13use crate::repository::Repository;
14
15use super::projection::TargetKind;
16use super::stats::Statistics;
17
18pub const RESULT_SCHEMA: &str = "majordomus/benchmark-result/v1";
20
21pub const LOCAL_DIR: &str = "benchmarks";
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
26#[serde(rename_all = "snake_case")]
27pub enum CacheMode {
28 Uncached,
30 Cold,
32 Warm,
34 NotApplicable,
36}
37
38#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
40pub struct BenchmarkResult {
41 pub key: String,
43 pub kind: TargetKind,
45 pub cache_mode: CacheMode,
47 pub stats: Statistics,
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub handler_invocations: Option<u64>,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
56pub struct Provenance {
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub commit: Option<String>,
60 pub dirty: bool,
62 pub build_profile: String,
64 pub os: String,
66 pub arch: String,
68 pub version: String,
70 pub registry_fingerprint: String,
72 #[serde(default)]
77 pub host: String,
78 #[serde(default, skip_serializing_if = "String::is_empty")]
83 pub load: String,
84}
85
86impl Provenance {
87 pub fn of(repo: &Repository, registry_fingerprint: &str) -> Self {
89 let git = crate::git::inspect(repo.root());
90 let (commit, dirty) = match git {
91 crate::git::GitState::Available(info) => (info.head, info.working_tree == "dirty"),
92 crate::git::GitState::Unavailable { .. } => (None, true),
93 };
94 Provenance {
95 commit,
96 dirty,
97 build_profile: if cfg!(debug_assertions) {
98 "debug".into()
99 } else {
100 "release".into()
101 },
102 os: std::env::consts::OS.into(),
103 arch: std::env::consts::ARCH.into(),
104 host: host_fingerprint(),
105 load: load_average()
106 .map(|l| format!("{l:.2}"))
107 .unwrap_or_default(),
108 version: crate::VERSION.into(),
109 registry_fingerprint: registry_fingerprint.into(),
110 }
111 }
112
113 pub fn platform(&self) -> String {
115 format!("{}-{}-{}", self.os, self.arch, self.build_profile)
116 }
117
118 pub fn same_host(&self, other: &Provenance) -> bool {
120 !self.host.is_empty() && self.host == other.host
121 }
122}
123
124pub fn host_fingerprint() -> String {
129 let cores = std::thread::available_parallelism()
130 .map(|n| n.get().to_string())
131 .unwrap_or_default();
132 let brand = cpu_brand()
133 .unwrap_or_else(|| format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH));
134 if cores.is_empty() {
135 brand
136 } else {
137 format!("{brand}/{cores}")
138 }
139}
140
141pub fn load_average() -> Option<f64> {
151 #[cfg(unix)]
152 {
153 let mut loads = [0f64; 1];
154 let n = unsafe { libc::getloadavg(loads.as_mut_ptr(), 1) };
157 (n == 1 && loads[0].is_finite() && loads[0] >= 0.0).then_some(loads[0])
158 }
159 #[cfg(not(unix))]
160 {
161 None
162 }
163}
164
165fn cpu_brand() -> Option<String> {
168 if cfg!(target_os = "macos") {
169 let out = std::process::Command::new("sysctl")
170 .args(["-n", "machdep.cpu.brand_string"])
171 .output()
172 .ok()?;
173 let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
174 return (!s.is_empty()).then_some(s);
175 }
176 if cfg!(target_os = "linux") {
177 let text = std::fs::read_to_string("/proc/cpuinfo").ok()?;
178 return text
179 .lines()
180 .find(|l| l.starts_with("model name"))
181 .and_then(|l| l.split_once(':'))
182 .map(|(_, v)| v.trim().to_string())
183 .filter(|v| !v.is_empty());
184 }
185 None
186}
187
188#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
190pub struct ResultDocument {
191 pub schema: String,
193 pub finished_at: String,
195 pub profile: String,
197 pub provenance: Provenance,
199 pub results: Vec<BenchmarkResult>,
201}
202
203impl ResultDocument {
204 pub fn write_local(&self, repo: &Repository) -> Result<PathBuf> {
206 let dir = repo.root().join(repo.local_path()).join(LOCAL_DIR);
207 std::fs::create_dir_all(&dir).map_err(|e| Error::io(&dir, e))?;
208 let stamp = self.finished_at.replace([':', '-'], "");
209 let path = dir.join(format!("{stamp}-{}.json", self.profile));
210 write_atomic(&path, &self.render())?;
211 Ok(path)
212 }
213
214 pub fn render(&self) -> String {
216 let mut s = serde_json::to_string_pretty(self).unwrap_or_default();
217 s.push('\n');
218 s
219 }
220
221 pub fn find(&self, key: &str, mode: CacheMode) -> Option<&BenchmarkResult> {
223 self.results
224 .iter()
225 .find(|r| r.key == key && r.cache_mode == mode)
226 }
227}
228
229pub fn write_atomic(path: &Path, content: &str) -> Result<()> {
232 let tmp = path.with_extension("json.tmp");
233 std::fs::write(&tmp, content).map_err(|e| Error::io(&tmp, e))?;
234 std::fs::rename(&tmp, path).map_err(|e| Error::io(path, e))
235}