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_scalar_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        // toml_edit returns `Some(Item::None)` for absent keys, so treat that as
30        // a genuinely missing parent rather than a navigable node.
31        current = current
32            .get_mut(parent)
33            .filter(|item| !item.is_none())
34            .ok_or_else(|| DocumentError::PathNotFound {
35                path: path.to_string(),
36            })?;
37    }
38    let table = current
39        .as_table_like_mut()
40        .ok_or_else(|| DocumentError::UnsupportedOperation {
41            format: "TOML".to_string(),
42            operation: "set".to_string(),
43            detail: "cannot address a key inside a non-table TOML value".to_string(),
44        })?;
45    match table.get_mut(last).filter(|item| !item.is_none()) {
46        Some(target) => {
47            if !target.is_value() {
48                return Err(DocumentError::UnsupportedOperation {
49                    format: "TOML".to_string(),
50                    operation: "set".to_string(),
51                    detail: "only existing scalar TOML values are supported by the document editor"
52                        .to_string(),
53                });
54            }
55            let decor = target.as_value().map(|value| value.decor().clone());
56            *target = item;
57            if let (Some(decor), Some(value)) = (decor, target.as_value_mut()) {
58                *value.decor_mut() = decor;
59            }
60        }
61        // New leaf: append into the (existing) parent table. Intermediate parent
62        // tables are not auto-created — a missing parent fails above.
63        None => {
64            table.insert(last, item);
65        }
66    }
67    Ok(document.to_string())
68}
69
70/// Remove an existing TOML item through `toml_edit`, retaining document decor.
71pub fn unset_preserving(content: &str, path: &str) -> DocumentResult<String> {
72    let segments = crate::document::parse_path(path)?;
73    if segments.iter().any(|segment| segment.contains(['.', '\\'])) {
74        return Err(DocumentError::UnsupportedOperation {
75            format: "TOML".to_string(),
76            operation: "unset".to_string(),
77            detail: "escaped TOML keys are not supported by the current document path adapter"
78                .to_string(),
79        });
80    }
81    let (last, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
82    let mut document =
83        content
84            .parse::<toml_edit::DocumentMut>()
85            .map_err(|error| DocumentError::ParseError {
86                format: "TOML".to_string(),
87                detail: error.to_string(),
88            })?;
89    let mut current = document.as_item_mut();
90    for parent in parents {
91        current = current
92            .get_mut(parent)
93            .ok_or_else(|| DocumentError::PathNotFound {
94                path: path.to_string(),
95            })?;
96    }
97    let table = current
98        .as_table_mut()
99        .ok_or_else(|| DocumentError::UnsupportedOperation {
100            format: "TOML".to_string(),
101            operation: "unset".to_string(),
102            detail: "only table entries can be removed by the current TOML editor".to_string(),
103        })?;
104    if table.remove(last).is_none() {
105        return Err(DocumentError::PathNotFound {
106            path: path.to_string(),
107        });
108    }
109    Ok(document.to_string())
110}
111
112fn toml_item(value: &Value) -> DocumentResult<toml_edit::Item> {
113    match value {
114        Value::Null => Err(DocumentError::UnsupportedOperation {
115            format: "TOML".to_string(),
116            operation: "set".to_string(),
117            detail: "TOML has no null value".to_string(),
118        }),
119        Value::Bool(value) => Ok(toml_edit::value(*value)),
120        Value::Integer(value) => Ok(toml_edit::value(*value)),
121        Value::Unsigned(value) => i64::try_from(*value).map(toml_edit::value).map_err(|_| {
122            DocumentError::UnsupportedOperation {
123                format: "TOML".to_string(),
124                operation: "set".to_string(),
125                detail: "unsigned integer exceeds TOML i64 range".to_string(),
126            }
127        }),
128        Value::Float(value) if value.is_finite() => Ok(toml_edit::value(*value)),
129        Value::Float(_) => Err(DocumentError::UnsupportedOperation {
130            format: "TOML".to_string(),
131            operation: "set".to_string(),
132            detail: "non-finite TOML float is not representable".to_string(),
133        }),
134        // A `Value::Number` literal is float-shaped (has a `.`/`e`) or
135        // integer-shaped (does not); an integer-shaped one only exists
136        // because it overflows `u64`, which also overflows TOML's 64-bit
137        // integer grammar, so it can never be written. A float-shaped one
138        // parses to `f64` cleanly (it already passed JSON-number syntax
139        // validation) and writes like any other TOML float — TOML floats
140        // are canonically `f64` at the format level, so this is not a
141        // fidelity regression versus `Value::Float` above.
142        Value::Number(text) if value.is_float() => text
143            .parse::<f64>()
144            .ok()
145            .filter(|value| value.is_finite())
146            .map(toml_edit::value)
147            .ok_or_else(|| DocumentError::UnsupportedOperation {
148                format: "TOML".to_string(),
149                operation: "set".to_string(),
150                detail: format!("float literal `{text}` is not representable in TOML"),
151            }),
152        Value::Number(text) => Err(DocumentError::UnsupportedOperation {
153            format: "TOML".to_string(),
154            operation: "set".to_string(),
155            detail: format!("integer literal `{text}` exceeds TOML's 64-bit integer range"),
156        }),
157        Value::String(value) => Ok(toml_edit::value(value.clone())),
158        Value::Array(_) | Value::Object(_) => Err(DocumentError::UnsupportedOperation {
159            format: "TOML".to_string(),
160            operation: "set".to_string(),
161            detail: "collection mutation requires a dedicated TOML editor".to_string(),
162        }),
163    }
164}
165
166pub fn load(content: &str) -> DocumentResult<Value> {
167    toml::from_str::<toml::Value>(content)
168        .map(value_to_our_value)
169        .map_err(|e| DocumentError::ParseError {
170            format: "TOML".to_string(),
171            detail: e.to_string(),
172        })
173}
174
175pub fn save(value: &Value) -> DocumentResult<String> {
176    let toml_val = our_value_to_toml_value(value)?;
177    toml::to_string_pretty(&toml_val).map_err(|e| DocumentError::ParseError {
178        format: "TOML".to_string(),
179        detail: e.to_string(),
180    })
181}
182
183fn value_to_our_value(v: toml::Value) -> Value {
184    match v {
185        toml::Value::Boolean(b) => Value::Bool(b),
186        toml::Value::Integer(i) => Value::Integer(i),
187        toml::Value::Float(f) => Value::Float(f),
188        toml::Value::String(s) => Value::String(s),
189        toml::Value::Array(a) => Value::Array(a.into_iter().map(value_to_our_value).collect()),
190        toml::Value::Table(t) => {
191            let map = t
192                .into_iter()
193                .map(|(k, v)| (k, value_to_our_value(v)))
194                .collect();
195            Value::Object(map)
196        }
197        toml::Value::Datetime(dt) => Value::String(dt.to_string()),
198    }
199}
200
201fn our_value_to_toml_value(v: &Value) -> DocumentResult<toml::Value> {
202    match v {
203        Value::Null => Err(DocumentError::UnsupportedOperation {
204            format: "TOML".to_string(),
205            operation: "save".to_string(),
206            detail: "TOML has no null value".to_string(),
207        }),
208        Value::Bool(b) => Ok(toml::Value::Boolean(*b)),
209        Value::Integer(i) => Ok(toml::Value::Integer(*i)),
210        Value::Unsigned(i) => i64::try_from(*i).map(toml::Value::Integer).map_err(|_| {
211            DocumentError::UnsupportedOperation {
212                format: "TOML".to_string(),
213                operation: "save".to_string(),
214                detail: "unsigned integer exceeds TOML i64 range".to_string(),
215            }
216        }),
217        Value::Float(f) => Ok(toml::Value::Float(*f)),
218        Value::Number(text) if v.is_float() => text
219            .parse::<f64>()
220            .ok()
221            .filter(|value| value.is_finite())
222            .map(toml::Value::Float)
223            .ok_or_else(|| DocumentError::UnsupportedOperation {
224                format: "TOML".to_string(),
225                operation: "save".to_string(),
226                detail: format!("float literal `{text}` is not representable in TOML"),
227            }),
228        Value::Number(text) => Err(DocumentError::UnsupportedOperation {
229            format: "TOML".to_string(),
230            operation: "save".to_string(),
231            detail: format!("integer literal `{text}` exceeds TOML's 64-bit integer range"),
232        }),
233        Value::String(s) => Ok(toml::Value::String(s.clone())),
234        Value::Array(a) => {
235            let arr = a
236                .iter()
237                .map(our_value_to_toml_value)
238                .collect::<DocumentResult<Vec<_>>>()?;
239            Ok(toml::Value::Array(arr))
240        }
241        Value::Object(o) => {
242            let mut table = toml::map::Map::new();
243            for (k, v) in o {
244                table.insert(k.clone(), our_value_to_toml_value(v)?);
245            }
246            Ok(toml::Value::Table(table))
247        }
248    }
249}