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    keep_bom(content, |body| set_body(body, path, value))
15}
16
17// Path segments arrive unescaped, and `toml_edit` quotes any key that needs
18// it, so `hosts.example\.com` from `paths` addresses `"example.com"` here
19// exactly as it does in every other format.
20fn set_body(content: &str, path: &str, value: &Value) -> DocumentResult<String> {
21    let segments = crate::document::parse_path(path)?;
22    let mut document =
23        content
24            .parse::<toml_edit::DocumentMut>()
25            .map_err(|error| DocumentError::ParseError {
26                format: "TOML".to_string(),
27                detail: error.to_string(),
28            })?;
29    let (last, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
30    let mut current = document.as_item_mut();
31    for parent in parents {
32        if current.is_array_of_tables() {
33            return Err(collection_refusal(
34                "editing an array of tables requires an explicit element identity",
35            ));
36        }
37        if current
38            .as_value()
39            .and_then(toml_edit::Value::as_array)
40            .is_some()
41        {
42            let index = parent
43                .parse::<usize>()
44                .map_err(|_| DocumentError::UnregisteredArray {
45                    path: path.to_string(),
46                })?;
47            current = current
48                .get_mut(index)
49                .ok_or_else(|| DocumentError::PathNotFound {
50                    path: path.to_string(),
51                })?;
52            continue;
53        }
54        // Auto-create a missing intermediate table so a sparse config can grow
55        // (`set imap.host` when `[imap]` is absent), matching `set_path`.
56        // toml_edit returns `Some(Item::None)` for absent keys, so treat that as
57        // a genuinely missing parent rather than a navigable node.
58        {
59            let table =
60                current
61                    .as_table_like_mut()
62                    .ok_or_else(|| DocumentError::UnsupportedOperation {
63                        format: "TOML".to_string(),
64                        operation: "set".to_string(),
65                        detail: "cannot address a key inside a non-table TOML value".to_string(),
66                    })?;
67            if table.get(parent).filter(|item| !item.is_none()).is_none() {
68                let mut created = toml_edit::Table::new();
69                created.set_implicit(true);
70                table.insert(parent, toml_edit::Item::Table(created));
71                hand_closing_space_to_new_entry(current);
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        if last == "-" {
87            let parent_key = crate::document::join_path(parents);
88            let mut values = match crate::document::get_path(
89                &load(content)?,
90                &parent_key,
91                crate::document::Addressing::INDEX_ONLY,
92            )? {
93                Value::Array(values) => values,
94                _ => return Err(collection_refusal("append target is not an array")),
95            };
96            let unchanged = values.len();
97            values.push(value.clone());
98            let existing = current
99                .as_array()
100                .ok_or_else(|| collection_refusal("append target is not an array"))?;
101            *current = toml_edit::Item::Value(array_value_with_prefix(
102                &values,
103                Some(existing),
104                unchanged,
105            )?);
106            return Ok(preserve_line_endings(content, document.to_string()));
107        }
108        let index = last
109            .parse::<usize>()
110            .map_err(|_| DocumentError::UnregisteredArray {
111                path: path.to_string(),
112            })?;
113        let target = current
114            .get_mut(index)
115            .ok_or_else(|| DocumentError::PathNotFound {
116                path: path.to_string(),
117            })?;
118        replace_item_preserving(target, value)?;
119    } else if current.is_array_of_tables() {
120        // The same refusal the parent walk and `unset` give, rather than a
121        // "non-table value" error about a node that is made of tables.
122        return Err(collection_refusal(
123            "editing an array of tables requires an explicit element identity",
124        ));
125    } else {
126        let table =
127            current
128                .as_table_like_mut()
129                .ok_or_else(|| DocumentError::UnsupportedOperation {
130                    format: "TOML".to_string(),
131                    operation: "set".to_string(),
132                    detail: "cannot address a key inside a non-table TOML value".to_string(),
133                })?;
134        match table.get_mut(last).filter(|item| !item.is_none()) {
135            Some(target) => replace_item_preserving(target, value)?,
136            // New leaf: append into the existing parent table.
137            None => {
138                table.insert(last, toml_item(value)?);
139                hand_closing_space_to_new_entry(current);
140            }
141        }
142    }
143    Ok(preserve_line_endings(content, document.to_string()))
144}
145
146/// After an entry was appended to `item`, move the space before `}` onto it.
147///
148/// In `{ y = 1 }` the space before `}` is the last value's suffix; left there
149/// it ends up before the new comma (`{ y = 1 , z = 2 }`). Hand it to the new
150/// last entry — a new leaf and a new table created on the way to one alike.
151/// Anything other than an inline table has no such space and is left alone.
152fn hand_closing_space_to_new_entry(item: &mut toml_edit::Item) {
153    let Some(inline) = item.as_inline_table_mut() else {
154        return;
155    };
156    let count = inline.len();
157    if count < 2 {
158        return;
159    }
160    let moved = inline.iter_mut().nth(count - 2).and_then(|(_, previous)| {
161        let suffix = previous.decor().suffix()?.as_str()?.to_string();
162        (!suffix.is_empty() && suffix.trim().is_empty()).then(|| {
163            previous.decor_mut().set_suffix("");
164            suffix
165        })
166    });
167    if let Some((_, added)) = inline.iter_mut().last() {
168        added.decor_mut().set_suffix(moved.unwrap_or_default());
169    }
170}
171
172/// Remove an existing TOML item through `toml_edit`, retaining document decor.
173pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
174    keep_bom(content, |body| unset_body(body, path))
175}
176
177fn unset_body(content: &str, path: &str) -> DocumentResult<String> {
178    let segments = crate::document::parse_path(path)?;
179    let (last, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
180    let mut document =
181        content
182            .parse::<toml_edit::DocumentMut>()
183            .map_err(|error| DocumentError::ParseError {
184                format: "TOML".to_string(),
185                detail: error.to_string(),
186            })?;
187    // The same walk `set` makes: an array is entered by index, so the two
188    // verbs reach the same nodes.
189    let mut current = document.as_item_mut();
190    for parent in parents {
191        if current.is_array_of_tables() {
192            return Err(DocumentError::UnsupportedOperation {
193                format: "TOML".to_string(),
194                operation: "unset".to_string(),
195                detail: "editing an array of tables requires an explicit element identity"
196                    .to_string(),
197            });
198        }
199        let next = if current.as_array().is_some() {
200            parent
201                .parse::<usize>()
202                .ok()
203                .and_then(|index| current.get_mut(index))
204        } else {
205            current.get_mut(parent)
206        };
207        current =
208            next.filter(|item| !item.is_none())
209                .ok_or_else(|| DocumentError::PathNotFound {
210                    path: path.to_string(),
211                })?;
212    }
213    if current.is_array_of_tables() {
214        return Err(DocumentError::UnsupportedOperation {
215            format: "TOML".to_string(),
216            operation: "unset".to_string(),
217            detail: "editing an array of tables requires an explicit element identity".to_string(),
218        });
219    }
220    if let Some(array) = current.as_array_mut() {
221        let index = last
222            .parse::<usize>()
223            .ok()
224            .filter(|index| *index < array.len())
225            .ok_or_else(|| DocumentError::PathNotFound {
226                path: path.to_string(),
227            })?;
228        let removed = array.remove(index);
229        remove_element_comment(array, index, &removed);
230        return Ok(preserve_line_endings(content, document.to_string()));
231    }
232    // `as_table_like_mut`, not `as_table_mut`: an inline table is a `Value`, not
233    // an `Item::Table`, and reading only the latter made `unset` refuse a key
234    // `set` had just written — `foo = { path = "..." }` could be edited but not
235    // emptied. The two verbs address the same nodes or neither is trustworthy.
236    let table = current
237        .as_table_like_mut()
238        .ok_or_else(|| DocumentError::UnsupportedOperation {
239            format: "TOML".to_string(),
240            operation: "unset".to_string(),
241            detail: "cannot address a key inside a non-table TOML value".to_string(),
242        })?;
243    let Some(removed) = table.remove(last) else {
244        return Err(DocumentError::PathNotFound {
245            path: path.to_string(),
246        });
247    };
248    // An inline table's closing space lives in the trailing decor of its final
249    // value, so removing the last entry takes ` }` down to `}` — a change to
250    // source the caller did not address. Hand that suffix to the new final
251    // entry, unless it already carries one of its own.
252    let removed_suffix = removed
253        .as_value()
254        .and_then(|value| value.decor().suffix())
255        .cloned();
256    if let (Some(inline), Some(suffix)) = (current.as_inline_table_mut(), removed_suffix)
257        && let Some((_, final_value)) = inline.iter_mut().last()
258    {
259        let carries_its_own = final_value
260            .decor()
261            .suffix()
262            .and_then(|raw| raw.as_str())
263            .is_some_and(|text| !text.is_empty());
264        if !carries_its_own {
265            final_value.decor_mut().set_suffix(suffix);
266        }
267    }
268    Ok(preserve_line_endings(content, document.to_string()))
269}
270
271/// `toml_edit` drops a leading byte-order mark, so a one-key edit would
272/// rewrite the file's first bytes. Edit the body and put the mark back.
273fn keep_bom(
274    content: &str,
275    edit: impl FnOnce(&str) -> DocumentResult<String>,
276) -> DocumentResult<String> {
277    match content.strip_prefix('\u{feff}') {
278        Some(body) => edit(body).map(|edited| format!("\u{feff}{edited}")),
279        None => edit(content),
280    }
281}
282
283fn preserve_line_endings(content: &str, rendered: String) -> String {
284    if content.contains("\r\n")
285        && content.as_bytes().iter().enumerate().all(|(index, byte)| {
286            *byte != b'\n' || (index > 0 && content.as_bytes()[index - 1] == b'\r')
287        })
288    {
289        rendered.replace("\r\n", "\n").replace('\n', "\r\n")
290    } else {
291        rendered
292    }
293}
294
295fn toml_item(value: &Value) -> DocumentResult<toml_edit::Item> {
296    toml_value(value, None).map(toml_edit::Item::Value)
297}
298
299fn toml_value(
300    value: &Value,
301    existing: Option<&toml_edit::Value>,
302) -> DocumentResult<toml_edit::Value> {
303    let mut converted = match value {
304        Value::Null => Err(DocumentError::UnsupportedOperation {
305            format: "TOML".to_string(),
306            operation: "set".to_string(),
307            detail: "TOML has no null value".to_string(),
308        }),
309        Value::Bool(value) => Ok(toml_edit::Value::from(*value)),
310        Value::Integer(value) => Ok(toml_edit::Value::from(*value)),
311        Value::Unsigned(value) => i64::try_from(*value)
312            .map(toml_edit::Value::from)
313            .map_err(|_| DocumentError::UnsupportedOperation {
314                format: "TOML".to_string(),
315                operation: "set".to_string(),
316                detail: "unsigned integer exceeds TOML i64 range".to_string(),
317            }),
318        Value::Float(value) if value.is_finite() => Ok(toml_edit::Value::from(*value)),
319        Value::Float(_) => Err(DocumentError::UnsupportedOperation {
320            format: "TOML".to_string(),
321            operation: "set".to_string(),
322            detail: "non-finite TOML float is not representable".to_string(),
323        }),
324        // A `Value::Number` literal is float-shaped (has a `.`/`e`) or
325        // integer-shaped (does not); an integer-shaped one only exists
326        // because it overflows `u64`, which also overflows TOML's 64-bit
327        // integer grammar, so it can never be written. A float-shaped one
328        // parses to `f64` cleanly (it already passed JSON-number syntax
329        // validation) and writes like any other TOML float — TOML floats
330        // are canonically `f64` at the format level, so this is not a
331        // fidelity regression versus `Value::Float` above.
332        Value::Number(text) if value.is_float() => text
333            .parse::<f64>()
334            .ok()
335            .filter(|value| value.is_finite())
336            .map(toml_edit::Value::from)
337            .ok_or_else(|| DocumentError::UnsupportedOperation {
338                format: "TOML".to_string(),
339                operation: "set".to_string(),
340                detail: "float literal is not representable in TOML".to_string(),
341            }),
342        Value::Number(_) => Err(DocumentError::UnsupportedOperation {
343            format: "TOML".to_string(),
344            operation: "set".to_string(),
345            detail: "integer literal exceeds TOML's 64-bit integer range".to_string(),
346        }),
347        Value::String(value) => {
348            // Reads report a datetime as its text, so text written back
349            // over a datetime stays one when it still spells a datetime;
350            // otherwise a read-edit-write round trip turns it into a string.
351            let existing_datetime = existing.filter(|item| item.is_datetime());
352            if let Some(existing) = existing_datetime.filter(|item| {
353                item.as_datetime()
354                    .is_some_and(|datetime| datetime.to_string() == *value)
355            }) {
356                Ok(existing.clone())
357            } else if let Some(datetime) =
358                existing_datetime.and_then(|_| value.parse::<toml_edit::Datetime>().ok())
359            {
360                Ok(toml_edit::Value::from(datetime))
361            } else if value.contains('\n') {
362                single_line_string(value)
363            } else {
364                Ok(toml_edit::Value::from(value.clone()))
365            }
366        }
367        Value::Array(values) => array_value(values, existing.and_then(toml_edit::Value::as_array)),
368        Value::Object(values) => {
369            inline_table_value(values, existing.and_then(toml_edit::Value::as_inline_table))
370        }
371    }?;
372    if let Some(existing) = existing {
373        *converted.decor_mut() = existing.decor().clone();
374    }
375    Ok(converted)
376}
377
378/// A string holding a newline, written as a one-line basic string.
379///
380/// The default rendering is a `\"\"\"` multiline literal, which puts the value's
381/// own newlines into the source as raw line breaks. [`preserve_line_endings`]
382/// cannot tell those from the document's line endings, so on a CRLF file it
383/// would rewrite the value's `\n` to `\r\n` — a successful `set` that stores
384/// something other than what it was given. Escaped, the value carries no raw
385/// line break for a line-ending pass to touch.
386fn single_line_string(value: &str) -> DocumentResult<toml_edit::Value> {
387    use std::fmt::Write as _;
388    let mut literal = String::with_capacity(value.len() + 2);
389    literal.push('"');
390    for character in value.chars() {
391        match character {
392            '\\' => literal.push_str("\\\\"),
393            '"' => literal.push_str("\\\""),
394            '\n' => literal.push_str("\\n"),
395            '\r' => literal.push_str("\\r"),
396            '\t' => literal.push_str("\\t"),
397            control if control.is_control() => {
398                let _ = write!(literal, "\\u{:04X}", u32::from(control));
399            }
400            other => literal.push(other),
401        }
402    }
403    literal.push('"');
404    let mut parsed = literal.parse::<toml_edit::Value>().map_err(|error| {
405        DocumentError::UnsupportedOperation {
406            format: "TOML".to_string(),
407            operation: "set".to_string(),
408            detail: format!("string is not representable in TOML: {error}"),
409        }
410    })?;
411    // A parsed value carries explicit empty decor; clear it so the surrounding
412    // table or array applies its own spacing as it does for any new value.
413    parsed.decor_mut().clear();
414    Ok(parsed)
415}
416
417/// Keep a decor's layout (newlines and indentation) and drop its comments.
418///
419/// An element's prefix decor holds whatever followed the previous element's
420/// comma, which in a multiline array is that element's trailing comment. Copying
421/// it verbatim onto an appended element would reproduce a comment beside a value
422/// the author never wrote it for — inventing content in the one module whose
423/// promise is that it preserves the author's bytes. The indentation is safe to
424/// reuse; the prose is not.
425fn layout_without_comments(decor: &str) -> String {
426    let mut kept = String::with_capacity(decor.len());
427    let mut rest = decor;
428    while let Some(hash) = rest.find('#') {
429        // Also drop the spaces that separated the comment from the value, or
430        // removing it leaves a ragged trailing run behind.
431        kept.push_str(rest[..hash].trim_end_matches([' ', '\t']));
432        match rest[hash..].find('\n') {
433            // Drop the comment body, keep the newline that ends it.
434            Some(newline) => rest = &rest[hash + newline..],
435            // A trailing comment with no newline ends the decor.
436            None => return kept,
437        }
438    }
439    kept.push_str(rest);
440    kept
441}
442
443/// Re-attach comments after `removed` left `array` at `index`.
444///
445/// An element's own comment is written after its comma, so it is stored in the
446/// *next* element's prefix, or in the array's trailing decor (the removed
447/// element's suffix, without a trailing comma) when it was the last one. Left
448/// alone, removal deletes the comment of the element before it and hands the
449/// removed element's comment to that neighbour. The first line of each decor
450/// is the comment beside the previous value; later lines (own-line comments,
451/// indentation) belong to what follows and stay put.
452///
453/// The first element is the same rule with `[` as the "previous value": the
454/// first line of its prefix is the comment beside the bracket (`[ # header`),
455/// which stays, while its later lines are the removed element's own-line
456/// comments, which go with it.
457fn remove_element_comment(array: &mut toml_edit::Array, index: usize, removed: &toml_edit::Value) {
458    let decor_text = |raw: Option<&toml_edit::RawString>| {
459        raw.and_then(toml_edit::RawString::as_str)
460            .unwrap_or_default()
461            .to_string()
462    };
463    let removed_prefix = decor_text(removed.decor().prefix());
464    if index == 0
465        && let Some(first) = array.get_mut(0)
466    {
467        let prefix = decor_text(first.decor().prefix());
468        let beside_bracket = removed_prefix.split('\n').next().unwrap_or_default();
469        let layout = if prefix.contains('\n') {
470            // The new first element keeps its own lines — the own-line
471            // comments above it — and drops its first line, which was the
472            // removed element's comment.
473            format!("{beside_bracket}{}", after_first_line(&prefix))
474        } else if removed_prefix.contains('\n') {
475            // `[\n  1, 2\n]`: the new first element sat on the removed one's
476            // line, so it takes the line break and indentation it had.
477            let indentation = &removed_prefix[removed_prefix.rfind('\n').unwrap_or_default()..];
478            format!("{beside_bracket}{indentation}")
479        } else {
480            // One line (`[ 1, 2 ]`): the space after `[` is the array's
481            // layout, so it stays at the front.
482            removed_prefix
483        };
484        first.decor_mut().set_prefix(layout);
485        return;
486    }
487    // The comment beside the element (or, for the first one, the bracket)
488    // before the removed one, which the removed element's prefix held.
489    let carried = comment_line(&removed_prefix);
490    if let Some(next) = array.get_mut(index) {
491        let prefix = decor_text(next.decor().prefix());
492        let rest = after_first_line(&prefix);
493        match (carried, comment_line(&prefix)) {
494            (Some(comment), _) => next.decor_mut().set_prefix(format!("{comment}{rest}")),
495            (None, Some(_)) => next.decor_mut().set_prefix(rest.to_string()),
496            (None, None) => {}
497        }
498        return;
499    }
500    let tail = format!(
501        "{}{}",
502        decor_text(removed.decor().suffix()),
503        array.trailing().as_str().unwrap_or_default()
504    );
505    let rest = after_first_line(&tail);
506    let trailing = match (carried, comment_line(&tail)) {
507        // A comment must end its line before the closing bracket.
508        (Some(comment), _) if rest.is_empty() => format!("{comment}\n"),
509        (Some(comment), _) => format!("{comment}{rest}"),
510        (None, Some(_)) => rest.to_string(),
511        (None, None) => tail.clone(),
512    };
513    array.set_trailing(trailing);
514}
515
516/// A decor's first line when it carries a comment — the comment written
517/// beside the preceding value — including the spaces before it.
518fn comment_line(decor: &str) -> Option<&str> {
519    let line = decor.split('\n').next().unwrap_or_default();
520    line.contains('#').then_some(line)
521}
522
523/// Everything from a decor's first line break on.
524fn after_first_line(decor: &str) -> &str {
525    decor.find('\n').map_or("", |newline| &decor[newline..])
526}
527
528/// The first comment in a decor string, without its leading whitespace.
529fn first_comment(decor: &str) -> Option<&str> {
530    let start = decor.find('#')?;
531    let end = decor[start..]
532        .find('\n')
533        .map_or(decor.len(), |newline| start + newline);
534    Some(&decor[start..end])
535}
536
537/// Rewrite a decor's first comment, or remove it when `replacement` is `None`.
538fn with_first_comment(decor: &str, replacement: Option<&str>) -> String {
539    let Some(start) = decor.find('#') else {
540        return decor.to_string();
541    };
542    let end = decor[start..]
543        .find('\n')
544        .map_or(decor.len(), |newline| start + newline);
545    let mut out = String::with_capacity(decor.len());
546    match replacement {
547        Some(comment) => {
548            out.push_str(&decor[..start]);
549            out.push_str(comment);
550        }
551        None => out.push_str(decor[..start].trim_end_matches([' ', '\t'])),
552    }
553    out.push_str(&decor[end..]);
554    out
555}
556
557fn array_value(
558    values: &[Value],
559    existing: Option<&toml_edit::Array>,
560) -> DocumentResult<toml_edit::Value> {
561    array_value_with_prefix(values, existing, 0)
562}
563
564/// `unchanged` leading elements are kept verbatim for an append operation.
565fn array_value_with_prefix(
566    values: &[Value],
567    existing: Option<&toml_edit::Array>,
568    unchanged: usize,
569) -> DocumentResult<toml_edit::Value> {
570    let existing_values = existing
571        .map(|array| array.iter().cloned().collect::<Vec<_>>())
572        .unwrap_or_default();
573    let mut array = existing.cloned().unwrap_or_default();
574    if array.len() > values.len() {
575        // The comment written after element N-1's comma is stored in element
576        // N's *prefix*, and the last element's trailing comment lives in the
577        // array's trailing decor. Truncating therefore deletes the comment that
578        // documented the last surviving element while keeping the one that
579        // documented a value being removed — leaving a comment attached to the
580        // wrong element. Carry the surviving element's own comment across.
581        let surviving_comment = existing_values
582            .get(values.len())
583            .and_then(|item| item.decor().prefix())
584            .and_then(toml_edit::RawString::as_str)
585            .and_then(first_comment)
586            .map(str::to_string);
587        while array.len() > values.len() {
588            let last = array.len() - 1;
589            array.remove(last);
590        }
591        let trailing = array.trailing().as_str().unwrap_or_default().to_string();
592        if first_comment(&trailing).is_some() {
593            array.set_trailing(with_first_comment(&trailing, surviving_comment.as_deref()));
594        }
595    }
596    let length_before_append = array.len();
597    // Without a trailing comma, the comment and line break after the last
598    // element are that element's suffix rather than the array's trailing
599    // decor, so the new comma would land after them (`"y" # c\n,`). Moving
600    // them to the trailing decor lets the branch below treat both spellings
601    // alike.
602    if values.len() > length_before_append
603        && let Some(last) = length_before_append
604            .checked_sub(1)
605            .and_then(|index| array.get_mut(index))
606    {
607        let suffix = last
608            .decor()
609            .suffix()
610            .and_then(toml_edit::RawString::as_str)
611            .unwrap_or_default()
612            .to_string();
613        if suffix.contains(['#', '\n']) {
614            last.decor_mut().set_suffix("");
615            let trailing = array.trailing().as_str().unwrap_or_default().to_string();
616            array.set_trailing(format!("{suffix}{trailing}"));
617        }
618    }
619    for (index, value) in values.iter().enumerate() {
620        if index < unchanged {
621            continue;
622        }
623        let hint = existing_values.get(index);
624        let mut converted = toml_value(value, hint)?;
625        if index < array.len() {
626            array.replace_formatted(index, converted);
627        } else {
628            let layout = existing_values
629                .last()
630                .and_then(|item| item.decor().prefix())
631                .and_then(toml_edit::RawString::as_str)
632                .map(layout_without_comments)
633                .unwrap_or_default();
634            // The first element of `[1]` has no prefix to copy, but a second
635            // one still needs the space after its comma.
636            let layout = if layout.is_empty() && index > 0 {
637                " ".to_string()
638            } else {
639                layout
640            };
641            converted.decor_mut().set_prefix(layout);
642            if index == length_before_append {
643                // A last element's inline comment is held in the array's
644                // trailing decor. Keep it beside that element when extending.
645                let trailing = array.trailing().as_str().unwrap_or_default().to_string();
646                if first_comment(&trailing).is_some() {
647                    let layout = converted
648                        .decor()
649                        .prefix()
650                        .and_then(toml_edit::RawString::as_str)
651                        .unwrap_or_default();
652                    let indent = layout.rsplit('\n').next().unwrap_or_default().to_string();
653                    converted.decor_mut().set_prefix(format!(
654                        "{}{indent}",
655                        trailing.trim_end_matches([' ', '\t'])
656                    ));
657                    // Everything up to the last line break moved in front of
658                    // the new element; only the bracket's own indentation stays.
659                    let bracket_indent = trailing.rsplit('\n').next().unwrap_or_default();
660                    array.set_trailing(format!("\n{bracket_indent}"));
661                }
662            }
663            array.push_formatted(converted);
664        }
665    }
666    // Appending to a single-line array: the previously-last element's suffix is
667    // the space that sat before `]`. Left where it is, it ends up before the new
668    // comma (`[ "one" , "two"]`), so move it to the array's trailing decor where
669    // it goes on separating the last value from the bracket. Done after the
670    // element loop, which restores each replaced element's original decor.
671    if values.len() > length_before_append
672        && let Some(last_index) = length_before_append.checked_sub(1)
673    {
674        let suffix = array
675            .get(last_index)
676            .and_then(|item| item.decor().suffix())
677            .and_then(toml_edit::RawString::as_str)
678            .filter(|suffix| !suffix.is_empty() && suffix.chars().all(|c| c == ' ' || c == '\t'))
679            .map(str::to_string);
680        let trailing_is_empty = array.trailing().as_str().is_none_or(str::is_empty);
681        if let Some(suffix) = suffix
682            && trailing_is_empty
683        {
684            if let Some(item) = array.get_mut(last_index) {
685                item.decor_mut().set_suffix("");
686            }
687            array.set_trailing(suffix);
688        }
689    }
690    if values.is_empty() {
691        array.set_trailing_comma(false);
692    }
693    if existing.is_none() {
694        array.fmt();
695    }
696    Ok(toml_edit::Value::Array(array))
697}
698
699fn inline_table_value(
700    values: &std::collections::BTreeMap<String, Value>,
701    existing: Option<&toml_edit::InlineTable>,
702) -> DocumentResult<toml_edit::Value> {
703    let mut table = existing.cloned().unwrap_or_default();
704    table.retain(|key, _| values.contains_key(key));
705    for (key, value) in values {
706        if let Some(current) = table.get_mut(key) {
707            *current = toml_value(value, Some(current))?;
708        } else {
709            table.insert(key, toml_value(value, None)?);
710        }
711    }
712    if values.is_empty() {
713        table.set_trailing_comma(false);
714    }
715    if existing.is_none() {
716        table.fmt();
717    }
718    Ok(toml_edit::Value::InlineTable(table))
719}
720
721fn replace_item_preserving(target: &mut toml_edit::Item, value: &Value) -> DocumentResult<()> {
722    if target.is_array_of_tables() {
723        return Err(DocumentError::UnsupportedOperation {
724            format: "TOML".to_string(),
725            operation: "set".to_string(),
726            detail: "editing an array of tables requires an explicit element identity".to_string(),
727        });
728    }
729    if let (Some(table), Value::Object(values)) = (target.as_table_mut(), value) {
730        return sync_table(table, values);
731    }
732    let converted = toml_value(value, target.as_value())?;
733    *target = toml_edit::Item::Value(converted);
734    Ok(())
735}
736
737fn sync_table(
738    table: &mut toml_edit::Table,
739    values: &std::collections::BTreeMap<String, Value>,
740) -> DocumentResult<()> {
741    table.retain(|key, _| values.contains_key(key));
742    for (key, value) in values {
743        if let Some(current) = table.get_mut(key) {
744            replace_item_preserving(current, value)?;
745        } else {
746            table.insert(key, toml_item(value)?);
747        }
748    }
749    Ok(())
750}
751
752fn collection_refusal(detail: &str) -> DocumentError {
753    DocumentError::UnsupportedOperation {
754        format: "TOML".to_string(),
755        operation: "set".to_string(),
756        detail: detail.to_string(),
757    }
758}
759
760pub fn load(content: &str) -> DocumentResult<Value> {
761    toml::from_str::<toml::Value>(content)
762        .map(value_to_our_value)
763        .map_err(|e| DocumentError::ParseError {
764            format: "TOML".to_string(),
765            detail: e.to_string(),
766        })
767}
768
769pub fn save(value: &Value) -> DocumentResult<String> {
770    let toml_val = our_value_to_toml_value(value)?;
771    toml::to_string_pretty(&toml_val).map_err(|e| DocumentError::ParseError {
772        format: "TOML".to_string(),
773        detail: e.to_string(),
774    })
775}
776
777fn value_to_our_value(v: toml::Value) -> Value {
778    match v {
779        toml::Value::Boolean(b) => Value::Bool(b),
780        toml::Value::Integer(i) => Value::Integer(i),
781        toml::Value::Float(f) => Value::Float(f),
782        toml::Value::String(s) => Value::String(s),
783        toml::Value::Array(a) => Value::Array(a.into_iter().map(value_to_our_value).collect()),
784        toml::Value::Table(t) => {
785            let map = t
786                .into_iter()
787                .map(|(k, v)| (k, value_to_our_value(v)))
788                .collect();
789            Value::Object(map)
790        }
791        toml::Value::Datetime(dt) => Value::String(dt.to_string()),
792    }
793}
794
795fn our_value_to_toml_value(v: &Value) -> DocumentResult<toml::Value> {
796    match v {
797        Value::Null => Err(DocumentError::UnsupportedOperation {
798            format: "TOML".to_string(),
799            operation: "save".to_string(),
800            detail: "TOML has no null value".to_string(),
801        }),
802        Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
803        Value::Integer(i) => Ok(toml::Value::Integer(*i)),
804        Value::Unsigned(i) => i64::try_from(*i).map(toml::Value::Integer).map_err(|_| {
805            DocumentError::UnsupportedOperation {
806                format: "TOML".to_string(),
807                operation: "save".to_string(),
808                detail: "unsigned integer exceeds TOML i64 range".to_string(),
809            }
810        }),
811        Value::Float(f) => Ok(toml::Value::Float(*f)),
812        Value::Number(text) if v.is_float() => text
813            .parse::<f64>()
814            .ok()
815            .filter(|value| value.is_finite())
816            .map(toml::Value::Float)
817            .ok_or_else(|| DocumentError::UnsupportedOperation {
818                format: "TOML".to_string(),
819                operation: "save".to_string(),
820                detail: "float literal is not representable in TOML".to_string(),
821            }),
822        Value::Number(_) => Err(DocumentError::UnsupportedOperation {
823            format: "TOML".to_string(),
824            operation: "save".to_string(),
825            detail: "integer literal exceeds TOML's 64-bit integer range".to_string(),
826        }),
827        Value::String(s) => Ok(toml::Value::String(s.clone())),
828        Value::Array(a) => {
829            let arr = a
830                .iter()
831                .map(our_value_to_toml_value)
832                .collect::<DocumentResult<Vec<_>>>()?;
833            Ok(toml::Value::Array(arr))
834        }
835        Value::Object(o) => {
836            let mut table = toml::map::Map::new();
837            for (k, v) in o {
838                table.insert(k.clone(), our_value_to_toml_value(v)?);
839            }
840            Ok(toml::Value::Table(table))
841        }
842    }
843}