1use anyhow::{Context, Result, bail};
11use serde::{Deserialize, Serialize};
12use std::fs;
13use std::io::Write;
14use std::path::{Path, PathBuf};
15
16use crate::notes;
17use crate::record::{Record, SCHEMA_VERSION};
18
19const ARTIFACT_VERSION: u32 = 1;
20const MAX_ARTIFACT_BYTES: u64 = 16 * 1024 * 1024;
21const MAX_RECORDS: usize = 10_000;
22
23#[derive(Debug, Serialize, Deserialize)]
24#[serde(deny_unknown_fields)]
25struct MeasurementArtifact {
26 v: u32,
27 commit: String,
28 records: Vec<Record>,
29}
30
31fn artifact_parent(path: &Path) -> PathBuf {
32 path.parent()
33 .filter(|p| !p.as_os_str().is_empty())
34 .unwrap_or_else(|| Path::new("."))
35 .to_path_buf()
36}
37
38fn ensure_export_size(payload_bytes: usize) -> Result<()> {
41 let file_bytes = payload_bytes as u64 + 1;
42 if file_bytes > MAX_ARTIFACT_BYTES {
43 bail!("artifact is {file_bytes} bytes; the limit is {MAX_ARTIFACT_BYTES}");
44 }
45 Ok(())
46}
47
48pub fn export(path: &Path, rev: &str) -> Result<(String, usize)> {
51 let commit = notes::rev_parse(rev).with_context(|| format!("cannot resolve {rev}"))?;
52 let records = notes::read(None, &commit)?;
53 if records.is_empty() {
54 bail!("no measurements recorded for {}", &commit[..12]);
55 }
56 if records.len() > MAX_RECORDS {
57 bail!(
58 "refusing to export {} records; the artifact limit is {MAX_RECORDS}",
59 records.len()
60 );
61 }
62
63 let artifact = MeasurementArtifact {
64 v: ARTIFACT_VERSION,
65 commit: commit.clone(),
66 records,
67 };
68 let bytes = serde_json::to_vec(&artifact)?;
69 ensure_export_size(bytes.len())?;
70
71 let parent = artifact_parent(path);
72 fs::create_dir_all(&parent)
73 .with_context(|| format!("could not create {}", parent.display()))?;
74 let mut temp = tempfile::NamedTempFile::new_in(&parent)
75 .with_context(|| format!("could not create a temporary file in {}", parent.display()))?;
76 temp.write_all(&bytes)?;
77 temp.write_all(b"\n")?;
78 temp.as_file().sync_all()?;
79 temp.persist(path)
80 .map_err(|e| e.error)
81 .with_context(|| format!("could not write {}", path.display()))?;
82
83 Ok((commit, artifact.records.len()))
84}
85
86fn read(path: &Path) -> Result<MeasurementArtifact> {
87 let metadata = fs::metadata(path)
88 .with_context(|| format!("could not inspect artifact {}", path.display()))?;
89 if metadata.len() > MAX_ARTIFACT_BYTES {
90 bail!(
91 "artifact is {} bytes; the limit is {MAX_ARTIFACT_BYTES}",
92 metadata.len()
93 );
94 }
95 let bytes = fs::read(path).with_context(|| format!("could not read {}", path.display()))?;
96 let artifact: MeasurementArtifact = serde_json::from_slice(&bytes)
97 .with_context(|| format!("{} is not a valid Tak artifact", path.display()))?;
98
99 if artifact.v != ARTIFACT_VERSION {
100 bail!(
101 "unsupported artifact version {} (expected {ARTIFACT_VERSION})",
102 artifact.v
103 );
104 }
105 if artifact.records.is_empty() {
106 bail!("artifact contains no measurements");
107 }
108 if artifact.records.len() > MAX_RECORDS {
109 bail!(
110 "artifact contains {} records; the limit is {MAX_RECORDS}",
111 artifact.records.len()
112 );
113 }
114 for record in &artifact.records {
115 if record.v != SCHEMA_VERSION {
116 bail!(
117 "artifact contains record schema {} (expected {SCHEMA_VERSION})",
118 record.v
119 );
120 }
121 record
124 .to_line()
125 .context("artifact contains an invalid record")?;
126 if record
127 .metrics
128 .get("instructions")
129 .is_some_and(|instructions| *instructions <= 0.0)
130 {
131 bail!("artifact contains a non-positive instruction count");
132 }
133 }
134 Ok(artifact)
135}
136
137pub fn publish(path: &Path, expect_rev: &str, remote: &str) -> Result<(String, usize)> {
140 let artifact = read(path)?;
141 let expected = notes::rev_parse(expect_rev)
142 .with_context(|| format!("cannot resolve expected revision {expect_rev}"))?;
143 if artifact.commit != expected {
144 bail!(
145 "artifact targets {}, but expected {}",
146 artifact.commit,
147 expected
148 );
149 }
150
151 notes::fetch(remote)?;
152 notes::append(&expected, &artifact.records)?;
153 notes::push(remote)?;
154 Ok((expected, artifact.records.len()))
155}
156
157#[cfg(test)]
158mod tests {
159 use super::*;
160 use std::collections::BTreeMap;
161
162 fn record() -> Record {
163 Record {
164 v: SCHEMA_VERSION,
165 bench: "startup".into(),
166 tool: "self".into(),
167 version: None,
168 runner: "test-runner".into(),
169 ts: "2026-08-31T00:00:00Z".into(),
170 metrics: BTreeMap::from([("instructions".into(), 123.0)]),
171 }
172 }
173
174 #[test]
175 fn unknown_fields_are_rejected() {
176 let json = r#"{"v":1,"commit":"abc","records":[],"surprise":true}"#;
177 assert!(serde_json::from_str::<MeasurementArtifact>(json).is_err());
178 }
179
180 #[test]
181 fn records_roundtrip_canonically() {
182 let artifact = MeasurementArtifact {
183 v: ARTIFACT_VERSION,
184 commit: "0".repeat(40),
185 records: vec![record()],
186 };
187 let json = serde_json::to_string(&artifact).unwrap();
188 let back: MeasurementArtifact = serde_json::from_str(&json).unwrap();
189 assert_eq!(
190 back.records[0].to_line().unwrap(),
191 record().to_line().unwrap()
192 );
193 }
194
195 #[test]
196 fn export_limit_includes_the_trailing_newline() {
197 assert!(ensure_export_size(MAX_ARTIFACT_BYTES as usize - 1).is_ok());
198 assert!(ensure_export_size(MAX_ARTIFACT_BYTES as usize).is_err());
199 }
200
201 #[test]
202 fn non_positive_instruction_counts_are_rejected() {
203 for instructions in [-1.0, 0.0] {
204 let mut invalid = record();
205 invalid.metrics.insert("instructions".into(), instructions);
206 let artifact = MeasurementArtifact {
207 v: ARTIFACT_VERSION,
208 commit: "0".repeat(40),
209 records: vec![invalid],
210 };
211 let file = tempfile::NamedTempFile::new().unwrap();
212 serde_json::to_writer(file.as_file(), &artifact).unwrap();
213 assert!(read(file.path()).is_err());
214 }
215 }
216}