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
38#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
46pub enum SymlinkPolicy {
47 #[default]
49 Follow,
50 NoFollow,
54}
55
56#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58pub enum CreateMode {
59 #[default]
61 NewOnly,
62 Replace,
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
68pub struct CreateOptions {
69 mode: CreateMode,
70 unix_mode: Option<u32>,
71}
72
73impl CreateOptions {
74 #[must_use]
79 pub const fn new() -> Self {
80 Self {
81 mode: CreateMode::NewOnly,
82 unix_mode: None,
83 }
84 }
85
86 #[must_use]
92 pub const fn unix_mode(mut self, unix_mode: u32) -> Self {
93 self.unix_mode = Some(unix_mode);
94 self
95 }
96
97 #[must_use]
99 pub const fn replace(mut self) -> Self {
100 self.mode = CreateMode::Replace;
101 self
102 }
103
104 #[must_use]
106 pub const fn mode(&self) -> CreateMode {
107 self.mode
108 }
109
110 #[must_use]
115 pub const fn configured_unix_mode(&self) -> Option<u32> {
116 self.unix_mode
117 }
118
119 const fn effective_unix_mode(&self, target_exists: bool) -> Option<u32> {
121 match self.unix_mode {
122 Some(mode) => Some(mode),
123 None if target_exists => None,
128 None => Some(0o600),
129 }
130 }
131}
132
133impl Default for CreateOptions {
134 fn default() -> Self {
135 Self::new()
136 }
137}
138
139#[derive(Debug, Clone)]
147pub struct Document {
148 source: String,
149 value: Value,
150 format: Format,
151}
152
153impl Document {
154 pub fn parse(source: &str, format: Format) -> DocumentResult<Document> {
160 let value = format.load(source)?;
161 Ok(Document {
162 source: source.to_string(),
163 value,
164 format,
165 })
166 }
167
168 pub fn from_reader<R: std::io::Read>(
174 mut reader: R,
175 format: Format,
176 ) -> DocumentResult<Document> {
177 let mut source = String::new();
178 reader.read_to_string(&mut source)?;
179 Document::parse(&source, format)
180 }
181
182 pub fn value(&self) -> &Value {
184 &self.value
185 }
186
187 pub fn source(&self) -> &str {
191 &self.source
192 }
193
194 pub fn format(&self) -> Format {
196 self.format
197 }
198
199 #[must_use]
205 pub fn addressing(&self) -> Addressing<'static> {
206 Addressing::INDEX_ONLY.with_array_rule(self.format.array_rule())
207 }
208
209 #[must_use]
212 pub fn addressing_keyed<'a>(&self, keyed_lists: &'a [KeyedList<'a>]) -> Addressing<'a> {
213 Addressing::keyed(keyed_lists).with_array_rule(self.format.array_rule())
214 }
215
216 pub fn value_at(&self, path: &str) -> DocumentResult<Value> {
224 crate::document::get_path(&self.value, path, self.addressing())
225 }
226
227 pub fn value_at_typed(
231 &self,
232 path: &str,
233 expected: crate::document::ValueType,
234 ) -> DocumentResult<Value> {
235 let value = self.value_at(path)?;
236 if crate::document::value_matches_type(&value, expected) {
237 Ok(value)
238 } else {
239 Err(DocumentError::TypeMismatch {
240 path: path.to_string(),
241 expected: expected.name().to_string(),
242 got: value.kind_name().to_string(),
243 hint: None,
244 })
245 }
246 }
247
248 pub fn decode<T: serde::de::DeserializeOwned>(&self) -> DocumentResult<T> {
254 crate::document::from_value(self.value(), "")
255 }
256
257 pub fn set_typed(
260 &mut self,
261 key: &str,
262 raw: Option<&str>,
263 value_type: crate::document::ValueType,
264 ) -> DocumentResult<()> {
265 let value = crate::document::value_from_type(value_type, raw)?;
266 self.set(key, value)
267 }
268
269 fn ensure_writable(&self, operation: &str) -> DocumentResult<()> {
277 if self.format.is_read_only() {
278 return Err(self.format.read_only_error(operation));
279 }
280 Ok(())
281 }
282
283 pub fn encode(&self) -> DocumentResult<String> {
289 self.format.save(&self.value)
290 }
291}
292
293#[derive(Debug, Clone)]
302pub struct DocumentFile {
303 doc: Document,
304 path: PathBuf,
305}
306
307impl DocumentFile {
308 pub fn open(
314 path: impl AsRef<Path>,
315 format_override: Option<Format>,
316 ) -> DocumentResult<DocumentFile> {
317 let path = path.as_ref().to_path_buf();
318 let format = resolve_format(&path, format_override)?;
319 let source = fs::read_to_string(&path).map_err(|error| DocumentError::IoError {
320 detail: format!("read `{}`: {error}", path.display()),
321 })?;
322 Ok(DocumentFile {
323 doc: Document::parse(&source, format)?,
324 path,
325 })
326 }
327
328 pub fn open_capped(
342 path: impl AsRef<Path>,
343 format_override: Option<Format>,
344 max_bytes: u64,
345 ) -> DocumentResult<DocumentFile> {
346 Self::open_capped_with_policy(path, format_override, max_bytes, SymlinkPolicy::Follow)
347 }
348
349 pub fn open_capped_with_policy(
355 path: impl AsRef<Path>,
356 format_override: Option<Format>,
357 max_bytes: u64,
358 symlink_policy: SymlinkPolicy,
359 ) -> DocumentResult<DocumentFile> {
360 let path = path.as_ref().to_path_buf();
361 let format = resolve_format(&path, format_override)?;
362 let file = open_read_handle(&path, symlink_policy)?;
363 let source = read_capped_source(file, &path, max_bytes)?;
364 Ok(DocumentFile {
365 doc: Document::parse(&source, format)?,
366 path,
367 })
368 }
369
370 pub fn create_atomic(
383 path: impl AsRef<Path>,
384 document: Document,
385 options: CreateOptions,
386 ) -> DocumentResult<DocumentFile> {
387 let path = path.as_ref().to_path_buf();
388 document.ensure_writable("create")?;
389 let path_format = resolve_format(&path, None)?;
392 if path_format != document.format() {
393 return Err(DocumentError::UnsupportedOperation {
394 format: document.format().name().to_string(),
395 operation: "create".to_string(),
396 detail: format!(
397 "path resolves to {}, so the created file could not be reopened",
398 path_format.name()
399 ),
400 });
401 }
402 validate_source_for_write(&document)?;
403 validate_create_options(options)?;
404 write_atomic_create(&path, document.source().as_bytes(), options)?;
405 Ok(DocumentFile {
406 doc: document,
407 path,
408 })
409 }
410
411 pub fn path(&self) -> &Path {
413 &self.path
414 }
415
416 pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
425 self.doc.ensure_writable(operation)?;
426 guard_mutation(&self.path, operation)?;
427 Ok(())
428 }
429}
430
431impl Document {
432 pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
446 let addressing = self.addressing();
447 self.set_addressed(key, value, addressing)
448 }
449
450 pub fn set_addressed(
458 &mut self,
459 key: &str,
460 value: Value,
461 addressing: Addressing<'_>,
462 ) -> DocumentResult<()> {
463 self.ensure_writable("set")?;
464 let key = &crate::document::resolve_path(&self.value, key, addressing)?;
465 let mut new_doc = self.value.clone();
466 crate::document::set_path(&mut new_doc, key, &value, Addressing::INDEX_ONLY)?;
467 let target = crate::document::get_path(&new_doc, key, Addressing::INDEX_ONLY)?;
468 #[allow(unreachable_patterns)]
469 let output = match self.format {
470 #[cfg(feature = "toml")]
471 Format::Toml => {
472 crate::document::format::toml::set_preserving(&self.source, key, &target)?
473 }
474 #[cfg(feature = "yaml")]
475 Format::Yaml => {
476 crate::document::format::yaml::set_preserving(&self.source, key, &target)?
477 }
478 Format::Json => {
479 crate::document::format::json::set_preserving(&self.source, key, &target)?
480 }
481 #[cfg(feature = "dotenv")]
482 Format::Dotenv => {
483 crate::document::format::dotenv::set_preserving(&self.source, key, &target)?
484 }
485 #[cfg(feature = "ini")]
486 Format::Ini => {
487 crate::document::format::ini::set_preserving(&self.source, key, &target)?
488 }
489 #[cfg(feature = "toml")]
490 Format::TomlFrontmatter => {
491 let parts = crate::document::format::frontmatter::split(
492 &self.source,
493 crate::document::format::frontmatter::Delimiter::Plus,
494 )?;
495 let new_fm =
496 crate::document::format::toml::set_preserving(parts.frontmatter, key, &target)?;
497 format!("{}{}{}", parts.pre, new_fm, parts.post)
498 }
499 #[cfg(feature = "yaml")]
500 Format::YamlFrontmatter => {
501 let parts = crate::document::format::frontmatter::split(
502 &self.source,
503 crate::document::format::frontmatter::Delimiter::Dash,
504 )?;
505 let new_fm =
506 crate::document::format::yaml::set_preserving(parts.frontmatter, key, &target)?;
507 format!("{}{}{}", parts.pre, new_fm, parts.post)
508 }
509 _ => self.format.save(&new_doc)?,
510 };
511 self.source = output;
512 self.value = new_doc;
513 Ok(())
514 }
515
516 pub fn add(
525 &mut self,
526 key: &str,
527 slug: &str,
528 slug_field: &str,
529 fields: &[(String, Value)],
530 ) -> DocumentResult<()> {
531 self.ensure_writable("add")?;
532 let mut value = self.value.clone();
533 let keyed_lists = [KeyedList {
534 prefix: key,
535 slug_field,
536 }];
537 crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
538 let array = if key.is_empty() {
539 &value
540 } else {
541 crate::document::get_path_ref(&value, key, self.addressing_keyed(&keyed_lists))?
542 };
543 let item = array
544 .as_array()
545 .and_then(|items| items.last())
546 .ok_or_else(|| DocumentError::UnsupportedOperation {
547 format: self.format.name().to_string(),
548 operation: "add".to_string(),
549 detail: "keyed list did not produce an array item".to_string(),
550 })?;
551 #[allow(unreachable_patterns)]
555 let output: String = match self.format {
556 Format::Json => crate::document::format::json::append_array_item_preserving(
557 &self.source,
558 key,
559 item,
560 )?,
561 #[cfg(feature = "yaml")]
562 Format::Yaml => crate::document::format::yaml::append_array_item_preserving(
563 &self.source,
564 key,
565 item,
566 )?,
567 #[cfg(feature = "yaml")]
572 Format::YamlFrontmatter => {
573 let parts = crate::document::format::frontmatter::split(
574 &self.source,
575 crate::document::format::frontmatter::Delimiter::Dash,
576 )?;
577 let new_fm = crate::document::format::yaml::append_array_item_preserving(
578 parts.frontmatter,
579 key,
580 item,
581 )?;
582 format!("{}{}{}", parts.pre, new_fm, parts.post)
583 }
584 _ => {
585 return Err(DocumentError::UnsupportedOperation {
586 format: self.format.name().to_string(),
587 operation: "add".to_string(),
588 detail: "keyed collection source editor is not implemented for this backend"
589 .to_string(),
590 });
591 }
592 };
593 self.source = output;
594 self.value = value;
595 Ok(())
596 }
597
598 pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
606 self.ensure_writable("remove")?;
607 let mut value = self.value.clone();
608 let keyed_lists = [KeyedList {
609 prefix: key,
610 slug_field,
611 }];
612 let removed_index = crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
613 #[allow(unreachable_patterns)]
617 let output: String = match self.format {
618 Format::Json => crate::document::format::json::remove_array_item_preserving(
619 &self.source,
620 key,
621 removed_index,
622 )?,
623 #[cfg(feature = "yaml")]
624 Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
625 &self.source,
626 key,
627 removed_index,
628 )?,
629 #[cfg(feature = "yaml")]
630 Format::YamlFrontmatter => {
631 let parts = crate::document::format::frontmatter::split(
632 &self.source,
633 crate::document::format::frontmatter::Delimiter::Dash,
634 )?;
635 let new_fm = crate::document::format::yaml::remove_array_item_preserving(
636 parts.frontmatter,
637 key,
638 removed_index,
639 )?;
640 format!("{}{}{}", parts.pre, new_fm, parts.post)
641 }
642 _ => {
643 return Err(DocumentError::UnsupportedOperation {
644 format: self.format.name().to_string(),
645 operation: "remove".to_string(),
646 detail: "keyed collection source editor is not implemented for this backend"
647 .to_string(),
648 });
649 }
650 };
651 self.source = output;
652 self.value = value;
653 Ok(())
654 }
655
656 pub fn unset(&mut self, key: &str) -> DocumentResult<bool> {
681 let addressing = self.addressing();
682 self.unset_addressed(key, addressing)
683 }
684
685 pub fn unset_addressed(
690 &mut self,
691 key: &str,
692 addressing: Addressing<'_>,
693 ) -> DocumentResult<bool> {
694 self.ensure_writable("unset")?;
695 let key = &crate::document::resolve_path(&self.value, key, addressing)?;
696 let segments = crate::document::parse_path(key)?;
697 let (leaf, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
698 let parent = if parents.is_empty() {
699 &self.value
700 } else {
701 let parent_path = crate::document::join_path(parents);
702 match crate::document::get_path_ref(&self.value, &parent_path, Addressing::INDEX_ONLY) {
703 Ok(parent) => parent,
704 Err(DocumentError::UnknownSegment { .. }) => return Ok(false),
706 Err(error) => return Err(error),
707 }
708 };
709 match parent {
710 Value::Object(object) => {
711 if !object.contains_key(leaf) {
712 return Ok(false);
713 }
714 }
715 Value::Array(array) => {
716 let index =
717 leaf.parse::<usize>()
718 .map_err(|_| DocumentError::UnregisteredArray {
719 path: crate::document::join_path(parents),
720 })?;
721 if index >= array.len() {
722 return Err(DocumentError::IndexOutOfBounds {
723 path: crate::document::join_path(parents),
724 index,
725 len: array.len(),
726 });
727 }
728 }
729 value => {
730 return Err(DocumentError::NotTraversable {
731 path: crate::document::join_path(parents),
732 got: value.kind_name().to_string(),
733 });
734 }
735 }
736 let mut value = self.value.clone();
737 crate::document::unset_path(&mut value, key)?;
738 #[allow(unreachable_patterns)]
739 let output = match self.format {
740 Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
741 #[cfg(feature = "toml")]
742 Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
743 #[cfg(feature = "yaml")]
744 Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
745 #[cfg(feature = "dotenv")]
746 Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
747 #[cfg(feature = "ini")]
748 Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
749 #[cfg(feature = "toml")]
750 Format::TomlFrontmatter => {
751 let parts = crate::document::format::frontmatter::split(
752 &self.source,
753 crate::document::format::frontmatter::Delimiter::Plus,
754 )?;
755 let new_fm =
756 crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
757 format!("{}{}{}", parts.pre, new_fm, parts.post)
758 }
759 #[cfg(feature = "yaml")]
760 Format::YamlFrontmatter => {
761 let parts = crate::document::format::frontmatter::split(
762 &self.source,
763 crate::document::format::frontmatter::Delimiter::Dash,
764 )?;
765 let new_fm =
766 crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
767 format!("{}{}{}", parts.pre, new_fm, parts.post)
768 }
769 _ => self.format.save(&value)?,
770 };
771 self.source = output;
772 self.value = value;
773 Ok(true)
774 }
775}
776
777impl DocumentFile {
778 pub fn edit<F>(&mut self, edit: F) -> DocumentResult<()>
785 where
786 F: FnOnce(&mut Document) -> DocumentResult<()>,
787 {
788 let mut draft = self.doc.clone();
789 edit(&mut draft)?;
790 self.save_document(&draft)?;
791 self.doc = draft;
792 Ok(())
793 }
794
795 pub fn edit_and_validate<T>(
806 &mut self,
807 edit: impl FnOnce(&mut Document) -> DocumentResult<()>,
808 ) -> DocumentResult<T>
809 where
810 T: serde::de::DeserializeOwned,
811 {
812 let mut draft = self.doc.clone();
813 edit(&mut draft)?;
814 let decoded = draft.decode::<T>()?;
815 self.save_document(&draft)?;
816 self.doc = draft;
817 Ok(decoded)
818 }
819
820 pub fn save(&self) -> DocumentResult<()> {
834 self.save_atomic(self.doc.source())
835 }
836
837 pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
850 self.ensure_writable("save")?;
854 validate_source_text_for_write(new_source, self.format)?;
861 write_atomic(&self.path, new_source.as_bytes(), "write")
862 }
863
864 fn save_document(&self, document: &Document) -> DocumentResult<()> {
865 document.ensure_writable("save")?;
866 validate_source_for_write(document)?;
867 write_atomic(&self.path, document.source().as_bytes(), "write")
868 }
869}
870
871impl std::ops::Deref for DocumentFile {
872 type Target = Document;
873
874 fn deref(&self) -> &Document {
875 &self.doc
876 }
877}
878
879impl std::ops::DerefMut for DocumentFile {
880 fn deref_mut(&mut self) -> &mut Document {
881 &mut self.doc
882 }
883}
884
885fn resolve_format(path: &Path, format_override: Option<Format>) -> DocumentResult<Format> {
886 match format_override {
887 Some(format) => Ok(format),
888 None => match Format::detect(path) {
893 Some(format) => Ok(format),
894 None => Err(match Format::unavailable(path) {
895 Some(feature) => DocumentError::UnsupportedOperation {
896 format: feature.to_string(),
897 operation: "open".to_string(),
898 detail: format!("requires Cargo feature `{feature}`"),
899 },
900 None => DocumentError::FormatUnknown {
901 path: path.display().to_string(),
902 },
903 }),
904 },
905 }
906}
907
908fn validate_source_for_write(document: &Document) -> DocumentResult<()> {
909 validate_source_text_for_write(document.source(), document.format())
910}
911
912fn validate_source_text_for_write(source: &str, format: Format) -> DocumentResult<()> {
913 Document::parse(source, format).map_err(|error| DocumentError::WriteWouldCorrupt {
914 format: format.name().to_string(),
915 detail: error.redacted_message(),
916 })?;
917 Ok(())
918}
919
920fn validate_create_options(options: CreateOptions) -> DocumentResult<()> {
921 if let Some(unix_mode) = options.unix_mode
922 && unix_mode & !0o777 != 0
923 {
924 return Err(DocumentError::InvalidArgument {
925 detail: format!("unix mode {unix_mode:o} contains bits outside 0o777"),
926 });
927 }
928 Ok(())
929}
930
931#[cfg(test)]
932mod tests {
933 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
934 use super::*;
935 use std::io::Cursor;
936
937 fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
938 let path = dir.join(name);
939 fs::write(&path, contents).unwrap();
940 path
941 }
942
943 #[test]
944 fn round_trip_open_json() {
945 let dir = tempfile::tempdir().unwrap();
946 let contents = r#"{"host": "example.com", "port": 993}"#;
947 let path = write_temp(dir.path(), "config.json", contents);
948
949 let doc = DocumentFile::open(&path, None).unwrap();
950
951 assert_eq!(doc.format(), Format::Json);
952 assert_eq!(
953 doc.value().get("host").and_then(Value::as_str),
954 Some("example.com")
955 );
956 assert_eq!(doc.source(), contents);
957 }
958
959 #[test]
960 fn value_at_reads_a_nested_address() {
961 let dir = tempfile::tempdir().unwrap();
962 let path = write_temp(
963 dir.path(),
964 "config.json",
965 r#"{"database": {"url": "postgres://x"}}"#,
966 );
967 let doc = DocumentFile::open(&path, None).unwrap();
968
969 assert_eq!(
970 doc.value_at("database.url").unwrap(),
971 Value::String("postgres://x".to_string())
972 );
973 assert_eq!(
974 doc.value_at("database.missing").unwrap_err().code(),
975 "document_path_not_found"
976 );
977 }
978
979 #[test]
980 fn open_capped_enforces_size_and_regular_file() {
981 let dir = tempfile::tempdir().unwrap();
982 let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
983
984 assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
986
987 let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
990 assert_eq!(err.code(), "document_too_large");
991
992 let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
994 assert_eq!(dir_err.code(), "document_io_failed");
995
996 let missing =
998 DocumentFile::open_capped(dir.path().join("absent.json"), None, 1024).unwrap_err();
999 assert_ne!(missing.code(), "document_too_large");
1000 }
1001
1002 #[cfg(unix)]
1003 #[test]
1004 fn capped_read_uses_the_open_handle_when_the_path_is_replaced() {
1005 let dir = tempfile::tempdir().unwrap();
1006 let original = r#"{"source":"original"}"#;
1007 let path = write_temp(dir.path(), "config.json", original);
1008 let handle = open_read_handle(&path, SymlinkPolicy::Follow).unwrap();
1009 inspect_capped_source(&handle, &path, 64).unwrap();
1010
1011 fs::rename(&path, dir.path().join("original.json")).unwrap();
1012 fs::write(&path, r#"{"source":"replacement"}"#).unwrap();
1013
1014 let source = read_capped_contents(handle, &path, 64).unwrap();
1015 assert_eq!(source, original);
1016 }
1017
1018 #[cfg(unix)]
1019 #[test]
1020 fn capped_read_rechecks_the_actual_bytes_after_metadata() {
1021 use std::io::Write as _;
1022
1023 let dir = tempfile::tempdir().unwrap();
1024 let path = write_temp(dir.path(), "config.json", "{}");
1025 let handle = open_read_handle(&path, SymlinkPolicy::Follow).unwrap();
1026 inspect_capped_source(&handle, &path, 4).unwrap();
1027
1028 let mut writer = OpenOptions::new().append(true).open(&path).unwrap();
1029 writer.write_all(b"123").unwrap();
1030 writer.sync_all().unwrap();
1031
1032 let error = read_capped_contents(handle, &path, 4).unwrap_err();
1033 assert_eq!(error.code(), "document_too_large");
1034 }
1035
1036 #[cfg(all(unix, feature = "libc"))]
1037 #[test]
1038 fn open_capped_can_atomically_refuse_a_symlink() {
1039 let dir = tempfile::tempdir().unwrap();
1040 let target = write_temp(dir.path(), "target.json", r#"{"k": "v"}"#);
1041 let link = dir.path().join("link.json");
1042 std::os::unix::fs::symlink(&target, &link).unwrap();
1043
1044 assert!(
1045 DocumentFile::open_capped_with_policy(&link, None, 1024, SymlinkPolicy::Follow).is_ok()
1046 );
1047 let error =
1048 DocumentFile::open_capped_with_policy(&link, None, 1024, SymlinkPolicy::NoFollow)
1049 .unwrap_err();
1050 assert_eq!(error.code(), "document_io_failed");
1051 }
1052
1053 #[test]
1054 fn create_atomic_is_no_clobber_and_returns_a_file_handle() {
1055 let dir = tempfile::tempdir().unwrap();
1056 let path = dir.path().join("config.json");
1057 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1058
1059 let created = DocumentFile::create_atomic(&path, document, CreateOptions::new()).unwrap();
1060 assert_eq!(created.path(), path);
1061 assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 993}"#);
1062
1063 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1064 let error =
1065 DocumentFile::create_atomic(&path, replacement, CreateOptions::new()).unwrap_err();
1066 assert_eq!(error.code(), "document_target_exists");
1067 assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 993}"#);
1068 }
1069
1070 #[test]
1071 fn create_atomic_requires_explicit_replace() {
1072 let dir = tempfile::tempdir().unwrap();
1073 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
1074 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1075
1076 let created =
1077 DocumentFile::create_atomic(&path, replacement, CreateOptions::new().replace())
1078 .unwrap();
1079
1080 assert_eq!(created.value_at("port").unwrap(), Value::Integer(1024));
1081 assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 1024}"#);
1082 }
1083
1084 #[cfg(unix)]
1089 #[test]
1090 fn create_atomic_replace_preserves_the_targets_mode_unless_told_otherwise() {
1091 use std::os::unix::fs::PermissionsExt as _;
1092
1093 let dir = tempfile::tempdir().unwrap();
1094 let mode_of = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
1095
1096 for original in [0o644, 0o600, 0o640] {
1097 let path = write_temp(dir.path(), &format!("m{original:o}.json"), r#"{"a": 1}"#);
1098 fs::set_permissions(&path, fs::Permissions::from_mode(original)).unwrap();
1099 let replacement = Document::parse(r#"{"a": 2}"#, Format::Json).unwrap();
1100 DocumentFile::create_atomic(&path, replacement, CreateOptions::new().replace())
1101 .unwrap();
1102 assert_eq!(
1103 mode_of(&path),
1104 original,
1105 "replace must keep the file's mode"
1106 );
1107 }
1108
1109 let path = write_temp(dir.path(), "explicit.json", r#"{"a": 1}"#);
1111 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1112 let replacement = Document::parse(r#"{"a": 2}"#, Format::Json).unwrap();
1113 DocumentFile::create_atomic(
1114 &path,
1115 replacement,
1116 CreateOptions::new().replace().unix_mode(0o600),
1117 )
1118 .unwrap();
1119 assert_eq!(mode_of(&path), 0o600);
1120
1121 let fresh = dir.path().join("fresh.json");
1123 let document = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1124 DocumentFile::create_atomic(&fresh, document, CreateOptions::new()).unwrap();
1125 assert_eq!(mode_of(&fresh), 0o600);
1126 }
1127
1128 #[cfg(feature = "toml")]
1132 #[test]
1133 fn create_atomic_refuses_a_document_the_path_could_not_reopen() {
1134 let dir = tempfile::tempdir().unwrap();
1135 let path = dir.path().join("mismatch.toml");
1136 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1137
1138 let error = DocumentFile::create_atomic(&path, document, CreateOptions::new()).unwrap_err();
1139
1140 assert_eq!(error.code(), "document_unsupported_operation");
1141 assert!(
1142 !path.exists(),
1143 "nothing may be written when the check fails"
1144 );
1145 }
1146
1147 #[cfg(unix)]
1148 #[test]
1149 fn create_atomic_applies_requested_private_mode() {
1150 use std::os::unix::fs::PermissionsExt as _;
1151
1152 let dir = tempfile::tempdir().unwrap();
1153 let path = dir.path().join("config.json");
1154 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1155
1156 DocumentFile::create_atomic(&path, document, CreateOptions::new().unix_mode(0o640))
1157 .unwrap();
1158
1159 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1160 assert_eq!(mode, 0o640);
1161 }
1162
1163 #[test]
1164 fn create_atomic_rejects_invalid_permission_bits_before_writing() {
1165 let dir = tempfile::tempdir().unwrap();
1166 let path = dir.path().join("config.json");
1167 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1168
1169 let error =
1170 DocumentFile::create_atomic(&path, document, CreateOptions::new().unix_mode(0o1600))
1171 .unwrap_err();
1172
1173 assert_eq!(error.code(), "document_invalid_argument");
1174 assert!(!path.exists());
1175 }
1176
1177 #[cfg(unix)]
1178 #[test]
1179 fn create_atomic_replace_refuses_symlinks_and_hardlinks() {
1180 let dir = tempfile::tempdir().unwrap();
1181 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
1182 let symlink = dir.path().join("symlink.json");
1183 std::os::unix::fs::symlink(&target, &symlink).unwrap();
1184 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1185
1186 let symlink_error = DocumentFile::create_atomic(
1187 &symlink,
1188 replacement.clone(),
1189 CreateOptions::new().replace(),
1190 )
1191 .unwrap_err();
1192 assert_eq!(symlink_error.code(), "document_unsupported_operation");
1193 assert_eq!(fs::read_to_string(&target).unwrap(), r#"{"port": 993}"#);
1194
1195 let hardlink = dir.path().join("hardlink.json");
1196 fs::hard_link(&target, &hardlink).unwrap();
1197 let hardlink_error =
1198 DocumentFile::create_atomic(&hardlink, replacement, CreateOptions::new().replace())
1199 .unwrap_err();
1200 assert_eq!(hardlink_error.code(), "document_unsupported_operation");
1201 assert_eq!(fs::read_to_string(&target).unwrap(), r#"{"port": 993}"#);
1202 }
1203
1204 #[cfg(unix)]
1205 #[test]
1206 fn create_atomic_replace_refuses_a_dangling_symlink() {
1207 let dir = tempfile::tempdir().unwrap();
1208 let missing = dir.path().join("missing.json");
1209 let symlink = dir.path().join("dangling.json");
1210 std::os::unix::fs::symlink(&missing, &symlink).unwrap();
1211 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1212
1213 let error =
1214 DocumentFile::create_atomic(&symlink, replacement, CreateOptions::new().replace())
1215 .unwrap_err();
1216
1217 assert_eq!(error.code(), "document_unsupported_operation");
1218 assert!(
1219 fs::symlink_metadata(&symlink)
1220 .unwrap()
1221 .file_type()
1222 .is_symlink()
1223 );
1224 assert!(!missing.exists());
1225 }
1226
1227 #[test]
1228 fn edit_rolls_back_memory_and_disk_when_the_closure_fails() {
1229 let dir = tempfile::tempdir().unwrap();
1230 let original = r#"{"port": 993}"#;
1231 let path = write_temp(dir.path(), "config.json", original);
1232 let mut document = DocumentFile::open(&path, None).unwrap();
1233
1234 let error = document
1235 .edit(|draft| {
1236 draft.set("port", Value::Integer(1024))?;
1237 Err(DocumentError::InvalidArgument {
1238 detail: "validation failed".to_string(),
1239 })
1240 })
1241 .unwrap_err();
1242
1243 assert_eq!(error.code(), "document_invalid_argument");
1244 assert_eq!(document.source(), original);
1245 assert_eq!(document.value_at("port").unwrap(), Value::Integer(993));
1246 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1247 }
1248
1249 #[test]
1250 fn typed_get_and_set_enforce_the_stated_type() {
1251 use crate::document::ValueType;
1252 let dir = tempfile::tempdir().unwrap();
1253 let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
1254 let mut doc = DocumentFile::open(&path, None).unwrap();
1255
1256 assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
1259 assert_eq!(
1260 doc.value_at_typed("port", ValueType::String)
1261 .unwrap_err()
1262 .code(),
1263 "document_type_mismatch"
1264 );
1265 assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
1266
1267 doc.set_typed("port", Some("9090"), ValueType::Number)
1269 .unwrap();
1270 assert_eq!(
1271 doc.value_at("port").unwrap(),
1272 Value::from(serde_json::json!(9090))
1273 );
1274 assert_eq!(
1275 doc.set_typed("port", Some("not-a-number"), ValueType::Number)
1276 .unwrap_err()
1277 .code(),
1278 "document_parse_failed"
1279 );
1280 }
1281
1282 #[test]
1283 fn decode_and_edit_and_validate_share_one_typed_boundary() {
1284 #[derive(Debug, serde::Deserialize)]
1285 #[serde(deny_unknown_fields)]
1286 struct Config {
1287 port: u16,
1288 }
1289
1290 let dir = tempfile::tempdir().unwrap();
1291 let original = r#"{"port": 8080}"#;
1292 let path = write_temp(dir.path(), "config.json", original);
1293 let mut document = DocumentFile::open(&path, None).unwrap();
1294
1295 assert_eq!(document.decode::<Config>().unwrap().port, 8080);
1296
1297 let error = document
1298 .edit_and_validate::<Config>(|draft| {
1299 draft.set("port", Value::String("invalid".to_string()))
1300 })
1301 .unwrap_err();
1302 assert_eq!(error.code(), "document_type_mismatch");
1303 assert_eq!(document.source(), original);
1304 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1305
1306 let config = document
1307 .edit_and_validate::<Config>(|draft| draft.set("port", Value::Unsigned(9090)))
1308 .unwrap();
1309 assert_eq!(config.port, 9090);
1310 assert_eq!(document.value_at("port").unwrap(), Value::Unsigned(9090));
1311 }
1312
1313 #[cfg(feature = "toml")]
1314 #[test]
1315 fn round_trip_open_toml() {
1316 let dir = tempfile::tempdir().unwrap();
1317 let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
1318 let path = write_temp(dir.path(), "config.toml", contents);
1319
1320 let doc = DocumentFile::open(&path, None).unwrap();
1321
1322 assert_eq!(doc.format(), Format::Toml);
1323 assert_eq!(
1324 doc.value().get("host").and_then(Value::as_str),
1325 Some("example.com")
1326 );
1327 assert_eq!(doc.source(), contents);
1328 }
1329
1330 #[cfg(feature = "toml")]
1331 #[test]
1332 fn set_scalar_preserves_toml_comments_and_formatting() {
1333 let dir = tempfile::tempdir().unwrap();
1334 let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
1335 let path = write_temp(dir.path(), "config.toml", contents);
1336 let mut doc = DocumentFile::open(&path, None).unwrap();
1337
1338 doc.set("port", Value::Integer(1024)).unwrap();
1339 doc.save().unwrap();
1340
1341 let saved = fs::read_to_string(&path).unwrap();
1342 assert!(saved.contains("# leading comment"));
1343 assert!(saved.contains("port = 1024"));
1344 assert_eq!(
1345 doc.value().get("port").and_then(Value::as_integer),
1346 Some(1024)
1347 );
1348 assert_eq!(doc.source(), saved);
1349 }
1350
1351 #[cfg(feature = "toml")]
1356 #[test]
1357 fn toml_array_edits_never_invent_or_misattribute_a_comment() {
1358 let contents = "paths = [\n \"one\", # first\n \"two\", # second\n]\n";
1359
1360 let mut grown = Document::parse(contents, Format::Toml).unwrap();
1361 grown
1362 .set(
1363 "paths",
1364 Value::Array(vec![
1365 Value::String("a".into()),
1366 Value::String("b".into()),
1367 Value::String("c".into()),
1368 ]),
1369 )
1370 .unwrap();
1371 assert_eq!(
1372 grown.source(),
1373 "paths = [\n \"a\", # first\n \"b\",\n \"c\", # second\n]\n",
1374 "an appended element must carry no comment of its own"
1375 );
1376
1377 let mut shrunk = Document::parse(contents, Format::Toml).unwrap();
1378 shrunk
1379 .set("paths", Value::Array(vec![Value::String("only".into())]))
1380 .unwrap();
1381 assert_eq!(
1382 shrunk.source(),
1383 "paths = [\n \"only\", # first\n]\n",
1384 "the surviving element keeps its own comment, not the removed one's"
1385 );
1386
1387 let mut replaced = Document::parse(contents, Format::Toml).unwrap();
1389 replaced
1390 .set(
1391 "paths",
1392 Value::Array(vec![Value::String("x".into()), Value::String("y".into())]),
1393 )
1394 .unwrap();
1395 assert_eq!(
1396 replaced.source(),
1397 "paths = [\n \"x\", # first\n \"y\", # second\n]\n"
1398 );
1399
1400 let mut inline = Document::parse("paths = [ \"one\" ]\n", Format::Toml).unwrap();
1402 inline
1403 .set(
1404 "paths",
1405 Value::Array(vec![
1406 Value::String("one".into()),
1407 Value::String("two".into()),
1408 ]),
1409 )
1410 .unwrap();
1411 assert_eq!(inline.source(), "paths = [ \"one\", \"two\" ]\n");
1412 }
1413
1414 #[cfg(feature = "toml")]
1415 #[test]
1416 fn set_toml_array_preserves_single_line_decor() {
1417 let contents = "# before\npaths = [ \"old\", 'second', ] # keep this\nother = 42\n";
1418 let mut document = Document::parse(contents, Format::Toml).unwrap();
1419
1420 document
1421 .set(
1422 "paths",
1423 Value::Array(vec![
1424 Value::String("new".to_string()),
1425 Value::String("next".to_string()),
1426 ]),
1427 )
1428 .unwrap();
1429
1430 assert_eq!(
1431 document.source(),
1432 "# before\npaths = [ \"new\", \"next\", ] # keep this\nother = 42\n"
1433 );
1434 }
1435
1436 #[cfg(feature = "toml")]
1437 #[test]
1438 fn set_toml_array_preserves_multiline_comments_and_trailing_comma() {
1439 let contents = "paths = [\n \"one\", # first\n \"two\", # second\n]\nother = 42\n";
1440 let mut document = Document::parse(contents, Format::Toml).unwrap();
1441
1442 document
1443 .set(
1444 "paths",
1445 Value::Array(vec![
1446 Value::String("uno".to_string()),
1447 Value::String("dos".to_string()),
1448 ]),
1449 )
1450 .unwrap();
1451
1452 assert_eq!(
1453 document.source(),
1454 "paths = [\n \"uno\", # first\n \"dos\", # second\n]\nother = 42\n"
1455 );
1456 }
1457
1458 #[cfg(feature = "toml")]
1459 #[test]
1460 fn set_toml_array_element_preserves_its_neighbors() {
1461 let contents = "paths = [\n \"one\", # first\n \"two\", # second\n]\n";
1462 let mut document = Document::parse(contents, Format::Toml).unwrap();
1463
1464 document
1465 .set("paths.1", Value::String("changed".to_string()))
1466 .unwrap();
1467
1468 assert_eq!(
1469 document.source(),
1470 "paths = [\n \"one\", # first\n \"changed\", # second\n]\n"
1471 );
1472 }
1473
1474 #[cfg(feature = "toml")]
1475 #[test]
1476 fn set_toml_array_can_become_empty_without_touching_neighbors() {
1477 let contents = "before = 1\npaths = [ \"one\", ] # list\nafter = 2\n";
1478 let mut document = Document::parse(contents, Format::Toml).unwrap();
1479
1480 document.set("paths", Value::Array(Vec::new())).unwrap();
1481
1482 assert_eq!(
1483 document.source(),
1484 "before = 1\npaths = [ ] # list\nafter = 2\n"
1485 );
1486 }
1487
1488 #[cfg(feature = "toml")]
1489 #[test]
1490 fn set_toml_inline_table_preserves_layout_and_comments() {
1491 let contents = "cache = { ttl_s = 1, enabled = true } # cache\nother = 42\n";
1492 let mut document = Document::parse(contents, Format::Toml).unwrap();
1493 let replacement = Value::from(serde_json::json!({
1494 "enabled": false,
1495 "ttl_s": 60
1496 }));
1497
1498 document.set("cache", replacement).unwrap();
1499
1500 assert_eq!(
1501 document.source(),
1502 "cache = { ttl_s = 60, enabled = false } # cache\nother = 42\n"
1503 );
1504 }
1505
1506 #[cfg(feature = "toml")]
1507 #[test]
1508 fn set_toml_ordinary_table_preserves_header_and_unrelated_section() {
1509 let contents = "# lead\n[cache] # cache header\nttl_s = 1 # ttl\nenabled = true\n\n[next]\nvalue = 9\n";
1510 let mut document = Document::parse(contents, Format::Toml).unwrap();
1511 let replacement = Value::from(serde_json::json!({
1512 "enabled": false,
1513 "ttl_s": 60
1514 }));
1515
1516 document.set("cache", replacement).unwrap();
1517
1518 assert_eq!(
1519 document.source(),
1520 "# lead\n[cache] # cache header\nttl_s = 60 # ttl\nenabled = false\n\n[next]\nvalue = 9\n"
1521 );
1522 }
1523
1524 #[cfg(feature = "toml")]
1525 #[test]
1526 fn set_toml_collection_preserves_unchanged_datetime_syntax() {
1527 let contents = "[cache]\nexpires_at = 2026-08-04T12:30:00Z\npaths = [\"one\"]\n";
1528 let mut document = Document::parse(contents, Format::Toml).unwrap();
1529 let replacement = document.value_at("cache").unwrap();
1530
1531 document.set("cache", replacement).unwrap();
1532
1533 assert_eq!(document.source(), contents);
1534 }
1535
1536 #[cfg(feature = "toml")]
1537 #[test]
1538 fn set_toml_array_of_tables_is_explicitly_refused() {
1539 let contents = "[[servers]]\nname = \"one\"\n[[servers]]\nname = \"two\"\n";
1540 let mut document = Document::parse(contents, Format::Toml).unwrap();
1541 let replacement = document.value_at("servers").unwrap();
1542
1543 let error = document.set("servers", replacement).unwrap_err();
1544
1545 assert_eq!(error.code(), "document_unsupported_operation");
1546 assert_eq!(document.source(), contents);
1547 }
1548
1549 #[cfg(feature = "ini")]
1550 #[test]
1551 fn save_refuses_source_its_own_parser_rejects() {
1552 let dir = tempfile::tempdir().unwrap();
1556 let original = "[db]\nhost=localhost\n";
1557 let path = write_temp(dir.path(), "config.ini", original);
1558 let doc = DocumentFile::open(&path, None).unwrap();
1559
1560 let error = doc
1561 .save_atomic("[db]\nhost=localhost\n\n[db]\nport=5432\n")
1562 .unwrap_err();
1563 assert_eq!(error.code(), "document_write_would_corrupt");
1564 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1566 }
1567
1568 #[cfg(feature = "ini")]
1569 #[test]
1570 fn save_writes_source_the_parser_accepts() {
1571 let dir = tempfile::tempdir().unwrap();
1572 let path = write_temp(dir.path(), "config.ini", "[db]\nhost=localhost\n");
1573 let mut doc = DocumentFile::open(&path, None).unwrap();
1574
1575 doc.set("db.port", Value::String("5432".to_string()))
1576 .unwrap();
1577 doc.save().unwrap();
1578
1579 assert_eq!(
1582 fs::read_to_string(&path).unwrap(),
1583 "[db]\nhost=localhost\nport=5432\n"
1584 );
1585 assert!(DocumentFile::open(&path, None).is_ok());
1586 }
1587
1588 #[cfg(unix)]
1589 #[test]
1590 fn atomic_save_preserves_file_mode() {
1591 use std::os::unix::fs::PermissionsExt;
1592
1593 let dir = tempfile::tempdir().unwrap();
1594 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
1595 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
1596 let mut doc = DocumentFile::open(&path, None).unwrap();
1597
1598 doc.set("port", Value::Integer(1024)).unwrap();
1599 doc.save().unwrap();
1600
1601 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1602 assert_eq!(mode, 0o640);
1603 }
1604
1605 #[cfg(unix)]
1606 #[test]
1607 fn symlink_target_is_rejected_for_mutation() {
1608 let dir = tempfile::tempdir().unwrap();
1609 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
1610 let link = dir.path().join("link.json");
1611 std::os::unix::fs::symlink(&target, &link).unwrap();
1612
1613 let mut doc = DocumentFile::open(&link, None).unwrap();
1615
1616 doc.set("port", Value::Integer(1024)).unwrap();
1618 let err = doc.save().unwrap_err();
1619 assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
1620
1621 let target_contents = fs::read_to_string(&target).unwrap();
1623 assert_eq!(target_contents, r#"{"port": 993}"#);
1624 }
1625
1626 #[test]
1627 fn from_reader_parses_in_memory_cursor() {
1628 let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
1629
1630 let doc = Document::from_reader(cursor, Format::Json).unwrap();
1631
1632 assert_eq!(
1633 doc.value().get("host").and_then(Value::as_str),
1634 Some("example.com")
1635 );
1636 }
1637
1638 #[test]
1639 fn document_from_str_encode_round_trip() {
1640 let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1641 let encoded = doc.encode().unwrap();
1642 let reparsed = Document::parse(&encoded, Format::Json).unwrap();
1643 assert_eq!(
1644 reparsed.value().get("a").and_then(Value::as_integer),
1645 Some(1)
1646 );
1647 }
1648
1649 #[test]
1650 fn document_edits_source_in_memory_without_a_file() {
1651 let mut doc = Document::parse("{\n \"host\": \"old\"\n}\n", Format::Json).unwrap();
1654 doc.set("host", Value::String("new".to_string())).unwrap();
1655 doc.set("imap.port", Value::Integer(993)).unwrap(); assert_eq!(
1658 doc.source(),
1659 "{\n \"host\": \"new\",\n \"imap\": {\n \"port\": 993\n }\n}\n"
1660 );
1661 assert_eq!(
1662 doc.value_at("imap.port").unwrap(),
1663 Value::from(serde_json::json!(993))
1664 );
1665 }
1666
1667 #[test]
1668 fn unset_is_false_for_anything_already_absent() {
1669 let mut doc = Document::parse(
1670 r#"{"service":{"host":"example","ports":[80]}}"#,
1671 Format::Json,
1672 )
1673 .unwrap();
1674
1675 assert!(!doc.unset("service.missing").unwrap());
1678 assert!(!doc.unset("missing.parent").unwrap());
1679 assert!(!doc.unset("missing.deeply.nested").unwrap());
1680
1681 assert!(doc.unset("service.host.child").is_err()); assert!(doc.unset("service.ports.9").is_err()); assert!(doc.unset(r"service\q").is_err()); }
1687
1688 #[cfg(feature = "markdown")]
1689 #[test]
1690 fn every_markdown_write_verb_is_refused() {
1691 let source = "# Title\n\nThe lead.\n";
1692 let mut doc = Document::parse(source, Format::Markdown).unwrap();
1693
1694 assert_eq!(
1697 doc.value_at("h1.0.text").unwrap(),
1698 Value::String("Title".to_string())
1699 );
1700 assert_eq!(
1701 doc.value_at("h1.Tit.paragraph.0.text").unwrap(),
1702 Value::String("The lead.".to_string())
1703 );
1704
1705 let refusals: Vec<DocumentError> = vec![
1709 doc.set("h1.0.text", Value::String("New".to_string()))
1710 .unwrap_err(),
1711 doc.add("preamble", "x", "type", &[]).unwrap_err(),
1712 doc.remove("preamble", "x", "type").unwrap_err(),
1713 doc.unset("h1.0").unwrap_err(),
1714 doc.unset("nothing.here").unwrap_err(),
1715 doc.encode().unwrap_err(),
1716 ];
1717 for error in refusals {
1718 assert_eq!(error.code(), "document_unsupported_operation");
1719 assert!(
1720 error.to_string().contains("read-only"),
1721 "refusal must name the reason: {error}"
1722 );
1723 }
1724
1725 assert_eq!(doc.source(), source);
1727 }
1728
1729 #[cfg(feature = "markdown")]
1730 #[test]
1731 fn markdown_save_never_reaches_disk() {
1732 let dir = tempfile::tempdir().unwrap();
1733 let path = write_temp(dir.path(), "README.md", "# Title\n");
1734 assert!(DocumentFile::open(&path, None).is_err());
1736
1737 let doc = DocumentFile::open(&path, Some(Format::Markdown)).unwrap();
1738 let error = doc.save().unwrap_err();
1740 assert_eq!(error.code(), "document_unsupported_operation");
1741 assert_eq!(fs::read_to_string(&path).unwrap(), "# Title\n");
1742 }
1743
1744 #[cfg(feature = "yaml")]
1745 #[test]
1746 fn yaml_write_rejects_cst_ambiguous_mapping_segments() {
1747 let mut numeric = Document::parse("\"123\": value\n", Format::Yaml).unwrap();
1748 assert!(
1749 numeric
1750 .set("123", Value::String("changed".to_string()))
1751 .is_err()
1752 );
1753 assert!(numeric.unset("123").is_err());
1754
1755 let mut bracketed = Document::parse("\"a[0]\": value\n", Format::Yaml).unwrap();
1756 assert!(
1757 bracketed
1758 .set("a[0]", Value::String("changed".to_string()))
1759 .is_err()
1760 );
1761 }
1762}