1use 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(preserve_line_endings(content, 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(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 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
270fn 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 parsed.decor_mut().clear();
306 Ok(parsed)
307}
308
309fn 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 kept.push_str(rest[..hash].trim_end_matches([' ', '\t']));
324 match rest[hash..].find('\n') {
325 Some(newline) => rest = &rest[hash + newline..],
327 None => return kept,
329 }
330 }
331 kept.push_str(rest);
332 kept
333}
334
335fn 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
344fn 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 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 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}