1#![allow(clippy::missing_errors_doc, clippy::missing_panics_doc)]
5
6use std::collections::BTreeSet;
7use std::fmt;
8
9use presolve_compiler::platform::{
10 validate_workspace_configuration_v1, WorkspaceConfiguration, WorkspacePath,
11};
12use serde::de::{self, Deserialize, Deserializer, MapAccess, SeqAccess, Visitor};
13use serde_json::Value;
14
15const FIELDS: [&str; 4] = [
16 "source_roots",
17 "feature_flags",
18 "target_profile",
19 "platform_options",
20];
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct CliWorkspaceConfigurationDecodeError {
24 pub code: &'static str,
25 pub pointer: String,
26 pub message: String,
27}
28impl fmt::Display for CliWorkspaceConfigurationDecodeError {
29 fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
30 write!(
31 output,
32 "{} at {}: {}",
33 self.code, self.pointer, self.message
34 )
35 }
36}
37impl std::error::Error for CliWorkspaceConfigurationDecodeError {}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct CliWorkspaceConfigurationEncodeError {
41 pub code: &'static str,
42 pub message: String,
43}
44impl fmt::Display for CliWorkspaceConfigurationEncodeError {
45 fn fmt(&self, output: &mut fmt::Formatter<'_>) -> fmt::Result {
46 write!(output, "{}: {}", self.code, self.message)
47 }
48}
49impl std::error::Error for CliWorkspaceConfigurationEncodeError {}
50
51fn decode_error(
52 code: &'static str,
53 pointer: impl Into<String>,
54 message: impl Into<String>,
55) -> CliWorkspaceConfigurationDecodeError {
56 CliWorkspaceConfigurationDecodeError {
57 code,
58 pointer: pointer.into(),
59 message: message.into(),
60 }
61}
62
63fn valid_scalar(value: &str) -> bool {
64 !value.trim().is_empty() && !value.bytes().any(|byte| byte.is_ascii_control())
65}
66
67fn strings(
68 value: &Value,
69 pointer: &str,
70) -> Result<Vec<String>, CliWorkspaceConfigurationDecodeError> {
71 let values = value.as_array().ok_or_else(|| {
72 decode_error(
73 "L9C005_INVALID_FIELD_TYPE",
74 pointer,
75 "expected an array of strings",
76 )
77 })?;
78 values
79 .iter()
80 .enumerate()
81 .map(|(index, entry)| {
82 entry.as_str().map(str::to_owned).ok_or_else(|| {
83 decode_error(
84 "L9C005_INVALID_FIELD_TYPE",
85 format!("{pointer}/{index}"),
86 "expected a string",
87 )
88 })
89 })
90 .collect()
91}
92
93#[allow(clippy::too_many_lines)]
97pub fn decode_cli_workspace_configuration_v1(
98 value: &Value,
99) -> Result<WorkspaceConfiguration, CliWorkspaceConfigurationDecodeError> {
100 let object = value
101 .as_object()
102 .ok_or_else(|| decode_error("L9C002_EXPECTED_OBJECT", "", "expected a JSON object"))?;
103 for key in object.keys() {
104 if !FIELDS.contains(&key.as_str()) {
105 return Err(decode_error(
106 "L9C003_UNKNOWN_FIELD",
107 format!("/{key}"),
108 "unknown public CLI configuration field",
109 ));
110 }
111 }
112 for field in FIELDS {
113 if !object.contains_key(field) {
114 return Err(decode_error(
115 "L9C004_MISSING_FIELD",
116 format!("/{field}"),
117 "required public CLI configuration field is missing",
118 ));
119 }
120 }
121
122 let roots = strings(&object["source_roots"], "/source_roots")?;
123 if roots.is_empty() {
124 return Err(decode_error(
125 "L9C006_INVALID_SOURCE_ROOT",
126 "/source_roots",
127 "at least one source root is required",
128 ));
129 }
130 let mut source_roots = Vec::with_capacity(roots.len());
131 let mut root_set = BTreeSet::new();
132 for (index, root) in roots.into_iter().enumerate() {
133 let root = WorkspacePath::new(&root).map_err(|_| {
134 decode_error(
135 "L9C006_INVALID_SOURCE_ROOT",
136 format!("/source_roots/{index}"),
137 "source root must be a normalized relative workspace path",
138 )
139 })?;
140 if !root_set.insert(root.clone()) {
141 return Err(decode_error(
142 "L9C007_DUPLICATE_SOURCE_ROOT",
143 format!("/source_roots/{index}"),
144 "source roots must be unique",
145 ));
146 }
147 source_roots.push(root);
148 }
149
150 let feature_flags = strings(&object["feature_flags"], "/feature_flags")?;
151 if feature_flags.iter().any(|flag| !valid_scalar(flag)) {
152 return Err(decode_error(
153 "L9C008_INVALID_FEATURE_FLAG",
154 "/feature_flags",
155 "feature flags must be non-empty printable strings",
156 ));
157 }
158 if feature_flags.windows(2).any(|pair| pair[0] >= pair[1]) {
159 return Err(decode_error(
160 "L9C009_DUPLICATE_OR_NONCANONICAL_FEATURE_FLAG",
161 "/feature_flags",
162 "feature flags must be unique and lexicographically ordered",
163 ));
164 }
165
166 let target_profile = object["target_profile"].as_str().ok_or_else(|| {
167 decode_error(
168 "L9C005_INVALID_FIELD_TYPE",
169 "/target_profile",
170 "expected a string",
171 )
172 })?;
173 if !matches!(target_profile, "default" | "development" | "production") {
174 return Err(decode_error(
175 "L9C010_INVALID_TARGET_PROFILE",
176 "/target_profile",
177 "target profile must be default, development, or production",
178 ));
179 }
180
181 let options = object["platform_options"].as_array().ok_or_else(|| {
182 decode_error(
183 "L9C005_INVALID_FIELD_TYPE",
184 "/platform_options",
185 "expected an array of [key, value] tuples",
186 )
187 })?;
188 let mut platform_options = Vec::with_capacity(options.len());
189 let mut option_keys = BTreeSet::new();
190 for (index, option) in options.iter().enumerate() {
191 let tuple = option.as_array().ok_or_else(|| {
192 decode_error(
193 "L9C011_INVALID_OPTION_TUPLE",
194 format!("/platform_options/{index}"),
195 "platform options must be [key, value] tuples",
196 )
197 })?;
198 if tuple.len() != 2 {
199 return Err(decode_error(
200 "L9C011_INVALID_OPTION_TUPLE",
201 format!("/platform_options/{index}"),
202 "platform options must contain exactly two strings",
203 ));
204 }
205 let key = tuple[0]
206 .as_str()
207 .filter(|key| valid_scalar(key))
208 .ok_or_else(|| {
209 decode_error(
210 "L9C012_INVALID_OPTION_KEY",
211 format!("/platform_options/{index}/0"),
212 "platform option key must be a non-empty printable string",
213 )
214 })?;
215 let option_value = tuple[1]
216 .as_str()
217 .filter(|option_value| valid_scalar(option_value))
218 .ok_or_else(|| {
219 decode_error(
220 "L9C013_INVALID_OPTION_VALUE",
221 format!("/platform_options/{index}/1"),
222 "platform option value must be a non-empty printable string",
223 )
224 })?;
225 if !option_keys.insert(key.to_owned()) {
226 return Err(decode_error(
227 "L9C014_DUPLICATE_OPTION_KEY",
228 format!("/platform_options/{index}/0"),
229 "platform option keys must be unique",
230 ));
231 }
232 platform_options.push((key.to_owned(), option_value.to_owned()));
233 }
234 if platform_options.windows(2).any(|pair| pair[0] >= pair[1]) {
235 return Err(decode_error(
236 "L9C015_NONCANONICAL_OPTION_ORDER",
237 "/platform_options",
238 "platform options must be lexicographically ordered",
239 ));
240 }
241
242 let configuration = WorkspaceConfiguration {
243 source_roots,
244 feature_flags,
245 target_profile: target_profile.to_owned(),
246 platform_options,
247 };
248 validate_workspace_configuration_v1(&configuration)
249 .map_err(|error| decode_error("L9C016_L3_VALIDATION_FAILED", "", error.message))?;
250 Ok(configuration)
251}
252
253pub fn decode_cli_workspace_configuration_bytes_v1(
256 bytes: &[u8],
257) -> Result<WorkspaceConfiguration, CliWorkspaceConfigurationDecodeError> {
258 let StrictValue(value) = serde_json::from_slice(bytes).map_err(|error| {
259 let code = if error.to_string().contains("duplicate object key") {
260 "L9C017_DUPLICATE_OBJECT_KEY"
261 } else {
262 "L9C001_INVALID_JSON"
263 };
264 decode_error(code, "", error.to_string())
265 })?;
266 decode_cli_workspace_configuration_v1(&value)
267}
268
269pub fn encode_cli_workspace_configuration_v1(
272 configuration: &WorkspaceConfiguration,
273) -> Result<Value, CliWorkspaceConfigurationEncodeError> {
274 let configuration = normalize_for_cli(configuration)?;
275 Ok(serde_json::json!({
276 "source_roots": configuration.source_roots.iter().map(ToString::to_string).collect::<Vec<_>>(),
277 "feature_flags": configuration.feature_flags,
278 "target_profile": configuration.target_profile,
279 "platform_options": configuration.platform_options.into_iter().map(|(key, value)| vec![key, value]).collect::<Vec<_>>(),
280 }))
281}
282
283pub fn encode_cli_workspace_configuration_bytes_v1(
285 configuration: &WorkspaceConfiguration,
286) -> Result<Vec<u8>, CliWorkspaceConfigurationEncodeError> {
287 let configuration = normalize_for_cli(configuration)?;
288 let quote = |value: &str| serde_json::to_string(value).expect("strings serialize");
289 let roots = configuration
290 .source_roots
291 .iter()
292 .map(ToString::to_string)
293 .map(|root| quote(&root))
294 .collect::<Vec<_>>()
295 .join(",");
296 let flags = configuration
297 .feature_flags
298 .iter()
299 .map(|flag| quote(flag))
300 .collect::<Vec<_>>()
301 .join(",");
302 let options = configuration
303 .platform_options
304 .iter()
305 .map(|(key, value)| format!("[{},{}]", quote(key), quote(value)))
306 .collect::<Vec<_>>()
307 .join(",");
308 Ok(format!("{{\"source_roots\":[{roots}],\"feature_flags\":[{flags}],\"target_profile\":{},\"platform_options\":[{options}]}}\n", quote(&configuration.target_profile)).into_bytes())
309}
310
311fn normalize_for_cli(
312 configuration: &WorkspaceConfiguration,
313) -> Result<WorkspaceConfiguration, CliWorkspaceConfigurationEncodeError> {
314 let mut configuration = configuration.clone();
315 configuration.feature_flags.sort();
316 configuration.feature_flags.dedup();
317 configuration.platform_options.sort();
318 if configuration
319 .platform_options
320 .windows(2)
321 .any(|pair| pair[0].0 == pair[1].0)
322 {
323 return Err(CliWorkspaceConfigurationEncodeError {
324 code: "L9C114_DUPLICATE_OPTION_KEY",
325 message: "platform option keys must be unique".into(),
326 });
327 }
328 if !matches!(
329 configuration.target_profile.as_str(),
330 "default" | "development" | "production"
331 ) {
332 return Err(CliWorkspaceConfigurationEncodeError {
333 code: "L9C110_INVALID_TARGET_PROFILE",
334 message: "target profile must be default, development, or production".into(),
335 });
336 }
337 if configuration
338 .feature_flags
339 .iter()
340 .any(|flag| !valid_scalar(flag))
341 || configuration
342 .platform_options
343 .iter()
344 .any(|(key, value)| !valid_scalar(key) || !valid_scalar(value))
345 {
346 return Err(CliWorkspaceConfigurationEncodeError {
347 code: "L9C108_INVALID_PUBLIC_CONFIGURATION",
348 message:
349 "public feature flags and platform options must be printable non-empty strings"
350 .into(),
351 });
352 }
353 validate_workspace_configuration_v1(&configuration).map_err(|error| {
354 CliWorkspaceConfigurationEncodeError {
355 code: "L9C116_L3_VALIDATION_FAILED",
356 message: error.message,
357 }
358 })?;
359 Ok(configuration)
360}
361
362struct StrictValue(Value);
363impl<'de> Deserialize<'de> for StrictValue {
364 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
365 struct StrictVisitor;
366 impl<'de> Visitor<'de> for StrictVisitor {
367 type Value = StrictValue;
368 fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
369 formatter.write_str("valid JSON without duplicate object keys")
370 }
371 fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
372 Ok(StrictValue(Value::Null))
373 }
374 fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
375 Ok(StrictValue(Value::Null))
376 }
377 fn visit_bool<E: de::Error>(self, value: bool) -> Result<Self::Value, E> {
378 Ok(StrictValue(Value::Bool(value)))
379 }
380 fn visit_i64<E: de::Error>(self, value: i64) -> Result<Self::Value, E> {
381 Ok(StrictValue(Value::Number(value.into())))
382 }
383 fn visit_u64<E: de::Error>(self, value: u64) -> Result<Self::Value, E> {
384 Ok(StrictValue(Value::Number(value.into())))
385 }
386 fn visit_f64<E: de::Error>(self, value: f64) -> Result<Self::Value, E> {
387 serde_json::Number::from_f64(value)
388 .map(|number| StrictValue(Value::Number(number)))
389 .ok_or_else(|| E::custom("invalid JSON number"))
390 }
391 fn visit_str<E: de::Error>(self, value: &str) -> Result<Self::Value, E> {
392 Ok(StrictValue(Value::String(value.into())))
393 }
394 fn visit_string<E: de::Error>(self, value: String) -> Result<Self::Value, E> {
395 Ok(StrictValue(Value::String(value)))
396 }
397 fn visit_seq<A: SeqAccess<'de>>(
398 self,
399 mut sequence: A,
400 ) -> Result<Self::Value, A::Error> {
401 let mut values = Vec::new();
402 while let Some(StrictValue(value)) = sequence.next_element()? {
403 values.push(value);
404 }
405 Ok(StrictValue(Value::Array(values)))
406 }
407 fn visit_map<A: MapAccess<'de>>(self, mut map: A) -> Result<Self::Value, A::Error> {
408 let mut values = serde_json::Map::new();
409 while let Some(key) = map.next_key::<String>()? {
410 if values.contains_key(&key) {
411 return Err(de::Error::custom("duplicate object key"));
412 }
413 let StrictValue(value) = map.next_value()?;
414 values.insert(key, value);
415 }
416 Ok(StrictValue(Value::Object(values)))
417 }
418 }
419 deserializer.deserialize_any(StrictVisitor)
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use presolve_compiler::platform::{
427 canonical_workspace_configuration_json_v1, workspace_configuration_fingerprint_v1,
428 };
429
430 fn minimum() -> WorkspaceConfiguration {
431 WorkspaceConfiguration::default()
432 }
433 fn nonempty() -> WorkspaceConfiguration {
434 WorkspaceConfiguration {
435 source_roots: vec![
436 WorkspacePath::new("app").unwrap(),
437 WorkspacePath::new("shared").unwrap(),
438 ],
439 feature_flags: vec!["alpha".into(), "strict".into()],
440 target_profile: "development".into(),
441 platform_options: vec![
442 ("emit".into(), "full".into()),
443 ("optimize".into(), "true".into()),
444 ],
445 }
446 }
447 fn round_trip(configuration: &WorkspaceConfiguration) {
448 validate_workspace_configuration_v1(configuration).unwrap();
449 let encoded = encode_cli_workspace_configuration_v1(configuration).unwrap();
450 let decoded = decode_cli_workspace_configuration_v1(&encoded).unwrap();
451 assert_eq!(&decoded, configuration);
452 assert_eq!(
453 workspace_configuration_fingerprint_v1(&decoded).unwrap(),
454 workspace_configuration_fingerprint_v1(configuration).unwrap()
455 );
456 }
457 #[test]
458 fn l9a_constructed_configurations_round_trip_and_preserve_l3_identity() {
459 round_trip(&minimum());
460 round_trip(&nonempty());
461 }
462 #[test]
463 fn l9a_frozen_l3_and_distinct_cli_fixtures_match() {
464 for (configuration, l3, cli) in [
465 (
466 minimum(),
467 include_bytes!("../fixtures/configuration/minimum-l3-v1.json").as_slice(),
468 include_bytes!("../fixtures/configuration/minimum-cli-v1.json").as_slice(),
469 ),
470 (
471 nonempty(),
472 include_bytes!("../fixtures/configuration/nonempty-l3-v1.json").as_slice(),
473 include_bytes!("../fixtures/configuration/nonempty-cli-v1.json").as_slice(),
474 ),
475 ] {
476 assert_eq!(
477 canonical_workspace_configuration_json_v1(&configuration).unwrap(),
478 l3
479 );
480 assert_eq!(
481 encode_cli_workspace_configuration_bytes_v1(&configuration).unwrap(),
482 cli
483 );
484 assert_ne!(l3, cli);
485 }
486 }
487 #[test]
488 fn l9a_strict_shape_and_duplicate_bytes_are_rejected() {
489 for input in [
490 r#"{"schema_version":1,"source_roots":["src"],"feature_flags":[],"target_profile":"default","platform_options":[]}"#,
491 r#"{"source_roots":["src"],"compiler_flags":[],"target_profile":"default","platform_options":[]}"#,
492 r#"{"source_roots":["src"],"feature_flags":[],"target_profile":"default","platform_options":[{"key":"a","value":"b"}]}"#,
493 r#"{"source_roots":["src"],"source_roots":["app"],"feature_flags":[],"target_profile":"default","platform_options":[]}"#,
494 ] {
495 assert!(decode_cli_workspace_configuration_bytes_v1(input.as_bytes()).is_err());
496 }
497 }
498 #[test]
499 fn l9a_twenty_shuffled_object_orders_are_equal() {
500 let canonical = encode_cli_workspace_configuration_v1(&nonempty()).unwrap();
501 for shift in 0..20 {
502 let fields = [
503 ("source_roots", canonical["source_roots"].to_string()),
504 ("feature_flags", canonical["feature_flags"].to_string()),
505 ("target_profile", canonical["target_profile"].to_string()),
506 (
507 "platform_options",
508 canonical["platform_options"].to_string(),
509 ),
510 ];
511 let bytes = (0..4)
512 .map(|index| {
513 let (key, value) = &fields[(index + shift) % 4];
514 format!("\"{key}\":{value}")
515 })
516 .collect::<Vec<_>>()
517 .join(",");
518 assert_eq!(
519 decode_cli_workspace_configuration_bytes_v1(format!("{{{bytes}}}").as_bytes())
520 .unwrap(),
521 nonempty()
522 );
523 }
524 }
525}