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 #[cfg(feature = "yaml")]
419 Format::YamlFrontmatter => {
420 let parts = crate::document::format::frontmatter::split(
421 &self.source,
422 crate::document::format::frontmatter::Delimiter::Dash,
423 )?;
424 let new_fm = crate::document::format::yaml::append_array_item_preserving(
425 parts.frontmatter,
426 key,
427 item,
428 )?;
429 format!("{}{}{}", parts.pre, new_fm, parts.post)
430 }
431 _ => {
432 return Err(DocumentError::UnsupportedOperation {
433 format: self.format.name().to_string(),
434 operation: "add".to_string(),
435 detail: "keyed collection source editor is not implemented for this backend"
436 .to_string(),
437 });
438 }
439 };
440 self.source = output;
441 self.value = value;
442 Ok(())
443 }
444
445 pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
453 self.ensure_writable("remove")?;
454 let mut value = self.value.clone();
455 let keyed_lists = [KeyedList {
456 prefix: key,
457 slug_field,
458 }];
459 let removed_index = crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
460 #[allow(unreachable_patterns)]
464 let output: String = match self.format {
465 Format::Json => crate::document::format::json::remove_array_item_preserving(
466 &self.source,
467 key,
468 removed_index,
469 )?,
470 #[cfg(feature = "yaml")]
471 Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
472 &self.source,
473 key,
474 removed_index,
475 )?,
476 #[cfg(feature = "yaml")]
477 Format::YamlFrontmatter => {
478 let parts = crate::document::format::frontmatter::split(
479 &self.source,
480 crate::document::format::frontmatter::Delimiter::Dash,
481 )?;
482 let new_fm = crate::document::format::yaml::remove_array_item_preserving(
483 parts.frontmatter,
484 key,
485 removed_index,
486 )?;
487 format!("{}{}{}", parts.pre, new_fm, parts.post)
488 }
489 _ => {
490 return Err(DocumentError::UnsupportedOperation {
491 format: self.format.name().to_string(),
492 operation: "remove".to_string(),
493 detail: "keyed collection source editor is not implemented for this backend"
494 .to_string(),
495 });
496 }
497 };
498 self.source = output;
499 self.value = value;
500 Ok(())
501 }
502
503 pub fn unset(&mut self, key: &str) -> DocumentResult<bool> {
528 let addressing = self.addressing();
529 self.unset_addressed(key, addressing)
530 }
531
532 pub fn unset_addressed(
537 &mut self,
538 key: &str,
539 addressing: Addressing<'_>,
540 ) -> DocumentResult<bool> {
541 self.ensure_writable("unset")?;
542 let key = &crate::document::resolve_path(&self.value, key, addressing)?;
543 let segments = crate::document::parse_path(key)?;
544 let (leaf, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
545 let parent = if parents.is_empty() {
546 &self.value
547 } else {
548 let parent_path = crate::document::join_path(parents);
549 match crate::document::get_path_ref(&self.value, &parent_path, Addressing::INDEX_ONLY) {
550 Ok(parent) => parent,
551 Err(DocumentError::UnknownSegment { .. }) => return Ok(false),
553 Err(error) => return Err(error),
554 }
555 };
556 match parent {
557 Value::Object(object) => {
558 if !object.contains_key(leaf) {
559 return Ok(false);
560 }
561 }
562 Value::Array(array) => {
563 let index =
564 leaf.parse::<usize>()
565 .map_err(|_| DocumentError::UnregisteredArray {
566 path: crate::document::join_path(parents),
567 })?;
568 if index >= array.len() {
569 return Err(DocumentError::IndexOutOfBounds {
570 path: crate::document::join_path(parents),
571 index,
572 len: array.len(),
573 });
574 }
575 }
576 value => {
577 return Err(DocumentError::NotTraversable {
578 path: crate::document::join_path(parents),
579 got: value.kind_name().to_string(),
580 });
581 }
582 }
583 let mut value = self.value.clone();
584 crate::document::unset_path(&mut value, key)?;
585 #[allow(unreachable_patterns)]
586 let output = match self.format {
587 Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
588 #[cfg(feature = "toml")]
589 Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
590 #[cfg(feature = "yaml")]
591 Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
592 #[cfg(feature = "dotenv")]
593 Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
594 #[cfg(feature = "ini")]
595 Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
596 #[cfg(feature = "toml")]
597 Format::TomlFrontmatter => {
598 let parts = crate::document::format::frontmatter::split(
599 &self.source,
600 crate::document::format::frontmatter::Delimiter::Plus,
601 )?;
602 let new_fm =
603 crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
604 format!("{}{}{}", parts.pre, new_fm, parts.post)
605 }
606 #[cfg(feature = "yaml")]
607 Format::YamlFrontmatter => {
608 let parts = crate::document::format::frontmatter::split(
609 &self.source,
610 crate::document::format::frontmatter::Delimiter::Dash,
611 )?;
612 let new_fm =
613 crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
614 format!("{}{}{}", parts.pre, new_fm, parts.post)
615 }
616 _ => self.format.save(&value)?,
617 };
618 self.source = output;
619 self.value = value;
620 Ok(true)
621 }
622}
623
624impl DocumentFile {
625 pub fn edit<F>(&mut self, edit: F) -> DocumentResult<()>
630 where
631 F: FnOnce(&mut Document) -> DocumentResult<()>,
632 {
633 edit(&mut self.doc)?;
634 self.save()
635 }
636
637 pub fn save(&self) -> DocumentResult<()> {
647 self.save_atomic(self.doc.source())
648 }
649
650 pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
661 self.ensure_writable("save")?;
665 Document::parse(new_source, self.format).map_err(|error| {
672 DocumentError::WriteWouldCorrupt {
673 format: self.format.name().to_string(),
674 detail: error.redacted_message(),
675 }
676 })?;
677 write_atomic(&self.path, new_source.as_bytes(), "write")
678 }
679}
680
681impl std::ops::Deref for DocumentFile {
682 type Target = Document;
683
684 fn deref(&self) -> &Document {
685 &self.doc
686 }
687}
688
689impl std::ops::DerefMut for DocumentFile {
690 fn deref_mut(&mut self) -> &mut Document {
691 &mut self.doc
692 }
693}
694
695fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
699 let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
700 detail: format!("{operation} preflight `{}`: {error}", path.display()),
701 })?;
702 if metadata.file_type().is_symlink() {
703 return Err(DocumentError::UnsupportedOperation {
704 format: "filesystem".to_string(),
705 operation: operation.to_string(),
706 detail: format!("refusing to mutate symlink `{}`", path.display()),
707 });
708 }
709 #[cfg(unix)]
710 {
711 use std::os::unix::fs::MetadataExt;
712 if metadata.nlink() > 1 {
713 return Err(DocumentError::UnsupportedOperation {
714 format: "filesystem".to_string(),
715 operation: operation.to_string(),
716 detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
717 });
718 }
719 }
720 Ok(metadata)
721}
722
723fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
726 let metadata = guard_mutation(path, operation)?;
727
728 let parent = path.parent().ok_or_else(|| DocumentError::IoError {
729 detail: format!(
730 "{operation} has no parent directory for `{}`",
731 path.display()
732 ),
733 })?;
734 let file_name = path
735 .file_name()
736 .and_then(|name| name.to_str())
737 .ok_or_else(|| DocumentError::IoError {
738 detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
739 })?;
740 let pid = std::process::id();
741 let mut temp_path = None;
742 let mut temp_file = None;
743 for attempt in 0..32_u32 {
744 let candidate = parent.join(format!(".{file_name}.afdata-document.{pid}.{attempt}.tmp"));
745 match OpenOptions::new()
746 .write(true)
747 .create_new(true)
748 .open(&candidate)
749 {
750 Ok(file) => {
751 temp_path = Some(candidate);
752 temp_file = Some(file);
753 break;
754 }
755 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
756 Err(error) => {
757 return Err(DocumentError::IoError {
758 detail: format!(
759 "{operation} create temporary file in `{}`: {error}",
760 parent.display()
761 ),
762 });
763 }
764 }
765 }
766 let temp_path = temp_path.ok_or_else(|| DocumentError::IoError {
767 detail: format!(
768 "{operation} could not allocate temporary file in `{}`",
769 parent.display()
770 ),
771 })?;
772 let mut temp_file = temp_file.ok_or_else(|| DocumentError::IoError {
773 detail: format!("{operation} temporary file handle missing"),
774 })?;
775 let result = (|| -> DocumentResult<()> {
776 temp_file
777 .write_all(bytes)
778 .map_err(|error| DocumentError::IoError {
779 detail: format!("{operation} write `{}`: {error}", path.display()),
780 })?;
781 temp_file
782 .sync_all()
783 .map_err(|error| DocumentError::IoError {
784 detail: format!("{operation} fsync `{}`: {error}", path.display()),
785 })?;
786 drop(temp_file);
787 fs::set_permissions(&temp_path, metadata.permissions()).map_err(|error| {
788 DocumentError::IoError {
789 detail: format!(
790 "{operation} preserve permissions `{}`: {error}",
791 path.display()
792 ),
793 }
794 })?;
795 fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
796 detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
797 })?;
798 Ok(())
799 })();
800 if result.is_err() {
801 let _ = fs::remove_file(&temp_path);
802 }
803 result
804}
805
806#[cfg(test)]
807mod tests {
808 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
809 use super::*;
810 use std::io::Cursor;
811
812 fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
813 let path = dir.join(name);
814 fs::write(&path, contents).unwrap();
815 path
816 }
817
818 #[test]
819 fn round_trip_open_json() {
820 let dir = tempfile::tempdir().unwrap();
821 let contents = r#"{"host": "example.com", "port": 993}"#;
822 let path = write_temp(dir.path(), "config.json", contents);
823
824 let doc = DocumentFile::open(&path, None).unwrap();
825
826 assert_eq!(doc.format(), Format::Json);
827 assert_eq!(
828 doc.value().get("host").and_then(Value::as_str),
829 Some("example.com")
830 );
831 assert_eq!(doc.source(), contents);
832 }
833
834 #[test]
835 fn value_at_reads_a_nested_address() {
836 let dir = tempfile::tempdir().unwrap();
837 let path = write_temp(
838 dir.path(),
839 "config.json",
840 r#"{"database": {"url": "postgres://x"}}"#,
841 );
842 let doc = DocumentFile::open(&path, None).unwrap();
843
844 assert_eq!(
845 doc.value_at("database.url").unwrap(),
846 Value::String("postgres://x".to_string())
847 );
848 assert_eq!(
849 doc.value_at("database.missing").unwrap_err().code(),
850 "document_path_not_found"
851 );
852 }
853
854 #[test]
855 fn open_capped_enforces_size_and_regular_file() {
856 let dir = tempfile::tempdir().unwrap();
857 let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
858
859 assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
861
862 let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
864 assert_eq!(err.code(), "document_io_failed");
865 assert!(err.to_string().contains("read limit"));
866
867 let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
869 assert_eq!(dir_err.code(), "document_io_failed");
870 }
871
872 #[test]
873 fn typed_get_and_set_enforce_the_stated_type() {
874 use crate::document::ValueType;
875 let dir = tempfile::tempdir().unwrap();
876 let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
877 let mut doc = DocumentFile::open(&path, None).unwrap();
878
879 assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
882 assert_eq!(
883 doc.value_at_typed("port", ValueType::String)
884 .unwrap_err()
885 .code(),
886 "document_type_mismatch"
887 );
888 assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
889
890 doc.set_typed("port", Some("9090"), ValueType::Number)
892 .unwrap();
893 assert_eq!(
894 doc.value_at("port").unwrap(),
895 Value::from(serde_json::json!(9090))
896 );
897 assert_eq!(
898 doc.set_typed("port", Some("not-a-number"), ValueType::Number)
899 .unwrap_err()
900 .code(),
901 "document_parse_failed"
902 );
903 }
904
905 #[cfg(feature = "toml")]
906 #[test]
907 fn round_trip_open_toml() {
908 let dir = tempfile::tempdir().unwrap();
909 let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
910 let path = write_temp(dir.path(), "config.toml", contents);
911
912 let doc = DocumentFile::open(&path, None).unwrap();
913
914 assert_eq!(doc.format(), Format::Toml);
915 assert_eq!(
916 doc.value().get("host").and_then(Value::as_str),
917 Some("example.com")
918 );
919 assert_eq!(doc.source(), contents);
920 }
921
922 #[cfg(feature = "toml")]
923 #[test]
924 fn set_scalar_preserves_toml_comments_and_formatting() {
925 let dir = tempfile::tempdir().unwrap();
926 let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
927 let path = write_temp(dir.path(), "config.toml", contents);
928 let mut doc = DocumentFile::open(&path, None).unwrap();
929
930 doc.set("port", Value::Integer(1024)).unwrap();
931 doc.save().unwrap();
932
933 let saved = fs::read_to_string(&path).unwrap();
934 assert!(saved.contains("# leading comment"));
935 assert!(saved.contains("port = 1024"));
936 assert_eq!(
937 doc.value().get("port").and_then(Value::as_integer),
938 Some(1024)
939 );
940 assert_eq!(doc.source(), saved);
941 }
942
943 #[test]
944 fn save_refuses_source_its_own_parser_rejects() {
945 let dir = tempfile::tempdir().unwrap();
949 let original = "[db]\nhost=localhost\n";
950 let path = write_temp(dir.path(), "config.ini", original);
951 let doc = DocumentFile::open(&path, None).unwrap();
952
953 let error = doc
954 .save_atomic("[db]\nhost=localhost\n\n[db]\nport=5432\n")
955 .unwrap_err();
956 assert_eq!(error.code(), "document_write_would_corrupt");
957 assert_eq!(fs::read_to_string(&path).unwrap(), original);
959 }
960
961 #[test]
962 fn save_writes_source_the_parser_accepts() {
963 let dir = tempfile::tempdir().unwrap();
964 let path = write_temp(dir.path(), "config.ini", "[db]\nhost=localhost\n");
965 let mut doc = DocumentFile::open(&path, None).unwrap();
966
967 doc.set("db.port", Value::String("5432".to_string()))
968 .unwrap();
969 doc.save().unwrap();
970
971 assert_eq!(
974 fs::read_to_string(&path).unwrap(),
975 "[db]\nhost=localhost\nport=5432\n"
976 );
977 assert!(DocumentFile::open(&path, None).is_ok());
978 }
979
980 #[cfg(unix)]
981 #[test]
982 fn atomic_save_preserves_file_mode() {
983 use std::os::unix::fs::PermissionsExt;
984
985 let dir = tempfile::tempdir().unwrap();
986 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
987 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
988 let mut doc = DocumentFile::open(&path, None).unwrap();
989
990 doc.set("port", Value::Integer(1024)).unwrap();
991 doc.save().unwrap();
992
993 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
994 assert_eq!(mode, 0o640);
995 }
996
997 #[cfg(unix)]
998 #[test]
999 fn symlink_target_is_rejected_for_mutation() {
1000 let dir = tempfile::tempdir().unwrap();
1001 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
1002 let link = dir.path().join("link.json");
1003 std::os::unix::fs::symlink(&target, &link).unwrap();
1004
1005 let mut doc = DocumentFile::open(&link, None).unwrap();
1007
1008 doc.set("port", Value::Integer(1024)).unwrap();
1010 let err = doc.save().unwrap_err();
1011 assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
1012
1013 let target_contents = fs::read_to_string(&target).unwrap();
1015 assert_eq!(target_contents, r#"{"port": 993}"#);
1016 }
1017
1018 #[test]
1019 fn from_reader_parses_in_memory_cursor() {
1020 let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
1021
1022 let doc = Document::from_reader(cursor, Format::Json).unwrap();
1023
1024 assert_eq!(
1025 doc.value().get("host").and_then(Value::as_str),
1026 Some("example.com")
1027 );
1028 }
1029
1030 #[test]
1031 fn document_from_str_encode_round_trip() {
1032 let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1033 let encoded = doc.encode().unwrap();
1034 let reparsed = Document::parse(&encoded, Format::Json).unwrap();
1035 assert_eq!(
1036 reparsed.value().get("a").and_then(Value::as_integer),
1037 Some(1)
1038 );
1039 }
1040
1041 #[test]
1042 fn document_edits_source_in_memory_without_a_file() {
1043 let mut doc = Document::parse("{\n \"host\": \"old\"\n}\n", Format::Json).unwrap();
1046 doc.set("host", Value::String("new".to_string())).unwrap();
1047 doc.set("imap.port", Value::Integer(993)).unwrap(); assert_eq!(
1050 doc.source(),
1051 "{\n \"host\": \"new\",\n \"imap\": {\n \"port\": 993\n }\n}\n"
1052 );
1053 assert_eq!(
1054 doc.value_at("imap.port").unwrap(),
1055 Value::from(serde_json::json!(993))
1056 );
1057 }
1058
1059 #[test]
1060 fn unset_is_false_for_anything_already_absent() {
1061 let mut doc = Document::parse(
1062 r#"{"service":{"host":"example","ports":[80]}}"#,
1063 Format::Json,
1064 )
1065 .unwrap();
1066
1067 assert!(!doc.unset("service.missing").unwrap());
1070 assert!(!doc.unset("missing.parent").unwrap());
1071 assert!(!doc.unset("missing.deeply.nested").unwrap());
1072
1073 assert!(doc.unset("service.host.child").is_err()); assert!(doc.unset("service.ports.9").is_err()); assert!(doc.unset(r"service\q").is_err()); }
1079
1080 #[cfg(feature = "markdown")]
1081 #[test]
1082 fn every_markdown_write_verb_is_refused() {
1083 let source = "# Title\n\nThe lead.\n";
1084 let mut doc = Document::parse(source, Format::Markdown).unwrap();
1085
1086 assert_eq!(
1089 doc.value_at("h1.0.text").unwrap(),
1090 Value::String("Title".to_string())
1091 );
1092 assert_eq!(
1093 doc.value_at("h1.Tit.paragraph.0.text").unwrap(),
1094 Value::String("The lead.".to_string())
1095 );
1096
1097 let refusals: Vec<DocumentError> = vec![
1101 doc.set("h1.0.text", Value::String("New".to_string()))
1102 .unwrap_err(),
1103 doc.add("preamble", "x", "type", &[]).unwrap_err(),
1104 doc.remove("preamble", "x", "type").unwrap_err(),
1105 doc.unset("h1.0").unwrap_err(),
1106 doc.unset("nothing.here").unwrap_err(),
1107 doc.encode().unwrap_err(),
1108 ];
1109 for error in refusals {
1110 assert_eq!(error.code(), "document_unsupported_operation");
1111 assert!(
1112 error.to_string().contains("read-only"),
1113 "refusal must name the reason: {error}"
1114 );
1115 }
1116
1117 assert_eq!(doc.source(), source);
1119 }
1120
1121 #[cfg(feature = "markdown")]
1122 #[test]
1123 fn markdown_save_never_reaches_disk() {
1124 let dir = tempfile::tempdir().unwrap();
1125 let path = write_temp(dir.path(), "README.md", "# Title\n");
1126 assert!(DocumentFile::open(&path, None).is_err());
1128
1129 let doc = DocumentFile::open(&path, Some(Format::Markdown)).unwrap();
1130 let error = doc.save().unwrap_err();
1132 assert_eq!(error.code(), "document_unsupported_operation");
1133 assert_eq!(fs::read_to_string(&path).unwrap(), "# Title\n");
1134 }
1135
1136 #[cfg(feature = "yaml")]
1137 #[test]
1138 fn yaml_write_rejects_cst_ambiguous_mapping_segments() {
1139 let mut numeric = Document::parse("\"123\": value\n", Format::Yaml).unwrap();
1140 assert!(
1141 numeric
1142 .set("123", Value::String("changed".to_string()))
1143 .is_err()
1144 );
1145 assert!(numeric.unset("123").is_err());
1146
1147 let mut bracketed = Document::parse("\"a[0]\": value\n", Format::Yaml).unwrap();
1148 assert!(
1149 bracketed
1150 .set("a[0]", Value::String("changed".to_string()))
1151 .is_err()
1152 );
1153 }
1154}