agent_first_data/document/format/
toml.rs1use crate::document::{DocumentError, DocumentResult, Value};
4
5pub 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 {
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 None => {
110 table.insert(last, toml_item(value)?);
111 }
112 }
113 }
114 Ok(document.to_string())
115}
116
117pub 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 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 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 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
256fn 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 kept.push_str(rest[..hash].trim_end_matches([' ', '\t']));
271 match rest[hash..].find('\n') {
272 Some(newline) => rest = &rest[hash + newline..],
274 None => return kept,
276 }
277 }
278 kept.push_str(rest);
279 kept
280}
281
282fn 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
291fn 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 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 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}