1use std::fs::{self, OpenOptions};
30use std::io::Write as _;
31use std::path::{Path, PathBuf};
32
33use crate::document::{DocumentError, DocumentResult, Format, KeyedList, Value};
34
35#[derive(Debug, Clone)]
39pub struct Document {
40 value: Value,
41 format: Format,
42}
43
44impl Document {
45 pub fn parse(source: &str, format: Format) -> DocumentResult<Document> {
51 let value = format.load(source)?;
52 Ok(Document { value, format })
53 }
54
55 pub fn from_reader<R: std::io::Read>(
61 mut reader: R,
62 format: Format,
63 ) -> DocumentResult<Document> {
64 let mut source = String::new();
65 reader.read_to_string(&mut source)?;
66 Document::parse(&source, format)
67 }
68
69 pub fn value(&self) -> &Value {
71 &self.value
72 }
73
74 pub fn value_mut(&mut self) -> &mut Value {
76 &mut self.value
77 }
78
79 pub fn format(&self) -> Format {
81 self.format
82 }
83
84 pub fn encode(&self) -> DocumentResult<String> {
90 self.format.save(&self.value)
91 }
92}
93
94#[derive(Debug)]
102pub struct DocumentFile {
103 path: PathBuf,
104 format: Format,
105 source: String,
106 value: Value,
107}
108
109impl DocumentFile {
110 pub fn open(
116 path: impl AsRef<Path>,
117 format_override: Option<Format>,
118 ) -> DocumentResult<DocumentFile> {
119 let path = path.as_ref().to_path_buf();
120 let format = match format_override {
121 Some(format) => format,
122 None => Format::detect(&path).ok_or_else(|| DocumentError::ParseError {
123 format: "format".to_string(),
124 detail: format!(
125 "cannot detect format from file extension `{}`; pass an explicit format",
126 path.display()
127 ),
128 })?,
129 };
130 let source = fs::read_to_string(&path).map_err(|error| DocumentError::IoError {
131 detail: format!("read `{}`: {error}", path.display()),
132 })?;
133 let value = format.load(&source)?;
134 Ok(DocumentFile {
135 path,
136 format,
137 source,
138 value,
139 })
140 }
141
142 pub fn open_capped(
150 path: impl AsRef<Path>,
151 format_override: Option<Format>,
152 max_bytes: u64,
153 ) -> DocumentResult<DocumentFile> {
154 let path = path.as_ref();
155 let metadata = fs::metadata(path).map_err(|error| DocumentError::IoError {
156 detail: format!("read `{}`: {error}", path.display()),
157 })?;
158 if !metadata.is_file() {
159 return Err(DocumentError::IoError {
160 detail: format!("`{}` is not a regular file", path.display()),
161 });
162 }
163 if metadata.len() > max_bytes {
164 return Err(DocumentError::IoError {
165 detail: format!(
166 "`{}` exceeds the {max_bytes}-byte read limit",
167 path.display()
168 ),
169 });
170 }
171 DocumentFile::open(path, format_override)
172 }
173
174 pub fn path(&self) -> &Path {
176 &self.path
177 }
178
179 pub fn value(&self) -> &Value {
182 &self.value
183 }
184
185 pub fn value_at(&self, path: &str) -> DocumentResult<Value> {
193 crate::document::get_path(&self.value, path, &[])
194 }
195
196 pub fn value_at_typed(
202 &self,
203 path: &str,
204 expected: crate::document::ValueType,
205 ) -> DocumentResult<Value> {
206 let value = self.value_at(path)?;
207 if crate::document::value_matches_type(&value, expected) {
208 Ok(value)
209 } else {
210 Err(DocumentError::TypeMismatch {
211 path: path.to_string(),
212 expected: expected.name().to_string(),
213 got: value.kind_name().to_string(),
214 hint: None,
215 })
216 }
217 }
218
219 pub fn set_typed(
226 &mut self,
227 key: &str,
228 raw: Option<&str>,
229 value_type: crate::document::ValueType,
230 ) -> DocumentResult<()> {
231 let value = crate::document::value_from_type(value_type, raw)?;
232 self.set(key, value)
233 }
234
235 pub fn format(&self) -> Format {
237 self.format
238 }
239
240 pub fn source(&self) -> &str {
243 &self.source
244 }
245
246 pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
256 guard_mutation(&self.path, operation)?;
257 Ok(())
258 }
259
260 pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
263 guard_mutation(&self.path, "set")?;
264 let mut new_doc = self.value.clone();
265 self.format.ensure_writable("set")?;
266 crate::document::set_path(&mut new_doc, key, &value, &[])?;
267 let target = crate::document::get_path(&new_doc, key, &[])?;
268 #[allow(unreachable_patterns)]
269 let output = match self.format {
270 #[cfg(feature = "toml")]
271 Format::Toml => {
272 crate::document::format::toml::set_scalar_preserving(&self.source, key, &target)?
273 }
274 #[cfg(feature = "yaml")]
275 Format::Yaml => {
276 crate::document::format::yaml::set_scalar_preserving(&self.source, key, &target)?
277 }
278 Format::Json => {
279 crate::document::format::json::set_scalar_preserving(&self.source, key, &target)?
280 }
281 #[cfg(feature = "dotenv")]
282 Format::Dotenv => {
283 crate::document::format::dotenv::set_scalar_preserving(&self.source, key, &target)?
284 }
285 #[cfg(feature = "ini")]
286 Format::Ini => {
287 crate::document::format::ini::set_scalar_preserving(&self.source, key, &target)?
288 }
289 #[cfg(feature = "toml")]
290 Format::TomlFrontmatter => {
291 let parts = crate::document::format::frontmatter::split(
292 &self.source,
293 crate::document::format::frontmatter::Delimiter::Plus,
294 )?;
295 let new_fm = crate::document::format::toml::set_scalar_preserving(
296 parts.frontmatter,
297 key,
298 &target,
299 )?;
300 format!("{}{}{}", parts.pre, new_fm, parts.post)
301 }
302 #[cfg(feature = "yaml")]
303 Format::YamlFrontmatter => {
304 let parts = crate::document::format::frontmatter::split(
305 &self.source,
306 crate::document::format::frontmatter::Delimiter::Dash,
307 )?;
308 let new_fm = crate::document::format::yaml::set_scalar_preserving(
309 parts.frontmatter,
310 key,
311 &target,
312 )?;
313 format!("{}{}{}", parts.pre, new_fm, parts.post)
314 }
315 _ => self.format.save(&new_doc)?,
316 };
317 self.save_atomic(&output)?;
318 self.source = output;
319 self.value = new_doc;
320 Ok(())
321 }
322
323 pub fn add(
331 &mut self,
332 key: &str,
333 slug: &str,
334 slug_field: &str,
335 fields: &[(String, Value)],
336 ) -> DocumentResult<()> {
337 guard_mutation(&self.path, "add")?;
338 let mut value = self.value.clone();
339 self.format.ensure_writable("add")?;
340 let keyed_lists = [KeyedList {
341 prefix: key,
342 slug_field,
343 }];
344 crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
345 let output: String = match self.format {
346 Format::Json => {
347 let array = crate::document::get_path(&value, key, &keyed_lists)?;
348 let item = array
349 .as_array()
350 .and_then(|items| items.last())
351 .ok_or_else(|| DocumentError::UnsupportedOperation {
352 format: "JSON".to_string(),
353 operation: "add".to_string(),
354 detail: "keyed list did not produce an array item".to_string(),
355 })?;
356 crate::document::format::json::append_array_item_preserving(
357 &self.source,
358 key,
359 item,
360 )?
361 }
362 #[cfg(feature = "yaml")]
363 Format::Yaml => {
364 let array = crate::document::get_path(&value, key, &keyed_lists)?;
365 let item = array
366 .as_array()
367 .and_then(|items| items.last())
368 .ok_or_else(|| DocumentError::UnsupportedOperation {
369 format: "YAML".to_string(),
370 operation: "add".to_string(),
371 detail: "keyed list did not produce an array item".to_string(),
372 })?;
373 crate::document::format::yaml::append_array_item_preserving(
374 &self.source,
375 key,
376 item,
377 )?
378 }
379 _ => {
380 return Err(DocumentError::UnsupportedOperation {
381 format: self.format.name().to_string(),
382 operation: "add".to_string(),
383 detail: "keyed collection source editor is not implemented for this backend"
384 .to_string(),
385 });
386 }
387 };
388 self.save_atomic(&output)?;
389 self.source = output;
390 self.value = value;
391 Ok(())
392 }
393
394 pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
401 guard_mutation(&self.path, "remove")?;
402 let mut value = self.value.clone();
403 self.format.ensure_writable("remove")?;
404 let keyed_lists = [KeyedList {
405 prefix: key,
406 slug_field,
407 }];
408 let original_array = crate::document::get_path(&value, key, &keyed_lists)?;
409 let removed_index = original_array
410 .as_array()
411 .and_then(|items| {
412 items
413 .iter()
414 .position(|item| item.get(slug_field).and_then(Value::as_str) == Some(slug))
415 })
416 .ok_or_else(|| DocumentError::SlugNotFound {
417 prefix: key.to_string(),
418 slug: slug.to_string(),
419 })?;
420 #[cfg(not(feature = "yaml"))]
421 let _ = removed_index;
422 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 slug,
428 slug_field,
429 )?,
430 #[cfg(feature = "yaml")]
431 Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
432 &self.source,
433 key,
434 removed_index,
435 )?,
436 _ => {
437 return Err(DocumentError::UnsupportedOperation {
438 format: self.format.name().to_string(),
439 operation: "remove".to_string(),
440 detail: "keyed collection source editor is not implemented for this backend"
441 .to_string(),
442 });
443 }
444 };
445 self.save_atomic(&output)?;
446 self.source = output;
447 self.value = value;
448 Ok(())
449 }
450
451 pub fn unset(&mut self, key: &str) -> DocumentResult<()> {
454 guard_mutation(&self.path, "unset")?;
455 let mut value = self.value.clone();
456 self.format.ensure_writable("unset")?;
457 crate::document::unset_path(&mut value, key)?;
458 #[allow(unreachable_patterns)]
459 let output = match self.format {
460 Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
461 #[cfg(feature = "toml")]
462 Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
463 #[cfg(feature = "yaml")]
464 Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
465 #[cfg(feature = "dotenv")]
466 Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
467 #[cfg(feature = "ini")]
468 Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
469 #[cfg(feature = "toml")]
470 Format::TomlFrontmatter => {
471 let parts = crate::document::format::frontmatter::split(
472 &self.source,
473 crate::document::format::frontmatter::Delimiter::Plus,
474 )?;
475 let new_fm =
476 crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
477 format!("{}{}{}", parts.pre, new_fm, parts.post)
478 }
479 #[cfg(feature = "yaml")]
480 Format::YamlFrontmatter => {
481 let parts = crate::document::format::frontmatter::split(
482 &self.source,
483 crate::document::format::frontmatter::Delimiter::Dash,
484 )?;
485 let new_fm =
486 crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
487 format!("{}{}{}", parts.pre, new_fm, parts.post)
488 }
489 _ => self.format.save(&value)?,
490 };
491 self.save_atomic(&output)?;
492 self.source = output;
493 self.value = value;
494 Ok(())
495 }
496
497 pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
513 write_atomic(&self.path, new_source.as_bytes(), "write")
514 }
515}
516
517fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
521 let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
522 detail: format!("{operation} preflight `{}`: {error}", path.display()),
523 })?;
524 if metadata.file_type().is_symlink() {
525 return Err(DocumentError::UnsupportedOperation {
526 format: "filesystem".to_string(),
527 operation: operation.to_string(),
528 detail: format!("refusing to mutate symlink `{}`", path.display()),
529 });
530 }
531 #[cfg(unix)]
532 {
533 use std::os::unix::fs::MetadataExt;
534 if metadata.nlink() > 1 {
535 return Err(DocumentError::UnsupportedOperation {
536 format: "filesystem".to_string(),
537 operation: operation.to_string(),
538 detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
539 });
540 }
541 }
542 Ok(metadata)
543}
544
545fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
548 let metadata = guard_mutation(path, operation)?;
549
550 let parent = path.parent().ok_or_else(|| DocumentError::IoError {
551 detail: format!(
552 "{operation} has no parent directory for `{}`",
553 path.display()
554 ),
555 })?;
556 let file_name = path
557 .file_name()
558 .and_then(|name| name.to_str())
559 .ok_or_else(|| DocumentError::IoError {
560 detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
561 })?;
562 let pid = std::process::id();
563 let mut temp_path = None;
564 let mut temp_file = None;
565 for attempt in 0..32_u32 {
566 let candidate = parent.join(format!(".{file_name}.afdata-document.{pid}.{attempt}.tmp"));
567 match OpenOptions::new()
568 .write(true)
569 .create_new(true)
570 .open(&candidate)
571 {
572 Ok(file) => {
573 temp_path = Some(candidate);
574 temp_file = Some(file);
575 break;
576 }
577 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
578 Err(error) => {
579 return Err(DocumentError::IoError {
580 detail: format!(
581 "{operation} create temporary file in `{}`: {error}",
582 parent.display()
583 ),
584 });
585 }
586 }
587 }
588 let temp_path = temp_path.ok_or_else(|| DocumentError::IoError {
589 detail: format!(
590 "{operation} could not allocate temporary file in `{}`",
591 parent.display()
592 ),
593 })?;
594 let mut temp_file = temp_file.ok_or_else(|| DocumentError::IoError {
595 detail: format!("{operation} temporary file handle missing"),
596 })?;
597 let result = (|| -> DocumentResult<()> {
598 temp_file
599 .write_all(bytes)
600 .map_err(|error| DocumentError::IoError {
601 detail: format!("{operation} write `{}`: {error}", path.display()),
602 })?;
603 temp_file
604 .sync_all()
605 .map_err(|error| DocumentError::IoError {
606 detail: format!("{operation} fsync `{}`: {error}", path.display()),
607 })?;
608 drop(temp_file);
609 fs::set_permissions(&temp_path, metadata.permissions()).map_err(|error| {
610 DocumentError::IoError {
611 detail: format!(
612 "{operation} preserve permissions `{}`: {error}",
613 path.display()
614 ),
615 }
616 })?;
617 fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
618 detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
619 })?;
620 Ok(())
621 })();
622 if result.is_err() {
623 let _ = fs::remove_file(&temp_path);
624 }
625 result
626}
627
628#[cfg(test)]
629mod tests {
630 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
631 use super::*;
632 use std::io::Cursor;
633
634 fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
635 let path = dir.join(name);
636 fs::write(&path, contents).unwrap();
637 path
638 }
639
640 #[test]
641 fn round_trip_open_json() {
642 let dir = tempfile::tempdir().unwrap();
643 let contents = r#"{"host": "example.com", "port": 993}"#;
644 let path = write_temp(dir.path(), "config.json", contents);
645
646 let doc = DocumentFile::open(&path, None).unwrap();
647
648 assert_eq!(doc.format(), Format::Json);
649 assert_eq!(
650 doc.value().get("host").and_then(Value::as_str),
651 Some("example.com")
652 );
653 assert_eq!(doc.source(), contents);
654 }
655
656 #[test]
657 fn value_at_reads_a_nested_address() {
658 let dir = tempfile::tempdir().unwrap();
659 let path = write_temp(
660 dir.path(),
661 "config.json",
662 r#"{"database": {"url": "postgres://x"}}"#,
663 );
664 let doc = DocumentFile::open(&path, None).unwrap();
665
666 assert_eq!(
667 doc.value_at("database.url").unwrap(),
668 Value::String("postgres://x".to_string())
669 );
670 assert_eq!(
671 doc.value_at("database.missing").unwrap_err().code(),
672 "document_path_not_found"
673 );
674 }
675
676 #[test]
677 fn open_capped_enforces_size_and_regular_file() {
678 let dir = tempfile::tempdir().unwrap();
679 let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
680
681 assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
683
684 let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
686 assert_eq!(err.code(), "document_io_failed");
687 assert!(err.to_string().contains("read limit"));
688
689 let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
691 assert_eq!(dir_err.code(), "document_io_failed");
692 }
693
694 #[test]
695 fn typed_get_and_set_enforce_the_stated_type() {
696 use crate::document::ValueType;
697 let dir = tempfile::tempdir().unwrap();
698 let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
699 let mut doc = DocumentFile::open(&path, None).unwrap();
700
701 assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
704 assert_eq!(
705 doc.value_at_typed("port", ValueType::String)
706 .unwrap_err()
707 .code(),
708 "document_type_mismatch"
709 );
710 assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
711
712 doc.set_typed("port", Some("9090"), ValueType::Number)
714 .unwrap();
715 assert_eq!(
716 doc.value_at("port").unwrap(),
717 Value::from(serde_json::json!(9090))
718 );
719 assert_eq!(
720 doc.set_typed("port", Some("not-a-number"), ValueType::Number)
721 .unwrap_err()
722 .code(),
723 "document_parse_failed"
724 );
725 }
726
727 #[cfg(feature = "toml")]
728 #[test]
729 fn round_trip_open_toml() {
730 let dir = tempfile::tempdir().unwrap();
731 let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
732 let path = write_temp(dir.path(), "config.toml", contents);
733
734 let doc = DocumentFile::open(&path, None).unwrap();
735
736 assert_eq!(doc.format(), Format::Toml);
737 assert_eq!(
738 doc.value().get("host").and_then(Value::as_str),
739 Some("example.com")
740 );
741 assert_eq!(doc.source(), contents);
742 }
743
744 #[cfg(feature = "toml")]
745 #[test]
746 fn set_scalar_preserves_toml_comments_and_formatting() {
747 let dir = tempfile::tempdir().unwrap();
748 let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
749 let path = write_temp(dir.path(), "config.toml", contents);
750 let mut doc = DocumentFile::open(&path, None).unwrap();
751
752 doc.set("port", Value::Integer(1024)).unwrap();
753
754 let saved = fs::read_to_string(&path).unwrap();
755 assert!(saved.contains("# leading comment"));
756 assert!(saved.contains("port = 1024"));
757 assert_eq!(
758 doc.value().get("port").and_then(Value::as_integer),
759 Some(1024)
760 );
761 assert_eq!(doc.source(), saved);
762 }
763
764 #[cfg(unix)]
765 #[test]
766 fn atomic_save_preserves_file_mode() {
767 use std::os::unix::fs::PermissionsExt;
768
769 let dir = tempfile::tempdir().unwrap();
770 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
771 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
772 let mut doc = DocumentFile::open(&path, None).unwrap();
773
774 doc.set("port", Value::Integer(1024)).unwrap();
775
776 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
777 assert_eq!(mode, 0o640);
778 }
779
780 #[cfg(unix)]
781 #[test]
782 fn symlink_target_is_rejected_for_mutation() {
783 let dir = tempfile::tempdir().unwrap();
784 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
785 let link = dir.path().join("link.json");
786 std::os::unix::fs::symlink(&target, &link).unwrap();
787
788 let mut doc = DocumentFile::open(&link, None).unwrap();
790
791 let err = doc.set("port", Value::Integer(1024)).unwrap_err();
793 assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
794
795 let target_contents = fs::read_to_string(&target).unwrap();
797 assert_eq!(target_contents, r#"{"port": 993}"#);
798 }
799
800 #[test]
801 fn from_reader_parses_in_memory_cursor() {
802 let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
803
804 let doc = Document::from_reader(cursor, Format::Json).unwrap();
805
806 assert_eq!(
807 doc.value().get("host").and_then(Value::as_str),
808 Some("example.com")
809 );
810 }
811
812 #[test]
813 fn document_from_str_encode_round_trip() {
814 let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
815 let encoded = doc.encode().unwrap();
816 let reparsed = Document::parse(&encoded, Format::Json).unwrap();
817 assert_eq!(
818 reparsed.value().get("a").and_then(Value::as_integer),
819 Some(1)
820 );
821 }
822}