Skip to main content

agent_first_data/document/format/
toml.rs

1//! TOML format backend (format-preserving via toml_edit).
2
3use crate::document::{DocumentError, DocumentResult, Value};
4
5/// Edit an existing TOML item without reserializing the surrounding document.
6///
7/// Scalars, arrays, inline tables, and ordinary tables are supported. Existing
8/// collection nodes are updated in place so their outer decor, element/key
9/// decor, ordering, multiline layout, comments, and trailing-comma style stay
10/// attached to the target wherever the replacement still has a corresponding
11/// element or key. Arrays of tables remain deliberately unsupported because
12/// replacing one safely requires an explicit identity policy.
13pub fn set_preserving(content: &str, path: &str, value: &Value) -> DocumentResult<String> {
14    let segments = crate::document::parse_path(path)?;
15    if segments.iter().any(|segment| segment.contains(['.', '\\'])) {
16        return Err(DocumentError::UnsupportedOperation {
17            format: "TOML".to_string(),
18            operation: "set".to_string(),
19            detail: "escaped TOML keys are not supported by the current document path adapter"
20                .to_string(),
21        });
22    }
23    let mut document =
24        content
25            .parse::<toml_edit::DocumentMut>()
26            .map_err(|error| DocumentError::ParseError {
27                format: "TOML".to_string(),
28                detail: error.to_string(),
29            })?;
30    let (last, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
31    let mut current = document.as_item_mut();
32    for parent in parents {
33        if current.is_array_of_tables() {
34            return Err(collection_refusal(
35                "editing an array of tables requires an explicit element identity",
36            ));
37        }
38        if current
39            .as_value()
40            .and_then(toml_edit::Value::as_array)
41            .is_some()
42        {
43            let index = parent
44                .parse::<usize>()
45                .map_err(|_| DocumentError::UnregisteredArray {
46                    path: path.to_string(),
47                })?;
48            current = current
49                .get_mut(index)
50                .ok_or_else(|| DocumentError::PathNotFound {
51                    path: path.to_string(),
52                })?;
53            continue;
54        }
55        // Auto-create a missing intermediate table so a sparse config can grow
56        // (`set imap.host` when `[imap]` is absent), matching `set_path`.
57        // toml_edit returns `Some(Item::None)` for absent keys, so treat that as
58        // a genuinely missing parent rather than a navigable node.
59        {
60            let table =
61                current
62                    .as_table_like_mut()
63                    .ok_or_else(|| DocumentError::UnsupportedOperation {
64                        format: "TOML".to_string(),
65                        operation: "set".to_string(),
66                        detail: "cannot address a key inside a non-table TOML value".to_string(),
67                    })?;
68            if table.get(parent).filter(|item| !item.is_none()).is_none() {
69                let mut created = toml_edit::Table::new();
70                created.set_implicit(true);
71                table.insert(parent, toml_edit::Item::Table(created));
72            }
73        }
74        current = current
75            .get_mut(parent)
76            .filter(|item| !item.is_none())
77            .ok_or_else(|| DocumentError::PathNotFound {
78                path: path.to_string(),
79            })?;
80    }
81    if current
82        .as_value()
83        .and_then(toml_edit::Value::as_array)
84        .is_some()
85    {
86        let index = last
87            .parse::<usize>()
88            .map_err(|_| DocumentError::UnregisteredArray {
89                path: path.to_string(),
90            })?;
91        let target = current
92            .get_mut(index)
93            .ok_or_else(|| DocumentError::PathNotFound {
94                path: path.to_string(),
95            })?;
96        replace_item_preserving(target, value)?;
97    } else {
98        let table =
99            current
100                .as_table_like_mut()
101                .ok_or_else(|| DocumentError::UnsupportedOperation {
102                    format: "TOML".to_string(),
103                    operation: "set".to_string(),
104                    detail: "cannot address a key inside a non-table TOML value".to_string(),
105                })?;
106        match table.get_mut(last).filter(|item| !item.is_none()) {
107            Some(target) => replace_item_preserving(target, value)?,
108            // New leaf: append into the existing parent table.
109            None => {
110                table.insert(last, toml_item(value)?);
111            }
112        }
113    }
114    Ok(document.to_string())
115}
116
117/// Remove an existing TOML item through `toml_edit`, retaining document decor.
118pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
119    let segments = crate::document::parse_path(path)?;
120    if segments.iter().any(|segment| segment.contains(['.', '\\'])) {
121        return Err(DocumentError::UnsupportedOperation {
122            format: "TOML".to_string(),
123            operation: "unset".to_string(),
124            detail: "escaped TOML keys are not supported by the current document path adapter"
125                .to_string(),
126        });
127    }
128    let (last, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
129    let mut document =
130        content
131            .parse::<toml_edit::DocumentMut>()
132            .map_err(|error| DocumentError::ParseError {
133                format: "TOML".to_string(),
134                detail: error.to_string(),
135            })?;
136    let mut current = document.as_item_mut();
137    for parent in parents {
138        current = current
139            .get_mut(parent)
140            .ok_or_else(|| DocumentError::PathNotFound {
141                path: path.to_string(),
142            })?;
143    }
144    // `as_table_like_mut`, not `as_table_mut`: an inline table is a `Value`, not
145    // an `Item::Table`, and reading only the latter made `unset` refuse a key
146    // `set` had just written — `foo = { path = "..." }` could be edited but not
147    // emptied. The two verbs address the same nodes or neither is trustworthy.
148    let table = current
149        .as_table_like_mut()
150        .ok_or_else(|| DocumentError::UnsupportedOperation {
151            format: "TOML".to_string(),
152            operation: "unset".to_string(),
153            detail: "cannot address a key inside a non-table TOML value".to_string(),
154        })?;
155    let Some(removed) = table.remove(last) else {
156        return Err(DocumentError::PathNotFound {
157            path: path.to_string(),
158        });
159    };
160    // An inline table's closing space lives in the trailing decor of its final
161    // value, so removing the last entry takes ` }` down to `}` — a change to
162    // source the caller did not address. Hand that suffix to the new final
163    // entry, unless it already carries one of its own.
164    let removed_suffix = removed
165        .as_value()
166        .and_then(|value| value.decor().suffix())
167        .cloned();
168    if let (Some(inline), Some(suffix)) = (current.as_inline_table_mut(), removed_suffix)
169        && let Some((_, final_value)) = inline.iter_mut().last()
170    {
171        let carries_its_own = final_value
172            .decor()
173            .suffix()
174            .and_then(|raw| raw.as_str())
175            .is_some_and(|text| !text.is_empty());
176        if !carries_its_own {
177            final_value.decor_mut().set_suffix(suffix);
178        }
179    }
180    Ok(document.to_string())
181}
182
183fn toml_item(value: &Value) -> DocumentResult<toml_edit::Item> {
184    toml_value(value, None).map(toml_edit::Item::Value)
185}
186
187fn toml_value(
188    value: &Value,
189    existing: Option<&toml_edit::Value>,
190) -> DocumentResult<toml_edit::Value> {
191    let mut converted = match value {
192        Value::Null => Err(DocumentError::UnsupportedOperation {
193            format: "TOML".to_string(),
194            operation: "set".to_string(),
195            detail: "TOML has no null value".to_string(),
196        }),
197        Value::Bool(value) => Ok(toml_edit::Value::from(*value)),
198        Value::Integer(value) => Ok(toml_edit::Value::from(*value)),
199        Value::Unsigned(value) => i64::try_from(*value)
200            .map(toml_edit::Value::from)
201            .map_err(|_| DocumentError::UnsupportedOperation {
202                format: "TOML".to_string(),
203                operation: "set".to_string(),
204                detail: "unsigned integer exceeds TOML i64 range".to_string(),
205            }),
206        Value::Float(value) if value.is_finite() => Ok(toml_edit::Value::from(*value)),
207        Value::Float(_) => Err(DocumentError::UnsupportedOperation {
208            format: "TOML".to_string(),
209            operation: "set".to_string(),
210            detail: "non-finite TOML float is not representable".to_string(),
211        }),
212        // A `Value::Number` literal is float-shaped (has a `.`/`e`) or
213        // integer-shaped (does not); an integer-shaped one only exists
214        // because it overflows `u64`, which also overflows TOML's 64-bit
215        // integer grammar, so it can never be written. A float-shaped one
216        // parses to `f64` cleanly (it already passed JSON-number syntax
217        // validation) and writes like any other TOML float — TOML floats
218        // are canonically `f64` at the format level, so this is not a
219        // fidelity regression versus `Value::Float` above.
220        Value::Number(text) if value.is_float() => text
221            .parse::<f64>()
222            .ok()
223            .filter(|value| value.is_finite())
224            .map(toml_edit::Value::from)
225            .ok_or_else(|| DocumentError::UnsupportedOperation {
226                format: "TOML".to_string(),
227                operation: "set".to_string(),
228                detail: format!("float literal `{text}` is not representable in TOML"),
229            }),
230        Value::Number(text) => Err(DocumentError::UnsupportedOperation {
231            format: "TOML".to_string(),
232            operation: "set".to_string(),
233            detail: format!("integer literal `{text}` exceeds TOML's 64-bit integer range"),
234        }),
235        Value::String(value) => {
236            if let Some(existing) = existing.filter(|item| {
237                item.as_datetime()
238                    .is_some_and(|datetime| datetime.to_string() == *value)
239            }) {
240                Ok(existing.clone())
241            } else {
242                Ok(toml_edit::Value::from(value.clone()))
243            }
244        }
245        Value::Array(values) => array_value(values, existing.and_then(toml_edit::Value::as_array)),
246        Value::Object(values) => {
247            inline_table_value(values, existing.and_then(toml_edit::Value::as_inline_table))
248        }
249    }?;
250    if let Some(existing) = existing {
251        *converted.decor_mut() = existing.decor().clone();
252    }
253    Ok(converted)
254}
255
256/// Keep a decor's layout (newlines and indentation) and drop its comments.
257///
258/// An element's prefix decor holds whatever followed the previous element's
259/// comma, which in a multiline array is that element's trailing comment. Copying
260/// it verbatim onto an appended element would reproduce a comment beside a value
261/// the author never wrote it for — inventing content in the one module whose
262/// promise is that it preserves the author's bytes. The indentation is safe to
263/// reuse; the prose is not.
264fn layout_without_comments(decor: &str) -> String {
265    let mut kept = String::with_capacity(decor.len());
266    let mut rest = decor;
267    while let Some(hash) = rest.find('#') {
268        // Also drop the spaces that separated the comment from the value, or
269        // removing it leaves a ragged trailing run behind.
270        kept.push_str(rest[..hash].trim_end_matches([' ', '\t']));
271        match rest[hash..].find('\n') {
272            // Drop the comment body, keep the newline that ends it.
273            Some(newline) => rest = &rest[hash + newline..],
274            // A trailing comment with no newline ends the decor.
275            None => return kept,
276        }
277    }
278    kept.push_str(rest);
279    kept
280}
281
282/// The first comment in a decor string, without its leading whitespace.
283fn first_comment(decor: &str) -> Option<&str> {
284    let start = decor.find('#')?;
285    let end = decor[start..]
286        .find('\n')
287        .map_or(decor.len(), |newline| start + newline);
288    Some(&decor[start..end])
289}
290
291/// Rewrite a decor's first comment, or remove it when `replacement` is `None`.
292fn with_first_comment(decor: &str, replacement: Option<&str>) -> String {
293    let Some(start) = decor.find('#') else {
294        return decor.to_string();
295    };
296    let end = decor[start..]
297        .find('\n')
298        .map_or(decor.len(), |newline| start + newline);
299    let mut out = String::with_capacity(decor.len());
300    match replacement {
301        Some(comment) => {
302            out.push_str(&decor[..start]);
303            out.push_str(comment);
304        }
305        None => out.push_str(decor[..start].trim_end_matches([' ', '\t'])),
306    }
307    out.push_str(&decor[end..]);
308    out
309}
310
311fn array_value(
312    values: &[Value],
313    existing: Option<&toml_edit::Array>,
314) -> DocumentResult<toml_edit::Value> {
315    let existing_values = existing
316        .map(|array| array.iter().cloned().collect::<Vec<_>>())
317        .unwrap_or_default();
318    let mut array = existing.cloned().unwrap_or_default();
319    if array.len() > values.len() {
320        // The comment written after element N-1's comma is stored in element
321        // N's *prefix*, and the last element's trailing comment lives in the
322        // array's trailing decor. Truncating therefore deletes the comment that
323        // documented the last surviving element while keeping the one that
324        // documented a value being removed — leaving a comment attached to the
325        // wrong element. Carry the surviving element's own comment across.
326        let surviving_comment = existing_values
327            .get(values.len())
328            .and_then(|item| item.decor().prefix())
329            .and_then(toml_edit::RawString::as_str)
330            .and_then(first_comment)
331            .map(str::to_string);
332        while array.len() > values.len() {
333            let last = array.len() - 1;
334            array.remove(last);
335        }
336        let trailing = array.trailing().as_str().unwrap_or_default().to_string();
337        if first_comment(&trailing).is_some() {
338            array.set_trailing(with_first_comment(&trailing, surviving_comment.as_deref()));
339        }
340    }
341    let length_before_append = array.len();
342    for (index, value) in values.iter().enumerate() {
343        let hint = existing_values.get(index);
344        let mut converted = toml_value(value, hint)?;
345        if index < array.len() {
346            array.replace_formatted(index, converted);
347        } else {
348            if let Some(prefix) = existing_values
349                .last()
350                .and_then(|item| item.decor().prefix())
351                .and_then(toml_edit::RawString::as_str)
352            {
353                converted
354                    .decor_mut()
355                    .set_prefix(layout_without_comments(prefix));
356            }
357            array.push_formatted(converted);
358        }
359    }
360    // Appending to a single-line array: the previously-last element's suffix is
361    // the space that sat before `]`. Left where it is, it ends up before the new
362    // comma (`[ "one" , "two"]`), so move it to the array's trailing decor where
363    // it goes on separating the last value from the bracket. Done after the
364    // element loop, which restores each replaced element's original decor.
365    if values.len() > length_before_append
366        && let Some(last_index) = length_before_append.checked_sub(1)
367    {
368        let suffix = array
369            .get(last_index)
370            .and_then(|item| item.decor().suffix())
371            .and_then(toml_edit::RawString::as_str)
372            .filter(|suffix| !suffix.is_empty() && suffix.chars().all(|c| c == ' ' || c == '\t'))
373            .map(str::to_string);
374        let trailing_is_empty = array.trailing().as_str().is_none_or(str::is_empty);
375        if let Some(suffix) = suffix
376            && trailing_is_empty
377        {
378            if let Some(item) = array.get_mut(last_index) {
379                item.decor_mut().set_suffix("");
380            }
381            array.set_trailing(suffix);
382        }
383    }
384    if values.is_empty() {
385        array.set_trailing_comma(false);
386    }
387    if existing.is_none() {
388        array.fmt();
389    }
390    Ok(toml_edit::Value::Array(array))
391}
392
393fn inline_table_value(
394    values: &std::collections::BTreeMap<String, Value>,
395    existing: Option<&toml_edit::InlineTable>,
396) -> DocumentResult<toml_edit::Value> {
397    let mut table = existing.cloned().unwrap_or_default();
398    table.retain(|key, _| values.contains_key(key));
399    for (key, value) in values {
400        if let Some(current) = table.get_mut(key) {
401            *current = toml_value(value, Some(current))?;
402        } else {
403            table.insert(key, toml_value(value, None)?);
404        }
405    }
406    if values.is_empty() {
407        table.set_trailing_comma(false);
408    }
409    if existing.is_none() {
410        table.fmt();
411    }
412    Ok(toml_edit::Value::InlineTable(table))
413}
414
415fn replace_item_preserving(target: &mut toml_edit::Item, value: &Value) -> DocumentResult<()> {
416    if target.is_array_of_tables() {
417        return Err(collection_refusal(
418            "editing an array of tables requires an explicit element identity",
419        ));
420    }
421    if let (Some(table), Value::Object(values)) = (target.as_table_mut(), value) {
422        return sync_table(table, values);
423    }
424    let converted = toml_value(value, target.as_value())?;
425    *target = toml_edit::Item::Value(converted);
426    Ok(())
427}
428
429fn sync_table(
430    table: &mut toml_edit::Table,
431    values: &std::collections::BTreeMap<String, Value>,
432) -> DocumentResult<()> {
433    table.retain(|key, _| values.contains_key(key));
434    for (key, value) in values {
435        if let Some(current) = table.get_mut(key) {
436            replace_item_preserving(current, value)?;
437        } else {
438            table.insert(key, toml_item(value)?);
439        }
440    }
441    Ok(())
442}
443
444fn collection_refusal(detail: &str) -> DocumentError {
445    DocumentError::UnsupportedOperation {
446        format: "TOML".to_string(),
447        operation: "set".to_string(),
448        detail: detail.to_string(),
449    }
450}
451
452pub fn load(content: &str) -> DocumentResult<Value> {
453    toml::from_str::<toml::Value>(content)
454        .map(value_to_our_value)
455        .map_err(|e| DocumentError::ParseError {
456            format: "TOML".to_string(),
457            detail: e.to_string(),
458        })
459}
460
461pub fn save(value: &Value) -> DocumentResult<String> {
462    let toml_val = our_value_to_toml_value(value)?;
463    toml::to_string_pretty(&toml_val).map_err(|e| DocumentError::ParseError {
464        format: "TOML".to_string(),
465        detail: e.to_string(),
466    })
467}
468
469fn value_to_our_value(v: toml::Value) -> Value {
470    match v {
471        toml::Value::Boolean(b) => Value::Bool(b),
472        toml::Value::Integer(i) => Value::Integer(i),
473        toml::Value::Float(f) => Value::Float(f),
474        toml::Value::String(s) => Value::String(s),
475        toml::Value::Array(a) => Value::Array(a.into_iter().map(value_to_our_value).collect()),
476        toml::Value::Table(t) => {
477            let map = t
478                .into_iter()
479                .map(|(k, v)| (k, value_to_our_value(v)))
480                .collect();
481            Value::Object(map)
482        }
483        toml::Value::Datetime(dt) => Value::String(dt.to_string()),
484    }
485}
486
487fn our_value_to_toml_value(v: &Value) -> DocumentResult<toml::Value> {
488    match v {
489        Value::Null => Err(DocumentError::UnsupportedOperation {
490            format: "TOML".to_string(),
491            operation: "save".to_string(),
492            detail: "TOML has no null value".to_string(),
493        }),
494        Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
495        Value::Integer(i) => Ok(toml::Value::Integer(*i)),
496        Value::Unsigned(i) => i64::try_from(*i).map(toml::Value::Integer).map_err(|_| {
497            DocumentError::UnsupportedOperation {
498                format: "TOML".to_string(),
499                operation: "save".to_string(),
500                detail: "unsigned integer exceeds TOML i64 range".to_string(),
501            }
502        }),
503        Value::Float(f) => Ok(toml::Value::Float(*f)),
504        Value::Number(text) if v.is_float() => text
505            .parse::<f64>()
506            .ok()
507            .filter(|value| value.is_finite())
508            .map(toml::Value::Float)
509            .ok_or_else(|| DocumentError::UnsupportedOperation {
510                format: "TOML".to_string(),
511                operation: "save".to_string(),
512                detail: format!("float literal `{text}` is not representable in TOML"),
513            }),
514        Value::Number(text) => Err(DocumentError::UnsupportedOperation {
515            format: "TOML".to_string(),
516            operation: "save".to_string(),
517            detail: format!("integer literal `{text}` exceeds TOML's 64-bit integer range"),
518        }),
519        Value::String(s) => Ok(toml::Value::String(s.clone())),
520        Value::Array(a) => {
521            let arr = a
522                .iter()
523                .map(our_value_to_toml_value)
524                .collect::<DocumentResult<Vec<_>>>()?;
525            Ok(toml::Value::Array(arr))
526        }
527        Value::Object(o) => {
528            let mut table = toml::map::Map::new();
529            for (k, v) in o {
530                table.insert(k.clone(), our_value_to_toml_value(v)?);
531            }
532            Ok(toml::Value::Table(table))
533        }
534    }
535}