eska_tool_runtime/
contract.rs1use serde_json::{Value, json};
4use std::{
5 ffi::OsString,
6 io::{self, Write},
7 process::ExitCode,
8};
9
10pub const SCHEMA_VERSION: u64 = 2;
11pub const TARGET: &str = env!("ESKA_BUILD_TARGET");
12pub const CONFIG_SCHEMA: &str = "designer-xml-4";
13pub const BRIDGE_SCHEMA: u64 = 1;
14
15#[must_use]
17pub fn respond(
18 args: &[OsString],
19 tool: &str,
20 version: &str,
21 metadata_api: Option<(u64, u64)>,
22) -> Option<ExitCode> {
23 if args.len() != 2 || args[1] != "__tool-contract" {
24 return None;
25 }
26 let value = json!({
27 "schema_version": SCHEMA_VERSION,
28 "tool": tool,
29 "target": TARGET,
30 "version": version,
31 "config_schema": CONFIG_SCHEMA,
32 "bridge_schema": BRIDGE_SCHEMA,
33 "metadata_api": metadata_api.map(|(major,minor)| json!({"major":major,"minor":minor})),
34 });
35 let mut output = io::stdout().lock();
36 let result = serde_json::to_writer(&mut output, &value)
37 .map_err(io::Error::other)
38 .and_then(|()| output.write_all(b"\n"));
39 Some(if result.is_ok() {
40 ExitCode::SUCCESS
41 } else {
42 ExitCode::FAILURE
43 })
44}
45
46#[must_use]
48pub fn compatible(value: &Value, expected_tool: &str) -> bool {
49 let Some(object) = value.as_object() else {
50 return false;
51 };
52 object.len() == 7
53 && [
54 "schema_version",
55 "tool",
56 "target",
57 "version",
58 "config_schema",
59 "bridge_schema",
60 "metadata_api",
61 ]
62 .iter()
63 .all(|key| object.contains_key(*key))
64 && value["schema_version"] == SCHEMA_VERSION
65 && value["tool"] == expected_tool
66 && value["target"] == TARGET
67 && value["version"].as_str().is_some_and(stable_version)
68 && value["config_schema"] == CONFIG_SCHEMA
69 && value["bridge_schema"] == BRIDGE_SCHEMA
70 && if expected_tool == "eska-project" {
71 value["metadata_api"].as_object().is_some_and(|api| {
72 api.len() == 2
73 && api.get("major").and_then(Value::as_u64) == Some(1)
74 && api
75 .get("minor")
76 .and_then(Value::as_u64)
77 .is_some_and(|minor| minor >= 12)
78 })
79 } else {
80 value["metadata_api"].is_null()
81 }
82}
83
84fn stable_version(value: &str) -> bool {
86 let parts: Vec<_> = value.split('.').collect();
87 parts.len() == 3
88 && parts.iter().all(|part| {
89 !part.is_empty()
90 && (*part == "0" || !part.starts_with('0'))
91 && part.bytes().all(|byte| byte.is_ascii_digit())
92 && part.parse::<u64>().is_ok()
93 })
94}
95
96#[cfg(test)]
97mod tests {
98 use super::*;
99
100 #[test]
102 fn contract_checks_capabilities_and_exact_identity() {
103 let value = json!({"schema_version":SCHEMA_VERSION,"tool":"eska-project","target":TARGET,"version":"0.14.3",
104 "config_schema":CONFIG_SCHEMA,"bridge_schema":1,"metadata_api":{"major":1,"minor":12}});
105 assert!(compatible(&value, "eska-project"));
106 for (key, replacement) in [
107 ("schema_version", json!(3)),
108 ("schema_version", json!(1)),
109 ("tool", json!("eska")),
110 ("version", json!("0.14.3-dev")),
111 ("config_schema", json!("designer-xml-1")),
112 ("bridge_schema", json!(2)),
113 ("metadata_api", json!({"major":2,"minor":12})),
114 ("metadata_api", json!({"major":1,"minor":11})),
115 ("metadata_api", json!({"wrong":1,"minor":12})),
116 ] {
117 let mut changed = value.clone();
118 changed[key] = replacement;
119 assert!(!compatible(&changed, "eska-project"), "{changed}");
120 }
121 let mut newer = value.clone();
122 newer["version"] = json!("0.15.0");
123 newer["metadata_api"]["minor"] = json!(13);
124 assert!(compatible(&newer, "eska-project"));
125 let mut partial = value;
126 partial.as_object_mut().unwrap().remove("bridge_schema");
127 assert!(!compatible(&partial, "eska-project"));
128 }
129
130 #[test]
132 fn unrelated_arguments_are_not_intercepted() {
133 for args in [
134 vec!["eska"],
135 vec!["eska", "--project-dir", "__tool-contract"],
136 vec!["eska", "__tool-contract", "--extra"],
137 ] {
138 let args: Vec<_> = args.into_iter().map(OsString::from).collect();
139 assert!(respond(&args, "eska", "0.14.3", None).is_none());
140 }
141 }
142}