Skip to main content

agent_first_data/document/format/
yaml.rs

1//! YAML format backend. Reads via the noyalib `Value` view; mutates via the
2//! lossless `cst::Document` editor so comments, ordering, styles, and untouched
3//! source bytes are preserved.
4
5use crate::document::{DocumentError, DocumentResult, Value};
6use noyalib::{
7    DuplicateKeyPolicy, Mapping as YamlMapping, ParserConfig, Value as YamlValue,
8    cst::{GreenChild, GreenNode, SyntaxKind, parse_document},
9    from_str_with_config, to_string,
10};
11
12pub fn load(content: &str) -> DocumentResult<Value> {
13    let (protected, literals) = protect_exact_numbers(content)?;
14    let parser_config = ParserConfig::new()
15        .duplicate_key_policy(DuplicateKeyPolicy::Error)
16        .lossless_u64_integers(true);
17    from_str_with_config::<YamlValue>(&protected, &parser_config)
18        .map(|value| value_to_our_value(value, &literals))
19        .map_err(|e| DocumentError::ParseError {
20            format: "YAML".to_string(),
21            detail: e.to_string(),
22        })
23}
24
25/// Replace one scalar in a YAML source document while retaining all unrelated
26/// source bytes. Escaped keys, keyed-list routes, and collection replacement
27/// are deliberately rejected until they have a lossless CST path adapter.
28pub fn set_preserving(content: &str, path: &str, value: &Value) -> DocumentResult<String> {
29    let segments = crate::document::parse_path(path)?;
30    let yaml_path = cst_path(&segments, "set")?;
31    guard_cst_segments(content, &segments, "set", true)?;
32    if !matches!(
33        value,
34        Value::Null
35            | Value::Bool(_)
36            | Value::Integer(_)
37            | Value::Unsigned(_)
38            | Value::Float(_)
39            | Value::Number(_)
40            | Value::String(_)
41    ) {
42        return Err(DocumentError::UnsupportedOperation {
43            format: "YAML".to_string(),
44            operation: "set".to_string(),
45            detail: "collection mutation requires a dedicated CST fragment editor".to_string(),
46        });
47    }
48    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
49        format: "YAML".to_string(),
50        detail: error.to_string(),
51    })?;
52    // Existing leaf: replace in place (preserves the scalar's style). Missing
53    // leaf under an existing mapping: splice a sibling entry via `insert_entry`.
54    let exists = load(content)
55        .ok()
56        .is_some_and(|loaded| crate::document::get_path_ref(&loaded, path, &[]).is_ok());
57    if exists {
58        let result = if let Value::Number(text) = value {
59            document.set(&yaml_path, text)
60        } else {
61            document.set_value(&yaml_path, &to_noyalib_value(value)?)
62        };
63        result.map_err(|error| DocumentError::UnsupportedOperation {
64            format: "YAML".to_string(),
65            operation: "set".to_string(),
66            detail: error.to_string(),
67        })?;
68    } else {
69        let (last, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
70        if last.parse::<usize>().is_ok() {
71            return Err(DocumentError::UnsupportedOperation {
72                format: "YAML".to_string(),
73                operation: "set".to_string(),
74                detail: "cannot create a new sequence index; the element must already exist"
75                    .to_string(),
76            });
77        }
78        let parent_path = if parents.is_empty() {
79            String::new()
80        } else {
81            cst_path(parents, "set")?
82        };
83        let fragment = yaml_fragment(value, "set")?;
84        document
85            .insert_entry(&parent_path, last, fragment.trim_end())
86            .map_err(|error| DocumentError::UnsupportedOperation {
87                format: "YAML".to_string(),
88                operation: "set".to_string(),
89                detail: error.to_string(),
90            })?;
91    }
92    document
93        .validate()
94        .map_err(|error| DocumentError::ParseError {
95            format: "YAML".to_string(),
96            detail: error.to_string(),
97        })?;
98    Ok(document.to_string())
99}
100
101/// Remove an existing YAML entry through the lossless CST editor.
102pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
103    let segments = crate::document::parse_path(path)?;
104    let yaml_path = cst_path(&segments, "unset")?;
105    guard_cst_segments(content, &segments, "unset", false)?;
106    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
107        format: "YAML".to_string(),
108        detail: error.to_string(),
109    })?;
110    document
111        .remove(&yaml_path)
112        .map_err(|error| DocumentError::UnsupportedOperation {
113            format: "YAML".to_string(),
114            operation: "unset".to_string(),
115            detail: error.to_string(),
116        })?;
117    document
118        .validate()
119        .map_err(|error| DocumentError::ParseError {
120            format: "YAML".to_string(),
121            detail: error.to_string(),
122        })?;
123    Ok(document.to_string())
124}
125
126/// Append an item to an existing block YAML sequence using the CST's
127/// indentation-aware editor.
128pub fn append_array_item_preserving(
129    content: &str,
130    path: &str,
131    item: &Value,
132) -> DocumentResult<String> {
133    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
134        format: "YAML".to_string(),
135        detail: error.to_string(),
136    })?;
137    let fragment = yaml_fragment(item, "add")?;
138    let yaml_path = cst_path(&crate::document::parse_path(path)?, "add")?;
139    document
140        .push_back(&yaml_path, fragment.trim_end())
141        .map_err(|error| DocumentError::UnsupportedOperation {
142            format: "YAML".to_string(),
143            operation: "add".to_string(),
144            detail: error.to_string(),
145        })?;
146    document
147        .validate()
148        .map_err(|error| DocumentError::ParseError {
149            format: "YAML".to_string(),
150            detail: error.to_string(),
151        })?;
152    Ok(document.to_string())
153}
154
155/// Remove one item from a YAML sequence by numeric index.
156pub fn remove_array_item_preserving(
157    content: &str,
158    path: &str,
159    index: usize,
160) -> DocumentResult<String> {
161    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
162        format: "YAML".to_string(),
163        detail: error.to_string(),
164    })?;
165    let yaml_path = cst_path(&crate::document::parse_path(path)?, "remove")?;
166    document
167        .remove(&format!("{yaml_path}[{index}]"))
168        .map_err(|error| DocumentError::UnsupportedOperation {
169            format: "YAML".to_string(),
170            operation: "remove".to_string(),
171            detail: error.to_string(),
172        })?;
173    document
174        .validate()
175        .map_err(|error| DocumentError::ParseError {
176            format: "YAML".to_string(),
177            detail: error.to_string(),
178        })?;
179    Ok(document.to_string())
180}
181
182fn cst_path(segments: &[String], operation: &str) -> DocumentResult<String> {
183    if segments.is_empty() {
184        return Err(DocumentError::UnsupportedOperation {
185            format: "YAML".to_string(),
186            operation: operation.to_string(),
187            detail: "root mutation is not supported by the CST path adapter".to_string(),
188        });
189    }
190    let mut path = String::new();
191    for segment in segments {
192        if segment.contains(['.', '\\', '[', ']']) {
193            return Err(DocumentError::UnsupportedOperation {
194                format: "YAML".to_string(),
195                operation: operation.to_string(),
196                detail:
197                    "escaped or bracketed YAML keys require a quoted-key CST span and are not supported"
198                        .to_string(),
199            });
200        }
201        if let Ok(index) = segment.parse::<usize>() {
202            path.push_str(&format!("[{index}]"));
203        } else {
204            if !path.is_empty() {
205                path.push('.');
206            }
207            path.push_str(segment);
208        }
209    }
210    Ok(path)
211}
212
213fn guard_cst_segments(
214    content: &str,
215    segments: &[String],
216    operation: &str,
217    allow_missing: bool,
218) -> DocumentResult<()> {
219    let root = load(content)?;
220    let mut current = &root;
221    for (index, segment) in segments.iter().enumerate() {
222        match current {
223            Value::Object(object) => {
224                if segment.parse::<usize>().is_ok() || segment.contains(['[', ']']) {
225                    return Err(DocumentError::UnsupportedOperation {
226                        format: "YAML".to_string(),
227                        operation: operation.to_string(),
228                        detail: format!(
229                            "mapping key `{segment}` is ambiguous in the CST path grammar"
230                        ),
231                    });
232                }
233                match object.get(segment) {
234                    Some(next) => current = next,
235                    None if allow_missing => {
236                        if segments[index..]
237                            .iter()
238                            .any(|part| part.parse::<usize>().is_ok() || part.contains(['[', ']']))
239                        {
240                            return Err(DocumentError::UnsupportedOperation {
241                                format: "YAML".to_string(),
242                                operation: operation.to_string(),
243                                detail: "a missing mapping chain contains a CST-ambiguous key"
244                                    .to_string(),
245                            });
246                        }
247                        return Ok(());
248                    }
249                    None => {
250                        return Err(DocumentError::PathNotFound {
251                            path: crate::document::join_path(&segments[..=index]),
252                        });
253                    }
254                }
255            }
256            Value::Array(values) => {
257                let array_index =
258                    segment
259                        .parse::<usize>()
260                        .map_err(|_| DocumentError::UnregisteredArray {
261                            path: crate::document::join_path(&segments[..index]),
262                        })?;
263                current =
264                    values
265                        .get(array_index)
266                        .ok_or_else(|| DocumentError::IndexOutOfBounds {
267                            path: crate::document::join_path(&segments[..index]),
268                            index: array_index,
269                            len: values.len(),
270                        })?;
271            }
272            value => {
273                return Err(DocumentError::NotTraversable {
274                    path: crate::document::join_path(&segments[..index]),
275                    got: value.kind_name().to_string(),
276                });
277            }
278        }
279    }
280    Ok(())
281}
282
283fn to_noyalib_value(value: &Value) -> DocumentResult<YamlValue> {
284    match value {
285        Value::Null => Ok(YamlValue::Null),
286        Value::Bool(value) => Ok(YamlValue::Bool(*value)),
287        Value::Integer(value) => Ok(YamlValue::from(*value)),
288        Value::Unsigned(value) => Ok(YamlValue::from(*value)),
289        Value::Float(value) if value.is_finite() => Ok(YamlValue::from(*value)),
290        Value::Float(_) => Err(DocumentError::UnsupportedOperation {
291            format: "YAML".to_string(),
292            operation: "set".to_string(),
293            detail: "non-finite YAML float is not representable".to_string(),
294        }),
295        // See the identical fallback in `format::toml::toml_item`: a
296        // float-shaped `Value::Number` literal parses to `f64` cleanly (YAML
297        // floats are canonically `f64` at the format level too); an
298        // integer-shaped one only exists because it overflows `u64`, so it
299        // can never be written through noyalib's numeric `Value`.
300        Value::Number(text) if value.is_float() => text
301            .parse::<f64>()
302            .ok()
303            .filter(|value| value.is_finite())
304            .map(YamlValue::from)
305            .ok_or_else(|| DocumentError::UnsupportedOperation {
306                format: "YAML".to_string(),
307                operation: "set".to_string(),
308                detail: format!("float literal `{text}` is not representable in YAML"),
309            }),
310        Value::Number(text) => Err(DocumentError::UnsupportedOperation {
311            format: "YAML".to_string(),
312            operation: "set".to_string(),
313            detail: format!("integer literal `{text}` exceeds YAML's 64-bit integer range"),
314        }),
315        Value::String(value) => Ok(YamlValue::String(value.clone())),
316        Value::Array(values) => Ok(YamlValue::Sequence(
317            values
318                .iter()
319                .map(to_noyalib_value)
320                .collect::<DocumentResult<Vec<_>>>()?,
321        )),
322        Value::Object(values) => {
323            let mut mapping = YamlMapping::new();
324            for (key, value) in values {
325                mapping.insert(key.clone(), to_noyalib_value(value)?);
326            }
327            Ok(YamlValue::Mapping(mapping))
328        }
329    }
330}
331
332pub fn save(value: &Value) -> DocumentResult<String> {
333    let mut prefix = "__AFDATA_EXACT_NUMBER_".to_string();
334    while value_contains_text(value, &prefix) {
335        prefix.push('_');
336    }
337    let mut literals = Vec::new();
338    let yaml_val = our_value_to_yaml_value(value, &prefix, &mut literals)?;
339    let mut output = to_string(&yaml_val).map_err(|e| DocumentError::ParseError {
340        format: "YAML".to_string(),
341        detail: e.to_string(),
342    })?;
343    for (sentinel, literal) in literals {
344        output = output.replace(&sentinel, &literal);
345    }
346    Ok(output)
347}
348
349fn value_to_our_value(v: YamlValue, literals: &std::collections::HashMap<String, String>) -> Value {
350    match v {
351        YamlValue::Null => Value::Null,
352        YamlValue::Bool(b) => Value::Bool(b),
353        YamlValue::Number(n) => {
354            if let Some(i) = n.as_i64() {
355                Value::Integer(i)
356            } else if let Some(u) = n.as_u64() {
357                Value::Unsigned(u)
358            } else {
359                Value::Float(n.as_f64())
360            }
361        }
362        YamlValue::String(s) => literals
363            .get(&s)
364            .cloned()
365            .map(Value::Number)
366            .unwrap_or(Value::String(s)),
367        YamlValue::Sequence(seq) => Value::Array(
368            seq.into_iter()
369                .map(|value| value_to_our_value(value, literals))
370                .collect(),
371        ),
372        YamlValue::Mapping(map) => {
373            let mut obj = std::collections::BTreeMap::new();
374            for (key, value) in map {
375                let key = literals.get(&key).cloned().unwrap_or(key);
376                obj.insert(key, value_to_our_value(value, literals));
377            }
378            Value::Object(obj)
379        }
380        YamlValue::Tagged(t) => {
381            // Tagged values: recurse on inner value
382            let (_, value) = t.into_parts();
383            value_to_our_value(value, literals)
384        }
385    }
386}
387
388fn our_value_to_yaml_value(
389    v: &Value,
390    prefix: &str,
391    literals: &mut Vec<(String, String)>,
392) -> DocumentResult<YamlValue> {
393    match v {
394        Value::Null => Ok(YamlValue::Null),
395        Value::Bool(b) => Ok(YamlValue::Bool(*b)),
396        Value::Integer(i) => Ok(YamlValue::Number((*i).into())),
397        Value::Unsigned(i) => Ok(YamlValue::Number((*i).into())),
398        Value::Float(f) if f.is_finite() => Ok(YamlValue::Number((*f).into())),
399        Value::Float(_) => Err(DocumentError::UnsupportedOperation {
400            format: "YAML".to_string(),
401            operation: "save".to_string(),
402            detail: "non-finite float is not representable in YAML".to_string(),
403        }),
404        Value::Number(text) => {
405            if !is_json_number(text) {
406                return Err(DocumentError::UnsupportedOperation {
407                    format: "YAML".to_string(),
408                    operation: "save".to_string(),
409                    detail: format!("invalid number literal `{text}`"),
410                });
411            }
412            let sentinel = format!("{prefix}{}__", literals.len());
413            literals.push((sentinel.clone(), text.clone()));
414            Ok(YamlValue::String(sentinel))
415        }
416        Value::String(s) => Ok(YamlValue::String(s.clone())),
417        Value::Array(a) => {
418            let seq = a
419                .iter()
420                .map(|value| our_value_to_yaml_value(value, prefix, literals))
421                .collect::<DocumentResult<Vec<_>>>()?;
422            Ok(YamlValue::Sequence(seq))
423        }
424        Value::Object(o) => {
425            let mut mapping = YamlMapping::new();
426            for (k, v) in o {
427                mapping.insert(k.clone(), our_value_to_yaml_value(v, prefix, literals)?);
428            }
429            Ok(YamlValue::Mapping(mapping))
430        }
431    }
432}
433
434fn yaml_fragment(value: &Value, operation: &str) -> DocumentResult<String> {
435    if let Value::Number(text) = value
436        && is_json_number(text)
437    {
438        return Ok(text.clone());
439    }
440    if matches!(value, Value::Array(_) | Value::Object(_)) {
441        return save(value);
442    }
443    to_string(&to_noyalib_value(value)?).map_err(|error| DocumentError::UnsupportedOperation {
444        format: "YAML".to_string(),
445        operation: operation.to_string(),
446        detail: error.to_string(),
447    })
448}
449
450fn protect_exact_numbers(
451    content: &str,
452) -> DocumentResult<(String, std::collections::HashMap<String, String>)> {
453    let document = parse_document(content).map_err(|error| DocumentError::ParseError {
454        format: "YAML".to_string(),
455        detail: error.to_string(),
456    })?;
457    let mut spans = Vec::new();
458    collect_exact_number_spans(document.syntax(), content, 0, &mut spans);
459    let mut prefix = "__AFDATA_EXACT_NUMBER_".to_string();
460    while content.contains(&prefix) {
461        prefix.push('_');
462    }
463    let mut protected = content.to_string();
464    let mut literals = std::collections::HashMap::new();
465    for (index, (start, end)) in spans.into_iter().enumerate().rev() {
466        let literal = content[start..end].to_string();
467        let sentinel = format!("{prefix}{index}__");
468        let quoted =
469            serde_json::to_string(&sentinel).map_err(|error| DocumentError::ParseError {
470                format: "YAML".to_string(),
471                detail: error.to_string(),
472            })?;
473        protected.replace_range(start..end, &quoted);
474        literals.insert(sentinel, literal);
475    }
476    Ok((protected, literals))
477}
478
479fn collect_exact_number_spans(
480    node: &GreenNode,
481    source: &str,
482    base: usize,
483    spans: &mut Vec<(usize, usize)>,
484) {
485    let mut offset = base;
486    for child in node.children() {
487        match child {
488            GreenChild::Node(node) => collect_exact_number_spans(node, source, offset, spans),
489            GreenChild::Token {
490                kind: SyntaxKind::PlainScalar,
491                len,
492            } => {
493                let token_end = offset + *len as usize;
494                let text = source[offset..token_end].trim_end_matches([' ', '\t', '\r', '\n']);
495                let end = offset + text.len();
496                if should_preserve_number(text) {
497                    spans.push((offset, end));
498                }
499            }
500            GreenChild::Token { .. } => {}
501        }
502        offset += child.text_len();
503    }
504}
505
506fn should_preserve_number(text: &str) -> bool {
507    if !is_json_number(text) {
508        return false;
509    }
510    text.contains(['.', 'e', 'E']) || (text.parse::<i64>().is_err() && text.parse::<u64>().is_err())
511}
512
513fn is_json_number(text: &str) -> bool {
514    serde_json::from_str::<serde_json::Value>(text).is_ok_and(|value| value.is_number())
515}
516
517fn value_contains_text(value: &Value, needle: &str) -> bool {
518    match value {
519        Value::Number(text) | Value::String(text) => text.contains(needle),
520        Value::Array(values) => values
521            .iter()
522            .any(|value| value_contains_text(value, needle)),
523        Value::Object(values) => values
524            .iter()
525            .any(|(key, value)| key.contains(needle) || value_contains_text(value, needle)),
526        _ => false,
527    }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::{load, save, set_preserving};
533    use crate::document::Value;
534
535    #[test]
536    fn preserves_large_integer_and_high_precision_float_literals() {
537        let source = concat!(
538            "huge: 123456789012345678901234567890\n",
539            "precise: 0.1000000000000000055511151231257827\n",
540        );
541        let value = load(source).expect("load");
542        assert_eq!(
543            value.get("huge"),
544            Some(&Value::Number("123456789012345678901234567890".to_string()))
545        );
546        assert_eq!(
547            value.get("precise"),
548            Some(&Value::Number(
549                "0.1000000000000000055511151231257827".to_string()
550            ))
551        );
552
553        let rendered = save(&value).expect("save");
554        assert!(rendered.contains("123456789012345678901234567890"));
555        assert!(rendered.contains("0.1000000000000000055511151231257827"));
556        assert_eq!(load(&rendered).expect("reload"), value);
557    }
558
559    #[test]
560    fn exact_number_set_keeps_the_literal() {
561        let edited = set_preserving(
562            "price: 1.0\n",
563            "price",
564            &Value::Number("12345678901234567890.123456789".to_string()),
565        )
566        .expect("set");
567        assert_eq!(edited, "price: 12345678901234567890.123456789\n");
568        assert_eq!(
569            load(&edited).expect("reload").get("price"),
570            Some(&Value::Number("12345678901234567890.123456789".to_string()))
571        );
572    }
573
574    #[test]
575    fn save_rejects_non_finite_float() {
576        let error = save(&Value::Float(f64::NAN)).expect_err("NaN must fail");
577        assert!(error.to_string().contains("non-finite"));
578    }
579}