1use schemars::generate::SchemaSettings;
16use schemars::{JsonSchema, Schema};
17use serde::{Deserialize, Serialize};
18
19use crate::schema_versions::{SchemaComponent, current_version};
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
23pub struct HostFacts {
24 pub os: String,
26 pub arch: String,
28 #[serde(default, skip_serializing_if = "Option::is_none")]
30 pub libc: Option<String>,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
33 pub shell: Option<String>,
34 pub target_triple: String,
36 #[serde(default)]
40 pub artifact_platforms: Vec<String>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
45pub struct UserFacts {
46 pub remote_user: String,
48 pub home: String,
50 pub temp_root: String,
52 #[serde(default)]
54 pub build_roots: Vec<String>,
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
59pub struct WorkerBinaryFacts {
60 pub rch_wkr_path: String,
62 pub version: String,
64 pub protocol_version: u32,
66}
67
68#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
70pub struct RustFacts {
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub cargo_path: Option<String>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub rustc_version: Option<String>,
77 #[serde(default)]
80 pub toolchains: Vec<String>,
81 #[serde(default)]
83 pub targets: Vec<String>,
84 #[serde(default)]
92 pub components: Vec<String>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
97pub struct RuntimeFacts {
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub bun_version: Option<String>,
100 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub node_version: Option<String>,
102 #[serde(default, skip_serializing_if = "Option::is_none")]
103 pub npm_version: Option<String>,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub nix_version: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub go_version: Option<String>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub zig_version: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub cargo_zigbuild_version: Option<String>,
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
121pub struct DiskRootFacts {
122 pub path: String,
123 pub total_bytes: u64,
124 pub available_bytes: u64,
125 pub available_inodes: u64,
126}
127
128#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
130pub struct DiskFacts {
131 #[serde(default)]
132 pub roots: Vec<DiskRootFacts>,
133}
134
135#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
137pub struct WorkerFacts {
138 pub schema_version: String,
140 pub worker_id: String,
142 pub collected_at_unix_ms: u64,
145 pub host: HostFacts,
146 pub user: UserFacts,
147 pub worker: WorkerBinaryFacts,
148 #[serde(default)]
149 pub rust: RustFacts,
150 #[serde(default)]
151 pub runtimes: RuntimeFacts,
152 #[serde(default)]
153 pub disk: DiskFacts,
154}
155
156impl WorkerFacts {
157 #[must_use]
159 pub fn new(
160 worker_id: impl Into<String>,
161 collected_at_unix_ms: u64,
162 host: HostFacts,
163 user: UserFacts,
164 worker: WorkerBinaryFacts,
165 ) -> Self {
166 Self {
167 schema_version: worker_facts_schema_version().to_string(),
168 worker_id: worker_id.into(),
169 collected_at_unix_ms,
170 host,
171 user,
172 worker,
173 rust: RustFacts::default(),
174 runtimes: RuntimeFacts::default(),
175 disk: DiskFacts::default(),
176 }
177 }
178
179 #[must_use]
183 pub fn supports_target(&self, triple: &str) -> bool {
184 self.host.target_triple == triple
185 || self.host.artifact_platforms.iter().any(|p| p == triple)
186 }
187
188 #[must_use]
196 pub fn has_toolchain(&self, name: &str) -> bool {
197 self.rust
198 .toolchains
199 .iter()
200 .any(|t| t == name || t.starts_with(&format!("{name}-")))
201 }
202
203 #[must_use]
205 pub fn has_target(&self, target: &str) -> bool {
206 self.rust.targets.iter().any(|t| t == target)
207 }
208
209 #[must_use]
224 pub fn has_component(&self, toolchain: &str, component: &str) -> bool {
225 let prefix = format!("{toolchain}-");
226 self.rust.components.iter().any(|entry| {
227 let Some((owner, name)) = entry.split_once(':') else {
228 return false;
229 };
230 name == component && (owner == toolchain || owner.starts_with(&prefix))
231 })
232 }
233}
234
235#[must_use]
237pub fn worker_facts_schema_version() -> &'static str {
238 current_version(SchemaComponent::WorkerFacts)
239}
240
241#[must_use]
244pub fn worker_facts_schema() -> Schema {
245 SchemaSettings::draft07()
246 .into_generator()
247 .into_root_schema_for::<WorkerFacts>()
248}
249
250#[must_use]
253pub fn derive_target_triple(os: &str, arch: &str, libc: Option<&str>) -> String {
254 match os {
255 "linux" => {
256 let env = libc.unwrap_or("gnu");
257 format!("{arch}-unknown-linux-{env}")
258 }
259 "macos" => format!("{arch}-apple-darwin"),
260 "windows" => format!("{arch}-pc-windows-msvc"),
261 other => format!("{arch}-unknown-{other}"),
262 }
263}
264
265#[cfg(test)]
266mod tests {
267 use super::*;
268
269 fn sample() -> WorkerFacts {
270 WorkerFacts::new(
271 "css",
272 1_700_000_000_000,
273 HostFacts {
274 os: "linux".to_string(),
275 arch: "x86_64".to_string(),
276 libc: Some("gnu".to_string()),
277 shell: Some("/bin/bash".to_string()),
278 target_triple: "x86_64-unknown-linux-gnu".to_string(),
279 artifact_platforms: vec!["x86_64-unknown-linux-gnu".to_string()],
280 },
281 UserFacts {
282 remote_user: "rch".to_string(),
283 home: "/home/rch".to_string(),
284 temp_root: "/data/tmp".to_string(),
285 build_roots: vec!["/data/tmp/rch-targets".to_string()],
286 },
287 WorkerBinaryFacts {
288 rch_wkr_path: "/home/rch/.local/bin/rch-wkr".to_string(),
289 version: "1.0.41".to_string(),
290 protocol_version: 3,
291 },
292 )
293 }
294
295 #[test]
296 fn new_stamps_current_schema_version() {
297 assert_eq!(sample().schema_version, "1.0.0");
298 assert_eq!(worker_facts_schema_version(), "1.0.0");
299 }
300
301 #[test]
302 fn serde_roundtrips() {
303 let f = sample();
304 let json = serde_json::to_string(&f).unwrap();
305 let back: WorkerFacts = serde_json::from_str(&json).unwrap();
306 assert_eq!(f, back);
307 }
308
309 #[test]
310 fn top_level_field_names_are_stable() {
311 let v = serde_json::to_value(sample()).unwrap();
312 let mut keys: Vec<&str> = v.as_object().unwrap().keys().map(String::as_str).collect();
313 keys.sort_unstable();
314 assert_eq!(
315 keys,
316 vec![
317 "collected_at_unix_ms",
318 "disk",
319 "host",
320 "runtimes",
321 "rust",
322 "schema_version",
323 "user",
324 "worker",
325 "worker_id",
326 ]
327 );
328 }
329
330 #[test]
331 fn host_facts_omit_optional_fields_when_absent() {
332 let v = serde_json::to_value(HostFacts {
333 os: "linux".to_string(),
334 arch: "aarch64".to_string(),
335 libc: None,
336 shell: None,
337 target_triple: "aarch64-unknown-linux-musl".to_string(),
338 artifact_platforms: vec![],
339 })
340 .unwrap();
341 assert!(v.get("libc").is_none());
342 assert!(v.get("shell").is_none());
343 assert_eq!(v["os"], "linux");
345 assert_eq!(v["target_triple"], "aarch64-unknown-linux-musl");
346 }
347
348 #[test]
349 fn supports_target_matches_own_triple_and_platforms() {
350 let mut f = sample();
351 assert!(f.supports_target("x86_64-unknown-linux-gnu"));
352 assert!(!f.supports_target("aarch64-apple-darwin"));
353 f.host
354 .artifact_platforms
355 .push("x86_64-unknown-linux-musl".to_string());
356 assert!(f.supports_target("x86_64-unknown-linux-musl"));
357 }
358
359 #[test]
360 fn has_toolchain_prefix_and_target_exact() {
361 let mut f = sample();
362 f.rust.toolchains = vec!["stable".to_string(), "nightly-2026-05-22".to_string()];
363 f.rust.targets = vec!["wasm32-unknown-unknown".to_string()];
364 assert!(f.has_toolchain("stable"));
365 assert!(f.has_toolchain("nightly")); assert!(!f.has_toolchain("beta"));
367 assert!(f.has_target("wasm32-unknown-unknown"));
368 assert!(!f.has_target("wasm32-wasi"));
369 }
370
371 #[test]
372 fn has_component_is_toolchain_scoped_and_component_exact() {
373 let mut f = sample();
374 f.rust.components = vec![
375 "stable:clippy".to_string(),
376 "nightly-2026-07-05-x86_64-unknown-linux-gnu:rustfmt".to_string(),
377 "nightlyfoo:clippy".to_string(),
378 "beta:clippy-preview".to_string(),
379 ];
380
381 assert!(f.has_component("stable", "clippy"));
383 assert!(f.has_component("nightly", "rustfmt"));
384 assert!(f.has_component("nightly-2026-07-05-x86_64-unknown-linux-gnu", "rustfmt"));
385
386 assert!(!f.has_component("nightly", "clippy"));
390
391 assert!(!f.has_component("nightl", "clippy"));
394
395 assert!(!f.has_component("beta", "clippy"));
398
399 assert!(!f.has_component("stable", "rust-analyzer"));
401 assert!(!f.has_component("does-not-exist", "clippy"));
402 }
403
404 #[test]
405 fn derive_target_triple_covers_platforms() {
406 assert_eq!(
407 derive_target_triple("linux", "x86_64", Some("gnu")),
408 "x86_64-unknown-linux-gnu"
409 );
410 assert_eq!(
411 derive_target_triple("linux", "aarch64", Some("musl")),
412 "aarch64-unknown-linux-musl"
413 );
414 assert_eq!(
415 derive_target_triple("linux", "x86_64", None),
416 "x86_64-unknown-linux-gnu"
417 );
418 assert_eq!(
419 derive_target_triple("macos", "aarch64", None),
420 "aarch64-apple-darwin"
421 );
422 }
423
424 #[test]
425 fn json_schema_is_generatable_and_describes_the_record() {
426 let schema = worker_facts_schema();
430 let text = serde_json::to_string(&schema).expect("schema serializes");
431 assert!(text.contains("WorkerFacts"));
432 for field in [
433 "schema_version",
434 "worker_id",
435 "collected_at_unix_ms",
436 "target_triple",
437 "artifact_platforms",
438 "rch_wkr_path",
439 "protocol_version",
440 ] {
441 assert!(text.contains(field), "schema omits field {field}");
442 }
443 }
444}