1mod read;
24mod write;
25
26use write::{guard_mutation, write_atomic, write_atomic_create};
27
28#[cfg(all(test, unix))]
29use read::{inspect_capped_source, read_capped_contents};
30use read::{open_read_handle, read_capped_source};
31use std::fs;
32#[cfg(all(test, unix))]
33use std::fs::OpenOptions;
34use std::path::{Path, PathBuf};
35
36use crate::document::{Addressing, DocumentError, DocumentResult, Format, KeyedList, Value};
37
38pub(crate) fn read_capped_file(
39 path: &Path,
40 max_bytes: u64,
41 symlink_policy: SymlinkPolicy,
42) -> DocumentResult<String> {
43 let file = open_read_handle(path, symlink_policy)?;
44 read_capped_source(file, path, max_bytes)
45}
46
47#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
55pub enum SymlinkPolicy {
56 #[default]
58 Follow,
59 NoFollow,
63}
64
65#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
67pub enum CreateMode {
68 #[default]
70 NewOnly,
71 Replace,
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub struct CreateOptions {
78 mode: CreateMode,
79 unix_mode: Option<u32>,
80}
81
82impl CreateOptions {
83 #[must_use]
88 pub const fn new() -> Self {
89 Self {
90 mode: CreateMode::NewOnly,
91 unix_mode: None,
92 }
93 }
94
95 #[must_use]
101 pub const fn unix_mode(mut self, unix_mode: u32) -> Self {
102 self.unix_mode = Some(unix_mode);
103 self
104 }
105
106 #[must_use]
108 pub const fn replace(mut self) -> Self {
109 self.mode = CreateMode::Replace;
110 self
111 }
112
113 #[must_use]
115 pub const fn mode(&self) -> CreateMode {
116 self.mode
117 }
118
119 #[must_use]
124 pub const fn configured_unix_mode(&self) -> Option<u32> {
125 self.unix_mode
126 }
127
128 const fn effective_unix_mode(&self, target_exists: bool) -> Option<u32> {
130 match self.unix_mode {
131 Some(mode) => Some(mode),
132 None if target_exists => None,
137 None => Some(0o600),
138 }
139 }
140}
141
142impl Default for CreateOptions {
143 fn default() -> Self {
144 Self::new()
145 }
146}
147
148#[derive(Debug, Clone)]
156pub struct Document {
157 source: String,
158 value: Value,
159 format: Format,
160}
161
162impl Document {
163 pub fn parse(source: &str, format: Format) -> DocumentResult<Document> {
169 let value = format.load(source)?;
170 Ok(Document {
171 source: source.to_string(),
172 value,
173 format,
174 })
175 }
176
177 pub fn from_reader<R: std::io::Read>(
183 mut reader: R,
184 format: Format,
185 ) -> DocumentResult<Document> {
186 let mut source = String::new();
187 reader.read_to_string(&mut source)?;
188 Document::parse(&source, format)
189 }
190
191 pub fn value(&self) -> &Value {
193 &self.value
194 }
195
196 pub fn source(&self) -> &str {
200 &self.source
201 }
202
203 pub fn format(&self) -> Format {
205 self.format
206 }
207
208 #[must_use]
214 pub fn addressing(&self) -> Addressing<'static> {
215 Addressing::INDEX_ONLY.with_array_rule(self.format.array_rule())
216 }
217
218 #[must_use]
221 pub fn addressing_keyed<'a>(&self, keyed_lists: &'a [KeyedList<'a>]) -> Addressing<'a> {
222 Addressing::keyed(keyed_lists).with_array_rule(self.format.array_rule())
223 }
224
225 pub fn value_at(&self, path: &str) -> DocumentResult<Value> {
233 crate::document::get_path(&self.value, path, self.addressing())
234 }
235
236 pub fn value_at_typed(
240 &self,
241 path: &str,
242 expected: crate::document::ValueType,
243 ) -> DocumentResult<Value> {
244 let value = self.value_at(path)?;
245 if crate::document::value_matches_type(&value, expected) {
246 Ok(value)
247 } else {
248 Err(DocumentError::TypeMismatch {
249 path: path.to_string(),
250 expected: expected.name().to_string(),
251 got: value.kind_name().to_string(),
252 hint: None,
253 })
254 }
255 }
256
257 pub fn decode<T: serde::de::DeserializeOwned>(&self) -> DocumentResult<T> {
263 crate::document::from_value(self.value(), "")
264 }
265
266 pub fn set_typed(
269 &mut self,
270 key: &str,
271 raw: Option<&str>,
272 value_type: crate::document::ValueType,
273 ) -> DocumentResult<()> {
274 let value = crate::document::value_from_type(value_type, raw)?;
275 self.set(key, value)
276 }
277
278 fn ensure_writable(&self, operation: &str) -> DocumentResult<()> {
286 if self.format.is_read_only() {
287 return Err(self.format.read_only_error(operation));
288 }
289 Ok(())
290 }
291
292 pub fn encode(&self) -> DocumentResult<String> {
298 self.format.save(&self.value)
299 }
300}
301
302#[derive(Debug, Clone)]
311pub struct DocumentFile {
312 doc: Document,
313 path: PathBuf,
314}
315
316impl DocumentFile {
317 pub fn open(
323 path: impl AsRef<Path>,
324 format_override: Option<Format>,
325 ) -> DocumentResult<DocumentFile> {
326 let path = path.as_ref().to_path_buf();
327 let format = resolve_format(&path, format_override)?;
328 let source = fs::read_to_string(&path).map_err(|error| DocumentError::IoError {
329 detail: format!("read `{}`: {error}", path.display()),
330 })?;
331 Ok(DocumentFile {
332 doc: Document::parse(&source, format)?,
333 path,
334 })
335 }
336
337 pub fn open_capped(
351 path: impl AsRef<Path>,
352 format_override: Option<Format>,
353 max_bytes: u64,
354 ) -> DocumentResult<DocumentFile> {
355 Self::open_capped_with_policy(path, format_override, max_bytes, SymlinkPolicy::Follow)
356 }
357
358 pub fn open_capped_with_policy(
364 path: impl AsRef<Path>,
365 format_override: Option<Format>,
366 max_bytes: u64,
367 symlink_policy: SymlinkPolicy,
368 ) -> DocumentResult<DocumentFile> {
369 let path = path.as_ref().to_path_buf();
370 let format = resolve_format(&path, format_override)?;
371 let source = read_capped_file(&path, max_bytes, symlink_policy)?;
372 Ok(DocumentFile {
373 doc: Document::parse(&source, format)?,
374 path,
375 })
376 }
377
378 pub fn create_atomic(
389 path: impl AsRef<Path>,
390 document: Document,
391 options: CreateOptions,
392 ) -> DocumentResult<DocumentFile> {
393 let path = path.as_ref().to_path_buf();
394 document.ensure_writable("create")?;
395 let path_format = resolve_format(&path, None)?;
398 if path_format != document.format() {
399 return Err(DocumentError::UnsupportedOperation {
400 format: document.format().name().to_string(),
401 operation: "create".to_string(),
402 detail: format!(
403 "path resolves to {}, so the created file could not be reopened",
404 path_format.name()
405 ),
406 });
407 }
408 validate_source_for_write(&document)?;
409 validate_create_options(options)?;
410 write_atomic_create(&path, document.source().as_bytes(), options)?;
411 Ok(DocumentFile {
412 doc: document,
413 path,
414 })
415 }
416
417 pub fn path(&self) -> &Path {
419 &self.path
420 }
421
422 pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
431 self.doc.ensure_writable(operation)?;
432 guard_mutation(&self.path, operation)?;
433 Ok(())
434 }
435}
436
437impl Document {
438 pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
452 let addressing = self.addressing();
453 self.set_addressed(key, value, addressing)
454 }
455
456 pub fn set_addressed(
464 &mut self,
465 key: &str,
466 value: Value,
467 addressing: Addressing<'_>,
468 ) -> DocumentResult<()> {
469 self.ensure_writable("set")?;
470 let base = self.set_base();
471 let key = &crate::document::resolve_path(&base, key, addressing)?;
472 let mut new_doc = base;
473 crate::document::set_path(&mut new_doc, key, &value, Addressing::INDEX_ONLY)?;
474 let target = value;
475 #[allow(unreachable_patterns)]
476 let output = match self.format {
477 #[cfg(feature = "toml")]
478 Format::Toml => {
479 crate::document::format::toml::set_preserving(&self.source, key, &target)?
480 }
481 #[cfg(feature = "yaml")]
482 Format::Yaml => {
483 crate::document::format::yaml::set_preserving(&self.source, key, &target)?
484 }
485 Format::Json => {
486 crate::document::format::json::set_preserving(&self.source, key, &target)?
487 }
488 #[cfg(feature = "dotenv")]
489 Format::Dotenv => {
490 crate::document::format::dotenv::set_preserving(&self.source, key, &target)?
491 }
492 #[cfg(feature = "ini")]
493 Format::Ini => {
494 crate::document::format::ini::set_preserving(&self.source, key, &target)?
495 }
496 #[cfg(feature = "toml")]
497 Format::TomlFrontmatter => {
498 let parts = crate::document::format::frontmatter::split(
499 &self.source,
500 crate::document::format::frontmatter::Delimiter::Plus,
501 )?;
502 let new_fm =
503 crate::document::format::toml::set_preserving(parts.frontmatter, key, &target)?;
504 format!("{}{}{}", parts.pre, new_fm, parts.post)
505 }
506 #[cfg(feature = "yaml")]
507 Format::YamlFrontmatter => {
508 let parts = crate::document::format::frontmatter::split(
509 &self.source,
510 crate::document::format::frontmatter::Delimiter::Dash,
511 )?;
512 let new_fm =
513 crate::document::format::yaml::set_preserving(parts.frontmatter, key, &target)?;
514 format!("{}{}{}", parts.pre, new_fm, parts.post)
515 }
516 _ => self.format.save(&new_doc)?,
517 };
518 self.source = output;
519 self.value = new_doc;
520 Ok(())
521 }
522
523 fn set_base(&self) -> Value {
527 #[cfg(feature = "yaml")]
528 if self.value.is_null() && self.blank_yaml_document() {
529 return Value::Object(Default::default());
530 }
531 self.value.clone()
532 }
533
534 #[cfg(feature = "yaml")]
536 fn blank_yaml_document(&self) -> bool {
537 match self.format {
538 Format::Yaml => crate::document::format::yaml::is_blank_document(&self.source),
539 Format::YamlFrontmatter => crate::document::format::frontmatter::split(
540 &self.source,
541 crate::document::format::frontmatter::Delimiter::Dash,
542 )
543 .is_ok_and(|parts| crate::document::format::yaml::is_blank_document(parts.frontmatter)),
544 _ => false,
545 }
546 }
547
548 pub fn add(
557 &mut self,
558 key: &str,
559 slug: &str,
560 slug_field: &str,
561 fields: &[(String, Value)],
562 ) -> DocumentResult<()> {
563 self.ensure_writable("add")?;
564 let mut value = self.value.clone();
565 let keyed_lists = [KeyedList {
566 prefix: key,
567 slug_field,
568 }];
569 crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
570 let array = if key.is_empty() {
571 &value
572 } else {
573 crate::document::get_path_ref(&value, key, self.addressing_keyed(&keyed_lists))?
574 };
575 let item = array
576 .as_array()
577 .and_then(|items| items.last())
578 .ok_or_else(|| DocumentError::UnsupportedOperation {
579 format: self.format.name().to_string(),
580 operation: "add".to_string(),
581 detail: "keyed list did not produce an array item".to_string(),
582 })?;
583 #[allow(unreachable_patterns)]
587 let output: String = match self.format {
588 Format::Json => crate::document::format::json::append_array_item_preserving(
589 &self.source,
590 key,
591 item,
592 )?,
593 #[cfg(feature = "yaml")]
594 Format::Yaml => crate::document::format::yaml::append_array_item_preserving(
595 &self.source,
596 key,
597 item,
598 )?,
599 #[cfg(feature = "yaml")]
604 Format::YamlFrontmatter => {
605 let parts = crate::document::format::frontmatter::split(
606 &self.source,
607 crate::document::format::frontmatter::Delimiter::Dash,
608 )?;
609 let new_fm = crate::document::format::yaml::append_array_item_preserving(
610 parts.frontmatter,
611 key,
612 item,
613 )?;
614 format!("{}{}{}", parts.pre, new_fm, parts.post)
615 }
616 _ => {
617 return Err(DocumentError::UnsupportedOperation {
618 format: self.format.name().to_string(),
619 operation: "add".to_string(),
620 detail: "keyed collection source editor is not implemented for this backend"
621 .to_string(),
622 });
623 }
624 };
625 self.source = output;
626 self.value = value;
627 Ok(())
628 }
629
630 pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
638 self.ensure_writable("remove")?;
639 let mut value = self.value.clone();
640 let keyed_lists = [KeyedList {
641 prefix: key,
642 slug_field,
643 }];
644 let removed_index = crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
645 #[allow(unreachable_patterns)]
649 let output: String = match self.format {
650 Format::Json => crate::document::format::json::remove_array_item_preserving(
651 &self.source,
652 key,
653 removed_index,
654 )?,
655 #[cfg(feature = "yaml")]
656 Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
657 &self.source,
658 key,
659 removed_index,
660 )?,
661 #[cfg(feature = "yaml")]
662 Format::YamlFrontmatter => {
663 let parts = crate::document::format::frontmatter::split(
664 &self.source,
665 crate::document::format::frontmatter::Delimiter::Dash,
666 )?;
667 let new_fm = crate::document::format::yaml::remove_array_item_preserving(
668 parts.frontmatter,
669 key,
670 removed_index,
671 )?;
672 format!("{}{}{}", parts.pre, new_fm, parts.post)
673 }
674 _ => {
675 return Err(DocumentError::UnsupportedOperation {
676 format: self.format.name().to_string(),
677 operation: "remove".to_string(),
678 detail: "keyed collection source editor is not implemented for this backend"
679 .to_string(),
680 });
681 }
682 };
683 self.source = output;
684 self.value = value;
685 Ok(())
686 }
687
688 pub fn unset(&mut self, key: &str) -> DocumentResult<bool> {
713 let addressing = self.addressing();
714 self.unset_addressed(key, addressing)
715 }
716
717 pub fn unset_addressed(
722 &mut self,
723 key: &str,
724 addressing: Addressing<'_>,
725 ) -> DocumentResult<bool> {
726 self.ensure_writable("unset")?;
727 let key = &crate::document::resolve_path(&self.value, key, addressing)?;
728 let segments = crate::document::parse_path(key)?;
729 let (leaf, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
730 let parent = if parents.is_empty() {
731 &self.value
732 } else {
733 let parent_path = crate::document::join_path(parents);
734 match crate::document::get_path_ref(&self.value, &parent_path, Addressing::INDEX_ONLY) {
735 Ok(parent) => parent,
736 Err(DocumentError::UnknownSegment { .. }) => return Ok(false),
738 Err(error) => return Err(error),
739 }
740 };
741 match parent {
742 Value::Object(object) => {
743 if !object.contains_key(leaf) {
744 return Ok(false);
745 }
746 }
747 Value::Array(_) if leaf == "-" => {
748 return Err(crate::document::append_position_refused(parents, true));
749 }
750 Value::Array(array) => {
751 let index =
752 leaf.parse::<usize>()
753 .map_err(|_| DocumentError::UnregisteredArray {
754 path: crate::document::join_path(parents),
755 })?;
756 if index >= array.len() {
757 return Err(DocumentError::IndexOutOfBounds {
758 path: crate::document::join_path(parents),
759 index,
760 len: array.len(),
761 });
762 }
763 }
764 value => {
765 return Err(DocumentError::NotTraversable {
766 path: crate::document::join_path(parents),
767 got: value.kind_name().to_string(),
768 });
769 }
770 }
771 let mut value = self.value.clone();
772 crate::document::unset_path(&mut value, key)?;
773 #[allow(unreachable_patterns)]
774 let output = match self.format {
775 Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
776 #[cfg(feature = "toml")]
777 Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
778 #[cfg(feature = "yaml")]
779 Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
780 #[cfg(feature = "dotenv")]
781 Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
782 #[cfg(feature = "ini")]
783 Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
784 #[cfg(feature = "toml")]
785 Format::TomlFrontmatter => {
786 let parts = crate::document::format::frontmatter::split(
787 &self.source,
788 crate::document::format::frontmatter::Delimiter::Plus,
789 )?;
790 let new_fm =
791 crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
792 format!("{}{}{}", parts.pre, new_fm, parts.post)
793 }
794 #[cfg(feature = "yaml")]
795 Format::YamlFrontmatter => {
796 let parts = crate::document::format::frontmatter::split(
797 &self.source,
798 crate::document::format::frontmatter::Delimiter::Dash,
799 )?;
800 let new_fm =
801 crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
802 format!("{}{}{}", parts.pre, new_fm, parts.post)
803 }
804 _ => self.format.save(&value)?,
805 };
806 self.source = output;
807 self.value = value;
808 Ok(true)
809 }
810}
811
812impl DocumentFile {
813 pub fn edit<F>(&mut self, edit: F) -> DocumentResult<()>
820 where
821 F: FnOnce(&mut Document) -> DocumentResult<()>,
822 {
823 let mut draft = self.doc.clone();
824 edit(&mut draft)?;
825 self.save_document(&draft)?;
826 self.doc = draft;
827 Ok(())
828 }
829
830 pub fn edit_and_validate<T>(
841 &mut self,
842 edit: impl FnOnce(&mut Document) -> DocumentResult<()>,
843 ) -> DocumentResult<T>
844 where
845 T: serde::de::DeserializeOwned,
846 {
847 let mut draft = self.doc.clone();
848 edit(&mut draft)?;
849 let decoded = draft.decode::<T>()?;
850 self.save_document(&draft)?;
851 self.doc = draft;
852 Ok(decoded)
853 }
854
855 pub fn save(&self) -> DocumentResult<()> {
869 self.save_atomic(self.doc.source())
870 }
871
872 pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
885 self.ensure_writable("save")?;
889 validate_source_text_for_write(new_source, self.format)?;
896 write_atomic(&self.path, new_source.as_bytes(), "write")
897 }
898
899 fn save_document(&self, document: &Document) -> DocumentResult<()> {
900 document.ensure_writable("save")?;
901 validate_source_for_write(document)?;
902 write_atomic(&self.path, document.source().as_bytes(), "write")
903 }
904}
905
906impl std::ops::Deref for DocumentFile {
907 type Target = Document;
908
909 fn deref(&self) -> &Document {
910 &self.doc
911 }
912}
913
914impl std::ops::DerefMut for DocumentFile {
915 fn deref_mut(&mut self) -> &mut Document {
916 &mut self.doc
917 }
918}
919
920fn resolve_format(path: &Path, format_override: Option<Format>) -> DocumentResult<Format> {
921 match format_override {
922 Some(format) => Ok(format),
923 None => match Format::detect(path) {
928 Some(format) => Ok(format),
929 None => Err(match Format::unavailable(path) {
930 Some(feature) => DocumentError::UnsupportedOperation {
931 format: feature.to_string(),
932 operation: "open".to_string(),
933 detail: format!("requires Cargo feature `{feature}`"),
934 },
935 None => DocumentError::FormatUnknown {
936 path: path.display().to_string(),
937 },
938 }),
939 },
940 }
941}
942
943fn validate_source_for_write(document: &Document) -> DocumentResult<()> {
944 validate_source_text_for_write(document.source(), document.format())
945}
946
947fn validate_source_text_for_write(source: &str, format: Format) -> DocumentResult<()> {
948 Document::parse(source, format).map_err(|error| DocumentError::WriteWouldCorrupt {
949 format: format.name().to_string(),
950 detail: error.redacted_message(),
951 })?;
952 Ok(())
953}
954
955fn validate_create_options(options: CreateOptions) -> DocumentResult<()> {
956 if let Some(unix_mode) = options.unix_mode
957 && unix_mode & !0o777 != 0
958 {
959 return Err(DocumentError::InvalidArgument {
960 detail: format!("unix mode {unix_mode:o} contains bits outside 0o777"),
961 });
962 }
963 Ok(())
964}
965
966#[cfg(test)]
967mod tests {
968 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
969 use super::*;
970 use std::io::Cursor;
971
972 fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
973 let path = dir.join(name);
974 fs::write(&path, contents).unwrap();
975 path
976 }
977
978 #[test]
979 fn round_trip_open_json() {
980 let dir = tempfile::tempdir().unwrap();
981 let contents = r#"{"host": "example.com", "port": 993}"#;
982 let path = write_temp(dir.path(), "config.json", contents);
983
984 let doc = DocumentFile::open(&path, None).unwrap();
985
986 assert_eq!(doc.format(), Format::Json);
987 assert_eq!(
988 doc.value().get("host").and_then(Value::as_str),
989 Some("example.com")
990 );
991 assert_eq!(doc.source(), contents);
992 }
993
994 #[test]
995 fn value_at_reads_a_nested_address() {
996 let dir = tempfile::tempdir().unwrap();
997 let path = write_temp(
998 dir.path(),
999 "config.json",
1000 r#"{"database": {"url": "postgres://x"}}"#,
1001 );
1002 let doc = DocumentFile::open(&path, None).unwrap();
1003
1004 assert_eq!(
1005 doc.value_at("database.url").unwrap(),
1006 Value::String("postgres://x".to_string())
1007 );
1008 assert_eq!(
1009 doc.value_at("database.missing").unwrap_err().code(),
1010 "document_path_not_found"
1011 );
1012 }
1013
1014 #[test]
1015 fn open_capped_enforces_size_and_regular_file() {
1016 let dir = tempfile::tempdir().unwrap();
1017 let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
1018
1019 assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
1021
1022 let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
1025 assert_eq!(err.code(), "document_too_large");
1026
1027 let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
1029 assert_eq!(dir_err.code(), "document_io_failed");
1030
1031 let missing =
1033 DocumentFile::open_capped(dir.path().join("absent.json"), None, 1024).unwrap_err();
1034 assert_ne!(missing.code(), "document_too_large");
1035 }
1036
1037 #[cfg(unix)]
1038 #[test]
1039 fn capped_read_uses_the_open_handle_when_the_path_is_replaced() {
1040 let dir = tempfile::tempdir().unwrap();
1041 let original = r#"{"source":"original"}"#;
1042 let path = write_temp(dir.path(), "config.json", original);
1043 let handle = open_read_handle(&path, SymlinkPolicy::Follow).unwrap();
1044 inspect_capped_source(&handle, &path, 64).unwrap();
1045
1046 fs::rename(&path, dir.path().join("original.json")).unwrap();
1047 fs::write(&path, r#"{"source":"replacement"}"#).unwrap();
1048
1049 let source = read_capped_contents(handle, &path, 64).unwrap();
1050 assert_eq!(source, original);
1051 }
1052
1053 #[cfg(unix)]
1054 #[test]
1055 fn capped_read_rechecks_the_actual_bytes_after_metadata() {
1056 use std::io::Write as _;
1057
1058 let dir = tempfile::tempdir().unwrap();
1059 let path = write_temp(dir.path(), "config.json", "{}");
1060 let handle = open_read_handle(&path, SymlinkPolicy::Follow).unwrap();
1061 inspect_capped_source(&handle, &path, 4).unwrap();
1062
1063 let mut writer = OpenOptions::new().append(true).open(&path).unwrap();
1064 writer.write_all(b"123").unwrap();
1065 writer.sync_all().unwrap();
1066
1067 let error = read_capped_contents(handle, &path, 4).unwrap_err();
1068 assert_eq!(error.code(), "document_too_large");
1069 }
1070
1071 #[cfg(all(unix, feature = "libc"))]
1072 #[test]
1073 fn open_capped_can_atomically_refuse_a_symlink() {
1074 let dir = tempfile::tempdir().unwrap();
1075 let target = write_temp(dir.path(), "target.json", r#"{"k": "v"}"#);
1076 let link = dir.path().join("link.json");
1077 std::os::unix::fs::symlink(&target, &link).unwrap();
1078
1079 assert!(
1080 DocumentFile::open_capped_with_policy(&link, None, 1024, SymlinkPolicy::Follow).is_ok()
1081 );
1082 let error =
1083 DocumentFile::open_capped_with_policy(&link, None, 1024, SymlinkPolicy::NoFollow)
1084 .unwrap_err();
1085 assert_eq!(error.code(), "document_io_failed");
1086 }
1087
1088 #[test]
1089 fn create_atomic_is_no_clobber_and_returns_a_file_handle() {
1090 let dir = tempfile::tempdir().unwrap();
1091 let path = dir.path().join("config.json");
1092 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1093
1094 let created = DocumentFile::create_atomic(&path, document, CreateOptions::new()).unwrap();
1095 assert_eq!(created.path(), path);
1096 assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 993}"#);
1097
1098 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1099 let error =
1100 DocumentFile::create_atomic(&path, replacement, CreateOptions::new()).unwrap_err();
1101 assert_eq!(error.code(), "document_target_exists");
1102 assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 993}"#);
1103 }
1104
1105 #[test]
1106 fn create_atomic_requires_explicit_replace() {
1107 let dir = tempfile::tempdir().unwrap();
1108 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
1109 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1110
1111 let created =
1112 DocumentFile::create_atomic(&path, replacement, CreateOptions::new().replace())
1113 .unwrap();
1114
1115 assert_eq!(created.value_at("port").unwrap(), Value::Integer(1024));
1116 assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 1024}"#);
1117 }
1118
1119 #[cfg(unix)]
1124 #[test]
1125 fn create_atomic_replace_preserves_the_targets_mode_unless_told_otherwise() {
1126 use std::os::unix::fs::PermissionsExt as _;
1127
1128 let dir = tempfile::tempdir().unwrap();
1129 let mode_of = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
1130
1131 for original in [0o644, 0o600, 0o640] {
1132 let path = write_temp(dir.path(), &format!("m{original:o}.json"), r#"{"a": 1}"#);
1133 fs::set_permissions(&path, fs::Permissions::from_mode(original)).unwrap();
1134 let replacement = Document::parse(r#"{"a": 2}"#, Format::Json).unwrap();
1135 DocumentFile::create_atomic(&path, replacement, CreateOptions::new().replace())
1136 .unwrap();
1137 assert_eq!(
1138 mode_of(&path),
1139 original,
1140 "replace must keep the file's mode"
1141 );
1142 }
1143
1144 let path = write_temp(dir.path(), "explicit.json", r#"{"a": 1}"#);
1146 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1147 let replacement = Document::parse(r#"{"a": 2}"#, Format::Json).unwrap();
1148 DocumentFile::create_atomic(
1149 &path,
1150 replacement,
1151 CreateOptions::new().replace().unix_mode(0o600),
1152 )
1153 .unwrap();
1154 assert_eq!(mode_of(&path), 0o600);
1155
1156 let fresh = dir.path().join("fresh.json");
1158 let document = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1159 DocumentFile::create_atomic(&fresh, document, CreateOptions::new()).unwrap();
1160 assert_eq!(mode_of(&fresh), 0o600);
1161 }
1162
1163 #[cfg(feature = "toml")]
1167 #[test]
1168 fn create_atomic_refuses_a_document_the_path_could_not_reopen() {
1169 let dir = tempfile::tempdir().unwrap();
1170 let path = dir.path().join("mismatch.toml");
1171 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1172
1173 let error = DocumentFile::create_atomic(&path, document, CreateOptions::new()).unwrap_err();
1174
1175 assert_eq!(error.code(), "document_unsupported_operation");
1176 assert!(
1177 !path.exists(),
1178 "nothing may be written when the check fails"
1179 );
1180 }
1181
1182 #[cfg(unix)]
1183 #[test]
1184 fn create_atomic_applies_requested_private_mode() {
1185 use std::os::unix::fs::PermissionsExt as _;
1186
1187 let dir = tempfile::tempdir().unwrap();
1188 let path = dir.path().join("config.json");
1189 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1190
1191 DocumentFile::create_atomic(&path, document, CreateOptions::new().unix_mode(0o640))
1192 .unwrap();
1193
1194 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1195 assert_eq!(mode, 0o640);
1196 }
1197
1198 #[test]
1199 fn create_atomic_rejects_invalid_permission_bits_before_writing() {
1200 let dir = tempfile::tempdir().unwrap();
1201 let path = dir.path().join("config.json");
1202 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1203
1204 let error =
1205 DocumentFile::create_atomic(&path, document, CreateOptions::new().unix_mode(0o1600))
1206 .unwrap_err();
1207
1208 assert_eq!(error.code(), "document_invalid_argument");
1209 assert!(!path.exists());
1210 }
1211
1212 #[cfg(unix)]
1213 #[test]
1214 fn create_atomic_replace_refuses_symlinks_and_hardlinks() {
1215 let dir = tempfile::tempdir().unwrap();
1216 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
1217 let symlink = dir.path().join("symlink.json");
1218 std::os::unix::fs::symlink(&target, &symlink).unwrap();
1219 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1220
1221 let symlink_error = DocumentFile::create_atomic(
1222 &symlink,
1223 replacement.clone(),
1224 CreateOptions::new().replace(),
1225 )
1226 .unwrap_err();
1227 assert_eq!(symlink_error.code(), "document_unsupported_operation");
1228 assert_eq!(fs::read_to_string(&target).unwrap(), r#"{"port": 993}"#);
1229
1230 let hardlink = dir.path().join("hardlink.json");
1231 fs::hard_link(&target, &hardlink).unwrap();
1232 let hardlink_error =
1233 DocumentFile::create_atomic(&hardlink, replacement, CreateOptions::new().replace())
1234 .unwrap_err();
1235 assert_eq!(hardlink_error.code(), "document_unsupported_operation");
1236 assert_eq!(fs::read_to_string(&target).unwrap(), r#"{"port": 993}"#);
1237 }
1238
1239 #[cfg(unix)]
1240 #[test]
1241 fn create_atomic_replace_refuses_a_dangling_symlink() {
1242 let dir = tempfile::tempdir().unwrap();
1243 let missing = dir.path().join("missing.json");
1244 let symlink = dir.path().join("dangling.json");
1245 std::os::unix::fs::symlink(&missing, &symlink).unwrap();
1246 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1247
1248 let error =
1249 DocumentFile::create_atomic(&symlink, replacement, CreateOptions::new().replace())
1250 .unwrap_err();
1251
1252 assert_eq!(error.code(), "document_unsupported_operation");
1253 assert!(
1254 fs::symlink_metadata(&symlink)
1255 .unwrap()
1256 .file_type()
1257 .is_symlink()
1258 );
1259 assert!(!missing.exists());
1260 }
1261
1262 #[test]
1263 fn edit_rolls_back_memory_and_disk_when_the_closure_fails() {
1264 let dir = tempfile::tempdir().unwrap();
1265 let original = r#"{"port": 993}"#;
1266 let path = write_temp(dir.path(), "config.json", original);
1267 let mut document = DocumentFile::open(&path, None).unwrap();
1268
1269 let error = document
1270 .edit(|draft| {
1271 draft.set("port", Value::Integer(1024))?;
1272 Err(DocumentError::InvalidArgument {
1273 detail: "validation failed".to_string(),
1274 })
1275 })
1276 .unwrap_err();
1277
1278 assert_eq!(error.code(), "document_invalid_argument");
1279 assert_eq!(document.source(), original);
1280 assert_eq!(document.value_at("port").unwrap(), Value::Integer(993));
1281 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1282 }
1283
1284 #[test]
1285 fn typed_get_and_set_enforce_the_stated_type() {
1286 use crate::document::ValueType;
1287 let dir = tempfile::tempdir().unwrap();
1288 let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
1289 let mut doc = DocumentFile::open(&path, None).unwrap();
1290
1291 assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
1294 assert_eq!(
1295 doc.value_at_typed("port", ValueType::String)
1296 .unwrap_err()
1297 .code(),
1298 "document_type_mismatch"
1299 );
1300 assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
1301
1302 doc.set_typed("port", Some("9090"), ValueType::Number)
1304 .unwrap();
1305 assert_eq!(
1306 doc.value_at("port").unwrap(),
1307 Value::from(serde_json::json!(9090))
1308 );
1309 assert_eq!(
1310 doc.set_typed("port", Some("not-a-number"), ValueType::Number)
1311 .unwrap_err()
1312 .code(),
1313 "document_parse_failed"
1314 );
1315 }
1316
1317 #[test]
1318 fn decode_and_edit_and_validate_share_one_typed_boundary() {
1319 #[derive(Debug, serde::Deserialize)]
1320 #[serde(deny_unknown_fields)]
1321 struct Config {
1322 port: u16,
1323 }
1324
1325 let dir = tempfile::tempdir().unwrap();
1326 let original = r#"{"port": 8080}"#;
1327 let path = write_temp(dir.path(), "config.json", original);
1328 let mut document = DocumentFile::open(&path, None).unwrap();
1329
1330 assert_eq!(document.decode::<Config>().unwrap().port, 8080);
1331
1332 let error = document
1333 .edit_and_validate::<Config>(|draft| {
1334 draft.set("port", Value::String("invalid".to_string()))
1335 })
1336 .unwrap_err();
1337 assert_eq!(error.code(), "document_type_mismatch");
1338 assert_eq!(document.source(), original);
1339 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1340
1341 let config = document
1342 .edit_and_validate::<Config>(|draft| draft.set("port", Value::Unsigned(9090)))
1343 .unwrap();
1344 assert_eq!(config.port, 9090);
1345 assert_eq!(document.value_at("port").unwrap(), Value::Unsigned(9090));
1346 }
1347
1348 #[cfg(feature = "toml")]
1349 #[test]
1350 fn round_trip_open_toml() {
1351 let dir = tempfile::tempdir().unwrap();
1352 let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
1353 let path = write_temp(dir.path(), "config.toml", contents);
1354
1355 let doc = DocumentFile::open(&path, None).unwrap();
1356
1357 assert_eq!(doc.format(), Format::Toml);
1358 assert_eq!(
1359 doc.value().get("host").and_then(Value::as_str),
1360 Some("example.com")
1361 );
1362 assert_eq!(doc.source(), contents);
1363 }
1364
1365 #[cfg(feature = "toml")]
1366 #[test]
1367 fn set_scalar_preserves_toml_comments_and_formatting() {
1368 let dir = tempfile::tempdir().unwrap();
1369 let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
1370 let path = write_temp(dir.path(), "config.toml", contents);
1371 let mut doc = DocumentFile::open(&path, None).unwrap();
1372
1373 doc.set("port", Value::Integer(1024)).unwrap();
1374 doc.save().unwrap();
1375
1376 let saved = fs::read_to_string(&path).unwrap();
1377 assert!(saved.contains("# leading comment"));
1378 assert!(saved.contains("port = 1024"));
1379 assert_eq!(
1380 doc.value().get("port").and_then(Value::as_integer),
1381 Some(1024)
1382 );
1383 assert_eq!(doc.source(), saved);
1384 }
1385
1386 #[cfg(feature = "toml")]
1391 #[test]
1392 fn toml_array_edits_never_invent_or_misattribute_a_comment() {
1393 let contents = "paths = [\n \"one\", # first\n \"two\", # second\n]\n";
1394
1395 let mut grown = Document::parse(contents, Format::Toml).unwrap();
1396 grown
1397 .set(
1398 "paths",
1399 Value::Array(vec![
1400 Value::String("a".into()),
1401 Value::String("b".into()),
1402 Value::String("c".into()),
1403 ]),
1404 )
1405 .unwrap();
1406 assert_eq!(
1407 grown.source(),
1408 "paths = [\n \"a\", # first\n \"b\", # second\n \"c\",\n]\n",
1409 "an appended element must carry no comment of its own"
1410 );
1411
1412 let mut shrunk = Document::parse(contents, Format::Toml).unwrap();
1413 shrunk
1414 .set("paths", Value::Array(vec![Value::String("only".into())]))
1415 .unwrap();
1416 assert_eq!(
1417 shrunk.source(),
1418 "paths = [\n \"only\", # first\n]\n",
1419 "the surviving element keeps its own comment, not the removed one's"
1420 );
1421
1422 let mut replaced = Document::parse(contents, Format::Toml).unwrap();
1424 replaced
1425 .set(
1426 "paths",
1427 Value::Array(vec![Value::String("x".into()), Value::String("y".into())]),
1428 )
1429 .unwrap();
1430 assert_eq!(
1431 replaced.source(),
1432 "paths = [\n \"x\", # first\n \"y\", # second\n]\n"
1433 );
1434
1435 let mut inline = Document::parse("paths = [ \"one\" ]\n", Format::Toml).unwrap();
1437 inline
1438 .set(
1439 "paths",
1440 Value::Array(vec![
1441 Value::String("one".into()),
1442 Value::String("two".into()),
1443 ]),
1444 )
1445 .unwrap();
1446 assert_eq!(inline.source(), "paths = [ \"one\", \"two\" ]\n");
1447 }
1448
1449 #[cfg(feature = "toml")]
1450 #[test]
1451 fn set_toml_array_preserves_single_line_decor() {
1452 let contents = "# before\npaths = [ \"old\", 'second', ] # keep this\nother = 42\n";
1453 let mut document = Document::parse(contents, Format::Toml).unwrap();
1454
1455 document
1456 .set(
1457 "paths",
1458 Value::Array(vec![
1459 Value::String("new".to_string()),
1460 Value::String("next".to_string()),
1461 ]),
1462 )
1463 .unwrap();
1464
1465 assert_eq!(
1466 document.source(),
1467 "# before\npaths = [ \"new\", \"next\", ] # keep this\nother = 42\n"
1468 );
1469 }
1470
1471 #[cfg(feature = "toml")]
1472 #[test]
1473 fn set_toml_array_preserves_multiline_comments_and_trailing_comma() {
1474 let contents = "paths = [\n \"one\", # first\n \"two\", # second\n]\nother = 42\n";
1475 let mut document = Document::parse(contents, Format::Toml).unwrap();
1476
1477 document
1478 .set(
1479 "paths",
1480 Value::Array(vec![
1481 Value::String("uno".to_string()),
1482 Value::String("dos".to_string()),
1483 ]),
1484 )
1485 .unwrap();
1486
1487 assert_eq!(
1488 document.source(),
1489 "paths = [\n \"uno\", # first\n \"dos\", # second\n]\nother = 42\n"
1490 );
1491 }
1492
1493 #[cfg(feature = "toml")]
1494 #[test]
1495 fn set_toml_array_element_preserves_its_neighbors() {
1496 let contents = "paths = [\n \"one\", # first\n \"two\", # second\n]\n";
1497 let mut document = Document::parse(contents, Format::Toml).unwrap();
1498
1499 document
1500 .set("paths.1", Value::String("changed".to_string()))
1501 .unwrap();
1502
1503 assert_eq!(
1504 document.source(),
1505 "paths = [\n \"one\", # first\n \"changed\", # second\n]\n"
1506 );
1507 }
1508
1509 #[cfg(feature = "toml")]
1510 #[test]
1511 fn set_toml_array_can_become_empty_without_touching_neighbors() {
1512 let contents = "before = 1\npaths = [ \"one\", ] # list\nafter = 2\n";
1513 let mut document = Document::parse(contents, Format::Toml).unwrap();
1514
1515 document.set("paths", Value::Array(Vec::new())).unwrap();
1516
1517 assert_eq!(
1518 document.source(),
1519 "before = 1\npaths = [ ] # list\nafter = 2\n"
1520 );
1521 }
1522
1523 #[cfg(feature = "toml")]
1524 #[test]
1525 fn set_toml_inline_table_preserves_layout_and_comments() {
1526 let contents = "cache = { ttl_s = 1, enabled = true } # cache\nother = 42\n";
1527 let mut document = Document::parse(contents, Format::Toml).unwrap();
1528 let replacement = Value::from(serde_json::json!({
1529 "enabled": false,
1530 "ttl_s": 60
1531 }));
1532
1533 document.set("cache", replacement).unwrap();
1534
1535 assert_eq!(
1536 document.source(),
1537 "cache = { ttl_s = 60, enabled = false } # cache\nother = 42\n"
1538 );
1539 }
1540
1541 #[cfg(feature = "toml")]
1542 #[test]
1543 fn set_toml_ordinary_table_preserves_header_and_unrelated_section() {
1544 let contents = "# lead\n[cache] # cache header\nttl_s = 1 # ttl\nenabled = true\n\n[next]\nvalue = 9\n";
1545 let mut document = Document::parse(contents, Format::Toml).unwrap();
1546 let replacement = Value::from(serde_json::json!({
1547 "enabled": false,
1548 "ttl_s": 60
1549 }));
1550
1551 document.set("cache", replacement).unwrap();
1552
1553 assert_eq!(
1554 document.source(),
1555 "# lead\n[cache] # cache header\nttl_s = 60 # ttl\nenabled = false\n\n[next]\nvalue = 9\n"
1556 );
1557 }
1558
1559 #[cfg(feature = "toml")]
1560 #[test]
1561 fn set_toml_collection_preserves_unchanged_datetime_syntax() {
1562 let contents = "[cache]\nexpires_at = 2026-08-04T12:30:00Z\npaths = [\"one\"]\n";
1563 let mut document = Document::parse(contents, Format::Toml).unwrap();
1564 let replacement = document.value_at("cache").unwrap();
1565
1566 document.set("cache", replacement).unwrap();
1567
1568 assert_eq!(document.source(), contents);
1569 }
1570
1571 #[cfg(feature = "toml")]
1572 #[test]
1573 fn set_toml_array_of_tables_is_explicitly_refused() {
1574 let contents = "[[servers]]\nname = \"one\"\n[[servers]]\nname = \"two\"\n";
1575 let mut document = Document::parse(contents, Format::Toml).unwrap();
1576 let replacement = document.value_at("servers").unwrap();
1577
1578 let error = document.set("servers", replacement).unwrap_err();
1579
1580 assert_eq!(error.code(), "document_unsupported_operation");
1581 assert_eq!(document.source(), contents);
1582 }
1583
1584 #[cfg(feature = "ini")]
1585 #[test]
1586 fn save_refuses_source_its_own_parser_rejects() {
1587 let dir = tempfile::tempdir().unwrap();
1591 let original = "[db]\nhost=localhost\n";
1592 let path = write_temp(dir.path(), "config.ini", original);
1593 let doc = DocumentFile::open(&path, None).unwrap();
1594
1595 let error = doc
1596 .save_atomic("[db]\nhost=localhost\n\n[db]\nport=5432\n")
1597 .unwrap_err();
1598 assert_eq!(error.code(), "document_write_would_corrupt");
1599 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1601 }
1602
1603 #[cfg(feature = "ini")]
1604 #[test]
1605 fn save_writes_source_the_parser_accepts() {
1606 let dir = tempfile::tempdir().unwrap();
1607 let path = write_temp(dir.path(), "config.ini", "[db]\nhost=localhost\n");
1608 let mut doc = DocumentFile::open(&path, None).unwrap();
1609
1610 doc.set("db.port", Value::String("5432".to_string()))
1611 .unwrap();
1612 doc.save().unwrap();
1613
1614 assert_eq!(
1617 fs::read_to_string(&path).unwrap(),
1618 "[db]\nhost=localhost\nport=5432\n"
1619 );
1620 assert!(DocumentFile::open(&path, None).is_ok());
1621 }
1622
1623 #[cfg(unix)]
1624 #[test]
1625 fn atomic_save_preserves_file_mode() {
1626 use std::os::unix::fs::PermissionsExt;
1627
1628 let dir = tempfile::tempdir().unwrap();
1629 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
1630 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
1631 let mut doc = DocumentFile::open(&path, None).unwrap();
1632
1633 doc.set("port", Value::Integer(1024)).unwrap();
1634 doc.save().unwrap();
1635
1636 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1637 assert_eq!(mode, 0o640);
1638 }
1639
1640 #[cfg(unix)]
1641 #[test]
1642 fn symlink_target_is_rejected_for_mutation() {
1643 let dir = tempfile::tempdir().unwrap();
1644 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
1645 let link = dir.path().join("link.json");
1646 std::os::unix::fs::symlink(&target, &link).unwrap();
1647
1648 let mut doc = DocumentFile::open(&link, None).unwrap();
1650
1651 doc.set("port", Value::Integer(1024)).unwrap();
1653 let err = doc.save().unwrap_err();
1654 assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
1655
1656 let target_contents = fs::read_to_string(&target).unwrap();
1658 assert_eq!(target_contents, r#"{"port": 993}"#);
1659 }
1660
1661 #[test]
1662 fn from_reader_parses_in_memory_cursor() {
1663 let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
1664
1665 let doc = Document::from_reader(cursor, Format::Json).unwrap();
1666
1667 assert_eq!(
1668 doc.value().get("host").and_then(Value::as_str),
1669 Some("example.com")
1670 );
1671 }
1672
1673 #[test]
1674 fn document_from_str_encode_round_trip() {
1675 let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1676 let encoded = doc.encode().unwrap();
1677 let reparsed = Document::parse(&encoded, Format::Json).unwrap();
1678 assert_eq!(
1679 reparsed.value().get("a").and_then(Value::as_integer),
1680 Some(1)
1681 );
1682 }
1683
1684 #[test]
1685 fn document_edits_source_in_memory_without_a_file() {
1686 let mut doc = Document::parse("{\n \"host\": \"old\"\n}\n", Format::Json).unwrap();
1689 doc.set("host", Value::String("new".to_string())).unwrap();
1690 doc.set("imap.port", Value::Integer(993)).unwrap(); assert_eq!(
1693 doc.source(),
1694 "{\n \"host\": \"new\",\n \"imap\": {\n \"port\": 993\n }\n}\n"
1695 );
1696 assert_eq!(
1697 doc.value_at("imap.port").unwrap(),
1698 Value::from(serde_json::json!(993))
1699 );
1700 }
1701
1702 #[test]
1703 fn unset_is_false_for_anything_already_absent() {
1704 let mut doc = Document::parse(
1705 r#"{"service":{"host":"example","ports":[80]}}"#,
1706 Format::Json,
1707 )
1708 .unwrap();
1709
1710 assert!(!doc.unset("service.missing").unwrap());
1713 assert!(!doc.unset("missing.parent").unwrap());
1714 assert!(!doc.unset("missing.deeply.nested").unwrap());
1715
1716 assert!(doc.unset("service.host.child").is_err()); assert!(doc.unset("service.ports.9").is_err()); assert!(doc.unset(r"service\q").is_err()); }
1722
1723 #[cfg(feature = "markdown")]
1724 #[test]
1725 fn every_markdown_write_verb_is_refused() {
1726 let source = "# Title\n\nThe lead.\n";
1727 let mut doc = Document::parse(source, Format::Markdown).unwrap();
1728
1729 assert_eq!(
1732 doc.value_at("h1.0.text").unwrap(),
1733 Value::String("Title".to_string())
1734 );
1735 assert_eq!(
1736 doc.value_at("h1.Tit.paragraph.0.text").unwrap(),
1737 Value::String("The lead.".to_string())
1738 );
1739
1740 let refusals: Vec<DocumentError> = vec![
1744 doc.set("h1.0.text", Value::String("New".to_string()))
1745 .unwrap_err(),
1746 doc.add("preamble", "x", "type", &[]).unwrap_err(),
1747 doc.remove("preamble", "x", "type").unwrap_err(),
1748 doc.unset("h1.0").unwrap_err(),
1749 doc.unset("nothing.here").unwrap_err(),
1750 doc.encode().unwrap_err(),
1751 ];
1752 for error in refusals {
1753 assert_eq!(error.code(), "document_unsupported_operation");
1754 assert!(
1755 error.to_string().contains("read-only"),
1756 "refusal must name the reason: {error}"
1757 );
1758 }
1759
1760 assert_eq!(doc.source(), source);
1762 }
1763
1764 #[cfg(feature = "markdown")]
1765 #[test]
1766 fn markdown_save_never_reaches_disk() {
1767 let dir = tempfile::tempdir().unwrap();
1768 let path = write_temp(dir.path(), "README.md", "# Title\n");
1769 assert!(DocumentFile::open(&path, None).is_err());
1771
1772 let doc = DocumentFile::open(&path, Some(Format::Markdown)).unwrap();
1773 let error = doc.save().unwrap_err();
1775 assert_eq!(error.code(), "document_unsupported_operation");
1776 assert_eq!(fs::read_to_string(&path).unwrap(), "# Title\n");
1777 }
1778
1779 #[cfg(feature = "yaml")]
1780 #[test]
1781 fn yaml_write_rejects_cst_ambiguous_mapping_segments() {
1782 let mut numeric = Document::parse("\"123\": value\n", Format::Yaml).unwrap();
1783 assert!(
1784 numeric
1785 .set("123", Value::String("changed".to_string()))
1786 .is_err()
1787 );
1788 assert!(numeric.unset("123").is_err());
1789
1790 let mut bracketed = Document::parse("\"a[0]\": value\n", Format::Yaml).unwrap();
1791 assert!(
1792 bracketed
1793 .set("a[0]", Value::String("changed".to_string()))
1794 .is_err()
1795 );
1796 }
1797}