1#![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}