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 => Format::detect(&path).ok_or_else(|| DocumentError::FormatUnknown {
201 path: path.display().to_string(),
202 })?,
203 };
204 let source = fs::read_to_string(&path).map_err(|error| DocumentError::IoError {
205 detail: format!("read `{}`: {error}", path.display()),
206 })?;
207 Ok(DocumentFile {
208 doc: Document::parse(&source, format)?,
209 path,
210 })
211 }
212
213 pub fn open_capped(
221 path: impl AsRef<Path>,
222 format_override: Option<Format>,
223 max_bytes: u64,
224 ) -> DocumentResult<DocumentFile> {
225 let path = path.as_ref();
226 let metadata = fs::metadata(path).map_err(|error| DocumentError::IoError {
227 detail: format!("read `{}`: {error}", path.display()),
228 })?;
229 if !metadata.is_file() {
230 return Err(DocumentError::IoError {
231 detail: format!("`{}` is not a regular file", path.display()),
232 });
233 }
234 if metadata.len() > max_bytes {
235 return Err(DocumentError::IoError {
236 detail: format!(
237 "`{}` exceeds the {max_bytes}-byte read limit",
238 path.display()
239 ),
240 });
241 }
242 DocumentFile::open(path, format_override)
243 }
244
245 pub fn path(&self) -> &Path {
247 &self.path
248 }
249
250 pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
259 self.doc.ensure_writable(operation)?;
260 guard_mutation(&self.path, operation)?;
261 Ok(())
262 }
263}
264
265impl Document {
266 pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
277 let addressing = self.addressing();
278 self.set_addressed(key, value, addressing)
279 }
280
281 pub fn set_addressed(
289 &mut self,
290 key: &str,
291 value: Value,
292 addressing: Addressing<'_>,
293 ) -> DocumentResult<()> {
294 self.ensure_writable("set")?;
295 let key = &crate::document::resolve_path(&self.value, key, addressing)?;
296 let mut new_doc = self.value.clone();
297 crate::document::set_path(&mut new_doc, key, &value, Addressing::INDEX_ONLY)?;
298 let target = crate::document::get_path(&new_doc, key, Addressing::INDEX_ONLY)?;
299 #[allow(unreachable_patterns)]
300 let output = match self.format {
301 #[cfg(feature = "toml")]
302 Format::Toml => {
303 crate::document::format::toml::set_preserving(&self.source, key, &target)?
304 }
305 #[cfg(feature = "yaml")]
306 Format::Yaml => {
307 crate::document::format::yaml::set_preserving(&self.source, key, &target)?
308 }
309 Format::Json => {
310 crate::document::format::json::set_preserving(&self.source, key, &target)?
311 }
312 #[cfg(feature = "dotenv")]
313 Format::Dotenv => {
314 crate::document::format::dotenv::set_preserving(&self.source, key, &target)?
315 }
316 #[cfg(feature = "ini")]
317 Format::Ini => {
318 crate::document::format::ini::set_preserving(&self.source, key, &target)?
319 }
320 #[cfg(feature = "toml")]
321 Format::TomlFrontmatter => {
322 let parts = crate::document::format::frontmatter::split(
323 &self.source,
324 crate::document::format::frontmatter::Delimiter::Plus,
325 )?;
326 let new_fm =
327 crate::document::format::toml::set_preserving(parts.frontmatter, key, &target)?;
328 format!("{}{}{}", parts.pre, new_fm, parts.post)
329 }
330 #[cfg(feature = "yaml")]
331 Format::YamlFrontmatter => {
332 let parts = crate::document::format::frontmatter::split(
333 &self.source,
334 crate::document::format::frontmatter::Delimiter::Dash,
335 )?;
336 let new_fm =
337 crate::document::format::yaml::set_preserving(parts.frontmatter, key, &target)?;
338 format!("{}{}{}", parts.pre, new_fm, parts.post)
339 }
340 _ => self.format.save(&new_doc)?,
341 };
342 self.source = output;
343 self.value = new_doc;
344 Ok(())
345 }
346
347 pub fn add(
356 &mut self,
357 key: &str,
358 slug: &str,
359 slug_field: &str,
360 fields: &[(String, Value)],
361 ) -> DocumentResult<()> {
362 self.ensure_writable("add")?;
363 let mut value = self.value.clone();
364 let keyed_lists = [KeyedList {
365 prefix: key,
366 slug_field,
367 }];
368 crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
369 let array = if key.is_empty() {
370 &value
371 } else {
372 crate::document::get_path_ref(&value, key, self.addressing_keyed(&keyed_lists))?
373 };
374 let item = array
375 .as_array()
376 .and_then(|items| items.last())
377 .ok_or_else(|| DocumentError::UnsupportedOperation {
378 format: self.format.name().to_string(),
379 operation: "add".to_string(),
380 detail: "keyed list did not produce an array item".to_string(),
381 })?;
382 let output: String = match self.format {
383 Format::Json => crate::document::format::json::append_array_item_preserving(
384 &self.source,
385 key,
386 item,
387 )?,
388 #[cfg(feature = "yaml")]
389 Format::Yaml => crate::document::format::yaml::append_array_item_preserving(
390 &self.source,
391 key,
392 item,
393 )?,
394 _ => {
395 return Err(DocumentError::UnsupportedOperation {
396 format: self.format.name().to_string(),
397 operation: "add".to_string(),
398 detail: "keyed collection source editor is not implemented for this backend"
399 .to_string(),
400 });
401 }
402 };
403 self.source = output;
404 self.value = value;
405 Ok(())
406 }
407
408 pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
416 self.ensure_writable("remove")?;
417 let mut value = self.value.clone();
418 let keyed_lists = [KeyedList {
419 prefix: key,
420 slug_field,
421 }];
422 let removed_index = crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
423 let output: String = match self.format {
424 Format::Json => crate::document::format::json::remove_array_item_preserving(
425 &self.source,
426 key,
427 removed_index,
428 )?,
429 #[cfg(feature = "yaml")]
430 Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
431 &self.source,
432 key,
433 removed_index,
434 )?,
435 _ => {
436 return Err(DocumentError::UnsupportedOperation {
437 format: self.format.name().to_string(),
438 operation: "remove".to_string(),
439 detail: "keyed collection source editor is not implemented for this backend"
440 .to_string(),
441 });
442 }
443 };
444 self.source = output;
445 self.value = value;
446 Ok(())
447 }
448
449 pub fn unset(&mut self, key: &str) -> DocumentResult<bool> {
474 let addressing = self.addressing();
475 self.unset_addressed(key, addressing)
476 }
477
478 pub fn unset_addressed(
483 &mut self,
484 key: &str,
485 addressing: Addressing<'_>,
486 ) -> DocumentResult<bool> {
487 self.ensure_writable("unset")?;
488 let key = &crate::document::resolve_path(&self.value, key, addressing)?;
489 let segments = crate::document::parse_path(key)?;
490 let (leaf, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
491 let parent = if parents.is_empty() {
492 &self.value
493 } else {
494 let parent_path = crate::document::join_path(parents);
495 match crate::document::get_path_ref(&self.value, &parent_path, Addressing::INDEX_ONLY) {
496 Ok(parent) => parent,
497 Err(DocumentError::UnknownSegment { .. }) => return Ok(false),
499 Err(error) => return Err(error),
500 }
501 };
502 match parent {
503 Value::Object(object) => {
504 if !object.contains_key(leaf) {
505 return Ok(false);
506 }
507 }
508 Value::Array(array) => {
509 let index =
510 leaf.parse::<usize>()
511 .map_err(|_| DocumentError::UnregisteredArray {
512 path: crate::document::join_path(parents),
513 })?;
514 if index >= array.len() {
515 return Err(DocumentError::IndexOutOfBounds {
516 path: crate::document::join_path(parents),
517 index,
518 len: array.len(),
519 });
520 }
521 }
522 value => {
523 return Err(DocumentError::NotTraversable {
524 path: crate::document::join_path(parents),
525 got: value.kind_name().to_string(),
526 });
527 }
528 }
529 let mut value = self.value.clone();
530 crate::document::unset_path(&mut value, key)?;
531 #[allow(unreachable_patterns)]
532 let output = match self.format {
533 Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
534 #[cfg(feature = "toml")]
535 Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
536 #[cfg(feature = "yaml")]
537 Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
538 #[cfg(feature = "dotenv")]
539 Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
540 #[cfg(feature = "ini")]
541 Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
542 #[cfg(feature = "toml")]
543 Format::TomlFrontmatter => {
544 let parts = crate::document::format::frontmatter::split(
545 &self.source,
546 crate::document::format::frontmatter::Delimiter::Plus,
547 )?;
548 let new_fm =
549 crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
550 format!("{}{}{}", parts.pre, new_fm, parts.post)
551 }
552 #[cfg(feature = "yaml")]
553 Format::YamlFrontmatter => {
554 let parts = crate::document::format::frontmatter::split(
555 &self.source,
556 crate::document::format::frontmatter::Delimiter::Dash,
557 )?;
558 let new_fm =
559 crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
560 format!("{}{}{}", parts.pre, new_fm, parts.post)
561 }
562 _ => self.format.save(&value)?,
563 };
564 self.source = output;
565 self.value = value;
566 Ok(true)
567 }
568}
569
570impl DocumentFile {
571 pub fn edit<F>(&mut self, edit: F) -> DocumentResult<()>
576 where
577 F: FnOnce(&mut Document) -> DocumentResult<()>,
578 {
579 edit(&mut self.doc)?;
580 self.save()
581 }
582
583 pub fn save(&self) -> DocumentResult<()> {
593 self.save_atomic(self.doc.source())
594 }
595
596 pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
607 self.ensure_writable("save")?;
611 Document::parse(new_source, self.format).map_err(|error| {
618 DocumentError::WriteWouldCorrupt {
619 format: self.format.name().to_string(),
620 detail: error.redacted_message(),
621 }
622 })?;
623 write_atomic(&self.path, new_source.as_bytes(), "write")
624 }
625}
626
627impl std::ops::Deref for DocumentFile {
628 type Target = Document;
629
630 fn deref(&self) -> &Document {
631 &self.doc
632 }
633}
634
635impl std::ops::DerefMut for DocumentFile {
636 fn deref_mut(&mut self) -> &mut Document {
637 &mut self.doc
638 }
639}
640
641fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
645 let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
646 detail: format!("{operation} preflight `{}`: {error}", path.display()),
647 })?;
648 if metadata.file_type().is_symlink() {
649 return Err(DocumentError::UnsupportedOperation {
650 format: "filesystem".to_string(),
651 operation: operation.to_string(),
652 detail: format!("refusing to mutate symlink `{}`", path.display()),
653 });
654 }
655 #[cfg(unix)]
656 {
657 use std::os::unix::fs::MetadataExt;
658 if metadata.nlink() > 1 {
659 return Err(DocumentError::UnsupportedOperation {
660 format: "filesystem".to_string(),
661 operation: operation.to_string(),
662 detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
663 });
664 }
665 }
666 Ok(metadata)
667}
668
669fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
672 let metadata = guard_mutation(path, operation)?;
673
674 let parent = path.parent().ok_or_else(|| DocumentError::IoError {
675 detail: format!(
676 "{operation} has no parent directory for `{}`",
677 path.display()
678 ),
679 })?;
680 let file_name = path
681 .file_name()
682 .and_then(|name| name.to_str())
683 .ok_or_else(|| DocumentError::IoError {
684 detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
685 })?;
686 let pid = std::process::id();
687 let mut temp_path = None;
688 let mut temp_file = None;
689 for attempt in 0..32_u32 {
690 let candidate = parent.join(format!(".{file_name}.afdata-document.{pid}.{attempt}.tmp"));
691 match OpenOptions::new()
692 .write(true)
693 .create_new(true)
694 .open(&candidate)
695 {
696 Ok(file) => {
697 temp_path = Some(candidate);
698 temp_file = Some(file);
699 break;
700 }
701 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
702 Err(error) => {
703 return Err(DocumentError::IoError {
704 detail: format!(
705 "{operation} create temporary file in `{}`: {error}",
706 parent.display()
707 ),
708 });
709 }
710 }
711 }
712 let temp_path = temp_path.ok_or_else(|| DocumentError::IoError {
713 detail: format!(
714 "{operation} could not allocate temporary file in `{}`",
715 parent.display()
716 ),
717 })?;
718 let mut temp_file = temp_file.ok_or_else(|| DocumentError::IoError {
719 detail: format!("{operation} temporary file handle missing"),
720 })?;
721 let result = (|| -> DocumentResult<()> {
722 temp_file
723 .write_all(bytes)
724 .map_err(|error| DocumentError::IoError {
725 detail: format!("{operation} write `{}`: {error}", path.display()),
726 })?;
727 temp_file
728 .sync_all()
729 .map_err(|error| DocumentError::IoError {
730 detail: format!("{operation} fsync `{}`: {error}", path.display()),
731 })?;
732 drop(temp_file);
733 fs::set_permissions(&temp_path, metadata.permissions()).map_err(|error| {
734 DocumentError::IoError {
735 detail: format!(
736 "{operation} preserve permissions `{}`: {error}",
737 path.display()
738 ),
739 }
740 })?;
741 fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
742 detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
743 })?;
744 Ok(())
745 })();
746 if result.is_err() {
747 let _ = fs::remove_file(&temp_path);
748 }
749 result
750}
751
752#[cfg(test)]
753mod tests {
754 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
755 use super::*;
756 use std::io::Cursor;
757
758 fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
759 let path = dir.join(name);
760 fs::write(&path, contents).unwrap();
761 path
762 }
763
764 #[test]
765 fn round_trip_open_json() {
766 let dir = tempfile::tempdir().unwrap();
767 let contents = r#"{"host": "example.com", "port": 993}"#;
768 let path = write_temp(dir.path(), "config.json", contents);
769
770 let doc = DocumentFile::open(&path, None).unwrap();
771
772 assert_eq!(doc.format(), Format::Json);
773 assert_eq!(
774 doc.value().get("host").and_then(Value::as_str),
775 Some("example.com")
776 );
777 assert_eq!(doc.source(), contents);
778 }
779
780 #[test]
781 fn value_at_reads_a_nested_address() {
782 let dir = tempfile::tempdir().unwrap();
783 let path = write_temp(
784 dir.path(),
785 "config.json",
786 r#"{"database": {"url": "postgres://x"}}"#,
787 );
788 let doc = DocumentFile::open(&path, None).unwrap();
789
790 assert_eq!(
791 doc.value_at("database.url").unwrap(),
792 Value::String("postgres://x".to_string())
793 );
794 assert_eq!(
795 doc.value_at("database.missing").unwrap_err().code(),
796 "document_path_not_found"
797 );
798 }
799
800 #[test]
801 fn open_capped_enforces_size_and_regular_file() {
802 let dir = tempfile::tempdir().unwrap();
803 let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
804
805 assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
807
808 let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
810 assert_eq!(err.code(), "document_io_failed");
811 assert!(err.to_string().contains("read limit"));
812
813 let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
815 assert_eq!(dir_err.code(), "document_io_failed");
816 }
817
818 #[test]
819 fn typed_get_and_set_enforce_the_stated_type() {
820 use crate::document::ValueType;
821 let dir = tempfile::tempdir().unwrap();
822 let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
823 let mut doc = DocumentFile::open(&path, None).unwrap();
824
825 assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
828 assert_eq!(
829 doc.value_at_typed("port", ValueType::String)
830 .unwrap_err()
831 .code(),
832 "document_type_mismatch"
833 );
834 assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
835
836 doc.set_typed("port", Some("9090"), ValueType::Number)
838 .unwrap();
839 assert_eq!(
840 doc.value_at("port").unwrap(),
841 Value::from(serde_json::json!(9090))
842 );
843 assert_eq!(
844 doc.set_typed("port", Some("not-a-number"), ValueType::Number)
845 .unwrap_err()
846 .code(),
847 "document_parse_failed"
848 );
849 }
850
851 #[cfg(feature = "toml")]
852 #[test]
853 fn round_trip_open_toml() {
854 let dir = tempfile::tempdir().unwrap();
855 let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
856 let path = write_temp(dir.path(), "config.toml", contents);
857
858 let doc = DocumentFile::open(&path, None).unwrap();
859
860 assert_eq!(doc.format(), Format::Toml);
861 assert_eq!(
862 doc.value().get("host").and_then(Value::as_str),
863 Some("example.com")
864 );
865 assert_eq!(doc.source(), contents);
866 }
867
868 #[cfg(feature = "toml")]
869 #[test]
870 fn set_scalar_preserves_toml_comments_and_formatting() {
871 let dir = tempfile::tempdir().unwrap();
872 let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
873 let path = write_temp(dir.path(), "config.toml", contents);
874 let mut doc = DocumentFile::open(&path, None).unwrap();
875
876 doc.set("port", Value::Integer(1024)).unwrap();
877 doc.save().unwrap();
878
879 let saved = fs::read_to_string(&path).unwrap();
880 assert!(saved.contains("# leading comment"));
881 assert!(saved.contains("port = 1024"));
882 assert_eq!(
883 doc.value().get("port").and_then(Value::as_integer),
884 Some(1024)
885 );
886 assert_eq!(doc.source(), saved);
887 }
888
889 #[test]
890 fn save_refuses_source_its_own_parser_rejects() {
891 let dir = tempfile::tempdir().unwrap();
895 let original = "[db]\nhost=localhost\n";
896 let path = write_temp(dir.path(), "config.ini", original);
897 let doc = DocumentFile::open(&path, None).unwrap();
898
899 let error = doc
900 .save_atomic("[db]\nhost=localhost\n\n[db]\nport=5432\n")
901 .unwrap_err();
902 assert_eq!(error.code(), "document_write_would_corrupt");
903 assert_eq!(fs::read_to_string(&path).unwrap(), original);
905 }
906
907 #[test]
908 fn save_writes_source_the_parser_accepts() {
909 let dir = tempfile::tempdir().unwrap();
910 let path = write_temp(dir.path(), "config.ini", "[db]\nhost=localhost\n");
911 let mut doc = DocumentFile::open(&path, None).unwrap();
912
913 doc.set("db.port", Value::String("5432".to_string()))
914 .unwrap();
915 doc.save().unwrap();
916
917 assert_eq!(
920 fs::read_to_string(&path).unwrap(),
921 "[db]\nhost=localhost\nport=5432\n"
922 );
923 assert!(DocumentFile::open(&path, None).is_ok());
924 }
925
926 #[cfg(unix)]
927 #[test]
928 fn atomic_save_preserves_file_mode() {
929 use std::os::unix::fs::PermissionsExt;
930
931 let dir = tempfile::tempdir().unwrap();
932 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
933 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
934 let mut doc = DocumentFile::open(&path, None).unwrap();
935
936 doc.set("port", Value::Integer(1024)).unwrap();
937 doc.save().unwrap();
938
939 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
940 assert_eq!(mode, 0o640);
941 }
942
943 #[cfg(unix)]
944 #[test]
945 fn symlink_target_is_rejected_for_mutation() {
946 let dir = tempfile::tempdir().unwrap();
947 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
948 let link = dir.path().join("link.json");
949 std::os::unix::fs::symlink(&target, &link).unwrap();
950
951 let mut doc = DocumentFile::open(&link, None).unwrap();
953
954 doc.set("port", Value::Integer(1024)).unwrap();
956 let err = doc.save().unwrap_err();
957 assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
958
959 let target_contents = fs::read_to_string(&target).unwrap();
961 assert_eq!(target_contents, r#"{"port": 993}"#);
962 }
963
964 #[test]
965 fn from_reader_parses_in_memory_cursor() {
966 let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
967
968 let doc = Document::from_reader(cursor, Format::Json).unwrap();
969
970 assert_eq!(
971 doc.value().get("host").and_then(Value::as_str),
972 Some("example.com")
973 );
974 }
975
976 #[test]
977 fn document_from_str_encode_round_trip() {
978 let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
979 let encoded = doc.encode().unwrap();
980 let reparsed = Document::parse(&encoded, Format::Json).unwrap();
981 assert_eq!(
982 reparsed.value().get("a").and_then(Value::as_integer),
983 Some(1)
984 );
985 }
986
987 #[test]
988 fn document_edits_source_in_memory_without_a_file() {
989 let mut doc = Document::parse("{\n \"host\": \"old\"\n}\n", Format::Json).unwrap();
992 doc.set("host", Value::String("new".to_string())).unwrap();
993 doc.set("imap.port", Value::Integer(993)).unwrap(); assert_eq!(
996 doc.source(),
997 "{\n \"host\": \"new\",\n \"imap\": {\n \"port\": 993\n }\n}\n"
998 );
999 assert_eq!(
1000 doc.value_at("imap.port").unwrap(),
1001 Value::from(serde_json::json!(993))
1002 );
1003 }
1004
1005 #[test]
1006 fn unset_is_false_for_anything_already_absent() {
1007 let mut doc = Document::parse(
1008 r#"{"service":{"host":"example","ports":[80]}}"#,
1009 Format::Json,
1010 )
1011 .unwrap();
1012
1013 assert!(!doc.unset("service.missing").unwrap());
1016 assert!(!doc.unset("missing.parent").unwrap());
1017 assert!(!doc.unset("missing.deeply.nested").unwrap());
1018
1019 assert!(doc.unset("service.host.child").is_err()); assert!(doc.unset("service.ports.9").is_err()); assert!(doc.unset(r"service\q").is_err()); }
1025
1026 #[cfg(feature = "markdown")]
1027 #[test]
1028 fn every_markdown_write_verb_is_refused() {
1029 let source = "# Title\n\nThe lead.\n";
1030 let mut doc = Document::parse(source, Format::Markdown).unwrap();
1031
1032 assert_eq!(
1035 doc.value_at("h1.0.text").unwrap(),
1036 Value::String("Title".to_string())
1037 );
1038 assert_eq!(
1039 doc.value_at("h1.Tit.paragraph.0.text").unwrap(),
1040 Value::String("The lead.".to_string())
1041 );
1042
1043 let refusals: Vec<DocumentError> = vec![
1047 doc.set("h1.0.text", Value::String("New".to_string()))
1048 .unwrap_err(),
1049 doc.add("preamble", "x", "type", &[]).unwrap_err(),
1050 doc.remove("preamble", "x", "type").unwrap_err(),
1051 doc.unset("h1.0").unwrap_err(),
1052 doc.unset("nothing.here").unwrap_err(),
1053 doc.encode().unwrap_err(),
1054 ];
1055 for error in refusals {
1056 assert_eq!(error.code(), "document_unsupported_operation");
1057 assert!(
1058 error.to_string().contains("read-only"),
1059 "refusal must name the reason: {error}"
1060 );
1061 }
1062
1063 assert_eq!(doc.source(), source);
1065 }
1066
1067 #[cfg(feature = "markdown")]
1068 #[test]
1069 fn markdown_save_never_reaches_disk() {
1070 let dir = tempfile::tempdir().unwrap();
1071 let path = write_temp(dir.path(), "README.md", "# Title\n");
1072 assert!(DocumentFile::open(&path, None).is_err());
1074
1075 let doc = DocumentFile::open(&path, Some(Format::Markdown)).unwrap();
1076 let error = doc.save().unwrap_err();
1078 assert_eq!(error.code(), "document_unsupported_operation");
1079 assert_eq!(fs::read_to_string(&path).unwrap(), "# Title\n");
1080 }
1081
1082 #[cfg(feature = "yaml")]
1083 #[test]
1084 fn yaml_write_rejects_cst_ambiguous_mapping_segments() {
1085 let mut numeric = Document::parse("\"123\": value\n", Format::Yaml).unwrap();
1086 assert!(
1087 numeric
1088 .set("123", Value::String("changed".to_string()))
1089 .is_err()
1090 );
1091 assert!(numeric.unset("123").is_err());
1092
1093 let mut bracketed = Document::parse("\"a[0]\": value\n", Format::Yaml).unwrap();
1094 assert!(
1095 bracketed
1096 .set("a[0]", Value::String("changed".to_string()))
1097 .is_err()
1098 );
1099 }
1100}