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_scalar_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::String(_)
37    ) {
38        return Err(DocumentError::UnsupportedOperation {
39            format: "YAML".to_string(),
40            operation: "set".to_string(),
41            detail: "collection mutation requires a dedicated CST fragment editor".to_string(),
42        });
43    }
44    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
45        format: "YAML".to_string(),
46        detail: error.to_string(),
47    })?;
48    // Existing leaf: replace in place (preserves the scalar's style). Missing
49    // leaf under an existing mapping: splice a sibling entry via `insert_entry`.
50    let exists = load(content)
51        .ok()
52        .is_some_and(|loaded| crate::document::get_path_ref(&loaded, path, &[]).is_ok());
53    if exists {
54        document
55            .set_value(&yaml_path, &to_noyalib_value(value)?)
56            .map_err(|error| DocumentError::UnsupportedOperation {
57                format: "YAML".to_string(),
58                operation: "set".to_string(),
59                detail: error.to_string(),
60            })?;
61    } else {
62        let (last, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
63        if last.parse::<usize>().is_ok() {
64            return Err(DocumentError::UnsupportedOperation {
65                format: "YAML".to_string(),
66                operation: "set".to_string(),
67                detail: "cannot create a new sequence index; the element must already exist"
68                    .to_string(),
69            });
70        }
71        let parent_path = if parents.is_empty() {
72            String::new()
73        } else {
74            cst_path(parents, "set")?
75        };
76        let fragment = to_string(&to_noyalib_value(value)?).map_err(|error| {
77            DocumentError::UnsupportedOperation {
78                format: "YAML".to_string(),
79                operation: "set".to_string(),
80                detail: error.to_string(),
81            }
82        })?;
83        document
84            .insert_entry(&parent_path, last, fragment.trim_end())
85            .map_err(|error| DocumentError::UnsupportedOperation {
86                format: "YAML".to_string(),
87                operation: "set".to_string(),
88                detail: error.to_string(),
89            })?;
90    }
91    document
92        .validate()
93        .map_err(|error| DocumentError::ParseError {
94            format: "YAML".to_string(),
95            detail: error.to_string(),
96        })?;
97    Ok(document.to_string())
98}
99
100/// Remove an existing YAML entry through the lossless CST editor.
101pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
102    let segments = crate::document::parse_path(path)?;
103    let yaml_path = cst_path(&segments, "unset")?;
104    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
105        format: "YAML".to_string(),
106        detail: error.to_string(),
107    })?;
108    document
109        .remove(&yaml_path)
110        .map_err(|error| DocumentError::UnsupportedOperation {
111            format: "YAML".to_string(),
112            operation: "unset".to_string(),
113            detail: error.to_string(),
114        })?;
115    document
116        .validate()
117        .map_err(|error| DocumentError::ParseError {
118            format: "YAML".to_string(),
119            detail: error.to_string(),
120        })?;
121    Ok(document.to_string())
122}
123
124/// Append an item to an existing block YAML sequence using the CST's
125/// indentation-aware editor.
126pub fn append_array_item_preserving(
127    content: &str,
128    path: &str,
129    item: &Value,
130) -> DocumentResult<String> {
131    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
132        format: "YAML".to_string(),
133        detail: error.to_string(),
134    })?;
135    let fragment = to_string(&to_noyalib_value(item)?).map_err(|error| {
136        DocumentError::UnsupportedOperation {
137            format: "YAML".to_string(),
138            operation: "add".to_string(),
139            detail: error.to_string(),
140        }
141    })?;
142    let yaml_path = cst_path(&crate::document::parse_path(path)?, "add")?;
143    document
144        .push_back(&yaml_path, fragment.trim_end())
145        .map_err(|error| DocumentError::UnsupportedOperation {
146            format: "YAML".to_string(),
147            operation: "add".to_string(),
148            detail: error.to_string(),
149        })?;
150    document
151        .validate()
152        .map_err(|error| DocumentError::ParseError {
153            format: "YAML".to_string(),
154            detail: error.to_string(),
155        })?;
156    Ok(document.to_string())
157}
158
159/// Remove one item from a YAML sequence by numeric index.
160pub fn remove_array_item_preserving(
161    content: &str,
162    path: &str,
163    index: usize,
164) -> DocumentResult<String> {
165    let mut document = parse_document(content).map_err(|error| DocumentError::ParseError {
166        format: "YAML".to_string(),
167        detail: error.to_string(),
168    })?;
169    let yaml_path = cst_path(&crate::document::parse_path(path)?, "remove")?;
170    document
171        .remove(&format!("{yaml_path}[{index}]"))
172        .map_err(|error| DocumentError::UnsupportedOperation {
173            format: "YAML".to_string(),
174            operation: "remove".to_string(),
175            detail: error.to_string(),
176        })?;
177    document
178        .validate()
179        .map_err(|error| DocumentError::ParseError {
180            format: "YAML".to_string(),
181            detail: error.to_string(),
182        })?;
183    Ok(document.to_string())
184}
185
186fn cst_path(segments: &[String], operation: &str) -> DocumentResult<String> {
187    if segments.is_empty() {
188        return Err(DocumentError::UnsupportedOperation {
189            format: "YAML".to_string(),
190            operation: operation.to_string(),
191            detail: "root mutation is not supported by the CST path adapter".to_string(),
192        });
193    }
194    let mut path = String::new();
195    for segment in segments {
196        if segment.contains(['.', '\\']) {
197            return Err(DocumentError::UnsupportedOperation {
198                format: "YAML".to_string(),
199                operation: operation.to_string(),
200                detail: "escaped YAML keys require a quoted-key CST span and are not supported"
201                    .to_string(),
202            });
203        }
204        if let Ok(index) = segment.parse::<usize>() {
205            path.push_str(&format!("[{index}]"));
206        } else {
207            if !path.is_empty() {
208                path.push('.');
209            }
210            path.push_str(segment);
211        }
212    }
213    Ok(path)
214}
215
216fn to_noyalib_value(value: &Value) -> DocumentResult<YamlValue> {
217    match value {
218        Value::Null => Ok(YamlValue::Null),
219        Value::Bool(value) => Ok(YamlValue::Bool(*value)),
220        Value::Integer(value) => Ok(YamlValue::from(*value)),
221        Value::Unsigned(value) => Ok(YamlValue::from(*value)),
222        Value::Float(value) if value.is_finite() => Ok(YamlValue::from(*value)),
223        Value::Float(_) => Err(DocumentError::UnsupportedOperation {
224            format: "YAML".to_string(),
225            operation: "set".to_string(),
226            detail: "non-finite YAML float is not representable".to_string(),
227        }),
228        Value::String(value) => Ok(YamlValue::String(value.clone())),
229        Value::Array(values) => Ok(YamlValue::Sequence(
230            values
231                .iter()
232                .map(to_noyalib_value)
233                .collect::<DocumentResult<Vec<_>>>()?,
234        )),
235        Value::Object(values) => {
236            let mut mapping = YamlMapping::new();
237            for (key, value) in values {
238                mapping.insert(key.clone(), to_noyalib_value(value)?);
239            }
240            Ok(YamlValue::Mapping(mapping))
241        }
242    }
243}
244
245pub fn save(value: &Value) -> DocumentResult<String> {
246    let yaml_val = our_value_to_yaml_value(value)?;
247    to_string(&yaml_val).map_err(|e| DocumentError::ParseError {
248        format: "YAML".to_string(),
249        detail: e.to_string(),
250    })
251}
252
253fn value_to_our_value(v: YamlValue) -> Value {
254    match v {
255        YamlValue::Null => Value::Null,
256        YamlValue::Bool(b) => Value::Bool(b),
257        YamlValue::Number(n) => {
258            if let Some(i) = n.as_i64() {
259                Value::Integer(i)
260            } else if let Some(u) = n.as_u64() {
261                Value::Unsigned(u)
262            } else {
263                Value::Float(n.as_f64())
264            }
265        }
266        YamlValue::String(s) => Value::String(s),
267        YamlValue::Sequence(seq) => Value::Array(seq.into_iter().map(value_to_our_value).collect()),
268        YamlValue::Mapping(map) => {
269            let mut obj = std::collections::BTreeMap::new();
270            for (key, value) in map {
271                obj.insert(key, value_to_our_value(value));
272            }
273            Value::Object(obj)
274        }
275        YamlValue::Tagged(t) => {
276            // Tagged values: recurse on inner value
277            let (_, value) = t.into_parts();
278            value_to_our_value(value)
279        }
280    }
281}
282
283fn our_value_to_yaml_value(v: &Value) -> DocumentResult<YamlValue> {
284    match v {
285        Value::Null => Ok(YamlValue::Null),
286        Value::Bool(b) => Ok(YamlValue::Bool(*b)),
287        Value::Integer(i) => Ok(YamlValue::Number((*i).into())),
288        Value::Unsigned(i) => Ok(YamlValue::Number((*i).into())),
289        Value::Float(f) => {
290            // Keep the existing config representation: floats serialize as scalars.
291            Ok(YamlValue::Number((*f).into()))
292        }
293        Value::String(s) => Ok(YamlValue::String(s.clone())),
294        Value::Array(a) => {
295            let seq = a
296                .iter()
297                .map(our_value_to_yaml_value)
298                .collect::<DocumentResult<Vec<_>>>()?;
299            Ok(YamlValue::Sequence(seq))
300        }
301        Value::Object(o) => {
302            let mut mapping = YamlMapping::new();
303            for (k, v) in o {
304                mapping.insert(k.clone(), our_value_to_yaml_value(v)?);
305            }
306            Ok(YamlValue::Mapping(mapping))
307        }
308    }
309}