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::parse_document, from_str_with_config, to_string,
9};
10
11pub fn load(content: &str) -> DocumentResult<Value> {
12    let parser_config = ParserConfig::new()
13        .duplicate_key_policy(DuplicateKeyPolicy::Error)
14        .lossless_u64_integers(true);
15    from_str_with_config::<YamlValue>(content, &parser_config)
16        .map(value_to_our_value)
17        .map_err(|e| DocumentError::ParseError {
18            format: "YAML".to_string(),
19            detail: e.to_string(),
20        })
21}
22
23/// Replace one scalar in a YAML source document while retaining all unrelated
24/// source bytes. Escaped keys, keyed-list routes, and collection replacement
25/// are deliberately rejected until they have a lossless CST path adapter.
26pub fn set_preserving(content: &str, path: &str, value: &Value) -> DocumentResult<String> {
27    let segments = crate::document::parse_path(path)?;
28    let yaml_path = cst_path(&segments, "set")?;
29    if !matches!(
30        value,
31        Value::Null
32            | Value::Bool(_)
33            | Value::Integer(_)
34            | Value::Unsigned(_)
35            | Value::Float(_)
36            | Value::Number(_)
37            | Value::String(_)
38    ) {
39        return Err(DocumentError::UnsupportedOperation {
40            format: "YAML".to_string(),
41            operation: "set".to_string(),
42            detail: "collection mutation requires a dedicated CST fragment editor".to_string(),
43        });
44    }
45    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
46        format: "YAML".to_string(),
47        detail: error.to_string(),
48    })?;
49    // Existing leaf: replace in place (preserves the scalar's style). Missing
50    // leaf under an existing mapping: splice a sibling entry via `insert_entry`.
51    let exists = load(content)
52        .ok()
53        .is_some_and(|loaded| crate::document::get_path_ref(&loaded, path, &[]).is_ok());
54    if exists {
55        document
56            .set_value(&yaml_path, &to_noyalib_value(value)?)
57            .map_err(|error| DocumentError::UnsupportedOperation {
58                format: "YAML".to_string(),
59                operation: "set".to_string(),
60                detail: error.to_string(),
61            })?;
62    } else {
63        let (last, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
64        if last.parse::<usize>().is_ok() {
65            return Err(DocumentError::UnsupportedOperation {
66                format: "YAML".to_string(),
67                operation: "set".to_string(),
68                detail: "cannot create a new sequence index; the element must already exist"
69                    .to_string(),
70            });
71        }
72        let parent_path = if parents.is_empty() {
73            String::new()
74        } else {
75            cst_path(parents, "set")?
76        };
77        let fragment = to_string(&to_noyalib_value(value)?).map_err(|error| {
78            DocumentError::UnsupportedOperation {
79                format: "YAML".to_string(),
80                operation: "set".to_string(),
81                detail: error.to_string(),
82            }
83        })?;
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    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
106        format: "YAML".to_string(),
107        detail: error.to_string(),
108    })?;
109    document
110        .remove(&yaml_path)
111        .map_err(|error| DocumentError::UnsupportedOperation {
112            format: "YAML".to_string(),
113            operation: "unset".to_string(),
114            detail: error.to_string(),
115        })?;
116    document
117        .validate()
118        .map_err(|error| DocumentError::ParseError {
119            format: "YAML".to_string(),
120            detail: error.to_string(),
121        })?;
122    Ok(document.to_string())
123}
124
125/// Append an item to an existing block YAML sequence using the CST's
126/// indentation-aware editor.
127pub fn append_array_item_preserving(
128    content: &str,
129    path: &str,
130    item: &Value,
131) -> DocumentResult<String> {
132    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
133        format: "YAML".to_string(),
134        detail: error.to_string(),
135    })?;
136    let fragment = to_string(&to_noyalib_value(item)?).map_err(|error| {
137        DocumentError::UnsupportedOperation {
138            format: "YAML".to_string(),
139            operation: "add".to_string(),
140            detail: error.to_string(),
141        }
142    })?;
143    let yaml_path = cst_path(&crate::document::parse_path(path)?, "add")?;
144    document
145        .push_back(&yaml_path, fragment.trim_end())
146        .map_err(|error| DocumentError::UnsupportedOperation {
147            format: "YAML".to_string(),
148            operation: "add".to_string(),
149            detail: error.to_string(),
150        })?;
151    document
152        .validate()
153        .map_err(|error| DocumentError::ParseError {
154            format: "YAML".to_string(),
155            detail: error.to_string(),
156        })?;
157    Ok(document.to_string())
158}
159
160/// Remove one item from a YAML sequence by numeric index.
161pub fn remove_array_item_preserving(
162    content: &str,
163    path: &str,
164    index: usize,
165) -> DocumentResult<String> {
166    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
167        format: "YAML".to_string(),
168        detail: error.to_string(),
169    })?;
170    let yaml_path = cst_path(&crate::document::parse_path(path)?, "remove")?;
171    document
172        .remove(&format!("{yaml_path}[{index}]"))
173        .map_err(|error| DocumentError::UnsupportedOperation {
174            format: "YAML".to_string(),
175            operation: "remove".to_string(),
176            detail: error.to_string(),
177        })?;
178    document
179        .validate()
180        .map_err(|error| DocumentError::ParseError {
181            format: "YAML".to_string(),
182            detail: error.to_string(),
183        })?;
184    Ok(document.to_string())
185}
186
187fn cst_path(segments: &[String], operation: &str) -> DocumentResult<String> {
188    if segments.is_empty() {
189        return Err(DocumentError::UnsupportedOperation {
190            format: "YAML".to_string(),
191            operation: operation.to_string(),
192            detail: "root mutation is not supported by the CST path adapter".to_string(),
193        });
194    }
195    let mut path = String::new();
196    for segment in segments {
197        if segment.contains(['.', '\\']) {
198            return Err(DocumentError::UnsupportedOperation {
199                format: "YAML".to_string(),
200                operation: operation.to_string(),
201                detail: "escaped YAML keys require a quoted-key CST span and are not supported"
202                    .to_string(),
203            });
204        }
205        if let Ok(index) = segment.parse::<usize>() {
206            path.push_str(&format!("[{index}]"));
207        } else {
208            if !path.is_empty() {
209                path.push('.');
210            }
211            path.push_str(segment);
212        }
213    }
214    Ok(path)
215}
216
217fn to_noyalib_value(value: &Value) -> DocumentResult<YamlValue> {
218    match value {
219        Value::Null => Ok(YamlValue::Null),
220        Value::Bool(value) => Ok(YamlValue::Bool(*value)),
221        Value::Integer(value) => Ok(YamlValue::from(*value)),
222        Value::Unsigned(value) => Ok(YamlValue::from(*value)),
223        Value::Float(value) if value.is_finite() => Ok(YamlValue::from(*value)),
224        Value::Float(_) => Err(DocumentError::UnsupportedOperation {
225            format: "YAML".to_string(),
226            operation: "set".to_string(),
227            detail: "non-finite YAML float is not representable".to_string(),
228        }),
229        // See the identical fallback in `format::toml::toml_item`: a
230        // float-shaped `Value::Number` literal parses to `f64` cleanly (YAML
231        // floats are canonically `f64` at the format level too); an
232        // integer-shaped one only exists because it overflows `u64`, so it
233        // can never be written through noyalib's numeric `Value`.
234        Value::Number(text) if value.is_float() => text
235            .parse::<f64>()
236            .ok()
237            .filter(|value| value.is_finite())
238            .map(YamlValue::from)
239            .ok_or_else(|| DocumentError::UnsupportedOperation {
240                format: "YAML".to_string(),
241                operation: "set".to_string(),
242                detail: format!("float literal `{text}` is not representable in YAML"),
243            }),
244        Value::Number(text) => Err(DocumentError::UnsupportedOperation {
245            format: "YAML".to_string(),
246            operation: "set".to_string(),
247            detail: format!("integer literal `{text}` exceeds YAML's 64-bit integer range"),
248        }),
249        Value::String(value) => Ok(YamlValue::String(value.clone())),
250        Value::Array(values) => Ok(YamlValue::Sequence(
251            values
252                .iter()
253                .map(to_noyalib_value)
254                .collect::<DocumentResult<Vec<_>>>()?,
255        )),
256        Value::Object(values) => {
257            let mut mapping = YamlMapping::new();
258            for (key, value) in values {
259                mapping.insert(key.clone(), to_noyalib_value(value)?);
260            }
261            Ok(YamlValue::Mapping(mapping))
262        }
263    }
264}
265
266pub fn save(value: &Value) -> DocumentResult<String> {
267    let yaml_val = our_value_to_yaml_value(value)?;
268    to_string(&yaml_val).map_err(|e| DocumentError::ParseError {
269        format: "YAML".to_string(),
270        detail: e.to_string(),
271    })
272}
273
274fn value_to_our_value(v: YamlValue) -> Value {
275    match v {
276        YamlValue::Null => Value::Null,
277        YamlValue::Bool(b) => Value::Bool(b),
278        YamlValue::Number(n) => {
279            if let Some(i) = n.as_i64() {
280                Value::Integer(i)
281            } else if let Some(u) = n.as_u64() {
282                Value::Unsigned(u)
283            } else {
284                Value::Float(n.as_f64())
285            }
286        }
287        YamlValue::String(s) => Value::String(s),
288        YamlValue::Sequence(seq) => Value::Array(seq.into_iter().map(value_to_our_value).collect()),
289        YamlValue::Mapping(map) => {
290            let mut obj = std::collections::BTreeMap::new();
291            for (key, value) in map {
292                obj.insert(key, value_to_our_value(value));
293            }
294            Value::Object(obj)
295        }
296        YamlValue::Tagged(t) => {
297            // Tagged values: recurse on inner value
298            let (_, value) = t.into_parts();
299            value_to_our_value(value)
300        }
301    }
302}
303
304fn our_value_to_yaml_value(v: &Value) -> DocumentResult<YamlValue> {
305    match v {
306        Value::Null => Ok(YamlValue::Null),
307        Value::Bool(b) => Ok(YamlValue::Bool(*b)),
308        Value::Integer(i) => Ok(YamlValue::Number((*i).into())),
309        Value::Unsigned(i) => Ok(YamlValue::Number((*i).into())),
310        Value::Float(f) => {
311            // Keep the existing config representation: floats serialize as scalars.
312            Ok(YamlValue::Number((*f).into()))
313        }
314        Value::Number(text) if v.is_float() => text
315            .parse::<f64>()
316            .ok()
317            .filter(|value| value.is_finite())
318            .map(|value| YamlValue::Number(value.into()))
319            .ok_or_else(|| DocumentError::UnsupportedOperation {
320                format: "YAML".to_string(),
321                operation: "save".to_string(),
322                detail: format!("float literal `{text}` is not representable in YAML"),
323            }),
324        Value::Number(text) => Err(DocumentError::UnsupportedOperation {
325            format: "YAML".to_string(),
326            operation: "save".to_string(),
327            detail: format!("integer literal `{text}` exceeds YAML's 64-bit integer range"),
328        }),
329        Value::String(s) => Ok(YamlValue::String(s.clone())),
330        Value::Array(a) => {
331            let seq = a
332                .iter()
333                .map(our_value_to_yaml_value)
334                .collect::<DocumentResult<Vec<_>>>()?;
335            Ok(YamlValue::Sequence(seq))
336        }
337        Value::Object(o) => {
338            let mut mapping = YamlMapping::new();
339            for (k, v) in o {
340                mapping.insert(k.clone(), our_value_to_yaml_value(v)?);
341            }
342            Ok(YamlValue::Mapping(mapping))
343        }
344    }
345}