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 scalar item in a TOML document without reserializing the
6/// surrounding document. Comments, ordering, whitespace, and datetime syntax
7/// outside the target remain owned by `toml_edit::DocumentMut`.
8pub fn set_preserving(content: &str, path: &str, value: &Value) -> DocumentResult<String> {
9    let segments = crate::document::parse_path(path)?;
10    if segments.iter().any(|segment| segment.contains(['.', '\\'])) {
11        return Err(DocumentError::UnsupportedOperation {
12            format: "TOML".to_string(),
13            operation: "set".to_string(),
14            detail: "escaped TOML keys are not supported by the current document path adapter"
15                .to_string(),
16        });
17    }
18    let mut document =
19        content
20            .parse::<toml_edit::DocumentMut>()
21            .map_err(|error| DocumentError::ParseError {
22                format: "TOML".to_string(),
23                detail: error.to_string(),
24            })?;
25    let item = toml_item(value)?;
26    let (last, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
27    let mut current = document.as_item_mut();
28    for parent in parents {
29        // Auto-create a missing intermediate table so a sparse config can grow
30        // (`set imap.host` when `[imap]` is absent), matching `set_path`.
31        // toml_edit returns `Some(Item::None)` for absent keys, so treat that as
32        // a genuinely missing parent rather than a navigable node.
33        {
34            let table =
35                current
36                    .as_table_like_mut()
37                    .ok_or_else(|| DocumentError::UnsupportedOperation {
38                        format: "TOML".to_string(),
39                        operation: "set".to_string(),
40                        detail: "cannot address a key inside a non-table TOML value".to_string(),
41                    })?;
42            if table.get(parent).filter(|item| !item.is_none()).is_none() {
43                let mut created = toml_edit::Table::new();
44                created.set_implicit(true);
45                table.insert(parent, toml_edit::Item::Table(created));
46            }
47        }
48        current = current
49            .get_mut(parent)
50            .filter(|item| !item.is_none())
51            .ok_or_else(|| DocumentError::PathNotFound {
52                path: path.to_string(),
53            })?;
54    }
55    let table = current
56        .as_table_like_mut()
57        .ok_or_else(|| DocumentError::UnsupportedOperation {
58            format: "TOML".to_string(),
59            operation: "set".to_string(),
60            detail: "cannot address a key inside a non-table TOML value".to_string(),
61        })?;
62    match table.get_mut(last).filter(|item| !item.is_none()) {
63        Some(target) => {
64            if !target.is_value() {
65                return Err(DocumentError::UnsupportedOperation {
66                    format: "TOML".to_string(),
67                    operation: "set".to_string(),
68                    detail: "only existing scalar TOML values are supported by the document editor"
69                        .to_string(),
70                });
71            }
72            let decor = target.as_value().map(|value| value.decor().clone());
73            *target = item;
74            if let (Some(decor), Some(value)) = (decor, target.as_value_mut()) {
75                *value.decor_mut() = decor;
76            }
77        }
78        // New leaf: append into the (existing) parent table. Intermediate parent
79        // tables are not auto-created — a missing parent fails above.
80        None => {
81            table.insert(last, item);
82        }
83    }
84    Ok(document.to_string())
85}
86
87/// Remove an existing TOML item through `toml_edit`, retaining document decor.
88pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
89    let segments = crate::document::parse_path(path)?;
90    if segments.iter().any(|segment| segment.contains(['.', '\\'])) {
91        return Err(DocumentError::UnsupportedOperation {
92            format: "TOML".to_string(),
93            operation: "unset".to_string(),
94            detail: "escaped TOML keys are not supported by the current document path adapter"
95                .to_string(),
96        });
97    }
98    let (last, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
99    let mut document =
100        content
101            .parse::<toml_edit::DocumentMut>()
102            .map_err(|error| DocumentError::ParseError {
103                format: "TOML".to_string(),
104                detail: error.to_string(),
105            })?;
106    let mut current = document.as_item_mut();
107    for parent in parents {
108        current = current
109            .get_mut(parent)
110            .ok_or_else(|| DocumentError::PathNotFound {
111                path: path.to_string(),
112            })?;
113    }
114    // `as_table_like_mut`, not `as_table_mut`: an inline table is a `Value`, not
115    // an `Item::Table`, and reading only the latter made `unset` refuse a key
116    // `set` had just written — `foo = { path = "..." }` could be edited but not
117    // emptied. The two verbs address the same nodes or neither is trustworthy.
118    let table = current
119        .as_table_like_mut()
120        .ok_or_else(|| DocumentError::UnsupportedOperation {
121            format: "TOML".to_string(),
122            operation: "unset".to_string(),
123            detail: "cannot address a key inside a non-table TOML value".to_string(),
124        })?;
125    let Some(removed) = table.remove(last) else {
126        return Err(DocumentError::PathNotFound {
127            path: path.to_string(),
128        });
129    };
130    // An inline table's closing space lives in the trailing decor of its final
131    // value, so removing the last entry takes ` }` down to `}` — a change to
132    // source the caller did not address. Hand that suffix to the new final
133    // entry, unless it already carries one of its own.
134    let removed_suffix = removed
135        .as_value()
136        .and_then(|value| value.decor().suffix())
137        .cloned();
138    if let (Some(inline), Some(suffix)) = (current.as_inline_table_mut(), removed_suffix)
139        && let Some((_, final_value)) = inline.iter_mut().last()
140    {
141        let carries_its_own = final_value
142            .decor()
143            .suffix()
144            .and_then(|raw| raw.as_str())
145            .is_some_and(|text| !text.is_empty());
146        if !carries_its_own {
147            final_value.decor_mut().set_suffix(suffix);
148        }
149    }
150    Ok(document.to_string())
151}
152
153fn toml_item(value: &Value) -> DocumentResult<toml_edit::Item> {
154    match value {
155        Value::Null => Err(DocumentError::UnsupportedOperation {
156            format: "TOML".to_string(),
157            operation: "set".to_string(),
158            detail: "TOML has no null value".to_string(),
159        }),
160        Value::Bool(value) => Ok(toml_edit::value(*value)),
161        Value::Integer(value) => Ok(toml_edit::value(*value)),
162        Value::Unsigned(value) => i64::try_from(*value).map(toml_edit::value).map_err(|_| {
163            DocumentError::UnsupportedOperation {
164                format: "TOML".to_string(),
165                operation: "set".to_string(),
166                detail: "unsigned integer exceeds TOML i64 range".to_string(),
167            }
168        }),
169        Value::Float(value) if value.is_finite() => Ok(toml_edit::value(*value)),
170        Value::Float(_) => Err(DocumentError::UnsupportedOperation {
171            format: "TOML".to_string(),
172            operation: "set".to_string(),
173            detail: "non-finite TOML float is not representable".to_string(),
174        }),
175        // A `Value::Number` literal is float-shaped (has a `.`/`e`) or
176        // integer-shaped (does not); an integer-shaped one only exists
177        // because it overflows `u64`, which also overflows TOML's 64-bit
178        // integer grammar, so it can never be written. A float-shaped one
179        // parses to `f64` cleanly (it already passed JSON-number syntax
180        // validation) and writes like any other TOML float — TOML floats
181        // are canonically `f64` at the format level, so this is not a
182        // fidelity regression versus `Value::Float` above.
183        Value::Number(text) if value.is_float() => text
184            .parse::<f64>()
185            .ok()
186            .filter(|value| value.is_finite())
187            .map(toml_edit::value)
188            .ok_or_else(|| DocumentError::UnsupportedOperation {
189                format: "TOML".to_string(),
190                operation: "set".to_string(),
191                detail: format!("float literal `{text}` is not representable in TOML"),
192            }),
193        Value::Number(text) => Err(DocumentError::UnsupportedOperation {
194            format: "TOML".to_string(),
195            operation: "set".to_string(),
196            detail: format!("integer literal `{text}` exceeds TOML's 64-bit integer range"),
197        }),
198        Value::String(value) => Ok(toml_edit::value(value.clone())),
199        Value::Array(_) | Value::Object(_) => Err(DocumentError::UnsupportedOperation {
200            format: "TOML".to_string(),
201            operation: "set".to_string(),
202            detail: "collection mutation requires a dedicated TOML editor".to_string(),
203        }),
204    }
205}
206
207pub fn load(content: &str) -> DocumentResult<Value> {
208    toml::from_str::<toml::Value>(content)
209        .map(value_to_our_value)
210        .map_err(|e| DocumentError::ParseError {
211            format: "TOML".to_string(),
212            detail: e.to_string(),
213        })
214}
215
216pub fn save(value: &Value) -> DocumentResult<String> {
217    let toml_val = our_value_to_toml_value(value)?;
218    toml::to_string_pretty(&toml_val).map_err(|e| DocumentError::ParseError {
219        format: "TOML".to_string(),
220        detail: e.to_string(),
221    })
222}
223
224fn value_to_our_value(v: toml::Value) -> Value {
225    match v {
226        toml::Value::Boolean(b) => Value::Bool(b),
227        toml::Value::Integer(i) => Value::Integer(i),
228        toml::Value::Float(f) => Value::Float(f),
229        toml::Value::String(s) => Value::String(s),
230        toml::Value::Array(a) => Value::Array(a.into_iter().map(value_to_our_value).collect()),
231        toml::Value::Table(t) => {
232            let map = t
233                .into_iter()
234                .map(|(k, v)| (k, value_to_our_value(v)))
235                .collect();
236            Value::Object(map)
237        }
238        toml::Value::Datetime(dt) => Value::String(dt.to_string()),
239    }
240}
241
242fn our_value_to_toml_value(v: &Value) -> DocumentResult<toml::Value> {
243    match v {
244        Value::Null => Err(DocumentError::UnsupportedOperation {
245            format: "TOML".to_string(),
246            operation: "save".to_string(),
247            detail: "TOML has no null value".to_string(),
248        }),
249        Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
250        Value::Integer(i) => Ok(toml::Value::Integer(*i)),
251        Value::Unsigned(i) => i64::try_from(*i).map(toml::Value::Integer).map_err(|_| {
252            DocumentError::UnsupportedOperation {
253                format: "TOML".to_string(),
254                operation: "save".to_string(),
255                detail: "unsigned integer exceeds TOML i64 range".to_string(),
256            }
257        }),
258        Value::Float(f) => Ok(toml::Value::Float(*f)),
259        Value::Number(text) if v.is_float() => text
260            .parse::<f64>()
261            .ok()
262            .filter(|value| value.is_finite())
263            .map(toml::Value::Float)
264            .ok_or_else(|| DocumentError::UnsupportedOperation {
265                format: "TOML".to_string(),
266                operation: "save".to_string(),
267                detail: format!("float literal `{text}` is not representable in TOML"),
268            }),
269        Value::Number(text) => Err(DocumentError::UnsupportedOperation {
270            format: "TOML".to_string(),
271            operation: "save".to_string(),
272            detail: format!("integer literal `{text}` exceeds TOML's 64-bit integer range"),
273        }),
274        Value::String(s) => Ok(toml::Value::String(s.clone())),
275        Value::Array(a) => {
276            let arr = a
277                .iter()
278                .map(our_value_to_toml_value)
279                .collect::<DocumentResult<Vec<_>>>()?;
280            Ok(toml::Value::Array(arr))
281        }
282        Value::Object(o) => {
283            let mut table = toml::map::Map::new();
284            for (k, v) in o {
285                table.insert(k.clone(), our_value_to_toml_value(v)?);
286            }
287            Ok(toml::Value::Table(table))
288        }
289    }
290}