Skip to main content

pharos_runtime_core/
yaml.rs

1//! Strict YAML preflight validation with exact duplicate-key locations.
2
3use std::collections::BTreeMap;
4use std::fs;
5use std::path::Path;
6
7use saphyr::LoadableYamlNode;
8use saphyr_parser::{Event, Parser, Span, SpannedEventReceiver};
9use serde_json::{Map, Value};
10
11#[derive(Debug, Clone, Eq, PartialEq)]
12/// Stable YAML diagnostic emitted before semantic contract compilation.
13///
14/// # Fields
15///
16/// - `code`: Stored field on `YamlDiagnostic`.
17/// - `line`: Stored field on `YamlDiagnostic`.
18/// - `column`: Stored field on `YamlDiagnostic`.
19/// - `message`: Stored field on `YamlDiagnostic`.
20///
21/// # Examples
22///
23/// ```rust,ignore
24/// let _ = std::mem::size_of::<pharos_runtime_core::yaml::YamlDiagnostic>();
25/// ```
26///
27/// # Related
28///
29/// - [`crate::yaml`] for constructors, helpers, and neighboring runtime types.
30pub struct YamlDiagnostic {
31    pub code: &'static str,
32    pub line: usize,
33    pub column: usize,
34    pub message: String,
35}
36
37#[derive(Debug)]
38enum Container {
39    Mapping {
40        expecting_key: bool,
41        keys: BTreeMap<String, (usize, usize)>,
42    },
43    Sequence,
44}
45
46#[derive(Debug, Default)]
47struct DuplicateKeyReceiver {
48    containers: Vec<Container>,
49    diagnostic: Option<YamlDiagnostic>,
50}
51
52impl DuplicateKeyReceiver {
53    /// Marks the next child node as the value of its containing mapping.
54    fn begin_child(&mut self, span: Span) {
55        let Some(Container::Mapping { expecting_key, .. }) = self.containers.last_mut() else {
56            return;
57        };
58        if *expecting_key {
59            self.diagnostic.get_or_insert_with(|| YamlDiagnostic {
60                code: "yaml_complex_key_unsupported",
61                line: span.start.line(),
62                column: span.start.col() + 1,
63                message: "mapping keys must be scalar values".to_owned(),
64            });
65        } else {
66            *expecting_key = true;
67        }
68    }
69}
70
71impl<'input> SpannedEventReceiver<'input> for DuplicateKeyReceiver {
72    /// Tracks scalar mapping keys before the high-level loader can overwrite duplicates.
73    fn on_event(&mut self, event: Event<'input>, span: Span) {
74        if self.diagnostic.is_some() {
75            return;
76        }
77        match event {
78            Event::MappingStart(..) => {
79                self.begin_child(span);
80                self.containers.push(Container::Mapping {
81                    expecting_key: true,
82                    keys: BTreeMap::new(),
83                });
84            }
85            Event::SequenceStart(..) => {
86                self.begin_child(span);
87                self.containers.push(Container::Sequence);
88            }
89            Event::MappingEnd | Event::SequenceEnd => {
90                self.containers.pop();
91            }
92            Event::Scalar(value, ..) => {
93                let Some(Container::Mapping {
94                    expecting_key,
95                    keys,
96                }) = self.containers.last_mut()
97                else {
98                    return;
99                };
100                if *expecting_key {
101                    if let Some((first_line, first_column)) = keys.get(value.as_ref()) {
102                        self.diagnostic = Some(YamlDiagnostic {
103                            code: "yaml_duplicate_key",
104                            line: span.start.line(),
105                            column: span.start.col() + 1,
106                            message: format!(
107                                "duplicate key {value:?}; first declared at {first_line}:{first_column}"
108                            ),
109                        });
110                    } else {
111                        keys.insert(
112                            value.into_owned(),
113                            (span.start.line(), span.start.col() + 1),
114                        );
115                        *expecting_key = false;
116                    }
117                } else {
118                    *expecting_key = true;
119                }
120            }
121            Event::Alias(_) => {
122                self.diagnostic = Some(YamlDiagnostic {
123                    code: "yaml_alias_unsupported",
124                    line: span.start.line(),
125                    column: span.start.col() + 1,
126                    message: "YAML aliases are not supported in executable contracts".to_owned(),
127                });
128            }
129            Event::Nothing
130            | Event::StreamStart
131            | Event::StreamEnd
132            | Event::DocumentStart(_)
133            | Event::DocumentEnd => {}
134        }
135    }
136}
137
138/// Rejects duplicate or complex YAML mapping keys before value materialization.
139///
140/// # Parameters
141///
142/// - `text`: Input consumed by [`crate::yaml::validate_unique_mapping_keys`].
143///
144/// # Returns
145///
146/// Returns the successful value produced by [`crate::yaml::validate_unique_mapping_keys`] or an error when the operation fails.
147///
148/// # Errors
149///
150/// Returns an error value when [`crate::yaml::validate_unique_mapping_keys`] cannot complete successfully.
151///
152/// # Performance
153///
154/// Performance is proportional to the size of the supplied inputs and any parsed or loaded data consumed by [`crate::yaml::validate_unique_mapping_keys`].
155///
156/// # Examples
157///
158/// ```rust,ignore
159/// let _result = pharos_runtime_core::yaml::validate_unique_mapping_keys("{}");
160/// ```
161///
162/// # Related
163///
164/// - [`crate::yaml`] for adjacent helpers that share the same runtime surface.
165pub fn validate_unique_mapping_keys(text: &str) -> Result<(), YamlDiagnostic> {
166    let mut receiver = DuplicateKeyReceiver::default();
167    Parser::new_from_str(text)
168        .load(&mut receiver, true)
169        .map_err(|error| YamlDiagnostic {
170            code: "yaml_parse_error",
171            line: error.marker().line(),
172            column: error.marker().col() + 1,
173            message: error.to_string(),
174        })?;
175    receiver.diagnostic.map_or(Ok(()), Err)
176}
177
178/// Converts a loaded YAML node into JSON.
179pub fn yaml_to_json(value: &saphyr::Yaml<'_>) -> Option<Value> {
180    match value {
181        saphyr::Yaml::BadValue | saphyr::Yaml::Alias(_) => None,
182        saphyr::Yaml::Tagged(_, node) => yaml_to_json(node),
183        saphyr::Yaml::Sequence(items) => items
184            .iter()
185            .map(yaml_to_json)
186            .collect::<Option<Vec<_>>>()
187            .map(Value::Array),
188        saphyr::Yaml::Mapping(entries) => {
189            let mut map = Map::new();
190            for (key, entry) in entries {
191                let key = if let Some(text) = key.as_str() {
192                    text.to_owned()
193                } else if let Some(number) = key.as_integer() {
194                    number.to_string()
195                } else if let Some(flag) = key.as_bool() {
196                    flag.to_string()
197                } else {
198                    key.clone().into_floating_point()?.to_string()
199                };
200                map.insert(key, yaml_to_json(entry)?);
201            }
202            Some(Value::Object(map))
203        }
204        saphyr::Yaml::Value(scalar) => match scalar {
205            saphyr::Scalar::Null => Some(Value::Null),
206            saphyr::Scalar::Boolean(flag) => Some(Value::Bool(*flag)),
207            saphyr::Scalar::Integer(number) => Some(Value::Number((*number).into())),
208            saphyr::Scalar::FloatingPoint(number) => {
209                serde_json::Number::from_f64((*number).into()).map(Value::Number)
210            }
211            saphyr::Scalar::String(text) => Some(Value::String(text.to_string())),
212        },
213        saphyr::Yaml::Representation(_, _, _) => None,
214    }
215}
216
217/// Parses one YAML document as JSON after duplicate-key validation.
218pub fn parse_yaml_text(text: &str) -> Option<Value> {
219    validate_unique_mapping_keys(text).ok()?;
220    let documents = saphyr::Yaml::load_from_str(text).ok()?;
221    let first = documents.into_iter().next()?;
222    yaml_to_json(&first)
223}
224
225/// Reads and parses one YAML file as JSON after duplicate-key validation.
226pub fn parse_yaml_file(path: &Path) -> Option<Value> {
227    parse_yaml_text(&fs::read_to_string(path).ok()?)
228}
229
230/// Emits a bounded structured document and verifies that YAML retains its values.
231pub fn serialize_yaml_value(value: &Value) -> Option<String> {
232    let json = serde_json::to_string(value).ok()?;
233    if json.len() > 8 * 1024 * 1024 {
234        return None;
235    }
236    let documents = saphyr::Yaml::load_from_str(&json).ok()?;
237    if documents.len() != 1 {
238        return None;
239    }
240    let mut output = String::new();
241    saphyr::YamlEmitter::new(&mut output)
242        .dump(&documents[0])
243        .ok()?;
244    if output.len() > 8 * 1024 * 1024 || parse_yaml_text(&output).as_ref() != Some(value) {
245        return None;
246    }
247    output.push('\n');
248    Some(output)
249}
250
251#[cfg(test)]
252mod tests {
253    use super::validate_unique_mapping_keys;
254
255    #[test]
256    fn structured_yaml_emission_preserves_ambiguous_strings_and_nested_values() {
257        let document = serde_json::json!({
258            "app_id": "copy_app", "nested": {"app_id": "unchanged"},
259            "values": ["true", "null", "01", "2026-10-06", "line one\nline two", "東京", 42, false, null],
260            "runtime": {"base_path": "/copy-app"},
261        });
262        let emitted =
263            super::serialize_yaml_value(&document).expect("emit YAML without losing types");
264        assert_eq!(super::parse_yaml_text(&emitted), Some(document));
265    }
266
267    #[test]
268    /// Performs rejects duplicate keys with exact location.
269    fn rejects_duplicate_keys_with_exact_location() {
270        let error = validate_unique_mapping_keys("route:\n  path: /first\n  path: /second\n")
271            .expect_err("duplicate key");
272        assert_eq!(error.code, "yaml_duplicate_key");
273        assert_eq!(error.line, 3);
274        assert_eq!(error.column, 3);
275        assert!(error.message.contains("first declared at 2:3"));
276    }
277
278    #[test]
279    /// Performs accepts repeated keys in distinct mappings.
280    fn accepts_repeated_keys_in_distinct_mappings() {
281        validate_unique_mapping_keys("routes:\n- path: /first\n- path: /second\n")
282            .expect("distinct mappings");
283    }
284}