1use std::fs::{self, OpenOptions};
24use std::io::Write as _;
25use std::path::{Path, PathBuf};
26
27use crate::document::{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 pub fn value_at(&self, path: &str) -> DocumentResult<Value> {
92 crate::document::get_path(&self.value, path, &[])
93 }
94
95 pub fn value_at_typed(
99 &self,
100 path: &str,
101 expected: crate::document::ValueType,
102 ) -> DocumentResult<Value> {
103 let value = self.value_at(path)?;
104 if crate::document::value_matches_type(&value, expected) {
105 Ok(value)
106 } else {
107 Err(DocumentError::TypeMismatch {
108 path: path.to_string(),
109 expected: expected.name().to_string(),
110 got: value.kind_name().to_string(),
111 hint: None,
112 })
113 }
114 }
115
116 pub fn set_typed(
119 &mut self,
120 key: &str,
121 raw: Option<&str>,
122 value_type: crate::document::ValueType,
123 ) -> DocumentResult<()> {
124 let value = crate::document::value_from_type(value_type, raw)?;
125 self.set(key, value)
126 }
127
128 pub fn encode(&self) -> DocumentResult<String> {
134 self.format.save(&self.value)
135 }
136}
137
138#[derive(Debug, Clone)]
146pub struct DocumentFile {
147 doc: Document,
148 path: PathBuf,
149}
150
151impl DocumentFile {
152 pub fn open(
158 path: impl AsRef<Path>,
159 format_override: Option<Format>,
160 ) -> DocumentResult<DocumentFile> {
161 let path = path.as_ref().to_path_buf();
162 let format = match format_override {
163 Some(format) => format,
164 None => Format::detect(&path).ok_or_else(|| DocumentError::FormatUnknown {
165 path: path.display().to_string(),
166 })?,
167 };
168 let source = fs::read_to_string(&path).map_err(|error| DocumentError::IoError {
169 detail: format!("read `{}`: {error}", path.display()),
170 })?;
171 Ok(DocumentFile {
172 doc: Document::parse(&source, format)?,
173 path,
174 })
175 }
176
177 pub fn open_capped(
185 path: impl AsRef<Path>,
186 format_override: Option<Format>,
187 max_bytes: u64,
188 ) -> DocumentResult<DocumentFile> {
189 let path = path.as_ref();
190 let metadata = fs::metadata(path).map_err(|error| DocumentError::IoError {
191 detail: format!("read `{}`: {error}", path.display()),
192 })?;
193 if !metadata.is_file() {
194 return Err(DocumentError::IoError {
195 detail: format!("`{}` is not a regular file", path.display()),
196 });
197 }
198 if metadata.len() > max_bytes {
199 return Err(DocumentError::IoError {
200 detail: format!(
201 "`{}` exceeds the {max_bytes}-byte read limit",
202 path.display()
203 ),
204 });
205 }
206 DocumentFile::open(path, format_override)
207 }
208
209 pub fn path(&self) -> &Path {
211 &self.path
212 }
213
214 pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
222 guard_mutation(&self.path, operation)?;
223 Ok(())
224 }
225}
226
227impl Document {
228 pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
239 let mut new_doc = self.value.clone();
240 crate::document::set_path(&mut new_doc, key, &value, &[])?;
241 let target = crate::document::get_path(&new_doc, key, &[])?;
242 #[allow(unreachable_patterns)]
243 let output = match self.format {
244 #[cfg(feature = "toml")]
245 Format::Toml => {
246 crate::document::format::toml::set_preserving(&self.source, key, &target)?
247 }
248 #[cfg(feature = "yaml")]
249 Format::Yaml => {
250 crate::document::format::yaml::set_preserving(&self.source, key, &target)?
251 }
252 Format::Json => {
253 crate::document::format::json::set_preserving(&self.source, key, &target)?
254 }
255 #[cfg(feature = "dotenv")]
256 Format::Dotenv => {
257 crate::document::format::dotenv::set_preserving(&self.source, key, &target)?
258 }
259 #[cfg(feature = "ini")]
260 Format::Ini => {
261 crate::document::format::ini::set_preserving(&self.source, key, &target)?
262 }
263 #[cfg(feature = "toml")]
264 Format::TomlFrontmatter => {
265 let parts = crate::document::format::frontmatter::split(
266 &self.source,
267 crate::document::format::frontmatter::Delimiter::Plus,
268 )?;
269 let new_fm =
270 crate::document::format::toml::set_preserving(parts.frontmatter, key, &target)?;
271 format!("{}{}{}", parts.pre, new_fm, parts.post)
272 }
273 #[cfg(feature = "yaml")]
274 Format::YamlFrontmatter => {
275 let parts = crate::document::format::frontmatter::split(
276 &self.source,
277 crate::document::format::frontmatter::Delimiter::Dash,
278 )?;
279 let new_fm =
280 crate::document::format::yaml::set_preserving(parts.frontmatter, key, &target)?;
281 format!("{}{}{}", parts.pre, new_fm, parts.post)
282 }
283 _ => self.format.save(&new_doc)?,
284 };
285 self.source = output;
286 self.value = new_doc;
287 Ok(())
288 }
289
290 pub fn add(
298 &mut self,
299 key: &str,
300 slug: &str,
301 slug_field: &str,
302 fields: &[(String, Value)],
303 ) -> DocumentResult<()> {
304 let mut value = self.value.clone();
305 let keyed_lists = [KeyedList {
306 prefix: key,
307 slug_field,
308 }];
309 crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
310 let output: String = match self.format {
311 Format::Json => {
312 let array = crate::document::get_path(&value, key, &keyed_lists)?;
313 let item = array
314 .as_array()
315 .and_then(|items| items.last())
316 .ok_or_else(|| DocumentError::UnsupportedOperation {
317 format: "JSON".to_string(),
318 operation: "add".to_string(),
319 detail: "keyed list did not produce an array item".to_string(),
320 })?;
321 crate::document::format::json::append_array_item_preserving(
322 &self.source,
323 key,
324 item,
325 )?
326 }
327 #[cfg(feature = "yaml")]
328 Format::Yaml => {
329 let array = crate::document::get_path(&value, key, &keyed_lists)?;
330 let item = array
331 .as_array()
332 .and_then(|items| items.last())
333 .ok_or_else(|| DocumentError::UnsupportedOperation {
334 format: "YAML".to_string(),
335 operation: "add".to_string(),
336 detail: "keyed list did not produce an array item".to_string(),
337 })?;
338 crate::document::format::yaml::append_array_item_preserving(
339 &self.source,
340 key,
341 item,
342 )?
343 }
344 _ => {
345 return Err(DocumentError::UnsupportedOperation {
346 format: self.format.name().to_string(),
347 operation: "add".to_string(),
348 detail: "keyed collection source editor is not implemented for this backend"
349 .to_string(),
350 });
351 }
352 };
353 self.source = output;
354 self.value = value;
355 Ok(())
356 }
357
358 pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
365 let mut value = self.value.clone();
366 let keyed_lists = [KeyedList {
367 prefix: key,
368 slug_field,
369 }];
370 let original_array = crate::document::get_path(&value, key, &keyed_lists)?;
371 let removed_index = original_array
372 .as_array()
373 .and_then(|items| {
374 items
375 .iter()
376 .position(|item| item.get(slug_field).and_then(Value::as_str) == Some(slug))
377 })
378 .ok_or_else(|| DocumentError::SlugNotFound {
379 prefix: key.to_string(),
380 slug: slug.to_string(),
381 })?;
382 #[cfg(not(feature = "yaml"))]
383 let _ = removed_index;
384 crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
385 let output: String = match self.format {
386 Format::Json => crate::document::format::json::remove_array_item_preserving(
387 &self.source,
388 key,
389 slug,
390 slug_field,
391 )?,
392 #[cfg(feature = "yaml")]
393 Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
394 &self.source,
395 key,
396 removed_index,
397 )?,
398 _ => {
399 return Err(DocumentError::UnsupportedOperation {
400 format: self.format.name().to_string(),
401 operation: "remove".to_string(),
402 detail: "keyed collection source editor is not implemented for this backend"
403 .to_string(),
404 });
405 }
406 };
407 self.source = output;
408 self.value = value;
409 Ok(())
410 }
411
412 pub fn unset(&mut self, key: &str) -> DocumentResult<bool> {
427 let segments = crate::document::parse_path(key)?;
428 let (leaf, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
429 let parent = if parents.is_empty() {
430 &self.value
431 } else {
432 let parent_path = crate::document::join_path(parents);
433 match crate::document::get_path_ref(&self.value, &parent_path, &[]) {
434 Ok(parent) => parent,
435 Err(DocumentError::UnknownSegment { .. }) => return Ok(false),
437 Err(error) => return Err(error),
438 }
439 };
440 match parent {
441 Value::Object(object) => {
442 if !object.contains_key(leaf) {
443 return Ok(false);
444 }
445 }
446 Value::Array(array) => {
447 let index =
448 leaf.parse::<usize>()
449 .map_err(|_| DocumentError::UnregisteredArray {
450 path: crate::document::join_path(parents),
451 })?;
452 if index >= array.len() {
453 return Err(DocumentError::IndexOutOfBounds {
454 path: crate::document::join_path(parents),
455 index,
456 len: array.len(),
457 });
458 }
459 }
460 value => {
461 return Err(DocumentError::NotTraversable {
462 path: crate::document::join_path(parents),
463 got: value.kind_name().to_string(),
464 });
465 }
466 }
467 let mut value = self.value.clone();
468 crate::document::unset_path(&mut value, key)?;
469 #[allow(unreachable_patterns)]
470 let output = match self.format {
471 Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
472 #[cfg(feature = "toml")]
473 Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
474 #[cfg(feature = "yaml")]
475 Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
476 #[cfg(feature = "dotenv")]
477 Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
478 #[cfg(feature = "ini")]
479 Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
480 #[cfg(feature = "toml")]
481 Format::TomlFrontmatter => {
482 let parts = crate::document::format::frontmatter::split(
483 &self.source,
484 crate::document::format::frontmatter::Delimiter::Plus,
485 )?;
486 let new_fm =
487 crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
488 format!("{}{}{}", parts.pre, new_fm, parts.post)
489 }
490 #[cfg(feature = "yaml")]
491 Format::YamlFrontmatter => {
492 let parts = crate::document::format::frontmatter::split(
493 &self.source,
494 crate::document::format::frontmatter::Delimiter::Dash,
495 )?;
496 let new_fm =
497 crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
498 format!("{}{}{}", parts.pre, new_fm, parts.post)
499 }
500 _ => self.format.save(&value)?,
501 };
502 self.source = output;
503 self.value = value;
504 Ok(true)
505 }
506}
507
508impl DocumentFile {
509 pub fn edit<F>(&mut self, edit: F) -> DocumentResult<()>
514 where
515 F: FnOnce(&mut Document) -> DocumentResult<()>,
516 {
517 edit(&mut self.doc)?;
518 self.save()
519 }
520
521 pub fn save(&self) -> DocumentResult<()> {
531 self.save_atomic(self.doc.source())
532 }
533
534 pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
545 Document::parse(new_source, self.format).map_err(|error| {
552 DocumentError::WriteWouldCorrupt {
553 format: self.format.name().to_string(),
554 detail: error.redacted_message(),
555 }
556 })?;
557 write_atomic(&self.path, new_source.as_bytes(), "write")
558 }
559}
560
561impl std::ops::Deref for DocumentFile {
562 type Target = Document;
563
564 fn deref(&self) -> &Document {
565 &self.doc
566 }
567}
568
569impl std::ops::DerefMut for DocumentFile {
570 fn deref_mut(&mut self) -> &mut Document {
571 &mut self.doc
572 }
573}
574
575fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
579 let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
580 detail: format!("{operation} preflight `{}`: {error}", path.display()),
581 })?;
582 if metadata.file_type().is_symlink() {
583 return Err(DocumentError::UnsupportedOperation {
584 format: "filesystem".to_string(),
585 operation: operation.to_string(),
586 detail: format!("refusing to mutate symlink `{}`", path.display()),
587 });
588 }
589 #[cfg(unix)]
590 {
591 use std::os::unix::fs::MetadataExt;
592 if metadata.nlink() > 1 {
593 return Err(DocumentError::UnsupportedOperation {
594 format: "filesystem".to_string(),
595 operation: operation.to_string(),
596 detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
597 });
598 }
599 }
600 Ok(metadata)
601}
602
603fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
606 let metadata = guard_mutation(path, operation)?;
607
608 let parent = path.parent().ok_or_else(|| DocumentError::IoError {
609 detail: format!(
610 "{operation} has no parent directory for `{}`",
611 path.display()
612 ),
613 })?;
614 let file_name = path
615 .file_name()
616 .and_then(|name| name.to_str())
617 .ok_or_else(|| DocumentError::IoError {
618 detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
619 })?;
620 let pid = std::process::id();
621 let mut temp_path = None;
622 let mut temp_file = None;
623 for attempt in 0..32_u32 {
624 let candidate = parent.join(format!(".{file_name}.afdata-document.{pid}.{attempt}.tmp"));
625 match OpenOptions::new()
626 .write(true)
627 .create_new(true)
628 .open(&candidate)
629 {
630 Ok(file) => {
631 temp_path = Some(candidate);
632 temp_file = Some(file);
633 break;
634 }
635 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
636 Err(error) => {
637 return Err(DocumentError::IoError {
638 detail: format!(
639 "{operation} create temporary file in `{}`: {error}",
640 parent.display()
641 ),
642 });
643 }
644 }
645 }
646 let temp_path = temp_path.ok_or_else(|| DocumentError::IoError {
647 detail: format!(
648 "{operation} could not allocate temporary file in `{}`",
649 parent.display()
650 ),
651 })?;
652 let mut temp_file = temp_file.ok_or_else(|| DocumentError::IoError {
653 detail: format!("{operation} temporary file handle missing"),
654 })?;
655 let result = (|| -> DocumentResult<()> {
656 temp_file
657 .write_all(bytes)
658 .map_err(|error| DocumentError::IoError {
659 detail: format!("{operation} write `{}`: {error}", path.display()),
660 })?;
661 temp_file
662 .sync_all()
663 .map_err(|error| DocumentError::IoError {
664 detail: format!("{operation} fsync `{}`: {error}", path.display()),
665 })?;
666 drop(temp_file);
667 fs::set_permissions(&temp_path, metadata.permissions()).map_err(|error| {
668 DocumentError::IoError {
669 detail: format!(
670 "{operation} preserve permissions `{}`: {error}",
671 path.display()
672 ),
673 }
674 })?;
675 fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
676 detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
677 })?;
678 Ok(())
679 })();
680 if result.is_err() {
681 let _ = fs::remove_file(&temp_path);
682 }
683 result
684}
685
686#[cfg(test)]
687mod tests {
688 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
689 use super::*;
690 use std::io::Cursor;
691
692 fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
693 let path = dir.join(name);
694 fs::write(&path, contents).unwrap();
695 path
696 }
697
698 #[test]
699 fn round_trip_open_json() {
700 let dir = tempfile::tempdir().unwrap();
701 let contents = r#"{"host": "example.com", "port": 993}"#;
702 let path = write_temp(dir.path(), "config.json", contents);
703
704 let doc = DocumentFile::open(&path, None).unwrap();
705
706 assert_eq!(doc.format(), Format::Json);
707 assert_eq!(
708 doc.value().get("host").and_then(Value::as_str),
709 Some("example.com")
710 );
711 assert_eq!(doc.source(), contents);
712 }
713
714 #[test]
715 fn value_at_reads_a_nested_address() {
716 let dir = tempfile::tempdir().unwrap();
717 let path = write_temp(
718 dir.path(),
719 "config.json",
720 r#"{"database": {"url": "postgres://x"}}"#,
721 );
722 let doc = DocumentFile::open(&path, None).unwrap();
723
724 assert_eq!(
725 doc.value_at("database.url").unwrap(),
726 Value::String("postgres://x".to_string())
727 );
728 assert_eq!(
729 doc.value_at("database.missing").unwrap_err().code(),
730 "document_path_not_found"
731 );
732 }
733
734 #[test]
735 fn open_capped_enforces_size_and_regular_file() {
736 let dir = tempfile::tempdir().unwrap();
737 let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
738
739 assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
741
742 let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
744 assert_eq!(err.code(), "document_io_failed");
745 assert!(err.to_string().contains("read limit"));
746
747 let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
749 assert_eq!(dir_err.code(), "document_io_failed");
750 }
751
752 #[test]
753 fn typed_get_and_set_enforce_the_stated_type() {
754 use crate::document::ValueType;
755 let dir = tempfile::tempdir().unwrap();
756 let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
757 let mut doc = DocumentFile::open(&path, None).unwrap();
758
759 assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
762 assert_eq!(
763 doc.value_at_typed("port", ValueType::String)
764 .unwrap_err()
765 .code(),
766 "document_type_mismatch"
767 );
768 assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
769
770 doc.set_typed("port", Some("9090"), ValueType::Number)
772 .unwrap();
773 assert_eq!(
774 doc.value_at("port").unwrap(),
775 Value::from(serde_json::json!(9090))
776 );
777 assert_eq!(
778 doc.set_typed("port", Some("not-a-number"), ValueType::Number)
779 .unwrap_err()
780 .code(),
781 "document_parse_failed"
782 );
783 }
784
785 #[cfg(feature = "toml")]
786 #[test]
787 fn round_trip_open_toml() {
788 let dir = tempfile::tempdir().unwrap();
789 let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
790 let path = write_temp(dir.path(), "config.toml", contents);
791
792 let doc = DocumentFile::open(&path, None).unwrap();
793
794 assert_eq!(doc.format(), Format::Toml);
795 assert_eq!(
796 doc.value().get("host").and_then(Value::as_str),
797 Some("example.com")
798 );
799 assert_eq!(doc.source(), contents);
800 }
801
802 #[cfg(feature = "toml")]
803 #[test]
804 fn set_scalar_preserves_toml_comments_and_formatting() {
805 let dir = tempfile::tempdir().unwrap();
806 let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
807 let path = write_temp(dir.path(), "config.toml", contents);
808 let mut doc = DocumentFile::open(&path, None).unwrap();
809
810 doc.set("port", Value::Integer(1024)).unwrap();
811 doc.save().unwrap();
812
813 let saved = fs::read_to_string(&path).unwrap();
814 assert!(saved.contains("# leading comment"));
815 assert!(saved.contains("port = 1024"));
816 assert_eq!(
817 doc.value().get("port").and_then(Value::as_integer),
818 Some(1024)
819 );
820 assert_eq!(doc.source(), saved);
821 }
822
823 #[test]
824 fn save_refuses_source_its_own_parser_rejects() {
825 let dir = tempfile::tempdir().unwrap();
829 let original = "[db]\nhost=localhost\n";
830 let path = write_temp(dir.path(), "config.ini", original);
831 let doc = DocumentFile::open(&path, None).unwrap();
832
833 let error = doc
834 .save_atomic("[db]\nhost=localhost\n\n[db]\nport=5432\n")
835 .unwrap_err();
836 assert_eq!(error.code(), "document_write_would_corrupt");
837 assert_eq!(fs::read_to_string(&path).unwrap(), original);
839 }
840
841 #[test]
842 fn save_writes_source_the_parser_accepts() {
843 let dir = tempfile::tempdir().unwrap();
844 let path = write_temp(dir.path(), "config.ini", "[db]\nhost=localhost\n");
845 let mut doc = DocumentFile::open(&path, None).unwrap();
846
847 doc.set("db.port", Value::String("5432".to_string()))
848 .unwrap();
849 doc.save().unwrap();
850
851 assert_eq!(
854 fs::read_to_string(&path).unwrap(),
855 "[db]\nhost=localhost\nport=5432\n"
856 );
857 assert!(DocumentFile::open(&path, None).is_ok());
858 }
859
860 #[cfg(unix)]
861 #[test]
862 fn atomic_save_preserves_file_mode() {
863 use std::os::unix::fs::PermissionsExt;
864
865 let dir = tempfile::tempdir().unwrap();
866 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
867 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
868 let mut doc = DocumentFile::open(&path, None).unwrap();
869
870 doc.set("port", Value::Integer(1024)).unwrap();
871 doc.save().unwrap();
872
873 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
874 assert_eq!(mode, 0o640);
875 }
876
877 #[cfg(unix)]
878 #[test]
879 fn symlink_target_is_rejected_for_mutation() {
880 let dir = tempfile::tempdir().unwrap();
881 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
882 let link = dir.path().join("link.json");
883 std::os::unix::fs::symlink(&target, &link).unwrap();
884
885 let mut doc = DocumentFile::open(&link, None).unwrap();
887
888 doc.set("port", Value::Integer(1024)).unwrap();
890 let err = doc.save().unwrap_err();
891 assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
892
893 let target_contents = fs::read_to_string(&target).unwrap();
895 assert_eq!(target_contents, r#"{"port": 993}"#);
896 }
897
898 #[test]
899 fn from_reader_parses_in_memory_cursor() {
900 let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
901
902 let doc = Document::from_reader(cursor, Format::Json).unwrap();
903
904 assert_eq!(
905 doc.value().get("host").and_then(Value::as_str),
906 Some("example.com")
907 );
908 }
909
910 #[test]
911 fn document_from_str_encode_round_trip() {
912 let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
913 let encoded = doc.encode().unwrap();
914 let reparsed = Document::parse(&encoded, Format::Json).unwrap();
915 assert_eq!(
916 reparsed.value().get("a").and_then(Value::as_integer),
917 Some(1)
918 );
919 }
920
921 #[test]
922 fn document_edits_source_in_memory_without_a_file() {
923 let mut doc = Document::parse("{\n \"host\": \"old\"\n}\n", Format::Json).unwrap();
926 doc.set("host", Value::String("new".to_string())).unwrap();
927 doc.set("imap.port", Value::Integer(993)).unwrap(); assert_eq!(
930 doc.source(),
931 "{\n \"host\": \"new\",\n \"imap\": {\n \"port\": 993\n }\n}\n"
932 );
933 assert_eq!(
934 doc.value_at("imap.port").unwrap(),
935 Value::from(serde_json::json!(993))
936 );
937 }
938
939 #[test]
940 fn unset_is_false_for_anything_already_absent() {
941 let mut doc = Document::parse(
942 r#"{"service":{"host":"example","ports":[80]}}"#,
943 Format::Json,
944 )
945 .unwrap();
946
947 assert!(!doc.unset("service.missing").unwrap());
950 assert!(!doc.unset("missing.parent").unwrap());
951 assert!(!doc.unset("missing.deeply.nested").unwrap());
952
953 assert!(doc.unset("service.host.child").is_err()); assert!(doc.unset("service.ports.9").is_err()); assert!(doc.unset(r"service\q").is_err()); }
959
960 #[cfg(feature = "yaml")]
961 #[test]
962 fn yaml_write_rejects_cst_ambiguous_mapping_segments() {
963 let mut numeric = Document::parse("\"123\": value\n", Format::Yaml).unwrap();
964 assert!(
965 numeric
966 .set("123", Value::String("changed".to_string()))
967 .is_err()
968 );
969 assert!(numeric.unset("123").is_err());
970
971 let mut bracketed = Document::parse("\"a[0]\": value\n", Format::Yaml).unwrap();
972 assert!(
973 bracketed
974 .set("a[0]", Value::String("changed".to_string()))
975 .is_err()
976 );
977 }
978}