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(preserve_line_endings(content, 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(preserve_line_endings(content, document.to_string()))
181}
182
183fn preserve_line_endings(content: &str, rendered: String) -> String {
184    if content.contains("\r\n")
185        && content.as_bytes().iter().enumerate().all(|(index, byte)| {
186            *byte != b'\n' || (index > 0 && content.as_bytes()[index - 1] == b'\r')
187        })
188    {
189        rendered.replace("\r\n", "\n").replace('\n', "\r\n")
190    } else {
191        rendered
192    }
193}
194
195fn toml_item(value: &Value) -> DocumentResult<toml_edit::Item> {
196    toml_value(value, None).map(toml_edit::Item::Value)
197}
198
199fn toml_value(
200    value: &Value,
201    existing: Option<&toml_edit::Value>,
202) -> DocumentResult<toml_edit::Value> {
203    let mut converted = match value {
204        Value::Null => Err(DocumentError::UnsupportedOperation {
205            format: "TOML".to_string(),
206            operation: "set".to_string(),
207            detail: "TOML has no null value".to_string(),
208        }),
209        Value::Bool(value) => Ok(toml_edit::Value::from(*value)),
210        Value::Integer(value) => Ok(toml_edit::Value::from(*value)),
211        Value::Unsigned(value) => i64::try_from(*value)
212            .map(toml_edit::Value::from)
213            .map_err(|_| DocumentError::UnsupportedOperation {
214                format: "TOML".to_string(),
215                operation: "set".to_string(),
216                detail: "unsigned integer exceeds TOML i64 range".to_string(),
217            }),
218        Value::Float(value) if value.is_finite() => Ok(toml_edit::Value::from(*value)),
219        Value::Float(_) => Err(DocumentError::UnsupportedOperation {
220            format: "TOML".to_string(),
221            operation: "set".to_string(),
222            detail: "non-finite TOML float is not representable".to_string(),
223        }),
224        // A `Value::Number` literal is float-shaped (has a `.`/`e`) or
225        // integer-shaped (does not); an integer-shaped one only exists
226        // because it overflows `u64`, which also overflows TOML's 64-bit
227        // integer grammar, so it can never be written. A float-shaped one
228        // parses to `f64` cleanly (it already passed JSON-number syntax
229        // validation) and writes like any other TOML float — TOML floats
230        // are canonically `f64` at the format level, so this is not a
231        // fidelity regression versus `Value::Float` above.
232        Value::Number(text) if value.is_float() => text
233            .parse::<f64>()
234            .ok()
235            .filter(|value| value.is_finite())
236            .map(toml_edit::Value::from)
237            .ok_or_else(|| DocumentError::UnsupportedOperation {
238                format: "TOML".to_string(),
239                operation: "set".to_string(),
240                detail: format!("float literal `{text}` is not representable in TOML"),
241            }),
242        Value::Number(text) => Err(DocumentError::UnsupportedOperation {
243            format: "TOML".to_string(),
244            operation: "set".to_string(),
245            detail: format!("integer literal `{text}` exceeds TOML's 64-bit integer range"),
246        }),
247        Value::String(value) => {
248            if let Some(existing) = existing.filter(|item| {
249                item.as_datetime()
250                    .is_some_and(|datetime| datetime.to_string() == *value)
251            }) {
252                Ok(existing.clone())
253            } else if value.contains('\n') {
254                single_line_string(value)
255            } else {
256                Ok(toml_edit::Value::from(value.clone()))
257            }
258        }
259        Value::Array(values) => array_value(values, existing.and_then(toml_edit::Value::as_array)),
260        Value::Object(values) => {
261            inline_table_value(values, existing.and_then(toml_edit::Value::as_inline_table))
262        }
263    }?;
264    if let Some(existing) = existing {
265        *converted.decor_mut() = existing.decor().clone();
266    }
267    Ok(converted)
268}
269
270/// A string holding a newline, written as a one-line basic string.
271///
272/// The default rendering is a `\"\"\"` multiline literal, which puts the value's
273/// own newlines into the source as raw line breaks. [`preserve_line_endings`]
274/// cannot tell those from the document's line endings, so on a CRLF file it
275/// would rewrite the value's `\n` to `\r\n` — a successful `set` that stores
276/// something other than what it was given. Escaped, the value carries no raw
277/// line break for a line-ending pass to touch.
278fn single_line_string(value: &str) -> DocumentResult<toml_edit::Value> {
279    use std::fmt::Write as _;
280    let mut literal = String::with_capacity(value.len() + 2);
281    literal.push('"');
282    for character in value.chars() {
283        match character {
284            '\\' => literal.push_str("\\\\"),
285            '"' => literal.push_str("\\\""),
286            '\n' => literal.push_str("\\n"),
287            '\r' => literal.push_str("\\r"),
288            '\t' => literal.push_str("\\t"),
289            control if control.is_control() => {
290                let _ = write!(literal, "\\u{:04X}", u32::from(control));
291            }
292            other => literal.push(other),
293        }
294    }
295    literal.push('"');
296    let mut parsed = literal.parse::<toml_edit::Value>().map_err(|error| {
297        DocumentError::UnsupportedOperation {
298            format: "TOML".to_string(),
299            operation: "set".to_string(),
300            detail: format!("string is not representable in TOML: {error}"),
301        }
302    })?;
303    // A parsed value carries explicit empty decor; clear it so the surrounding
304    // table or array applies its own spacing as it does for any new value.
305    parsed.decor_mut().clear();
306    Ok(parsed)
307}
308
309/// Keep a decor's layout (newlines and indentation) and drop its comments.
310///
311/// An element's prefix decor holds whatever followed the previous element's
312/// comma, which in a multiline array is that element's trailing comment. Copying
313/// it verbatim onto an appended element would reproduce a comment beside a value
314/// the author never wrote it for — inventing content in the one module whose
315/// promise is that it preserves the author's bytes. The indentation is safe to
316/// reuse; the prose is not.
317fn layout_without_comments(decor: &str) -> String {
318    let mut kept = String::with_capacity(decor.len());
319    let mut rest = decor;
320    while let Some(hash) = rest.find('#') {
321        // Also drop the spaces that separated the comment from the value, or
322        // removing it leaves a ragged trailing run behind.
323        kept.push_str(rest[..hash].trim_end_matches([' ', '\t']));
324        match rest[hash..].find('\n') {
325            // Drop the comment body, keep the newline that ends it.
326            Some(newline) => rest = &rest[hash + newline..],
327            // A trailing comment with no newline ends the decor.
328            None => return kept,
329        }
330    }
331    kept.push_str(rest);
332    kept
333}
334
335/// The first comment in a decor string, without its leading whitespace.
336fn first_comment(decor: &str) -> Option<&str> {
337    let start = decor.find('#')?;
338    let end = decor[start..]
339        .find('\n')
340        .map_or(decor.len(), |newline| start + newline);
341    Some(&decor[start..end])
342}
343
344/// Rewrite a decor's first comment, or remove it when `replacement` is `None`.
345fn with_first_comment(decor: &str, replacement: Option<&str>) -> String {
346    let Some(start) = decor.find('#') else {
347        return decor.to_string();
348    };
349    let end = decor[start..]
350        .find('\n')
351        .map_or(decor.len(), |newline| start + newline);
352    let mut out = String::with_capacity(decor.len());
353    match replacement {
354        Some(comment) => {
355            out.push_str(&decor[..start]);
356            out.push_str(comment);
357        }
358        None => out.push_str(decor[..start].trim_end_matches([' ', '\t'])),
359    }
360    out.push_str(&decor[end..]);
361    out
362}
363
364fn array_value(
365    values: &[Value],
366    existing: Option<&toml_edit::Array>,
367) -> DocumentResult<toml_edit::Value> {
368    let existing_values = existing
369        .map(|array| array.iter().cloned().collect::<Vec<_>>())
370        .unwrap_or_default();
371    let mut array = existing.cloned().unwrap_or_default();
372    if array.len() > values.len() {
373        // The comment written after element N-1's comma is stored in element
374        // N's *prefix*, and the last element's trailing comment lives in the
375        // array's trailing decor. Truncating therefore deletes the comment that
376        // documented the last surviving element while keeping the one that
377        // documented a value being removed — leaving a comment attached to the
378        // wrong element. Carry the surviving element's own comment across.
379        let surviving_comment = existing_values
380            .get(values.len())
381            .and_then(|item| item.decor().prefix())
382            .and_then(toml_edit::RawString::as_str)
383            .and_then(first_comment)
384            .map(str::to_string);
385        while array.len() > values.len() {
386            let last = array.len() - 1;
387            array.remove(last);
388        }
389        let trailing = array.trailing().as_str().unwrap_or_default().to_string();
390        if first_comment(&trailing).is_some() {
391            array.set_trailing(with_first_comment(&trailing, surviving_comment.as_deref()));
392        }
393    }
394    let length_before_append = array.len();
395    for (index, value) in values.iter().enumerate() {
396        let hint = existing_values.get(index);
397        let mut converted = toml_value(value, hint)?;
398        if index < array.len() {
399            array.replace_formatted(index, converted);
400        } else {
401            if let Some(prefix) = existing_values
402                .last()
403                .and_then(|item| item.decor().prefix())
404                .and_then(toml_edit::RawString::as_str)
405            {
406                converted
407                    .decor_mut()
408                    .set_prefix(layout_without_comments(prefix));
409            }
410            array.push_formatted(converted);
411        }
412    }
413    // Appending to a single-line array: the previously-last element's suffix is
414    // the space that sat before `]`. Left where it is, it ends up before the new
415    // comma (`[ "one" , "two"]`), so move it to the array's trailing decor where
416    // it goes on separating the last value from the bracket. Done after the
417    // element loop, which restores each replaced element's original decor.
418    if values.len() > length_before_append
419        && let Some(last_index) = length_before_append.checked_sub(1)
420    {
421        let suffix = array
422            .get(last_index)
423            .and_then(|item| item.decor().suffix())
424            .and_then(toml_edit::RawString::as_str)
425            .filter(|suffix| !suffix.is_empty() && suffix.chars().all(|c| c == ' ' || c == '\t'))
426            .map(str::to_string);
427        let trailing_is_empty = array.trailing().as_str().is_none_or(str::is_empty);
428        if let Some(suffix) = suffix
429            && trailing_is_empty
430        {
431            if let Some(item) = array.get_mut(last_index) {
432                item.decor_mut().set_suffix("");
433            }
434            array.set_trailing(suffix);
435        }
436    }
437    if values.is_empty() {
438        array.set_trailing_comma(false);
439    }
440    if existing.is_none() {
441        array.fmt();
442    }
443    Ok(toml_edit::Value::Array(array))
444}
445
446fn inline_table_value(
447    values: &std::collections::BTreeMap<String, Value>,
448    existing: Option<&toml_edit::InlineTable>,
449) -> DocumentResult<toml_edit::Value> {
450    let mut table = existing.cloned().unwrap_or_default();
451    table.retain(|key, _| values.contains_key(key));
452    for (key, value) in values {
453        if let Some(current) = table.get_mut(key) {
454            *current = toml_value(value, Some(current))?;
455        } else {
456            table.insert(key, toml_value(value, None)?);
457        }
458    }
459    if values.is_empty() {
460        table.set_trailing_comma(false);
461    }
462    if existing.is_none() {
463        table.fmt();
464    }
465    Ok(toml_edit::Value::InlineTable(table))
466}
467
468fn replace_item_preserving(target: &mut toml_edit::Item, value: &Value) -> DocumentResult<()> {
469    if target.is_array_of_tables() {
470        return Err(collection_refusal(
471            "editing an array of tables requires an explicit element identity",
472        ));
473    }
474    if let (Some(table), Value::Object(values)) = (target.as_table_mut(), value) {
475        return sync_table(table, values);
476    }
477    let converted = toml_value(value, target.as_value())?;
478    *target = toml_edit::Item::Value(converted);
479    Ok(())
480}
481
482fn sync_table(
483    table: &mut toml_edit::Table,
484    values: &std::collections::BTreeMap<String, Value>,
485) -> DocumentResult<()> {
486    table.retain(|key, _| values.contains_key(key));
487    for (key, value) in values {
488        if let Some(current) = table.get_mut(key) {
489            replace_item_preserving(current, value)?;
490        } else {
491            table.insert(key, toml_item(value)?);
492        }
493    }
494    Ok(())
495}
496
497fn collection_refusal(detail: &str) -> DocumentError {
498    DocumentError::UnsupportedOperation {
499        format: "TOML".to_string(),
500        operation: "set".to_string(),
501        detail: detail.to_string(),
502    }
503}
504
505pub fn load(content: &str) -> DocumentResult<Value> {
506    toml::from_str::<toml::Value>(content)
507        .map(value_to_our_value)
508        .map_err(|e| DocumentError::ParseError {
509            format: "TOML".to_string(),
510            detail: e.to_string(),
511        })
512}
513
514pub fn save(value: &Value) -> DocumentResult<String> {
515    let toml_val = our_value_to_toml_value(value)?;
516    toml::to_string_pretty(&toml_val).map_err(|e| DocumentError::ParseError {
517        format: "TOML".to_string(),
518        detail: e.to_string(),
519    })
520}
521
522fn value_to_our_value(v: toml::Value) -> Value {
523    match v {
524        toml::Value::Boolean(b) => Value::Bool(b),
525        toml::Value::Integer(i) => Value::Integer(i),
526        toml::Value::Float(f) => Value::Float(f),
527        toml::Value::String(s) => Value::String(s),
528        toml::Value::Array(a) => Value::Array(a.into_iter().map(value_to_our_value).collect()),
529        toml::Value::Table(t) => {
530            let map = t
531                .into_iter()
532                .map(|(k, v)| (k, value_to_our_value(v)))
533                .collect();
534            Value::Object(map)
535        }
536        toml::Value::Datetime(dt) => Value::String(dt.to_string()),
537    }
538}
539
540fn our_value_to_toml_value(v: &Value) -> DocumentResult<toml::Value> {
541    match v {
542        Value::Null => Err(DocumentError::UnsupportedOperation {
543            format: "TOML".to_string(),
544            operation: "save".to_string(),
545            detail: "TOML has no null value".to_string(),
546        }),
547        Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
548        Value::Integer(i) => Ok(toml::Value::Integer(*i)),
549        Value::Unsigned(i) => i64::try_from(*i).map(toml::Value::Integer).map_err(|_| {
550            DocumentError::UnsupportedOperation {
551                format: "TOML".to_string(),
552                operation: "save".to_string(),
553                detail: "unsigned integer exceeds TOML i64 range".to_string(),
554            }
555        }),
556        Value::Float(f) => Ok(toml::Value::Float(*f)),
557        Value::Number(text) if v.is_float() => text
558            .parse::<f64>()
559            .ok()
560            .filter(|value| value.is_finite())
561            .map(toml::Value::Float)
562            .ok_or_else(|| DocumentError::UnsupportedOperation {
563                format: "TOML".to_string(),
564                operation: "save".to_string(),
565                detail: format!("float literal `{text}` is not representable in TOML"),
566            }),
567        Value::Number(text) => Err(DocumentError::UnsupportedOperation {
568            format: "TOML".to_string(),
569            operation: "save".to_string(),
570            detail: format!("integer literal `{text}` exceeds TOML's 64-bit integer range"),
571        }),
572        Value::String(s) => Ok(toml::Value::String(s.clone())),
573        Value::Array(a) => {
574            let arr = a
575                .iter()
576                .map(our_value_to_toml_value)
577                .collect::<DocumentResult<Vec<_>>>()?;
578            Ok(toml::Value::Array(arr))
579        }
580        Value::Object(o) => {
581            let mut table = toml::map::Map::new();
582            for (k, v) in o {
583                table.insert(k.clone(), our_value_to_toml_value(v)?);
584            }
585            Ok(toml::Value::Table(table))
586        }
587    }
588}