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 format(&self) -> Format {
198 self.format
199 }
200
201 pub fn source(&self) -> &str {
204 &self.source
205 }
206
207 pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
217 guard_mutation(&self.path, operation)?;
218 Ok(())
219 }
220
221 pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
224 guard_mutation(&self.path, "set")?;
225 let mut new_doc = self.value.clone();
226 self.format.ensure_writable("set")?;
227 crate::document::set_path(&mut new_doc, key, &value, &[])?;
228 let target = crate::document::get_path(&new_doc, key, &[])?;
229 #[allow(unreachable_patterns)]
230 let output = match self.format {
231 #[cfg(feature = "toml")]
232 Format::Toml => {
233 crate::document::format::toml::set_scalar_preserving(&self.source, key, &target)?
234 }
235 #[cfg(feature = "yaml")]
236 Format::Yaml => {
237 crate::document::format::yaml::set_scalar_preserving(&self.source, key, &target)?
238 }
239 Format::Json => {
240 crate::document::format::json::set_scalar_preserving(&self.source, key, &target)?
241 }
242 #[cfg(feature = "dotenv")]
243 Format::Dotenv => {
244 crate::document::format::dotenv::set_scalar_preserving(&self.source, key, &target)?
245 }
246 #[cfg(feature = "ini")]
247 Format::Ini => {
248 crate::document::format::ini::set_scalar_preserving(&self.source, key, &target)?
249 }
250 #[cfg(feature = "toml")]
251 Format::TomlFrontmatter => {
252 let parts = crate::document::format::frontmatter::split(
253 &self.source,
254 crate::document::format::frontmatter::Delimiter::Plus,
255 )?;
256 let new_fm = crate::document::format::toml::set_scalar_preserving(
257 parts.frontmatter,
258 key,
259 &target,
260 )?;
261 format!("{}{}{}", parts.pre, new_fm, parts.post)
262 }
263 #[cfg(feature = "yaml")]
264 Format::YamlFrontmatter => {
265 let parts = crate::document::format::frontmatter::split(
266 &self.source,
267 crate::document::format::frontmatter::Delimiter::Dash,
268 )?;
269 let new_fm = crate::document::format::yaml::set_scalar_preserving(
270 parts.frontmatter,
271 key,
272 &target,
273 )?;
274 format!("{}{}{}", parts.pre, new_fm, parts.post)
275 }
276 _ => self.format.save(&new_doc)?,
277 };
278 self.save_atomic(&output)?;
279 self.source = output;
280 self.value = new_doc;
281 Ok(())
282 }
283
284 pub fn add(
292 &mut self,
293 key: &str,
294 slug: &str,
295 slug_field: &str,
296 fields: &[(String, Value)],
297 ) -> DocumentResult<()> {
298 guard_mutation(&self.path, "add")?;
299 let mut value = self.value.clone();
300 self.format.ensure_writable("add")?;
301 let keyed_lists = [KeyedList {
302 prefix: key,
303 slug_field,
304 }];
305 crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
306 let output: String = match self.format {
307 Format::Json => {
308 let array = crate::document::get_path(&value, key, &keyed_lists)?;
309 let item = array
310 .as_array()
311 .and_then(|items| items.last())
312 .ok_or_else(|| DocumentError::UnsupportedOperation {
313 format: "JSON".to_string(),
314 operation: "add".to_string(),
315 detail: "keyed list did not produce an array item".to_string(),
316 })?;
317 crate::document::format::json::append_array_item_preserving(
318 &self.source,
319 key,
320 item,
321 )?
322 }
323 #[cfg(feature = "yaml")]
324 Format::Yaml => {
325 let array = crate::document::get_path(&value, key, &keyed_lists)?;
326 let item = array
327 .as_array()
328 .and_then(|items| items.last())
329 .ok_or_else(|| DocumentError::UnsupportedOperation {
330 format: "YAML".to_string(),
331 operation: "add".to_string(),
332 detail: "keyed list did not produce an array item".to_string(),
333 })?;
334 crate::document::format::yaml::append_array_item_preserving(
335 &self.source,
336 key,
337 item,
338 )?
339 }
340 _ => {
341 return Err(DocumentError::UnsupportedOperation {
342 format: self.format.name().to_string(),
343 operation: "add".to_string(),
344 detail: "keyed collection source editor is not implemented for this backend"
345 .to_string(),
346 });
347 }
348 };
349 self.save_atomic(&output)?;
350 self.source = output;
351 self.value = value;
352 Ok(())
353 }
354
355 pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
362 guard_mutation(&self.path, "remove")?;
363 let mut value = self.value.clone();
364 self.format.ensure_writable("remove")?;
365 let keyed_lists = [KeyedList {
366 prefix: key,
367 slug_field,
368 }];
369 let original_array = crate::document::get_path(&value, key, &keyed_lists)?;
370 let removed_index = original_array
371 .as_array()
372 .and_then(|items| {
373 items
374 .iter()
375 .position(|item| item.get(slug_field).and_then(Value::as_str) == Some(slug))
376 })
377 .ok_or_else(|| DocumentError::SlugNotFound {
378 prefix: key.to_string(),
379 slug: slug.to_string(),
380 })?;
381 #[cfg(not(feature = "yaml"))]
382 let _ = removed_index;
383 crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
384 let output: String = match self.format {
385 Format::Json => crate::document::format::json::remove_array_item_preserving(
386 &self.source,
387 key,
388 slug,
389 slug_field,
390 )?,
391 #[cfg(feature = "yaml")]
392 Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
393 &self.source,
394 key,
395 removed_index,
396 )?,
397 _ => {
398 return Err(DocumentError::UnsupportedOperation {
399 format: self.format.name().to_string(),
400 operation: "remove".to_string(),
401 detail: "keyed collection source editor is not implemented for this backend"
402 .to_string(),
403 });
404 }
405 };
406 self.save_atomic(&output)?;
407 self.source = output;
408 self.value = value;
409 Ok(())
410 }
411
412 pub fn unset(&mut self, key: &str) -> DocumentResult<()> {
415 guard_mutation(&self.path, "unset")?;
416 let mut value = self.value.clone();
417 self.format.ensure_writable("unset")?;
418 crate::document::unset_path(&mut value, key)?;
419 #[allow(unreachable_patterns)]
420 let output = match self.format {
421 Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
422 #[cfg(feature = "toml")]
423 Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
424 #[cfg(feature = "yaml")]
425 Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
426 #[cfg(feature = "dotenv")]
427 Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
428 #[cfg(feature = "ini")]
429 Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
430 #[cfg(feature = "toml")]
431 Format::TomlFrontmatter => {
432 let parts = crate::document::format::frontmatter::split(
433 &self.source,
434 crate::document::format::frontmatter::Delimiter::Plus,
435 )?;
436 let new_fm =
437 crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
438 format!("{}{}{}", parts.pre, new_fm, parts.post)
439 }
440 #[cfg(feature = "yaml")]
441 Format::YamlFrontmatter => {
442 let parts = crate::document::format::frontmatter::split(
443 &self.source,
444 crate::document::format::frontmatter::Delimiter::Dash,
445 )?;
446 let new_fm =
447 crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
448 format!("{}{}{}", parts.pre, new_fm, parts.post)
449 }
450 _ => self.format.save(&value)?,
451 };
452 self.save_atomic(&output)?;
453 self.source = output;
454 self.value = value;
455 Ok(())
456 }
457
458 pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
474 write_atomic(&self.path, new_source.as_bytes(), "write")
475 }
476}
477
478fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
482 let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
483 detail: format!("{operation} preflight `{}`: {error}", path.display()),
484 })?;
485 if metadata.file_type().is_symlink() {
486 return Err(DocumentError::UnsupportedOperation {
487 format: "filesystem".to_string(),
488 operation: operation.to_string(),
489 detail: format!("refusing to mutate symlink `{}`", path.display()),
490 });
491 }
492 #[cfg(unix)]
493 {
494 use std::os::unix::fs::MetadataExt;
495 if metadata.nlink() > 1 {
496 return Err(DocumentError::UnsupportedOperation {
497 format: "filesystem".to_string(),
498 operation: operation.to_string(),
499 detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
500 });
501 }
502 }
503 Ok(metadata)
504}
505
506fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
509 let metadata = guard_mutation(path, operation)?;
510
511 let parent = path.parent().ok_or_else(|| DocumentError::IoError {
512 detail: format!(
513 "{operation} has no parent directory for `{}`",
514 path.display()
515 ),
516 })?;
517 let file_name = path
518 .file_name()
519 .and_then(|name| name.to_str())
520 .ok_or_else(|| DocumentError::IoError {
521 detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
522 })?;
523 let pid = std::process::id();
524 let mut temp_path = None;
525 let mut temp_file = None;
526 for attempt in 0..32_u32 {
527 let candidate = parent.join(format!(".{file_name}.afdata-document.{pid}.{attempt}.tmp"));
528 match OpenOptions::new()
529 .write(true)
530 .create_new(true)
531 .open(&candidate)
532 {
533 Ok(file) => {
534 temp_path = Some(candidate);
535 temp_file = Some(file);
536 break;
537 }
538 Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
539 Err(error) => {
540 return Err(DocumentError::IoError {
541 detail: format!(
542 "{operation} create temporary file in `{}`: {error}",
543 parent.display()
544 ),
545 });
546 }
547 }
548 }
549 let temp_path = temp_path.ok_or_else(|| DocumentError::IoError {
550 detail: format!(
551 "{operation} could not allocate temporary file in `{}`",
552 parent.display()
553 ),
554 })?;
555 let mut temp_file = temp_file.ok_or_else(|| DocumentError::IoError {
556 detail: format!("{operation} temporary file handle missing"),
557 })?;
558 let result = (|| -> DocumentResult<()> {
559 temp_file
560 .write_all(bytes)
561 .map_err(|error| DocumentError::IoError {
562 detail: format!("{operation} write `{}`: {error}", path.display()),
563 })?;
564 temp_file
565 .sync_all()
566 .map_err(|error| DocumentError::IoError {
567 detail: format!("{operation} fsync `{}`: {error}", path.display()),
568 })?;
569 drop(temp_file);
570 fs::set_permissions(&temp_path, metadata.permissions()).map_err(|error| {
571 DocumentError::IoError {
572 detail: format!(
573 "{operation} preserve permissions `{}`: {error}",
574 path.display()
575 ),
576 }
577 })?;
578 fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
579 detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
580 })?;
581 Ok(())
582 })();
583 if result.is_err() {
584 let _ = fs::remove_file(&temp_path);
585 }
586 result
587}
588
589#[cfg(test)]
590mod tests {
591 #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
592 use super::*;
593 use std::io::Cursor;
594
595 fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
596 let path = dir.join(name);
597 fs::write(&path, contents).unwrap();
598 path
599 }
600
601 #[test]
602 fn round_trip_open_json() {
603 let dir = tempfile::tempdir().unwrap();
604 let contents = r#"{"host": "example.com", "port": 993}"#;
605 let path = write_temp(dir.path(), "config.json", contents);
606
607 let doc = DocumentFile::open(&path, None).unwrap();
608
609 assert_eq!(doc.format(), Format::Json);
610 assert_eq!(
611 doc.value().get("host").and_then(Value::as_str),
612 Some("example.com")
613 );
614 assert_eq!(doc.source(), contents);
615 }
616
617 #[test]
618 fn value_at_reads_a_nested_address() {
619 let dir = tempfile::tempdir().unwrap();
620 let path = write_temp(
621 dir.path(),
622 "config.json",
623 r#"{"database": {"url": "postgres://x"}}"#,
624 );
625 let doc = DocumentFile::open(&path, None).unwrap();
626
627 assert_eq!(
628 doc.value_at("database.url").unwrap(),
629 Value::String("postgres://x".to_string())
630 );
631 assert_eq!(
632 doc.value_at("database.missing").unwrap_err().code(),
633 "document_path_not_found"
634 );
635 }
636
637 #[test]
638 fn open_capped_enforces_size_and_regular_file() {
639 let dir = tempfile::tempdir().unwrap();
640 let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
641
642 assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
644
645 let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
647 assert_eq!(err.code(), "document_io_failed");
648 assert!(err.to_string().contains("read limit"));
649
650 let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
652 assert_eq!(dir_err.code(), "document_io_failed");
653 }
654
655 #[cfg(feature = "toml")]
656 #[test]
657 fn round_trip_open_toml() {
658 let dir = tempfile::tempdir().unwrap();
659 let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
660 let path = write_temp(dir.path(), "config.toml", contents);
661
662 let doc = DocumentFile::open(&path, None).unwrap();
663
664 assert_eq!(doc.format(), Format::Toml);
665 assert_eq!(
666 doc.value().get("host").and_then(Value::as_str),
667 Some("example.com")
668 );
669 assert_eq!(doc.source(), contents);
670 }
671
672 #[cfg(feature = "toml")]
673 #[test]
674 fn set_scalar_preserves_toml_comments_and_formatting() {
675 let dir = tempfile::tempdir().unwrap();
676 let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
677 let path = write_temp(dir.path(), "config.toml", contents);
678 let mut doc = DocumentFile::open(&path, None).unwrap();
679
680 doc.set("port", Value::Integer(1024)).unwrap();
681
682 let saved = fs::read_to_string(&path).unwrap();
683 assert!(saved.contains("# leading comment"));
684 assert!(saved.contains("port = 1024"));
685 assert_eq!(
686 doc.value().get("port").and_then(Value::as_integer),
687 Some(1024)
688 );
689 assert_eq!(doc.source(), saved);
690 }
691
692 #[cfg(unix)]
693 #[test]
694 fn atomic_save_preserves_file_mode() {
695 use std::os::unix::fs::PermissionsExt;
696
697 let dir = tempfile::tempdir().unwrap();
698 let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
699 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
700 let mut doc = DocumentFile::open(&path, None).unwrap();
701
702 doc.set("port", Value::Integer(1024)).unwrap();
703
704 let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
705 assert_eq!(mode, 0o640);
706 }
707
708 #[cfg(unix)]
709 #[test]
710 fn symlink_target_is_rejected_for_mutation() {
711 let dir = tempfile::tempdir().unwrap();
712 let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
713 let link = dir.path().join("link.json");
714 std::os::unix::fs::symlink(&target, &link).unwrap();
715
716 let mut doc = DocumentFile::open(&link, None).unwrap();
718
719 let err = doc.set("port", Value::Integer(1024)).unwrap_err();
721 assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
722
723 let target_contents = fs::read_to_string(&target).unwrap();
725 assert_eq!(target_contents, r#"{"port": 993}"#);
726 }
727
728 #[test]
729 fn from_reader_parses_in_memory_cursor() {
730 let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
731
732 let doc = Document::from_reader(cursor, Format::Json).unwrap();
733
734 assert_eq!(
735 doc.value().get("host").and_then(Value::as_str),
736 Some("example.com")
737 );
738 }
739
740 #[test]
741 fn document_from_str_encode_round_trip() {
742 let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
743 let encoded = doc.encode().unwrap();
744 let reparsed = Document::parse(&encoded, Format::Json).unwrap();
745 assert_eq!(
746 reparsed.value().get("a").and_then(Value::as_integer),
747 Some(1)
748 );
749 }
750}