1mod read;
24mod write;
25
26use write::{guard_mutation, write_atomic, write_atomic_create};
27
28#[cfg(feature = "cli")]
29use read::read_capped_source;
30#[cfg(all(test, unix))]
31use read::{inspect_capped_source, read_capped_contents};
32use read::{open_read_handle, read_capped_source_bytes};
33use std::fs;
34#[cfg(all(test, unix))]
35use std::fs::OpenOptions;
36use std::path::{Path, PathBuf};
37
38use crate::document::{Addressing, DocumentError, DocumentResult, Format, KeyedList, Value};
39
40#[cfg(feature = "cli")]
41pub(crate) fn read_capped_file(
42 path: &Path,
43 max_bytes: u64,
44 symlink_policy: SymlinkPolicy,
45) -> DocumentResult<String> {
46 let file = open_read_handle(path, symlink_policy)?;
47 read_capped_source(file, path, max_bytes)
48}
49
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58pub enum SymlinkPolicy {
59 #[default]
61 Follow,
62 NoFollow,
66}
67
68#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
70pub enum CreateMode {
71 #[default]
73 NewOnly,
74 Replace,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub struct CreateOptions {
81 mode: CreateMode,
82 unix_mode: Option<u32>,
83}
84
85impl CreateOptions {
86 #[must_use]
91 pub const fn new() -> Self {
92 Self {
93 mode: CreateMode::NewOnly,
94 unix_mode: None,
95 }
96 }
97
98 #[must_use]
104 pub const fn unix_mode(mut self, unix_mode: u32) -> Self {
105 self.unix_mode = Some(unix_mode);
106 self
107 }
108
109 #[must_use]
111 pub const fn replace(mut self) -> Self {
112 self.mode = CreateMode::Replace;
113 self
114 }
115
116 #[must_use]
118 pub const fn mode(&self) -> CreateMode {
119 self.mode
120 }
121
122 #[must_use]
127 pub const fn configured_unix_mode(&self) -> Option<u32> {
128 self.unix_mode
129 }
130
131 const fn effective_unix_mode(&self, target_exists: bool) -> Option<u32> {
133 match self.unix_mode {
134 Some(mode) => Some(mode),
135 None if target_exists => None,
140 None => Some(0o600),
141 }
142 }
143}
144
145impl Default for CreateOptions {
146 fn default() -> Self {
147 Self::new()
148 }
149}
150
151#[derive(Debug, Clone)]
159pub struct Document {
160 source: String,
161 value: Value,
162 format: Format,
163}
164
165impl Document {
166 pub fn parse(source: &str, format: Format) -> DocumentResult<Document> {
172 let value = format.load(source)?;
173 Ok(Document {
174 source: source.to_string(),
175 value,
176 format,
177 })
178 }
179
180 pub fn from_reader<R: std::io::Read>(
186 mut reader: R,
187 format: Format,
188 ) -> DocumentResult<Document> {
189 let mut bytes = Vec::new();
190 reader.read_to_end(&mut bytes)?;
191 if let Some(document) = Document::parse_binary(&bytes, format)? {
192 return Ok(document);
193 }
194 let source = String::from_utf8(bytes)
195 .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?;
196 Document::parse_labelled(&source, format, "input")
197 }
198
199 fn parse_labelled(source: &str, format: Format, label: &str) -> DocumentResult<Document> {
204 match Document::parse(source, format) {
205 Err(DocumentError::ParseError { .. }) if source.is_empty() => {
206 Err(DocumentError::EmptyInput {
207 source: label.to_string(),
208 })
209 }
210 #[cfg(feature = "plist")]
211 Err(DocumentError::ParseError { .. })
212 if format == Format::Json
213 && crate::document::format::plist::looks_like_xml(source.as_bytes()) =>
214 {
215 Err(crate::document::format::plist::wrong_format_xml(
216 format.name(),
217 ))
218 }
219 other => other,
220 }
221 }
222
223 fn from_bytes(bytes: Vec<u8>, format: Format, label: &str) -> DocumentResult<Document> {
226 if let Some(document) = Document::parse_binary(&bytes, format)? {
227 return Ok(document);
228 }
229 let source = String::from_utf8(bytes).map_err(|error| DocumentError::IoError {
230 detail: format!(
231 "read `{label}`: document is not UTF-8 (valid through byte {})",
232 error.utf8_error().valid_up_to()
233 ),
234 })?;
235 Document::parse_labelled(&source, format, label)
236 }
237
238 #[cfg(feature = "plist")]
243 fn parse_binary(bytes: &[u8], format: Format) -> DocumentResult<Option<Document>> {
244 use crate::document::format::plist;
245 if !plist::is_binary(bytes) {
246 return Ok(None);
247 }
248 if format != Format::Plist {
249 return Err(plist::wrong_format(format.name()));
250 }
251 Ok(Some(Document {
252 source: String::new(),
253 value: plist::load_binary(bytes)?,
254 format,
255 }))
256 }
257
258 #[cfg(not(feature = "plist"))]
259 #[allow(clippy::unnecessary_wraps)]
260 fn parse_binary(_bytes: &[u8], _format: Format) -> DocumentResult<Option<Document>> {
261 Ok(None)
262 }
263
264 pub fn value(&self) -> &Value {
266 &self.value
267 }
268
269 pub fn source(&self) -> &str {
273 &self.source
274 }
275
276 pub fn format(&self) -> Format {
278 self.format
279 }
280
281 #[must_use]
287 pub fn addressing(&self) -> Addressing<'static> {
288 Addressing::INDEX_ONLY.with_array_rule(self.format.array_rule())
289 }
290
291 #[must_use]
294 pub fn addressing_keyed<'a>(&self, keyed_lists: &'a [KeyedList<'a>]) -> Addressing<'a> {
295 Addressing::keyed(keyed_lists).with_array_rule(self.format.array_rule())
296 }
297
298 pub fn value_at(&self, path: &str) -> DocumentResult<Value> {
306 crate::document::get_path(&self.value, path, self.addressing())
307 }
308
309 pub fn value_at_typed(
313 &self,
314 path: &str,
315 expected: crate::document::ValueType,
316 ) -> DocumentResult<Value> {
317 let value = self.value_at(path)?;
318 if crate::document::value_matches_type(&value, expected) {
319 Ok(value)
320 } else {
321 Err(DocumentError::TypeMismatch {
322 path: path.to_string(),
323 expected: expected.name().to_string(),
324 got: value.kind_name().to_string(),
325 hint: None,
326 })
327 }
328 }
329
330 pub fn decode<T: serde::de::DeserializeOwned>(&self) -> DocumentResult<T> {
336 crate::document::from_value(self.value(), "")
337 }
338
339 pub fn set_typed(
342 &mut self,
343 key: &str,
344 raw: Option<&str>,
345 value_type: crate::document::ValueType,
346 ) -> DocumentResult<()> {
347 let value = crate::document::value_from_type(value_type, raw)?;
348 self.set(key, value)
349 }
350
351 fn ensure_writable(&self, operation: &str) -> DocumentResult<()> {
359 if self.format.is_read_only() {
360 return Err(self.format.read_only_error(operation));
361 }
362 Ok(())
363 }
364
365 pub fn encode(&self) -> DocumentResult<String> {
371 self.format.save(&self.value)
372 }
373}
374
375#[derive(Debug, Clone)]
384pub struct DocumentFile {
385 doc: Document,
386 path: PathBuf,
387}
388
389impl DocumentFile {
390 pub fn open(
396 path: impl AsRef<Path>,
397 format_override: Option<Format>,
398 ) -> DocumentResult<DocumentFile> {
399 let path = path.as_ref().to_path_buf();
400 let format = resolve_format(&path, format_override)?;
401 let bytes = fs::read(&path).map_err(|error| DocumentError::IoError {
402 detail: format!("read `{}`: {error}", path.display()),
403 })?;
404 Ok(DocumentFile {
405 doc: Document::from_bytes(bytes, format, &path.display().to_string())?,
406 path,
407 })
408 }
409
410 pub fn open_capped(
424 path: impl AsRef<Path>,
425 format_override: Option<Format>,
426 max_bytes: u64,
427 ) -> DocumentResult<DocumentFile> {
428 Self::open_capped_with_policy(path, format_override, max_bytes, SymlinkPolicy::Follow)
429 }
430
431 pub fn open_capped_with_policy(
437 path: impl AsRef<Path>,
438 format_override: Option<Format>,
439 max_bytes: u64,
440 symlink_policy: SymlinkPolicy,
441 ) -> DocumentResult<DocumentFile> {
442 let path = path.as_ref().to_path_buf();
443 let format = resolve_format(&path, format_override)?;
444 let file = open_read_handle(&path, symlink_policy)?;
445 let bytes = read_capped_source_bytes(file, &path, max_bytes)?;
446 Ok(DocumentFile {
447 doc: Document::from_bytes(bytes, format, &path.display().to_string())?,
448 path,
449 })
450 }
451
452 pub fn create_atomic(
463 path: impl AsRef<Path>,
464 document: Document,
465 options: CreateOptions,
466 ) -> DocumentResult<DocumentFile> {
467 let path = path.as_ref().to_path_buf();
468 document.ensure_writable("create")?;
469 let path_format = resolve_format(&path, None)?;
472 if path_format != document.format() {
473 return Err(DocumentError::UnsupportedOperation {
474 format: document.format().name().to_string(),
475 operation: "create".to_string(),
476 detail: format!(
477 "path resolves to {}, so the created file could not be reopened",
478 path_format.name()
479 ),
480 });
481 }
482 validate_source_for_write(&document)?;
483 validate_create_options(options)?;
484 write_atomic_create(&path, document.source().as_bytes(), options)?;
485 Ok(DocumentFile {
486 doc: document,
487 path,
488 })
489 }
490
491 pub fn path(&self) -> &Path {
493 &self.path
494 }
495
496 pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
505 self.doc.ensure_writable(operation)?;
506 guard_mutation(&self.path, operation)?;
507 Ok(())
508 }
509}
510
511impl Document {
512 pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
526 let addressing = self.addressing();
527 self.set_addressed(key, value, addressing)
528 }
529
530 pub fn set_addressed(
538 &mut self,
539 key: &str,
540 value: Value,
541 addressing: Addressing<'_>,
542 ) -> DocumentResult<()> {
543 self.ensure_writable("set")?;
544 let base = self.set_base();
545 let key = &crate::document::resolve_path(&base, key, addressing)?;
546 let mut new_doc = base;
547 crate::document::set_path(&mut new_doc, key, &value, Addressing::INDEX_ONLY)?;
548 let target = value;
549 #[allow(unreachable_patterns)]
550 let output = match self.format {
551 #[cfg(feature = "toml")]
552 Format::Toml => {
553 crate::document::format::toml::set_preserving(&self.source, key, &target)?
554 }
555 #[cfg(feature = "yaml")]
556 Format::Yaml => {
557 crate::document::format::yaml::set_preserving(&self.source, key, &target)?
558 }
559 Format::Json => {
560 crate::document::format::json::set_preserving(&self.source, key, &target)?
561 }
562 #[cfg(feature = "dotenv")]
563 Format::Dotenv => {
564 crate::document::format::dotenv::set_preserving(&self.source, key, &target)?
565 }
566 #[cfg(feature = "ini")]
567 Format::Ini => {
568 crate::document::format::ini::set_preserving(&self.source, key, &target)?
569 }
570 #[cfg(feature = "toml")]
571 Format::TomlFrontmatter => {
572 let parts = crate::document::format::frontmatter::split(
573 &self.source,
574 crate::document::format::frontmatter::Delimiter::Plus,
575 )?;
576 let new_fm =
577 crate::document::format::toml::set_preserving(parts.frontmatter, key, &target)?;
578 format!("{}{}{}", parts.pre, new_fm, parts.post)
579 }
580 #[cfg(feature = "yaml")]
581 Format::YamlFrontmatter => {
582 let parts = crate::document::format::frontmatter::split(
583 &self.source,
584 crate::document::format::frontmatter::Delimiter::Dash,
585 )?;
586 let new_fm =
587 crate::document::format::yaml::set_preserving(parts.frontmatter, key, &target)?;
588 format!("{}{}{}", parts.pre, new_fm, parts.post)
589 }
590 _ => self.format.save(&new_doc)?,
591 };
592 self.source = output;
593 self.value = new_doc;
594 Ok(())
595 }
596
597 fn set_base(&self) -> Value {
601 #[cfg(feature = "yaml")]
602 if self.value.is_null() && self.blank_yaml_document() {
603 return Value::Object(Default::default());
604 }
605 self.value.clone()
606 }
607
608 #[cfg(feature = "yaml")]
610 fn blank_yaml_document(&self) -> bool {
611 match self.format {
612 Format::Yaml => crate::document::format::yaml::is_blank_document(&self.source),
613 Format::YamlFrontmatter => crate::document::format::frontmatter::split(
614 &self.source,
615 crate::document::format::frontmatter::Delimiter::Dash,
616 )
617 .is_ok_and(|parts| crate::document::format::yaml::is_blank_document(parts.frontmatter)),
618 _ => false,
619 }
620 }
621
622 pub fn add(
631 &mut self,
632 key: &str,
633 slug: &str,
634 slug_field: &str,
635 fields: &[(String, Value)],
636 ) -> DocumentResult<()> {
637 self.ensure_writable("add")?;
638 let mut value = self.value.clone();
639 let keyed_lists = [KeyedList {
640 prefix: key,
641 slug_field,
642 }];
643 crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
644 let array = if key.is_empty() {
645 &value
646 } else {
647 crate::document::get_path_ref(&value, key, self.addressing_keyed(&keyed_lists))?
648 };
649 let item = array
650 .as_array()
651 .and_then(|items| items.last())
652 .ok_or_else(|| DocumentError::UnsupportedOperation {
653 format: self.format.name().to_string(),
654 operation: "add".to_string(),
655 detail: "keyed list did not produce an array item".to_string(),
656 })?;
657 #[allow(unreachable_patterns)]
661 let output: String = match self.format {
662 Format::Json => crate::document::format::json::append_array_item_preserving(
663 &self.source,
664 key,
665 item,
666 )?,
667 #[cfg(feature = "yaml")]
668 Format::Yaml => crate::document::format::yaml::append_array_item_preserving(
669 &self.source,
670 key,
671 item,
672 )?,
673 #[cfg(feature = "yaml")]
678 Format::YamlFrontmatter => {
679 let parts = crate::document::format::frontmatter::split(
680 &self.source,
681 crate::document::format::frontmatter::Delimiter::Dash,
682 )?;
683 let new_fm = crate::document::format::yaml::append_array_item_preserving(
684 parts.frontmatter,
685 key,
686 item,
687 )?;
688 format!("{}{}{}", parts.pre, new_fm, parts.post)
689 }
690 _ => {
691 return Err(DocumentError::UnsupportedOperation {
692 format: self.format.name().to_string(),
693 operation: "add".to_string(),
694 detail: "keyed collection source editor is not implemented for this backend"
695 .to_string(),
696 });
697 }
698 };
699 self.source = output;
700 self.value = value;
701 Ok(())
702 }
703
704 pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
712 self.ensure_writable("remove")?;
713 let mut value = self.value.clone();
714 let keyed_lists = [KeyedList {
715 prefix: key,
716 slug_field,
717 }];
718 let removed_index = crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
719 #[allow(unreachable_patterns)]
723 let output: String = match self.format {
724 Format::Json => crate::document::format::json::remove_array_item_preserving(
725 &self.source,
726 key,
727 removed_index,
728 )?,
729 #[cfg(feature = "yaml")]
730 Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
731 &self.source,
732 key,
733 removed_index,
734 )?,
735 #[cfg(feature = "yaml")]
736 Format::YamlFrontmatter => {
737 let parts = crate::document::format::frontmatter::split(
738 &self.source,
739 crate::document::format::frontmatter::Delimiter::Dash,
740 )?;
741 let new_fm = crate::document::format::yaml::remove_array_item_preserving(
742 parts.frontmatter,
743 key,
744 removed_index,
745 )?;
746 format!("{}{}{}", parts.pre, new_fm, parts.post)
747 }
748 _ => {
749 return Err(DocumentError::UnsupportedOperation {
750 format: self.format.name().to_string(),
751 operation: "remove".to_string(),
752 detail: "keyed collection source editor is not implemented for this backend"
753 .to_string(),
754 });
755 }
756 };
757 self.source = output;
758 self.value = value;
759 Ok(())
760 }
761
762 pub fn unset(&mut self, key: &str) -> DocumentResult<bool> {
787 let addressing = self.addressing();
788 self.unset_addressed(key, addressing)
789 }
790
791 pub fn unset_addressed(
796 &mut self,
797 key: &str,
798 addressing: Addressing<'_>,
799 ) -> DocumentResult<bool> {
800 self.ensure_writable("unset")?;
801 let key = &crate::document::resolve_path(&self.value, key, addressing)?;
802 let segments = crate::document::parse_path(key)?;
803 let (leaf, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
804 let parent = if parents.is_empty() {
805 &self.value
806 } else {
807 let parent_path = crate::document::join_path(parents);
808 match crate::document::get_path_ref(&self.value, &parent_path, Addressing::INDEX_ONLY) {
809 Ok(parent) => parent,
810 Err(DocumentError::UnknownSegment { .. }) => return Ok(false),
812 Err(error) => return Err(error),
813 }
814 };
815 match parent {
816 Value::Object(object) => {
817 if !object.contains_key(leaf) {
818 return Ok(false);
819 }
820 }
821 Value::Array(_) if leaf == "-" => {
822 return Err(crate::document::append_position_refused(parents, true));
823 }
824 Value::Array(array) => {
825 let index =
826 leaf.parse::<usize>()
827 .map_err(|_| DocumentError::UnregisteredArray {
828 path: crate::document::join_path(parents),
829 })?;
830 if index >= array.len() {
831 return Err(DocumentError::IndexOutOfBounds {
832 path: crate::document::join_path(parents),
833 index,
834 len: array.len(),
835 });
836 }
837 }
838 value => {
839 return Err(DocumentError::NotTraversable {
840 path: crate::document::join_path(parents),
841 got: value.kind_name().to_string(),
842 });
843 }
844 }
845 let mut value = self.value.clone();
846 crate::document::unset_path(&mut value, key)?;
847 #[allow(unreachable_patterns)]
848 let output = match self.format {
849 Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
850 #[cfg(feature = "toml")]
851 Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
852 #[cfg(feature = "yaml")]
853 Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
854 #[cfg(feature = "dotenv")]
855 Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
856 #[cfg(feature = "ini")]
857 Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
858 #[cfg(feature = "toml")]
859 Format::TomlFrontmatter => {
860 let parts = crate::document::format::frontmatter::split(
861 &self.source,
862 crate::document::format::frontmatter::Delimiter::Plus,
863 )?;
864 let new_fm =
865 crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
866 format!("{}{}{}", parts.pre, new_fm, parts.post)
867 }
868 #[cfg(feature = "yaml")]
869 Format::YamlFrontmatter => {
870 let parts = crate::document::format::frontmatter::split(
871 &self.source,
872 crate::document::format::frontmatter::Delimiter::Dash,
873 )?;
874 let new_fm =
875 crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
876 format!("{}{}{}", parts.pre, new_fm, parts.post)
877 }
878 _ => self.format.save(&value)?,
879 };
880 self.source = output;
881 self.value = value;
882 Ok(true)
883 }
884}
885
886impl DocumentFile {
887 pub fn edit<F>(&mut self, edit: F) -> DocumentResult<()>
894 where
895 F: FnOnce(&mut Document) -> DocumentResult<()>,
896 {
897 let mut draft = self.doc.clone();
898 edit(&mut draft)?;
899 self.save_document(&draft)?;
900 self.doc = draft;
901 Ok(())
902 }
903
904 pub fn edit_and_validate<T>(
915 &mut self,
916 edit: impl FnOnce(&mut Document) -> DocumentResult<()>,
917 ) -> DocumentResult<T>
918 where
919 T: serde::de::DeserializeOwned,
920 {
921 let mut draft = self.doc.clone();
922 edit(&mut draft)?;
923 let decoded = draft.decode::<T>()?;
924 self.save_document(&draft)?;
925 self.doc = draft;
926 Ok(decoded)
927 }
928
929 pub fn save(&self) -> DocumentResult<()> {
943 self.save_atomic(self.doc.source())
944 }
945
946 pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
959 self.ensure_writable("save")?;
963 validate_source_text_for_write(new_source, self.format)?;
970 write_atomic(&self.path, new_source.as_bytes(), "write")
971 }
972
973 fn save_document(&self, document: &Document) -> DocumentResult<()> {
974 document.ensure_writable("save")?;
975 validate_source_for_write(document)?;
976 write_atomic(&self.path, document.source().as_bytes(), "write")
977 }
978}
979
980impl std::ops::Deref for DocumentFile {
981 type Target = Document;
982
983 fn deref(&self) -> &Document {
984 &self.doc
985 }
986}
987
988impl std::ops::DerefMut for DocumentFile {
989 fn deref_mut(&mut self) -> &mut Document {
990 &mut self.doc
991 }
992}
993
994fn resolve_format(path: &Path, format_override: Option<Format>) -> DocumentResult<Format> {
995 match format_override {
996 Some(format) => Ok(format),
997 None => match Format::detect(path) {
1002 Some(format) => Ok(format),
1003 None => Err(match Format::unavailable(path) {
1004 Some(feature) => DocumentError::UnsupportedOperation {
1005 format: feature.to_string(),
1006 operation: "open".to_string(),
1007 detail: format!("requires Cargo feature `{feature}`"),
1008 },
1009 None => DocumentError::FormatUnknown {
1010 path: path.display().to_string(),
1011 },
1012 }),
1013 },
1014 }
1015}
1016
1017fn validate_source_for_write(document: &Document) -> DocumentResult<()> {
1018 validate_source_text_for_write(document.source(), document.format())
1019}
1020
1021fn validate_source_text_for_write(source: &str, format: Format) -> DocumentResult<()> {
1022 Document::parse(source, format).map_err(|error| DocumentError::WriteWouldCorrupt {
1023 format: format.name().to_string(),
1024 detail: error.redacted_message(),
1025 })?;
1026 Ok(())
1027}
1028
1029fn validate_create_options(options: CreateOptions) -> DocumentResult<()> {
1030 if let Some(unix_mode) = options.unix_mode
1031 && unix_mode & !0o777 != 0
1032 {
1033 return Err(DocumentError::InvalidArgument {
1034 detail: format!("unix mode {unix_mode:o} contains bits outside 0o777"),
1035 });
1036 }
1037 Ok(())
1038}
1039
1040#[cfg(test)]
1041mod tests {
1042 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
1043 use super::*;
1044 use std::io::Cursor;
1045
1046 fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
1047 let path = dir.join(name);
1048 fs::write(&path, contents).unwrap();
1049 path
1050 }
1051
1052 #[test]
1053 fn round_trip_open_json() {
1054 let dir = tempfile::tempdir().unwrap();
1055 let contents = r#"{"host": "example.com", "port": 993}"#;
1056 let path = write_temp(dir.path(), "config.json", contents);
1057
1058 let doc = DocumentFile::open(&path, None).unwrap();
1059
1060 assert_eq!(doc.format(), Format::Json);
1061 assert_eq!(
1062 doc.value().get("host").and_then(Value::as_str),
1063 Some("example.com")
1064 );
1065 assert_eq!(doc.source(), contents);
1066 }
1067
1068 #[test]
1069 fn value_at_reads_a_nested_address() {
1070 let dir = tempfile::tempdir().unwrap();
1071 let path = write_temp(
1072 dir.path(),
1073 "config.json",
1074 r#"{"database": {"url": "postgres://x"}}"#,
1075 );
1076 let doc = DocumentFile::open(&path, None).unwrap();
1077
1078 assert_eq!(
1079 doc.value_at("database.url").unwrap(),
1080 Value::String("postgres://x".to_string())
1081 );
1082 assert_eq!(
1083 doc.value_at("database.missing").unwrap_err().code(),
1084 "document_path_not_found"
1085 );
1086 }
1087
1088 #[test]
1089 fn open_capped_enforces_size_and_regular_file() {
1090 let dir = tempfile::tempdir().unwrap();
1091 let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
1092
1093 assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
1095
1096 let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
1099 assert_eq!(err.code(), "document_too_large");
1100
1101 let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
1103 assert_eq!(dir_err.code(), "document_io_failed");
1104
1105 let missing =
1107 DocumentFile::open_capped(dir.path().join("absent.json"), None, 1024).unwrap_err();
1108 assert_ne!(missing.code(), "document_too_large");
1109 }
1110
1111 #[cfg(unix)]
1112 #[test]
1113 fn capped_read_uses_the_open_handle_when_the_path_is_replaced() {
1114 let dir = tempfile::tempdir().unwrap();
1115 let original = r#"{"source":"original"}"#;
1116 let path = write_temp(dir.path(), "config.json", original);
1117 let handle = open_read_handle(&path, SymlinkPolicy::Follow).unwrap();
1118 inspect_capped_source(&handle, &path, 64).unwrap();
1119
1120 fs::rename(&path, dir.path().join("original.json")).unwrap();
1121 fs::write(&path, r#"{"source":"replacement"}"#).unwrap();
1122
1123 let source = read_capped_contents(handle, &path, 64).unwrap();
1124 assert_eq!(source, original);
1125 }
1126
1127 #[cfg(unix)]
1128 #[test]
1129 fn capped_read_rechecks_the_actual_bytes_after_metadata() {
1130 use std::io::Write as _;
1131
1132 let dir = tempfile::tempdir().unwrap();
1133 let path = write_temp(dir.path(), "config.json", "{}");
1134 let handle = open_read_handle(&path, SymlinkPolicy::Follow).unwrap();
1135 inspect_capped_source(&handle, &path, 4).unwrap();
1136
1137 let mut writer = OpenOptions::new().append(true).open(&path).unwrap();
1138 writer.write_all(b"123").unwrap();
1139 writer.sync_all().unwrap();
1140
1141 let error = read_capped_contents(handle, &path, 4).unwrap_err();
1142 assert_eq!(error.code(), "document_too_large");
1143 }
1144
1145 #[cfg(all(unix, feature = "libc"))]
1146 #[test]
1147 fn open_capped_can_atomically_refuse_a_symlink() {
1148 let dir = tempfile::tempdir().unwrap();
1149 let target = write_temp(dir.path(), "target.json", r#"{"k": "v"}"#);
1150 let link = dir.path().join("link.json");
1151 std::os::unix::fs::symlink(&target, &link).unwrap();
1152
1153 assert!(
1154 DocumentFile::open_capped_with_policy(&link, None, 1024, SymlinkPolicy::Follow).is_ok()
1155 );
1156 let error =
1157 DocumentFile::open_capped_with_policy(&link, None, 1024, SymlinkPolicy::NoFollow)
1158 .unwrap_err();
1159 assert_eq!(error.code(), "document_io_failed");
1160 }
1161
1162 #[test]
1163 fn create_atomic_is_no_clobber_and_returns_a_file_handle() {
1164 let dir = tempfile::tempdir().unwrap();
1165 let path = dir.path().join("config.json");
1166 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1167
1168 let created = DocumentFile::create_atomic(&path, document, CreateOptions::new()).unwrap();
1169 assert_eq!(created.path(), path);
1170 assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 993}"#);
1171
1172 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1173 let error =
1174 DocumentFile::create_atomic(&path, replacement, CreateOptions::new()).unwrap_err();
1175 assert_eq!(error.code(), "document_target_exists");
1176 assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 993}"#);
1177 }
1178
1179 #[test]
1180 fn create_atomic_requires_explicit_replace() {
1181 let dir = tempfile::tempdir().unwrap();
1182 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
1183 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1184
1185 let created =
1186 DocumentFile::create_atomic(&path, replacement, CreateOptions::new().replace())
1187 .unwrap();
1188
1189 assert_eq!(created.value_at("port").unwrap(), Value::Integer(1024));
1190 assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 1024}"#);
1191 }
1192
1193 #[cfg(unix)]
1198 #[test]
1199 fn create_atomic_replace_preserves_the_targets_mode_unless_told_otherwise() {
1200 use std::os::unix::fs::PermissionsExt as _;
1201
1202 let dir = tempfile::tempdir().unwrap();
1203 let mode_of = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
1204
1205 for original in [0o644, 0o600, 0o640] {
1206 let path = write_temp(dir.path(), &format!("m{original:o}.json"), r#"{"a": 1}"#);
1207 fs::set_permissions(&path, fs::Permissions::from_mode(original)).unwrap();
1208 let replacement = Document::parse(r#"{"a": 2}"#, Format::Json).unwrap();
1209 DocumentFile::create_atomic(&path, replacement, CreateOptions::new().replace())
1210 .unwrap();
1211 assert_eq!(
1212 mode_of(&path),
1213 original,
1214 "replace must keep the file's mode"
1215 );
1216 }
1217
1218 let path = write_temp(dir.path(), "explicit.json", r#"{"a": 1}"#);
1220 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1221 let replacement = Document::parse(r#"{"a": 2}"#, Format::Json).unwrap();
1222 DocumentFile::create_atomic(
1223 &path,
1224 replacement,
1225 CreateOptions::new().replace().unix_mode(0o600),
1226 )
1227 .unwrap();
1228 assert_eq!(mode_of(&path), 0o600);
1229
1230 let fresh = dir.path().join("fresh.json");
1232 let document = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1233 DocumentFile::create_atomic(&fresh, document, CreateOptions::new()).unwrap();
1234 assert_eq!(mode_of(&fresh), 0o600);
1235 }
1236
1237 #[cfg(feature = "toml")]
1241 #[test]
1242 fn create_atomic_refuses_a_document_the_path_could_not_reopen() {
1243 let dir = tempfile::tempdir().unwrap();
1244 let path = dir.path().join("mismatch.toml");
1245 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1246
1247 let error = DocumentFile::create_atomic(&path, document, CreateOptions::new()).unwrap_err();
1248
1249 assert_eq!(error.code(), "document_unsupported_operation");
1250 assert!(
1251 !path.exists(),
1252 "nothing may be written when the check fails"
1253 );
1254 }
1255
1256 #[cfg(unix)]
1257 #[test]
1258 fn create_atomic_applies_requested_private_mode() {
1259 use std::os::unix::fs::PermissionsExt as _;
1260
1261 let dir = tempfile::tempdir().unwrap();
1262 let path = dir.path().join("config.json");
1263 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1264
1265 DocumentFile::create_atomic(&path, document, CreateOptions::new().unix_mode(0o640))
1266 .unwrap();
1267
1268 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1269 assert_eq!(mode, 0o640);
1270 }
1271
1272 #[test]
1273 fn create_atomic_rejects_invalid_permission_bits_before_writing() {
1274 let dir = tempfile::tempdir().unwrap();
1275 let path = dir.path().join("config.json");
1276 let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1277
1278 let error =
1279 DocumentFile::create_atomic(&path, document, CreateOptions::new().unix_mode(0o1600))
1280 .unwrap_err();
1281
1282 assert_eq!(error.code(), "document_invalid_argument");
1283 assert!(!path.exists());
1284 }
1285
1286 #[cfg(unix)]
1287 #[test]
1288 fn create_atomic_replace_refuses_symlinks_and_hardlinks() {
1289 let dir = tempfile::tempdir().unwrap();
1290 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
1291 let symlink = dir.path().join("symlink.json");
1292 std::os::unix::fs::symlink(&target, &symlink).unwrap();
1293 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1294
1295 let symlink_error = DocumentFile::create_atomic(
1296 &symlink,
1297 replacement.clone(),
1298 CreateOptions::new().replace(),
1299 )
1300 .unwrap_err();
1301 assert_eq!(symlink_error.code(), "document_unsupported_operation");
1302 assert_eq!(fs::read_to_string(&target).unwrap(), r#"{"port": 993}"#);
1303
1304 let hardlink = dir.path().join("hardlink.json");
1305 fs::hard_link(&target, &hardlink).unwrap();
1306 let hardlink_error =
1307 DocumentFile::create_atomic(&hardlink, replacement, CreateOptions::new().replace())
1308 .unwrap_err();
1309 assert_eq!(hardlink_error.code(), "document_unsupported_operation");
1310 assert_eq!(fs::read_to_string(&target).unwrap(), r#"{"port": 993}"#);
1311 }
1312
1313 #[cfg(unix)]
1314 #[test]
1315 fn create_atomic_replace_refuses_a_dangling_symlink() {
1316 let dir = tempfile::tempdir().unwrap();
1317 let missing = dir.path().join("missing.json");
1318 let symlink = dir.path().join("dangling.json");
1319 std::os::unix::fs::symlink(&missing, &symlink).unwrap();
1320 let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1321
1322 let error =
1323 DocumentFile::create_atomic(&symlink, replacement, CreateOptions::new().replace())
1324 .unwrap_err();
1325
1326 assert_eq!(error.code(), "document_unsupported_operation");
1327 assert!(
1328 fs::symlink_metadata(&symlink)
1329 .unwrap()
1330 .file_type()
1331 .is_symlink()
1332 );
1333 assert!(!missing.exists());
1334 }
1335
1336 #[test]
1337 fn edit_rolls_back_memory_and_disk_when_the_closure_fails() {
1338 let dir = tempfile::tempdir().unwrap();
1339 let original = r#"{"port": 993}"#;
1340 let path = write_temp(dir.path(), "config.json", original);
1341 let mut document = DocumentFile::open(&path, None).unwrap();
1342
1343 let error = document
1344 .edit(|draft| {
1345 draft.set("port", Value::Integer(1024))?;
1346 Err(DocumentError::InvalidArgument {
1347 detail: "validation failed".to_string(),
1348 })
1349 })
1350 .unwrap_err();
1351
1352 assert_eq!(error.code(), "document_invalid_argument");
1353 assert_eq!(document.source(), original);
1354 assert_eq!(document.value_at("port").unwrap(), Value::Integer(993));
1355 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1356 }
1357
1358 #[test]
1359 fn typed_get_and_set_enforce_the_stated_type() {
1360 use crate::document::ValueType;
1361 let dir = tempfile::tempdir().unwrap();
1362 let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
1363 let mut doc = DocumentFile::open(&path, None).unwrap();
1364
1365 assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
1368 assert_eq!(
1369 doc.value_at_typed("port", ValueType::String)
1370 .unwrap_err()
1371 .code(),
1372 "document_type_mismatch"
1373 );
1374 assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
1375
1376 doc.set_typed("port", Some("9090"), ValueType::Number)
1378 .unwrap();
1379 assert_eq!(
1380 doc.value_at("port").unwrap(),
1381 Value::from(serde_json::json!(9090))
1382 );
1383 assert_eq!(
1384 doc.set_typed("port", Some("not-a-number"), ValueType::Number)
1385 .unwrap_err()
1386 .code(),
1387 "document_parse_failed"
1388 );
1389 }
1390
1391 #[test]
1392 fn decode_and_edit_and_validate_share_one_typed_boundary() {
1393 #[derive(Debug, serde::Deserialize)]
1394 #[serde(deny_unknown_fields)]
1395 struct Config {
1396 port: u16,
1397 }
1398
1399 let dir = tempfile::tempdir().unwrap();
1400 let original = r#"{"port": 8080}"#;
1401 let path = write_temp(dir.path(), "config.json", original);
1402 let mut document = DocumentFile::open(&path, None).unwrap();
1403
1404 assert_eq!(document.decode::<Config>().unwrap().port, 8080);
1405
1406 let error = document
1407 .edit_and_validate::<Config>(|draft| {
1408 draft.set("port", Value::String("invalid".to_string()))
1409 })
1410 .unwrap_err();
1411 assert_eq!(error.code(), "document_type_mismatch");
1412 assert_eq!(document.source(), original);
1413 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1414
1415 let config = document
1416 .edit_and_validate::<Config>(|draft| draft.set("port", Value::Unsigned(9090)))
1417 .unwrap();
1418 assert_eq!(config.port, 9090);
1419 assert_eq!(document.value_at("port").unwrap(), Value::Unsigned(9090));
1420 }
1421
1422 #[cfg(feature = "toml")]
1423 #[test]
1424 fn round_trip_open_toml() {
1425 let dir = tempfile::tempdir().unwrap();
1426 let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
1427 let path = write_temp(dir.path(), "config.toml", contents);
1428
1429 let doc = DocumentFile::open(&path, None).unwrap();
1430
1431 assert_eq!(doc.format(), Format::Toml);
1432 assert_eq!(
1433 doc.value().get("host").and_then(Value::as_str),
1434 Some("example.com")
1435 );
1436 assert_eq!(doc.source(), contents);
1437 }
1438
1439 #[cfg(feature = "toml")]
1440 #[test]
1441 fn set_scalar_preserves_toml_comments_and_formatting() {
1442 let dir = tempfile::tempdir().unwrap();
1443 let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
1444 let path = write_temp(dir.path(), "config.toml", contents);
1445 let mut doc = DocumentFile::open(&path, None).unwrap();
1446
1447 doc.set("port", Value::Integer(1024)).unwrap();
1448 doc.save().unwrap();
1449
1450 let saved = fs::read_to_string(&path).unwrap();
1451 assert!(saved.contains("# leading comment"));
1452 assert!(saved.contains("port = 1024"));
1453 assert_eq!(
1454 doc.value().get("port").and_then(Value::as_integer),
1455 Some(1024)
1456 );
1457 assert_eq!(doc.source(), saved);
1458 }
1459
1460 #[cfg(feature = "toml")]
1465 #[test]
1466 fn toml_array_edits_never_invent_or_misattribute_a_comment() {
1467 let contents = "paths = [\n \"one\", # first\n \"two\", # second\n]\n";
1468
1469 let mut grown = Document::parse(contents, Format::Toml).unwrap();
1470 grown
1471 .set(
1472 "paths",
1473 Value::Array(vec![
1474 Value::String("a".into()),
1475 Value::String("b".into()),
1476 Value::String("c".into()),
1477 ]),
1478 )
1479 .unwrap();
1480 assert_eq!(
1481 grown.source(),
1482 "paths = [\n \"a\", # first\n \"b\", # second\n \"c\",\n]\n",
1483 "an appended element must carry no comment of its own"
1484 );
1485
1486 let mut shrunk = Document::parse(contents, Format::Toml).unwrap();
1487 shrunk
1488 .set("paths", Value::Array(vec![Value::String("only".into())]))
1489 .unwrap();
1490 assert_eq!(
1491 shrunk.source(),
1492 "paths = [\n \"only\", # first\n]\n",
1493 "the surviving element keeps its own comment, not the removed one's"
1494 );
1495
1496 let mut replaced = Document::parse(contents, Format::Toml).unwrap();
1498 replaced
1499 .set(
1500 "paths",
1501 Value::Array(vec![Value::String("x".into()), Value::String("y".into())]),
1502 )
1503 .unwrap();
1504 assert_eq!(
1505 replaced.source(),
1506 "paths = [\n \"x\", # first\n \"y\", # second\n]\n"
1507 );
1508
1509 let mut inline = Document::parse("paths = [ \"one\" ]\n", Format::Toml).unwrap();
1511 inline
1512 .set(
1513 "paths",
1514 Value::Array(vec![
1515 Value::String("one".into()),
1516 Value::String("two".into()),
1517 ]),
1518 )
1519 .unwrap();
1520 assert_eq!(inline.source(), "paths = [ \"one\", \"two\" ]\n");
1521 }
1522
1523 #[cfg(feature = "toml")]
1524 #[test]
1525 fn set_toml_array_preserves_single_line_decor() {
1526 let contents = "# before\npaths = [ \"old\", 'second', ] # keep this\nother = 42\n";
1527 let mut document = Document::parse(contents, Format::Toml).unwrap();
1528
1529 document
1530 .set(
1531 "paths",
1532 Value::Array(vec![
1533 Value::String("new".to_string()),
1534 Value::String("next".to_string()),
1535 ]),
1536 )
1537 .unwrap();
1538
1539 assert_eq!(
1540 document.source(),
1541 "# before\npaths = [ \"new\", \"next\", ] # keep this\nother = 42\n"
1542 );
1543 }
1544
1545 #[cfg(feature = "toml")]
1546 #[test]
1547 fn set_toml_array_preserves_multiline_comments_and_trailing_comma() {
1548 let contents = "paths = [\n \"one\", # first\n \"two\", # second\n]\nother = 42\n";
1549 let mut document = Document::parse(contents, Format::Toml).unwrap();
1550
1551 document
1552 .set(
1553 "paths",
1554 Value::Array(vec![
1555 Value::String("uno".to_string()),
1556 Value::String("dos".to_string()),
1557 ]),
1558 )
1559 .unwrap();
1560
1561 assert_eq!(
1562 document.source(),
1563 "paths = [\n \"uno\", # first\n \"dos\", # second\n]\nother = 42\n"
1564 );
1565 }
1566
1567 #[cfg(feature = "toml")]
1568 #[test]
1569 fn set_toml_array_element_preserves_its_neighbors() {
1570 let contents = "paths = [\n \"one\", # first\n \"two\", # second\n]\n";
1571 let mut document = Document::parse(contents, Format::Toml).unwrap();
1572
1573 document
1574 .set("paths.1", Value::String("changed".to_string()))
1575 .unwrap();
1576
1577 assert_eq!(
1578 document.source(),
1579 "paths = [\n \"one\", # first\n \"changed\", # second\n]\n"
1580 );
1581 }
1582
1583 #[cfg(feature = "toml")]
1584 #[test]
1585 fn set_toml_array_can_become_empty_without_touching_neighbors() {
1586 let contents = "before = 1\npaths = [ \"one\", ] # list\nafter = 2\n";
1587 let mut document = Document::parse(contents, Format::Toml).unwrap();
1588
1589 document.set("paths", Value::Array(Vec::new())).unwrap();
1590
1591 assert_eq!(
1592 document.source(),
1593 "before = 1\npaths = [ ] # list\nafter = 2\n"
1594 );
1595 }
1596
1597 #[cfg(feature = "toml")]
1598 #[test]
1599 fn set_toml_inline_table_preserves_layout_and_comments() {
1600 let contents = "cache = { ttl_s = 1, enabled = true } # cache\nother = 42\n";
1601 let mut document = Document::parse(contents, Format::Toml).unwrap();
1602 let replacement = Value::from(serde_json::json!({
1603 "enabled": false,
1604 "ttl_s": 60
1605 }));
1606
1607 document.set("cache", replacement).unwrap();
1608
1609 assert_eq!(
1610 document.source(),
1611 "cache = { ttl_s = 60, enabled = false } # cache\nother = 42\n"
1612 );
1613 }
1614
1615 #[cfg(feature = "toml")]
1616 #[test]
1617 fn set_toml_ordinary_table_preserves_header_and_unrelated_section() {
1618 let contents = "# lead\n[cache] # cache header\nttl_s = 1 # ttl\nenabled = true\n\n[next]\nvalue = 9\n";
1619 let mut document = Document::parse(contents, Format::Toml).unwrap();
1620 let replacement = Value::from(serde_json::json!({
1621 "enabled": false,
1622 "ttl_s": 60
1623 }));
1624
1625 document.set("cache", replacement).unwrap();
1626
1627 assert_eq!(
1628 document.source(),
1629 "# lead\n[cache] # cache header\nttl_s = 60 # ttl\nenabled = false\n\n[next]\nvalue = 9\n"
1630 );
1631 }
1632
1633 #[cfg(feature = "toml")]
1634 #[test]
1635 fn set_toml_collection_preserves_unchanged_datetime_syntax() {
1636 let contents = "[cache]\nexpires_at = 2026-08-04T12:30:00Z\npaths = [\"one\"]\n";
1637 let mut document = Document::parse(contents, Format::Toml).unwrap();
1638 let replacement = document.value_at("cache").unwrap();
1639
1640 document.set("cache", replacement).unwrap();
1641
1642 assert_eq!(document.source(), contents);
1643 }
1644
1645 #[cfg(feature = "toml")]
1646 #[test]
1647 fn set_toml_array_of_tables_is_explicitly_refused() {
1648 let contents = "[[servers]]\nname = \"one\"\n[[servers]]\nname = \"two\"\n";
1649 let mut document = Document::parse(contents, Format::Toml).unwrap();
1650 let replacement = document.value_at("servers").unwrap();
1651
1652 let error = document.set("servers", replacement).unwrap_err();
1653
1654 assert_eq!(error.code(), "document_unsupported_operation");
1655 assert_eq!(document.source(), contents);
1656 }
1657
1658 #[cfg(feature = "ini")]
1659 #[test]
1660 fn save_refuses_source_its_own_parser_rejects() {
1661 let dir = tempfile::tempdir().unwrap();
1665 let original = "[db]\nhost=localhost\n";
1666 let path = write_temp(dir.path(), "config.ini", original);
1667 let doc = DocumentFile::open(&path, None).unwrap();
1668
1669 let error = doc
1670 .save_atomic("[db]\nhost=localhost\n\n[db]\nport=5432\n")
1671 .unwrap_err();
1672 assert_eq!(error.code(), "document_write_would_corrupt");
1673 assert_eq!(fs::read_to_string(&path).unwrap(), original);
1675 }
1676
1677 #[cfg(feature = "ini")]
1678 #[test]
1679 fn save_writes_source_the_parser_accepts() {
1680 let dir = tempfile::tempdir().unwrap();
1681 let path = write_temp(dir.path(), "config.ini", "[db]\nhost=localhost\n");
1682 let mut doc = DocumentFile::open(&path, None).unwrap();
1683
1684 doc.set("db.port", Value::String("5432".to_string()))
1685 .unwrap();
1686 doc.save().unwrap();
1687
1688 assert_eq!(
1691 fs::read_to_string(&path).unwrap(),
1692 "[db]\nhost=localhost\nport=5432\n"
1693 );
1694 assert!(DocumentFile::open(&path, None).is_ok());
1695 }
1696
1697 #[cfg(unix)]
1698 #[test]
1699 fn atomic_save_preserves_file_mode() {
1700 use std::os::unix::fs::PermissionsExt;
1701
1702 let dir = tempfile::tempdir().unwrap();
1703 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
1704 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
1705 let mut doc = DocumentFile::open(&path, None).unwrap();
1706
1707 doc.set("port", Value::Integer(1024)).unwrap();
1708 doc.save().unwrap();
1709
1710 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1711 assert_eq!(mode, 0o640);
1712 }
1713
1714 #[cfg(unix)]
1715 #[test]
1716 fn symlink_target_is_rejected_for_mutation() {
1717 let dir = tempfile::tempdir().unwrap();
1718 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
1719 let link = dir.path().join("link.json");
1720 std::os::unix::fs::symlink(&target, &link).unwrap();
1721
1722 let mut doc = DocumentFile::open(&link, None).unwrap();
1724
1725 doc.set("port", Value::Integer(1024)).unwrap();
1727 let err = doc.save().unwrap_err();
1728 assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
1729
1730 let target_contents = fs::read_to_string(&target).unwrap();
1732 assert_eq!(target_contents, r#"{"port": 993}"#);
1733 }
1734
1735 #[test]
1736 fn from_reader_parses_in_memory_cursor() {
1737 let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
1738
1739 let doc = Document::from_reader(cursor, Format::Json).unwrap();
1740
1741 assert_eq!(
1742 doc.value().get("host").and_then(Value::as_str),
1743 Some("example.com")
1744 );
1745 }
1746
1747 #[test]
1748 fn document_from_str_encode_round_trip() {
1749 let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1750 let encoded = doc.encode().unwrap();
1751 let reparsed = Document::parse(&encoded, Format::Json).unwrap();
1752 assert_eq!(
1753 reparsed.value().get("a").and_then(Value::as_integer),
1754 Some(1)
1755 );
1756 }
1757
1758 #[test]
1759 fn document_edits_source_in_memory_without_a_file() {
1760 let mut doc = Document::parse("{\n \"host\": \"old\"\n}\n", Format::Json).unwrap();
1763 doc.set("host", Value::String("new".to_string())).unwrap();
1764 doc.set("imap.port", Value::Integer(993)).unwrap(); assert_eq!(
1767 doc.source(),
1768 "{\n \"host\": \"new\",\n \"imap\": {\n \"port\": 993\n }\n}\n"
1769 );
1770 assert_eq!(
1771 doc.value_at("imap.port").unwrap(),
1772 Value::from(serde_json::json!(993))
1773 );
1774 }
1775
1776 #[test]
1777 fn unset_is_false_for_anything_already_absent() {
1778 let mut doc = Document::parse(
1779 r#"{"service":{"host":"example","ports":[80]}}"#,
1780 Format::Json,
1781 )
1782 .unwrap();
1783
1784 assert!(!doc.unset("service.missing").unwrap());
1787 assert!(!doc.unset("missing.parent").unwrap());
1788 assert!(!doc.unset("missing.deeply.nested").unwrap());
1789
1790 assert!(doc.unset("service.host.child").is_err()); assert!(doc.unset("service.ports.9").is_err()); assert!(doc.unset(r"service\q").is_err()); }
1796
1797 #[cfg(feature = "markdown")]
1798 #[test]
1799 fn every_markdown_write_verb_is_refused() {
1800 let source = "# Title\n\nThe lead.\n";
1801 let mut doc = Document::parse(source, Format::Markdown).unwrap();
1802
1803 assert_eq!(
1806 doc.value_at("h1.0.text").unwrap(),
1807 Value::String("Title".to_string())
1808 );
1809 assert_eq!(
1810 doc.value_at("h1.Tit.paragraph.0.text").unwrap(),
1811 Value::String("The lead.".to_string())
1812 );
1813
1814 let refusals: Vec<DocumentError> = vec![
1818 doc.set("h1.0.text", Value::String("New".to_string()))
1819 .unwrap_err(),
1820 doc.add("preamble", "x", "type", &[]).unwrap_err(),
1821 doc.remove("preamble", "x", "type").unwrap_err(),
1822 doc.unset("h1.0").unwrap_err(),
1823 doc.unset("nothing.here").unwrap_err(),
1824 doc.encode().unwrap_err(),
1825 ];
1826 for error in refusals {
1827 assert_eq!(error.code(), "document_unsupported_operation");
1828 assert!(
1829 error.to_string().contains("read-only"),
1830 "refusal must name the reason: {error}"
1831 );
1832 }
1833
1834 assert_eq!(doc.source(), source);
1836 }
1837
1838 #[cfg(feature = "markdown")]
1839 #[test]
1840 fn markdown_save_never_reaches_disk() {
1841 let dir = tempfile::tempdir().unwrap();
1842 let path = write_temp(dir.path(), "README.md", "# Title\n");
1843 assert!(DocumentFile::open(&path, None).is_err());
1845
1846 let doc = DocumentFile::open(&path, Some(Format::Markdown)).unwrap();
1847 let error = doc.save().unwrap_err();
1849 assert_eq!(error.code(), "document_unsupported_operation");
1850 assert_eq!(fs::read_to_string(&path).unwrap(), "# Title\n");
1851 }
1852
1853 #[cfg(feature = "yaml")]
1854 #[test]
1855 fn yaml_write_rejects_cst_ambiguous_mapping_segments() {
1856 let mut numeric = Document::parse("\"123\": value\n", Format::Yaml).unwrap();
1857 assert!(
1858 numeric
1859 .set("123", Value::String("changed".to_string()))
1860 .is_err()
1861 );
1862 assert!(numeric.unset("123").is_err());
1863
1864 let mut bracketed = Document::parse("\"a[0]\": value\n", Format::Yaml).unwrap();
1865 assert!(
1866 bracketed
1867 .set("a[0]", Value::String("changed".to_string()))
1868 .is_err()
1869 );
1870 }
1871}