pharos_runtime_core/
yaml.rs1use 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)]
12pub 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 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 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
138pub 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
178pub 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
217pub 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
225pub fn parse_yaml_file(path: &Path) -> Option<Value> {
227 parse_yaml_text(&fs::read_to_string(path).ok()?)
228}
229
230pub 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 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 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}