Skip to main content

presolve_compiler/
tooling_schema.rs

1//! L10 transport-neutral tooling schema registry and negotiation.
2//!
3//! This module is intentionally independent from L3-L8 execution and durable
4//! products. It only names already-published schemas and rejects unavailable
5//! future tooling products.
6
7#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
8
9use std::fmt;
10
11pub const TOOLING_SCHEMA_NEGOTIATION_V1_SCHEMA: &str = "presolve.tooling-schema-negotiation";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ToolingSchemaAvailabilityV1 {
15    Available,
16    Reserved,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct ToolingSchemaEntryV1 {
21    pub schema: &'static str,
22    pub version: u32,
23    pub availability: ToolingSchemaAvailabilityV1,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct ToolingSchemaNegotiationRequestV1 {
28    pub schema: String,
29    pub versions: Vec<u32>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct ToolingSchemaNegotiationResponseV1 {
34    pub schema: String,
35    pub accepted_version: u32,
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct ToolingSchemaNegotiationErrorV1 {
40    pub code: &'static str,
41    pub message: String,
42}
43
44impl fmt::Display for ToolingSchemaNegotiationErrorV1 {
45    fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
46        write!(output, "{}: {}", self.code, self.message)
47    }
48}
49
50impl std::error::Error for ToolingSchemaNegotiationErrorV1 {}
51
52#[must_use]
53pub const fn tooling_schema_registry_v1() -> &'static [ToolingSchemaEntryV1] {
54    &[
55        ToolingSchemaEntryV1 {
56            schema: "presolve.workspace-configuration",
57            version: 1,
58            availability: ToolingSchemaAvailabilityV1::Available,
59        },
60        ToolingSchemaEntryV1 {
61            schema: "presolve.workspace-snapshot",
62            version: 1,
63            availability: ToolingSchemaAvailabilityV1::Available,
64        },
65        ToolingSchemaEntryV1 {
66            schema: "presolve.workspace-graph",
67            version: 1,
68            availability: ToolingSchemaAvailabilityV1::Available,
69        },
70        ToolingSchemaEntryV1 {
71            schema: "presolve.compiler-service-protocol",
72            version: 1,
73            availability: ToolingSchemaAvailabilityV1::Available,
74        },
75        ToolingSchemaEntryV1 {
76            schema: "presolve.persistent-artifact-cache",
77            version: 1,
78            availability: ToolingSchemaAvailabilityV1::Available,
79        },
80        ToolingSchemaEntryV1 {
81            schema: "presolve.cache-inspection-report.v1",
82            version: 1,
83            availability: ToolingSchemaAvailabilityV1::Available,
84        },
85        ToolingSchemaEntryV1 {
86            schema: "presolve.workspace-manifest",
87            version: 1,
88            availability: ToolingSchemaAvailabilityV1::Available,
89        },
90        ToolingSchemaEntryV1 {
91            schema: "presolve.watch-session-configuration",
92            version: 1,
93            availability: ToolingSchemaAvailabilityV1::Available,
94        },
95        ToolingSchemaEntryV1 {
96            schema: "presolve.watch-change-batch",
97            version: 1,
98            availability: ToolingSchemaAvailabilityV1::Available,
99        },
100        ToolingSchemaEntryV1 {
101            schema: "presolve.watch-execution-plan",
102            version: 1,
103            availability: ToolingSchemaAvailabilityV1::Available,
104        },
105        ToolingSchemaEntryV1 {
106            schema: "presolve.watch-event",
107            version: 1,
108            availability: ToolingSchemaAvailabilityV1::Available,
109        },
110        ToolingSchemaEntryV1 {
111            schema: "presolve.watch-session-snapshot",
112            version: 1,
113            availability: ToolingSchemaAvailabilityV1::Available,
114        },
115        ToolingSchemaEntryV1 {
116            schema: "presolve.watch-execution-report",
117            version: 1,
118            availability: ToolingSchemaAvailabilityV1::Available,
119        },
120        ToolingSchemaEntryV1 {
121            schema: "presolve.build-trace",
122            version: 1,
123            availability: ToolingSchemaAvailabilityV1::Available,
124        },
125        ToolingSchemaEntryV1 {
126            schema: "presolve.compile-cost-report",
127            version: 1,
128            availability: ToolingSchemaAvailabilityV1::Available,
129        },
130        ToolingSchemaEntryV1 {
131            schema: "presolve.artifact-graph",
132            version: 1,
133            availability: ToolingSchemaAvailabilityV1::Available,
134        },
135        ToolingSchemaEntryV1 {
136            schema: "presolve.query-snapshot",
137            version: 1,
138            availability: ToolingSchemaAvailabilityV1::Available,
139        },
140    ]
141}
142
143pub fn decode_tooling_schema_negotiation_request_v1(
144    bytes: &[u8],
145) -> Result<ToolingSchemaNegotiationRequestV1, ToolingSchemaNegotiationErrorV1> {
146    let value: serde_json::Value =
147        serde_json::from_slice(bytes).map_err(|error| ToolingSchemaNegotiationErrorV1 {
148            code: "L10S001_INVALID_NEGOTIATION_JSON",
149            message: error.to_string(),
150        })?;
151    let object = value.as_object().ok_or(ToolingSchemaNegotiationErrorV1 {
152        code: "L10S002_INVALID_NEGOTIATION_SHAPE",
153        message: "request must be an object".into(),
154    })?;
155    if object.len() != 2 || !object.contains_key("schema") || !object.contains_key("versions") {
156        return Err(ToolingSchemaNegotiationErrorV1 {
157            code: "L10S002_INVALID_NEGOTIATION_SHAPE",
158            message: "request contains unknown or missing fields".into(),
159        });
160    }
161    let schema = object
162        .get("schema")
163        .and_then(serde_json::Value::as_str)
164        .filter(|value| !value.is_empty())
165        .ok_or(ToolingSchemaNegotiationErrorV1 {
166            code: "L10S002_INVALID_NEGOTIATION_SHAPE",
167            message: "schema must be a non-empty string".into(),
168        })?
169        .to_owned();
170    let versions = object
171        .get("versions")
172        .and_then(serde_json::Value::as_array)
173        .ok_or(ToolingSchemaNegotiationErrorV1 {
174            code: "L10S002_INVALID_NEGOTIATION_SHAPE",
175            message: "versions must be an array".into(),
176        })?
177        .iter()
178        .map(|value| {
179            value
180                .as_u64()
181                .and_then(|value| u32::try_from(value).ok())
182                .filter(|value| *value > 0)
183                .ok_or(ToolingSchemaNegotiationErrorV1 {
184                    code: "L10S003_INVALID_VERSION_LIST",
185                    message: "versions must contain positive u32 values".into(),
186                })
187        })
188        .collect::<Result<Vec<_>, _>>()?;
189    if versions.is_empty() || versions.windows(2).any(|pair| pair[0] <= pair[1]) {
190        return Err(ToolingSchemaNegotiationErrorV1 {
191            code: "L10S003_INVALID_VERSION_LIST",
192            message: "versions must be unique descending values".into(),
193        });
194    }
195    Ok(ToolingSchemaNegotiationRequestV1 { schema, versions })
196}
197
198pub fn negotiate_tooling_schema_v1(
199    request: &ToolingSchemaNegotiationRequestV1,
200) -> Result<ToolingSchemaNegotiationResponseV1, ToolingSchemaNegotiationErrorV1> {
201    let entry = tooling_schema_registry_v1()
202        .iter()
203        .find(|entry| entry.schema == request.schema)
204        .ok_or(ToolingSchemaNegotiationErrorV1 {
205            code: "L10S004_UNKNOWN_SCHEMA",
206            message: "schema is not registered".into(),
207        })?;
208    if entry.availability == ToolingSchemaAvailabilityV1::Reserved {
209        return Err(ToolingSchemaNegotiationErrorV1 {
210            code: "L10S005_RESERVED_SCHEMA",
211            message: "schema has no canonical producer".into(),
212        });
213    }
214    if !request.versions.contains(&entry.version) {
215        return Err(ToolingSchemaNegotiationErrorV1 {
216            code: "L10S006_NO_SHARED_VERSION",
217            message: "no supported version was requested".into(),
218        });
219    }
220    Ok(ToolingSchemaNegotiationResponseV1 {
221        schema: request.schema.clone(),
222        accepted_version: entry.version,
223    })
224}
225
226#[must_use]
227pub fn encode_tooling_schema_negotiation_response_v1(
228    response: &ToolingSchemaNegotiationResponseV1,
229) -> Vec<u8> {
230    format!(
231        "{{\"schema\":\"{}\",\"version\":{}}}\n",
232        response.schema, response.accepted_version
233    )
234    .into_bytes()
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240    #[test]
241    fn tooling_registry_is_deterministic_and_approved_entries_negotiate() {
242        assert_eq!(tooling_schema_registry_v1(), tooling_schema_registry_v1());
243        for schema in ["presolve.build-trace", "presolve.query-snapshot"] {
244            let request = decode_tooling_schema_negotiation_request_v1(
245                format!(r#"{{"schema":"{schema}","versions":[1]}}"#).as_bytes(),
246            )
247            .unwrap();
248            assert_eq!(
249                negotiate_tooling_schema_v1(&request)
250                    .unwrap()
251                    .accepted_version,
252                1
253            );
254        }
255    }
256    #[test]
257    fn l10_negotiation_validates_shape_versions_and_compatibility() {
258        let request = decode_tooling_schema_negotiation_request_v1(
259            br#"{"schema":"presolve.workspace-graph","versions":[2,1]}"#,
260        )
261        .unwrap();
262        let response = negotiate_tooling_schema_v1(&request).unwrap();
263        assert_eq!(
264            encode_tooling_schema_negotiation_response_v1(&response),
265            b"{\"schema\":\"presolve.workspace-graph\",\"version\":1}\n"
266        );
267        for bytes in [
268            br"{}".as_slice(),
269            br#"{"schema":"x","versions":[]}"#.as_slice(),
270            br#"{"schema":"x","versions":[1,1]}"#.as_slice(),
271        ] {
272            assert!(decode_tooling_schema_negotiation_request_v1(bytes).is_err());
273        }
274        let unknown =
275            decode_tooling_schema_negotiation_request_v1(br#"{"schema":"x","versions":[1]}"#)
276                .unwrap();
277        assert_eq!(
278            negotiate_tooling_schema_v1(&unknown).unwrap_err().code,
279            "L10S004_UNKNOWN_SCHEMA"
280        );
281    }
282
283    #[test]
284    fn l10_compatibility_fixtures_freeze_acceptance_and_rejection_behavior() {
285        let accepted = decode_tooling_schema_negotiation_request_v1(include_bytes!(
286            "../fixtures/tooling-schema/workspace-graph-v1.request.json"
287        ))
288        .unwrap();
289        assert_eq!(
290            encode_tooling_schema_negotiation_response_v1(
291                &negotiate_tooling_schema_v1(&accepted).unwrap()
292            ),
293            include_bytes!("../fixtures/tooling-schema/workspace-graph-v1.response.json")
294        );
295
296        for (request, code, decodes) in [
297            (
298                include_bytes!("../fixtures/tooling-schema/invalid-shape-v1.request.json")
299                    .as_slice(),
300                include_str!("../fixtures/tooling-schema/invalid-shape-v1.code").trim(),
301                false,
302            ),
303            (
304                include_bytes!("../fixtures/tooling-schema/invalid-version-list-v1.request.json")
305                    .as_slice(),
306                include_str!("../fixtures/tooling-schema/invalid-version-list-v1.code").trim(),
307                false,
308            ),
309            (
310                include_bytes!("../fixtures/tooling-schema/unknown-schema-v1.request.json")
311                    .as_slice(),
312                include_str!("../fixtures/tooling-schema/unknown-schema-v1.code").trim(),
313                true,
314            ),
315            (
316                include_bytes!("../fixtures/tooling-schema/no-shared-version-v1.request.json")
317                    .as_slice(),
318                include_str!("../fixtures/tooling-schema/no-shared-version-v1.code").trim(),
319                true,
320            ),
321        ] {
322            let outcome = decode_tooling_schema_negotiation_request_v1(request)
323                .and_then(|decoded| negotiate_tooling_schema_v1(&decoded));
324            assert_eq!(
325                outcome.unwrap_err().code,
326                code,
327                "fixture decodes: {decodes}"
328            );
329        }
330    }
331}