Skip to main content

eska_tool_runtime/
contract.rs

1//! Installation capabilities are independent of release versions and human help.
2
3use 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-1";
13pub const BRIDGE_SCHEMA: u64 = 1;
14
15/// Answer only the exact machine entry, before locale, discovery, signals or native probes.
16#[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/// Check supported schemas rather than assuming that equal or newer release numbers interoperate.
47#[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
84/// Stable numeric triples exclude shell fragments, prereleases and ambiguous leading zeros.
85fn 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    /// Compatible minor releases remain selectable; changed schemas and partial replies do not.
101    #[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-2")),
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    /// An option value equal to the machine entry must remain an ordinary project path.
131    #[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}