1use std::fs::{self, OpenOptions};
24use std::io::Write as _;
25use std::path::{Path, PathBuf};
26
27use crate::document::{Addressing, DocumentError, DocumentResult, Format, KeyedList, Value};
28
29#[derive(Debug, Clone)]
37pub struct Document {
38 source: String,
39 value: Value,
40 format: Format,
41}
42
43impl Document {
44 pub fn parse(source: &str, format: Format) -> DocumentResult<Document> {
50 let value = format.load(source)?;
51 Ok(Document {
52 source: source.to_string(),
53 value,
54 format,
55 })
56 }
57
58 pub fn from_reader<R: std::io::Read>(
64 mut reader: R,
65 format: Format,
66 ) -> DocumentResult<Document> {
67 let mut source = String::new();
68 reader.read_to_string(&mut source)?;
69 Document::parse(&source, format)
70 }
71
72 pub fn value(&self) -> &Value {
74 &self.value
75 }
76
77 pub fn source(&self) -> &str {
81 &self.source
82 }
83
84 pub fn format(&self) -> Format {
86 self.format
87 }
88
89 #[must_use]
95 pub fn addressing(&self) -> Addressing<'static> {
96 Addressing::INDEX_ONLY.with_array_rule(self.format.array_rule())
97 }
98
99 #[must_use]
102 pub fn addressing_keyed<'a>(&self, keyed_lists: &'a [KeyedList<'a>]) -> Addressing<'a> {
103 Addressing::keyed(keyed_lists).with_array_rule(self.format.array_rule())
104 }
105
106 pub fn value_at(&self, path: &str) -> DocumentResult<Value> {
114 crate::document::get_path(&self.value, path, self.addressing())
115 }
116
117 pub fn value_at_typed(
121 &self,
122 path: &str,
123 expected: crate::document::ValueType,
124 ) -> DocumentResult<Value> {
125 let value = self.value_at(path)?;
126 if crate::document::value_matches_type(&value, expected) {
127 Ok(value)
128 } else {
129 Err(DocumentError::TypeMismatch {
130 path: path.to_string(),
131 expected: expected.name().to_string(),
132 got: value.kind_name().to_string(),
133 hint: None,
134 })
135 }
136 }
137
138 pub fn set_typed(
141 &mut self,
142 key: &str,
143 raw: Option<&str>,
144 value_type: crate::document::ValueType,
145 ) -> DocumentResult<()> {
146 let value = crate::document::value_from_type(value_type, raw)?;
147 self.set(key, value)
148 }
149
150 fn ensure_writable(&self, operation: &str) -> DocumentResult<()> {
158 if self.format.is_read_only() {
159 return Err(self.format.read_only_error(operation));
160 }
161 Ok(())
162 }
163
164 pub fn encode(&self) -> DocumentResult<String> {
170 self.format.save(&self.value)
171 }
172}
173
174#[derive(Debug, Clone)]
182pub struct DocumentFile {
183 doc: Document,
184 path: PathBuf,
185}
186
187impl DocumentFile {
188 pub fn open(
194 path: impl AsRef<Path>,
195 format_override: Option<Format>,
196 ) -> DocumentResult<DocumentFile> {
197 let path = path.as_ref().to_path_buf();
198 let format = match format_override {
199 Some(format) => format,
200 None => match Format::detect(&path) {
205 Some(format) => format,
206 None => {
207 return Err(match Format::unavailable(&path) {
208 Some(feature) => DocumentError::UnsupportedOperation {
209 format: feature.to_string(),
210 operation: "open".to_string(),
211 detail: format!("requires Cargo feature `{feature}`"),
212 },
213 None => DocumentError::FormatUnknown {
214 path: path.display().to_string(),
215 },
216 });
217 }
218 },
219 };
220 let source = fs::read_to_string(&path).map_err(|error| DocumentError::IoError {
221 detail: format!("read `{}`: {error}", path.display()),
222 })?;
223 Ok(DocumentFile {
224 doc: Document::parse(&source, format)?,
225 path,
226 })
227 }
228
229 pub fn open_capped(
237 path: impl AsRef<Path>,
238 format_override: Option<Format>,
239 max_bytes: u64,
240 ) -> DocumentResult<DocumentFile> {
241 let path = path.as_ref();
242 let metadata = fs::metadata(path).map_err(|error| DocumentError::IoError {
243 detail: format!("read `{}`: {error}", path.display()),
244 })?;
245 if !metadata.is_file() {
246 return Err(DocumentError::IoError {
247 detail: format!("`{}` is not a regular file", path.display()),
248 });
249 }
250 if metadata.len() > max_bytes {
251 return Err(DocumentError::IoError {
252 detail: format!(
253 "`{}` exceeds the {max_bytes}-byte read limit",
254 path.display()
255 ),
256 });
257 }
258 DocumentFile::open(path, format_override)
259 }
260
261 pub fn path(&self) -> &Path {
263 &self.path
264 }
265
266 pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
275 self.doc.ensure_writable(operation)?;
276 guard_mutation(&self.path, operation)?;
277 Ok(())
278 }
279}
280
281impl Document {
282 pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
293 let addressing = self.addressing();
294 self.set_addressed(key, value, addressing)
295 }
296
297 pub fn set_addressed(
305 &mut self,
306 key: &str,
307 value: Value,
308 addressing: Addressing<'_>,
309 ) -> DocumentResult<()> {
310 self.ensure_writable("set")?;
311 let key = &crate::document::resolve_path(&self.value, key, addressing)?;
312 let mut new_doc = self.value.clone();
313 crate::document::set_path(&mut new_doc, key, &value, Addressing::INDEX_ONLY)?;
314 let target = crate::document::get_path(&new_doc, key, Addressing::INDEX_ONLY)?;
315 #[allow(unreachable_patterns)]
316 let output = match self.format {
317 #[cfg(feature = "toml")]
318 Format::Toml => {
319 crate::document::format::toml::set_preserving(&self.source, key, &target)?
320 }
321 #[cfg(feature = "yaml")]
322 Format::Yaml => {
323 crate::document::format::yaml::set_preserving(&self.source, key, &target)?
324 }
325 Format::Json => {
326 crate::document::format::json::set_preserving(&self.source, key, &target)?
327 }
328 #[cfg(feature = "dotenv")]
329 Format::Dotenv => {
330 crate::document::format::dotenv::set_preserving(&self.source, key, &target)?
331 }
332 #[cfg(feature = "ini")]
333 Format::Ini => {
334 crate::document::format::ini::set_preserving(&self.source, key, &target)?
335 }
336 #[cfg(feature = "toml")]
337 Format::TomlFrontmatter => {
338 let parts = crate::document::format::frontmatter::split(
339 &self.source,
340 crate::document::format::frontmatter::Delimiter::Plus,
341 )?;
342 let new_fm =
343 crate::document::format::toml::set_preserving(parts.frontmatter, key, &target)?;
344 format!("{}{}{}", parts.pre, new_fm, parts.post)
345 }
346 #[cfg(feature = "yaml")]
347 Format::YamlFrontmatter => {
348 let parts = crate::document::format::frontmatter::split(
349 &self.source,
350 crate::document::format::frontmatter::Delimiter::Dash,
351 )?;
352 let new_fm =
353 crate::document::format::yaml::set_preserving(parts.frontmatter, key, &target)?;
354 format!("{}{}{}", parts.pre, new_fm, parts.post)
355 }
356 _ => self.format.save(&new_doc)?,
357 };
358 self.source = output;
359 self.value = new_doc;
360 Ok(())
361 }
362
363 pub fn add(
372 &mut self,
373 key: &str,
374 slug: &str,
375 slug_field: &str,
376 fields: &[(String, Value)],
377 ) -> DocumentResult<()> {
378 self.ensure_writable("add")?;
379 let mut value = self.value.clone();
380 let keyed_lists = [KeyedList {
381 prefix: key,
382 slug_field,
383 }];
384 crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
385 let array = if key.is_empty() {
386 &value
387 } else {
388 crate::document::get_path_ref(&value, key, self.addressing_keyed(&keyed_lists))?
389 };
390 let item = array
391 .as_array()
392 .and_then(|items| items.last())
393 .ok_or_else(|| DocumentError::UnsupportedOperation {
394 format: self.format.name().to_string(),
395 operation: "add".to_string(),
396 detail: "keyed list did not produce an array item".to_string(),
397 })?;
398 #[allow(unreachable_patterns)]
402 let output: String = match self.format {
403 Format::Json => crate::document::format::json::append_array_item_preserving(
404 &self.source,
405 key,
406 item,
407 )?,
408 #[cfg(feature = "yaml")]
409 Format::Yaml => crate::document::format::yaml::append_array_item_preserving(
410 &self.source,
411 key,
412 item,
413 )?,
414 _ => {
415 return Err(DocumentError::UnsupportedOperation {
416 format: self.format.name().to_string(),
417 operation: "add".to_string(),
418 detail: "keyed collection source editor is not implemented for this backend"
419 .to_string(),
420 });
421 }
422 };
423 self.source = output;
424 self.value = value;
425 Ok(())
426 }
427
428 pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
436 self.ensure_writable("remove")?;
437 let mut value = self.value.clone();
438 let keyed_lists = [KeyedList {
439 prefix: key,
440 slug_field,
441 }];
442 let removed_index = crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
443 #[allow(unreachable_patterns)]
447 let output: String = match self.format {
448 Format::Json => crate::document::format::json::remove_array_item_preserving(
449 &self.source,
450 key,
451 removed_index,
452 )?,
453 #[cfg(feature = "yaml")]
454 Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
455 &self.source,
456 key,
457 removed_index,
458 )?,
459 _ => {
460 return Err(DocumentError::UnsupportedOperation {
461 format: self.format.name().to_string(),
462 operation: "remove".to_string(),
463 detail: "keyed collection source editor is not implemented for this backend"
464 .to_string(),
465 });
466 }
467 };
468 self.source = output;
469 self.value = value;
470 Ok(())
471 }
472
473 pub fn unset(&mut self, key: &str) -> DocumentResult<bool> {
498 let addressing = self.addressing();
499 self.unset_addressed(key, addressing)
500 }
501
502 pub fn unset_addressed(
507 &mut self,
508 key: &str,
509 addressing: Addressing<'_>,
510 ) -> DocumentResult<bool> {
511 self.ensure_writable("unset")?;
512 let key = &crate::document::resolve_path(&self.value, key, addressing)?;
513 let segments = crate::document::parse_path(key)?;
514 let (leaf, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
515 let parent = if parents.is_empty() {
516 &self.value
517 } else {
518 let parent_path = crate::document::join_path(parents);
519 match crate::document::get_path_ref(&self.value, &parent_path, Addressing::INDEX_ONLY) {
520 Ok(parent) => parent,
521 Err(DocumentError::UnknownSegment { .. }) => return Ok(false),
523 Err(error) => return Err(error),
524 }
525 };
526 match parent {
527 Value::Object(object) => {
528 if !object.contains_key(leaf) {
529 return Ok(false);
530 }
531 }
532 Value::Array(array) => {
533 let index =
534 leaf.parse::<usize>()
535 .map_err(|_| DocumentError::UnregisteredArray {
536 path: crate::document::join_path(parents),
537 })?;
538 if index >= array.len() {
539 return Err(DocumentError::IndexOutOfBounds {
540 path: crate::document::join_path(parents),
541 index,
542 len: array.len(),
543 });
544 }
545 }
546 value => {
547 return Err(DocumentError::NotTraversable {
548 path: crate::document::join_path(parents),
549 got: value.kind_name().to_string(),
550 });
551 }
552 }
553 let mut value = self.value.clone();
554 crate::document::unset_path(&mut value, key)?;
555 #[allow(unreachable_patterns)]
556 let output = match self.format {
557 Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
558 #[cfg(feature = "toml")]
559 Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
560 #[cfg(feature = "yaml")]
561 Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
562 #[cfg(feature = "dotenv")]
563 Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
564 #[cfg(feature = "ini")]
565 Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
566 #[cfg(feature = "toml")]
567 Format::TomlFrontmatter => {
568 let parts = crate::document::format::frontmatter::split(
569 &self.source,
570 crate::document::format::frontmatter::Delimiter::Plus,
571 )?;
572 let new_fm =
573 crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
574 format!("{}{}{}", parts.pre, new_fm, parts.post)
575 }
576 #[cfg(feature = "yaml")]
577 Format::YamlFrontmatter => {
578 let parts = crate::document::format::frontmatter::split(
579 &self.source,
580 crate::document::format::frontmatter::Delimiter::Dash,
581 )?;
582 let new_fm =
583 crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
584 format!("{}{}{}", parts.pre, new_fm, parts.post)
585 }
586 _ => self.format.save(&value)?,
587 };
588 self.source = output;
589 self.value = value;
590 Ok(true)
591 }
592}
593
594impl DocumentFile {
595 pub fn edit<F>(&mut self, edit: F) -> DocumentResult<()>
600 where
601 F: FnOnce(&mut Document) -> DocumentResult<()>,
602 {
603 edit(&mut self.doc)?;
604 self.save()
605 }
606
607 pub fn save(&self) -> DocumentResult<()> {
617 self.save_atomic(self.doc.source())
618 }
619
620 pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
631 self.ensure_writable("save")?;
635 Document::parse(new_source, self.format).map_err(|error| {
642 DocumentError::WriteWouldCorrupt {
643 format: self.format.name().to_string(),
644 detail: error.redacted_message(),
645 }
646 })?;
647 write_atomic(&self.path, new_source.as_bytes(), "write")
648 }
649}
650
651impl std::ops::Deref for DocumentFile {
652 type Target = Document;
653
654 fn deref(&self) -> &Document {
655 &self.doc
656 }
657}
658
659impl std::ops::DerefMut for DocumentFile {
660 fn deref_mut(&mut self) -> &mut Document {
661 &mut self.doc
662 }
663}
664
665fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
669 let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
670 detail: format!("{operation} preflight `{}`: {error}", path.display()),
671 })?;
672 if metadata.file_type().is_symlink() {
673 return Err(DocumentError::UnsupportedOperation {
674 format: "filesystem".to_string(),
675 operation: operation.to_string(),
676 detail: format!("refusing to mutate symlink `{}`", path.display()),
677 });
678 }
679 #[cfg(unix)]
680 {
681 use std::os::unix::fs::MetadataExt;
682 if metadata.nlink() > 1 {
683 return Err(DocumentError::UnsupportedOperation {
684 format: "filesystem".to_string(),
685 operation: operation.to_string(),
686 detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
687 });
688 }
689 }
690 Ok(metadata)
691}
692
693fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
696 let metadata = guard_mutation(path, operation)?;
697
698 let parent = path.parent().ok_or_else(|| DocumentError::IoError {
699 detail: format!(
700 "{operation} has no parent directory for `{}`",
701 path.display()
702 ),
703 })?;
704 let file_name = path
705 .file_name()
706 .and_then(|name| name.to_str())
707 .ok_or_else(|| DocumentError::IoError {
708 detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
709 })?;
710 let pid = std::process::id();
711 let mut temp_path = None;
712 let mut temp_file = None;
713 for attempt in 0..32_u32 {
714 let candidate = parent.join(format!(".{file_name}.afdata-document.{pid}.{attempt}.tmp"));
715 match OpenOptions::new()
716 .write(true)
717 .create_new(true)
718 .open(&candidate)
719 {
720 Ok(file) => {
721 temp_path = Some(candidate);
722 temp_file = Some(file);
723 break;
724 }
725 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
726 Err(error) => {
727 return Err(DocumentError::IoError {
728 detail: format!(
729 "{operation} create temporary file in `{}`: {error}",
730 parent.display()
731 ),
732 });
733 }
734 }
735 }
736 let temp_path = temp_path.ok_or_else(|| DocumentError::IoError {
737 detail: format!(
738 "{operation} could not allocate temporary file in `{}`",
739 parent.display()
740 ),
741 })?;
742 let mut temp_file = temp_file.ok_or_else(|| DocumentError::IoError {
743 detail: format!("{operation} temporary file handle missing"),
744 })?;
745 let result = (|| -> DocumentResult<()> {
746 temp_file
747 .write_all(bytes)
748 .map_err(|error| DocumentError::IoError {
749 detail: format!("{operation} write `{}`: {error}", path.display()),
750 })?;
751 temp_file
752 .sync_all()
753 .map_err(|error| DocumentError::IoError {
754 detail: format!("{operation} fsync `{}`: {error}", path.display()),
755 })?;
756 drop(temp_file);
757 fs::set_permissions(&temp_path, metadata.permissions()).map_err(|error| {
758 DocumentError::IoError {
759 detail: format!(
760 "{operation} preserve permissions `{}`: {error}",
761 path.display()
762 ),
763 }
764 })?;
765 fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
766 detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
767 })?;
768 Ok(())
769 })();
770 if result.is_err() {
771 let _ = fs::remove_file(&temp_path);
772 }
773 result
774}
775
776#[cfg(test)]
777mod tests {
778 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
779 use super::*;
780 use std::io::Cursor;
781
782 fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
783 let path = dir.join(name);
784 fs::write(&path, contents).unwrap();
785 path
786 }
787
788 #[test]
789 fn round_trip_open_json() {
790 let dir = tempfile::tempdir().unwrap();
791 let contents = r#"{"host": "example.com", "port": 993}"#;
792 let path = write_temp(dir.path(), "config.json", contents);
793
794 let doc = DocumentFile::open(&path, None).unwrap();
795
796 assert_eq!(doc.format(), Format::Json);
797 assert_eq!(
798 doc.value().get("host").and_then(Value::as_str),
799 Some("example.com")
800 );
801 assert_eq!(doc.source(), contents);
802 }
803
804 #[test]
805 fn value_at_reads_a_nested_address() {
806 let dir = tempfile::tempdir().unwrap();
807 let path = write_temp(
808 dir.path(),
809 "config.json",
810 r#"{"database": {"url": "postgres://x"}}"#,
811 );
812 let doc = DocumentFile::open(&path, None).unwrap();
813
814 assert_eq!(
815 doc.value_at("database.url").unwrap(),
816 Value::String("postgres://x".to_string())
817 );
818 assert_eq!(
819 doc.value_at("database.missing").unwrap_err().code(),
820 "document_path_not_found"
821 );
822 }
823
824 #[test]
825 fn open_capped_enforces_size_and_regular_file() {
826 let dir = tempfile::tempdir().unwrap();
827 let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
828
829 assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
831
832 let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
834 assert_eq!(err.code(), "document_io_failed");
835 assert!(err.to_string().contains("read limit"));
836
837 let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
839 assert_eq!(dir_err.code(), "document_io_failed");
840 }
841
842 #[test]
843 fn typed_get_and_set_enforce_the_stated_type() {
844 use crate::document::ValueType;
845 let dir = tempfile::tempdir().unwrap();
846 let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
847 let mut doc = DocumentFile::open(&path, None).unwrap();
848
849 assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
852 assert_eq!(
853 doc.value_at_typed("port", ValueType::String)
854 .unwrap_err()
855 .code(),
856 "document_type_mismatch"
857 );
858 assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
859
860 doc.set_typed("port", Some("9090"), ValueType::Number)
862 .unwrap();
863 assert_eq!(
864 doc.value_at("port").unwrap(),
865 Value::from(serde_json::json!(9090))
866 );
867 assert_eq!(
868 doc.set_typed("port", Some("not-a-number"), ValueType::Number)
869 .unwrap_err()
870 .code(),
871 "document_parse_failed"
872 );
873 }
874
875 #[cfg(feature = "toml")]
876 #[test]
877 fn round_trip_open_toml() {
878 let dir = tempfile::tempdir().unwrap();
879 let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
880 let path = write_temp(dir.path(), "config.toml", contents);
881
882 let doc = DocumentFile::open(&path, None).unwrap();
883
884 assert_eq!(doc.format(), Format::Toml);
885 assert_eq!(
886 doc.value().get("host").and_then(Value::as_str),
887 Some("example.com")
888 );
889 assert_eq!(doc.source(), contents);
890 }
891
892 #[cfg(feature = "toml")]
893 #[test]
894 fn set_scalar_preserves_toml_comments_and_formatting() {
895 let dir = tempfile::tempdir().unwrap();
896 let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
897 let path = write_temp(dir.path(), "config.toml", contents);
898 let mut doc = DocumentFile::open(&path, None).unwrap();
899
900 doc.set("port", Value::Integer(1024)).unwrap();
901 doc.save().unwrap();
902
903 let saved = fs::read_to_string(&path).unwrap();
904 assert!(saved.contains("# leading comment"));
905 assert!(saved.contains("port = 1024"));
906 assert_eq!(
907 doc.value().get("port").and_then(Value::as_integer),
908 Some(1024)
909 );
910 assert_eq!(doc.source(), saved);
911 }
912
913 #[test]
914 fn save_refuses_source_its_own_parser_rejects() {
915 let dir = tempfile::tempdir().unwrap();
919 let original = "[db]\nhost=localhost\n";
920 let path = write_temp(dir.path(), "config.ini", original);
921 let doc = DocumentFile::open(&path, None).unwrap();
922
923 let error = doc
924 .save_atomic("[db]\nhost=localhost\n\n[db]\nport=5432\n")
925 .unwrap_err();
926 assert_eq!(error.code(), "document_write_would_corrupt");
927 assert_eq!(fs::read_to_string(&path).unwrap(), original);
929 }
930
931 #[test]
932 fn save_writes_source_the_parser_accepts() {
933 let dir = tempfile::tempdir().unwrap();
934 let path = write_temp(dir.path(), "config.ini", "[db]\nhost=localhost\n");
935 let mut doc = DocumentFile::open(&path, None).unwrap();
936
937 doc.set("db.port", Value::String("5432".to_string()))
938 .unwrap();
939 doc.save().unwrap();
940
941 assert_eq!(
944 fs::read_to_string(&path).unwrap(),
945 "[db]\nhost=localhost\nport=5432\n"
946 );
947 assert!(DocumentFile::open(&path, None).is_ok());
948 }
949
950 #[cfg(unix)]
951 #[test]
952 fn atomic_save_preserves_file_mode() {
953 use std::os::unix::fs::PermissionsExt;
954
955 let dir = tempfile::tempdir().unwrap();
956 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
957 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
958 let mut doc = DocumentFile::open(&path, None).unwrap();
959
960 doc.set("port", Value::Integer(1024)).unwrap();
961 doc.save().unwrap();
962
963 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
964 assert_eq!(mode, 0o640);
965 }
966
967 #[cfg(unix)]
968 #[test]
969 fn symlink_target_is_rejected_for_mutation() {
970 let dir = tempfile::tempdir().unwrap();
971 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
972 let link = dir.path().join("link.json");
973 std::os::unix::fs::symlink(&target, &link).unwrap();
974
975 let mut doc = DocumentFile::open(&link, None).unwrap();
977
978 doc.set("port", Value::Integer(1024)).unwrap();
980 let err = doc.save().unwrap_err();
981 assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
982
983 let target_contents = fs::read_to_string(&target).unwrap();
985 assert_eq!(target_contents, r#"{"port": 993}"#);
986 }
987
988 #[test]
989 fn from_reader_parses_in_memory_cursor() {
990 let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
991
992 let doc = Document::from_reader(cursor, Format::Json).unwrap();
993
994 assert_eq!(
995 doc.value().get("host").and_then(Value::as_str),
996 Some("example.com")
997 );
998 }
999
1000 #[test]
1001 fn document_from_str_encode_round_trip() {
1002 let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1003 let encoded = doc.encode().unwrap();
1004 let reparsed = Document::parse(&encoded, Format::Json).unwrap();
1005 assert_eq!(
1006 reparsed.value().get("a").and_then(Value::as_integer),
1007 Some(1)
1008 );
1009 }
1010
1011 #[test]
1012 fn document_edits_source_in_memory_without_a_file() {
1013 let mut doc = Document::parse("{\n \"host\": \"old\"\n}\n", Format::Json).unwrap();
1016 doc.set("host", Value::String("new".to_string())).unwrap();
1017 doc.set("imap.port", Value::Integer(993)).unwrap(); assert_eq!(
1020 doc.source(),
1021 "{\n \"host\": \"new\",\n \"imap\": {\n \"port\": 993\n }\n}\n"
1022 );
1023 assert_eq!(
1024 doc.value_at("imap.port").unwrap(),
1025 Value::from(serde_json::json!(993))
1026 );
1027 }
1028
1029 #[test]
1030 fn unset_is_false_for_anything_already_absent() {
1031 let mut doc = Document::parse(
1032 r#"{"service":{"host":"example","ports":[80]}}"#,
1033 Format::Json,
1034 )
1035 .unwrap();
1036
1037 assert!(!doc.unset("service.missing").unwrap());
1040 assert!(!doc.unset("missing.parent").unwrap());
1041 assert!(!doc.unset("missing.deeply.nested").unwrap());
1042
1043 assert!(doc.unset("service.host.child").is_err()); assert!(doc.unset("service.ports.9").is_err()); assert!(doc.unset(r"service\q").is_err()); }
1049
1050 #[cfg(feature = "markdown")]
1051 #[test]
1052 fn every_markdown_write_verb_is_refused() {
1053 let source = "# Title\n\nThe lead.\n";
1054 let mut doc = Document::parse(source, Format::Markdown).unwrap();
1055
1056 assert_eq!(
1059 doc.value_at("h1.0.text").unwrap(),
1060 Value::String("Title".to_string())
1061 );
1062 assert_eq!(
1063 doc.value_at("h1.Tit.paragraph.0.text").unwrap(),
1064 Value::String("The lead.".to_string())
1065 );
1066
1067 let refusals: Vec<DocumentError> = vec![
1071 doc.set("h1.0.text", Value::String("New".to_string()))
1072 .unwrap_err(),
1073 doc.add("preamble", "x", "type", &[]).unwrap_err(),
1074 doc.remove("preamble", "x", "type").unwrap_err(),
1075 doc.unset("h1.0").unwrap_err(),
1076 doc.unset("nothing.here").unwrap_err(),
1077 doc.encode().unwrap_err(),
1078 ];
1079 for error in refusals {
1080 assert_eq!(error.code(), "document_unsupported_operation");
1081 assert!(
1082 error.to_string().contains("read-only"),
1083 "refusal must name the reason: {error}"
1084 );
1085 }
1086
1087 assert_eq!(doc.source(), source);
1089 }
1090
1091 #[cfg(feature = "markdown")]
1092 #[test]
1093 fn markdown_save_never_reaches_disk() {
1094 let dir = tempfile::tempdir().unwrap();
1095 let path = write_temp(dir.path(), "README.md", "# Title\n");
1096 assert!(DocumentFile::open(&path, None).is_err());
1098
1099 let doc = DocumentFile::open(&path, Some(Format::Markdown)).unwrap();
1100 let error = doc.save().unwrap_err();
1102 assert_eq!(error.code(), "document_unsupported_operation");
1103 assert_eq!(fs::read_to_string(&path).unwrap(), "# Title\n");
1104 }
1105
1106 #[cfg(feature = "yaml")]
1107 #[test]
1108 fn yaml_write_rejects_cst_ambiguous_mapping_segments() {
1109 let mut numeric = Document::parse("\"123\": value\n", Format::Yaml).unwrap();
1110 assert!(
1111 numeric
1112 .set("123", Value::String("changed".to_string()))
1113 .is_err()
1114 );
1115 assert!(numeric.unset("123").is_err());
1116
1117 let mut bracketed = Document::parse("\"a[0]\": value\n", Format::Yaml).unwrap();
1118 assert!(
1119 bracketed
1120 .set("a[0]", Value::String("changed".to_string()))
1121 .is_err()
1122 );
1123 }
1124}