Skip to main content

presolve_cli/
configuration_codec.rs

1//! Strict L9 public `presolve.json` configuration codec.
2//!
3//! This adapter is deliberately separate from L3's frozen durable serializer.
4#![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/// Decodes the strict, public L9 configuration representation into the
94/// existing L3 Rust semantic product. It neither reads paths nor calls an L3
95/// JSON decoder (none is introduced by L9).
96#[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
253/// Strict byte decoding additionally rejects duplicate object keys before a
254/// `serde_json::Value` can erase their evidence.
255pub 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
269/// Encodes a normalized public CLI configuration. It calls existing L3
270/// validation but does not use the L3 serializer as an authoring format.
271pub 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
283/// Returns canonical public CLI JSON bytes in the L9 field order.
284pub 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}