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::ParseError {
165 format: "format".to_string(),
166 detail: format!(
167 "cannot detect format from file extension `{}`; pass an explicit format",
168 path.display()
169 ),
170 })?,
171 };
172 let source = fs::read_to_string(&path).map_err(|error| DocumentError::IoError {
173 detail: format!("read `{}`: {error}", path.display()),
174 })?;
175 Ok(DocumentFile {
176 doc: Document::parse(&source, format)?,
177 path,
178 })
179 }
180
181 pub fn open_capped(
189 path: impl AsRef<Path>,
190 format_override: Option<Format>,
191 max_bytes: u64,
192 ) -> DocumentResult<DocumentFile> {
193 let path = path.as_ref();
194 let metadata = fs::metadata(path).map_err(|error| DocumentError::IoError {
195 detail: format!("read `{}`: {error}", path.display()),
196 })?;
197 if !metadata.is_file() {
198 return Err(DocumentError::IoError {
199 detail: format!("`{}` is not a regular file", path.display()),
200 });
201 }
202 if metadata.len() > max_bytes {
203 return Err(DocumentError::IoError {
204 detail: format!(
205 "`{}` exceeds the {max_bytes}-byte read limit",
206 path.display()
207 ),
208 });
209 }
210 DocumentFile::open(path, format_override)
211 }
212
213 pub fn path(&self) -> &Path {
215 &self.path
216 }
217
218 pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
226 guard_mutation(&self.path, operation)?;
227 Ok(())
228 }
229}
230
231impl Document {
232 pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
243 let mut new_doc = self.value.clone();
244 self.format.ensure_writable("set")?;
245 crate::document::set_path(&mut new_doc, key, &value, &[])?;
246 let target = crate::document::get_path(&new_doc, key, &[])?;
247 #[allow(unreachable_patterns)]
248 let output = match self.format {
249 #[cfg(feature = "toml")]
250 Format::Toml => {
251 crate::document::format::toml::set_preserving(&self.source, key, &target)?
252 }
253 #[cfg(feature = "yaml")]
254 Format::Yaml => {
255 crate::document::format::yaml::set_preserving(&self.source, key, &target)?
256 }
257 Format::Json => {
258 crate::document::format::json::set_preserving(&self.source, key, &target)?
259 }
260 #[cfg(feature = "dotenv")]
261 Format::Dotenv => {
262 crate::document::format::dotenv::set_preserving(&self.source, key, &target)?
263 }
264 #[cfg(feature = "ini")]
265 Format::Ini => {
266 crate::document::format::ini::set_preserving(&self.source, key, &target)?
267 }
268 #[cfg(feature = "toml")]
269 Format::TomlFrontmatter => {
270 let parts = crate::document::format::frontmatter::split(
271 &self.source,
272 crate::document::format::frontmatter::Delimiter::Plus,
273 )?;
274 let new_fm =
275 crate::document::format::toml::set_preserving(parts.frontmatter, key, &target)?;
276 format!("{}{}{}", parts.pre, new_fm, parts.post)
277 }
278 #[cfg(feature = "yaml")]
279 Format::YamlFrontmatter => {
280 let parts = crate::document::format::frontmatter::split(
281 &self.source,
282 crate::document::format::frontmatter::Delimiter::Dash,
283 )?;
284 let new_fm =
285 crate::document::format::yaml::set_preserving(parts.frontmatter, key, &target)?;
286 format!("{}{}{}", parts.pre, new_fm, parts.post)
287 }
288 _ => self.format.save(&new_doc)?,
289 };
290 self.source = output;
291 self.value = new_doc;
292 Ok(())
293 }
294
295 pub fn add(
303 &mut self,
304 key: &str,
305 slug: &str,
306 slug_field: &str,
307 fields: &[(String, Value)],
308 ) -> DocumentResult<()> {
309 let mut value = self.value.clone();
310 self.format.ensure_writable("add")?;
311 let keyed_lists = [KeyedList {
312 prefix: key,
313 slug_field,
314 }];
315 crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
316 let output: String = match self.format {
317 Format::Json => {
318 let array = crate::document::get_path(&value, key, &keyed_lists)?;
319 let item = array
320 .as_array()
321 .and_then(|items| items.last())
322 .ok_or_else(|| DocumentError::UnsupportedOperation {
323 format: "JSON".to_string(),
324 operation: "add".to_string(),
325 detail: "keyed list did not produce an array item".to_string(),
326 })?;
327 crate::document::format::json::append_array_item_preserving(
328 &self.source,
329 key,
330 item,
331 )?
332 }
333 #[cfg(feature = "yaml")]
334 Format::Yaml => {
335 let array = crate::document::get_path(&value, key, &keyed_lists)?;
336 let item = array
337 .as_array()
338 .and_then(|items| items.last())
339 .ok_or_else(|| DocumentError::UnsupportedOperation {
340 format: "YAML".to_string(),
341 operation: "add".to_string(),
342 detail: "keyed list did not produce an array item".to_string(),
343 })?;
344 crate::document::format::yaml::append_array_item_preserving(
345 &self.source,
346 key,
347 item,
348 )?
349 }
350 _ => {
351 return Err(DocumentError::UnsupportedOperation {
352 format: self.format.name().to_string(),
353 operation: "add".to_string(),
354 detail: "keyed collection source editor is not implemented for this backend"
355 .to_string(),
356 });
357 }
358 };
359 self.source = output;
360 self.value = value;
361 Ok(())
362 }
363
364 pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
371 let mut value = self.value.clone();
372 self.format.ensure_writable("remove")?;
373 let keyed_lists = [KeyedList {
374 prefix: key,
375 slug_field,
376 }];
377 let original_array = crate::document::get_path(&value, key, &keyed_lists)?;
378 let removed_index = original_array
379 .as_array()
380 .and_then(|items| {
381 items
382 .iter()
383 .position(|item| item.get(slug_field).and_then(Value::as_str) == Some(slug))
384 })
385 .ok_or_else(|| DocumentError::SlugNotFound {
386 prefix: key.to_string(),
387 slug: slug.to_string(),
388 })?;
389 #[cfg(not(feature = "yaml"))]
390 let _ = removed_index;
391 crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
392 let output: String = match self.format {
393 Format::Json => crate::document::format::json::remove_array_item_preserving(
394 &self.source,
395 key,
396 slug,
397 slug_field,
398 )?,
399 #[cfg(feature = "yaml")]
400 Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
401 &self.source,
402 key,
403 removed_index,
404 )?,
405 _ => {
406 return Err(DocumentError::UnsupportedOperation {
407 format: self.format.name().to_string(),
408 operation: "remove".to_string(),
409 detail: "keyed collection source editor is not implemented for this backend"
410 .to_string(),
411 });
412 }
413 };
414 self.source = output;
415 self.value = value;
416 Ok(())
417 }
418
419 pub fn unset(&mut self, key: &str) -> DocumentResult<bool> {
427 if self.value_at(key).is_err() {
428 return Ok(false);
429 }
430 let mut value = self.value.clone();
431 self.format.ensure_writable("unset")?;
432 crate::document::unset_path(&mut value, key)?;
433 #[allow(unreachable_patterns)]
434 let output = match self.format {
435 Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
436 #[cfg(feature = "toml")]
437 Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
438 #[cfg(feature = "yaml")]
439 Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
440 #[cfg(feature = "dotenv")]
441 Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
442 #[cfg(feature = "ini")]
443 Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
444 #[cfg(feature = "toml")]
445 Format::TomlFrontmatter => {
446 let parts = crate::document::format::frontmatter::split(
447 &self.source,
448 crate::document::format::frontmatter::Delimiter::Plus,
449 )?;
450 let new_fm =
451 crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
452 format!("{}{}{}", parts.pre, new_fm, parts.post)
453 }
454 #[cfg(feature = "yaml")]
455 Format::YamlFrontmatter => {
456 let parts = crate::document::format::frontmatter::split(
457 &self.source,
458 crate::document::format::frontmatter::Delimiter::Dash,
459 )?;
460 let new_fm =
461 crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
462 format!("{}{}{}", parts.pre, new_fm, parts.post)
463 }
464 _ => self.format.save(&value)?,
465 };
466 self.source = output;
467 self.value = value;
468 Ok(true)
469 }
470}
471
472impl DocumentFile {
473 pub fn edit<F>(&mut self, edit: F) -> DocumentResult<()>
478 where
479 F: FnOnce(&mut Document) -> DocumentResult<()>,
480 {
481 edit(&mut self.doc)?;
482 self.save()
483 }
484
485 pub fn save(&self) -> DocumentResult<()> {
495 self.save_atomic(self.doc.source())
496 }
497
498 pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
509 write_atomic(&self.path, new_source.as_bytes(), "write")
510 }
511}
512
513impl std::ops::Deref for DocumentFile {
514 type Target = Document;
515
516 fn deref(&self) -> &Document {
517 &self.doc
518 }
519}
520
521impl std::ops::DerefMut for DocumentFile {
522 fn deref_mut(&mut self) -> &mut Document {
523 &mut self.doc
524 }
525}
526
527fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
531 let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
532 detail: format!("{operation} preflight `{}`: {error}", path.display()),
533 })?;
534 if metadata.file_type().is_symlink() {
535 return Err(DocumentError::UnsupportedOperation {
536 format: "filesystem".to_string(),
537 operation: operation.to_string(),
538 detail: format!("refusing to mutate symlink `{}`", path.display()),
539 });
540 }
541 #[cfg(unix)]
542 {
543 use std::os::unix::fs::MetadataExt;
544 if metadata.nlink() > 1 {
545 return Err(DocumentError::UnsupportedOperation {
546 format: "filesystem".to_string(),
547 operation: operation.to_string(),
548 detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
549 });
550 }
551 }
552 Ok(metadata)
553}
554
555fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
558 let metadata = guard_mutation(path, operation)?;
559
560 let parent = path.parent().ok_or_else(|| DocumentError::IoError {
561 detail: format!(
562 "{operation} has no parent directory for `{}`",
563 path.display()
564 ),
565 })?;
566 let file_name = path
567 .file_name()
568 .and_then(|name| name.to_str())
569 .ok_or_else(|| DocumentError::IoError {
570 detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
571 })?;
572 let pid = std::process::id();
573 let mut temp_path = None;
574 let mut temp_file = None;
575 for attempt in 0..32_u32 {
576 let candidate = parent.join(format!(".{file_name}.afdata-document.{pid}.{attempt}.tmp"));
577 match OpenOptions::new()
578 .write(true)
579 .create_new(true)
580 .open(&candidate)
581 {
582 Ok(file) => {
583 temp_path = Some(candidate);
584 temp_file = Some(file);
585 break;
586 }
587 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
588 Err(error) => {
589 return Err(DocumentError::IoError {
590 detail: format!(
591 "{operation} create temporary file in `{}`: {error}",
592 parent.display()
593 ),
594 });
595 }
596 }
597 }
598 let temp_path = temp_path.ok_or_else(|| DocumentError::IoError {
599 detail: format!(
600 "{operation} could not allocate temporary file in `{}`",
601 parent.display()
602 ),
603 })?;
604 let mut temp_file = temp_file.ok_or_else(|| DocumentError::IoError {
605 detail: format!("{operation} temporary file handle missing"),
606 })?;
607 let result = (|| -> DocumentResult<()> {
608 temp_file
609 .write_all(bytes)
610 .map_err(|error| DocumentError::IoError {
611 detail: format!("{operation} write `{}`: {error}", path.display()),
612 })?;
613 temp_file
614 .sync_all()
615 .map_err(|error| DocumentError::IoError {
616 detail: format!("{operation} fsync `{}`: {error}", path.display()),
617 })?;
618 drop(temp_file);
619 fs::set_permissions(&temp_path, metadata.permissions()).map_err(|error| {
620 DocumentError::IoError {
621 detail: format!(
622 "{operation} preserve permissions `{}`: {error}",
623 path.display()
624 ),
625 }
626 })?;
627 fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
628 detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
629 })?;
630 Ok(())
631 })();
632 if result.is_err() {
633 let _ = fs::remove_file(&temp_path);
634 }
635 result
636}
637
638#[cfg(test)]
639mod tests {
640 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
641 use super::*;
642 use std::io::Cursor;
643
644 fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
645 let path = dir.join(name);
646 fs::write(&path, contents).unwrap();
647 path
648 }
649
650 #[test]
651 fn round_trip_open_json() {
652 let dir = tempfile::tempdir().unwrap();
653 let contents = r#"{"host": "example.com", "port": 993}"#;
654 let path = write_temp(dir.path(), "config.json", contents);
655
656 let doc = DocumentFile::open(&path, None).unwrap();
657
658 assert_eq!(doc.format(), Format::Json);
659 assert_eq!(
660 doc.value().get("host").and_then(Value::as_str),
661 Some("example.com")
662 );
663 assert_eq!(doc.source(), contents);
664 }
665
666 #[test]
667 fn value_at_reads_a_nested_address() {
668 let dir = tempfile::tempdir().unwrap();
669 let path = write_temp(
670 dir.path(),
671 "config.json",
672 r#"{"database": {"url": "postgres://x"}}"#,
673 );
674 let doc = DocumentFile::open(&path, None).unwrap();
675
676 assert_eq!(
677 doc.value_at("database.url").unwrap(),
678 Value::String("postgres://x".to_string())
679 );
680 assert_eq!(
681 doc.value_at("database.missing").unwrap_err().code(),
682 "document_path_not_found"
683 );
684 }
685
686 #[test]
687 fn open_capped_enforces_size_and_regular_file() {
688 let dir = tempfile::tempdir().unwrap();
689 let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
690
691 assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
693
694 let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
696 assert_eq!(err.code(), "document_io_failed");
697 assert!(err.to_string().contains("read limit"));
698
699 let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
701 assert_eq!(dir_err.code(), "document_io_failed");
702 }
703
704 #[test]
705 fn typed_get_and_set_enforce_the_stated_type() {
706 use crate::document::ValueType;
707 let dir = tempfile::tempdir().unwrap();
708 let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
709 let mut doc = DocumentFile::open(&path, None).unwrap();
710
711 assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
714 assert_eq!(
715 doc.value_at_typed("port", ValueType::String)
716 .unwrap_err()
717 .code(),
718 "document_type_mismatch"
719 );
720 assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
721
722 doc.set_typed("port", Some("9090"), ValueType::Number)
724 .unwrap();
725 assert_eq!(
726 doc.value_at("port").unwrap(),
727 Value::from(serde_json::json!(9090))
728 );
729 assert_eq!(
730 doc.set_typed("port", Some("not-a-number"), ValueType::Number)
731 .unwrap_err()
732 .code(),
733 "document_parse_failed"
734 );
735 }
736
737 #[cfg(feature = "toml")]
738 #[test]
739 fn round_trip_open_toml() {
740 let dir = tempfile::tempdir().unwrap();
741 let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
742 let path = write_temp(dir.path(), "config.toml", contents);
743
744 let doc = DocumentFile::open(&path, None).unwrap();
745
746 assert_eq!(doc.format(), Format::Toml);
747 assert_eq!(
748 doc.value().get("host").and_then(Value::as_str),
749 Some("example.com")
750 );
751 assert_eq!(doc.source(), contents);
752 }
753
754 #[cfg(feature = "toml")]
755 #[test]
756 fn set_scalar_preserves_toml_comments_and_formatting() {
757 let dir = tempfile::tempdir().unwrap();
758 let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
759 let path = write_temp(dir.path(), "config.toml", contents);
760 let mut doc = DocumentFile::open(&path, None).unwrap();
761
762 doc.set("port", Value::Integer(1024)).unwrap();
763 doc.save().unwrap();
764
765 let saved = fs::read_to_string(&path).unwrap();
766 assert!(saved.contains("# leading comment"));
767 assert!(saved.contains("port = 1024"));
768 assert_eq!(
769 doc.value().get("port").and_then(Value::as_integer),
770 Some(1024)
771 );
772 assert_eq!(doc.source(), saved);
773 }
774
775 #[cfg(unix)]
776 #[test]
777 fn atomic_save_preserves_file_mode() {
778 use std::os::unix::fs::PermissionsExt;
779
780 let dir = tempfile::tempdir().unwrap();
781 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
782 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
783 let mut doc = DocumentFile::open(&path, None).unwrap();
784
785 doc.set("port", Value::Integer(1024)).unwrap();
786 doc.save().unwrap();
787
788 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
789 assert_eq!(mode, 0o640);
790 }
791
792 #[cfg(unix)]
793 #[test]
794 fn symlink_target_is_rejected_for_mutation() {
795 let dir = tempfile::tempdir().unwrap();
796 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
797 let link = dir.path().join("link.json");
798 std::os::unix::fs::symlink(&target, &link).unwrap();
799
800 let mut doc = DocumentFile::open(&link, None).unwrap();
802
803 doc.set("port", Value::Integer(1024)).unwrap();
805 let err = doc.save().unwrap_err();
806 assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
807
808 let target_contents = fs::read_to_string(&target).unwrap();
810 assert_eq!(target_contents, r#"{"port": 993}"#);
811 }
812
813 #[test]
814 fn from_reader_parses_in_memory_cursor() {
815 let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
816
817 let doc = Document::from_reader(cursor, Format::Json).unwrap();
818
819 assert_eq!(
820 doc.value().get("host").and_then(Value::as_str),
821 Some("example.com")
822 );
823 }
824
825 #[test]
826 fn document_from_str_encode_round_trip() {
827 let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
828 let encoded = doc.encode().unwrap();
829 let reparsed = Document::parse(&encoded, Format::Json).unwrap();
830 assert_eq!(
831 reparsed.value().get("a").and_then(Value::as_integer),
832 Some(1)
833 );
834 }
835
836 #[test]
837 fn document_edits_source_in_memory_without_a_file() {
838 let mut doc = Document::parse("{\n \"host\": \"old\"\n}\n", Format::Json).unwrap();
841 doc.set("host", Value::String("new".to_string())).unwrap();
842 doc.set("imap.port", Value::Integer(993)).unwrap(); assert_eq!(
845 doc.source(),
846 "{\n \"host\": \"new\",\n \"imap\": {\n \"port\": 993\n }\n}\n"
847 );
848 assert_eq!(
849 doc.value_at("imap.port").unwrap(),
850 Value::from(serde_json::json!(993))
851 );
852 }
853}