Skip to main content

agent_first_data/document/
file.rs

1//! Format-neutral in-memory document value, plus a file-backed document
2//! facade with safe, source-preserving edits.
3//!
4//! [`DocumentFile`] lifts the file-safety and source-preserving edit
5//! orchestration that previously lived only in a CLI binary: mutation
6//! methods refuse to write through a symlink or (on unix) a hardlinked
7//! file, and every write goes to a same-directory temp file that is
8//! fsynced, has the original file's permissions re-applied, and is
9//! atomically renamed over the target — so a crash or error mid-write never
10//! leaves a partial file.
11//!
12//! This module never redacts values on decode/encode/save/edit — it reads
13//! and writes raw values as-is; redaction is the caller's responsibility.
14//!
15//! # Capability matrix
16//!
17//! - [`Document`] (in-memory): [`Document::value_mut`] allows arbitrary
18//!   in-memory edits; [`Document::encode`] re-renders the value fresh from
19//!   scratch — formatting and comments are NOT preserved. No file, no atomic
20//!   write.
21//! - [`DocumentFile`] (file-backed): reads via [`DocumentFile::value`] (paired
22//!   with the free function [`crate::document::get_path`]), and
23//!   source-preserving typed write verbs [`DocumentFile::set`]/
24//!   [`DocumentFile::unset`]/[`DocumentFile::add`]/[`DocumentFile::remove`];
25//!   every write is atomic (symlink/hardlink-guarded temp file + fsync +
26//!   permission-preserving rename). There is no `value_mut` — edits go
27//!   through the verbs above so the original source's formatting survives.
28
29use 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/// A format-neutral in-memory document: a parsed [`Value`] plus the
36/// [`Format`] it was parsed from. Has no file or stdin coupling — construct
37/// it from a string or any [`std::io::Read`] the caller supplies.
38#[derive(Debug, Clone)]
39pub struct Document {
40    value: Value,
41    format: Format,
42}
43
44impl Document {
45    /// Parse `source` in the given `format`.
46    ///
47    /// Named `parse` (not `from_str`) deliberately: this takes an explicit
48    /// `format` argument, so it is not the single-argument `std::str::FromStr`
49    /// contract that `from_str` would imply.
50    pub fn parse(source: &str, format: Format) -> DocumentResult<Document> {
51        let value = format.load(source)?;
52        Ok(Document { value, format })
53    }
54
55    /// Read `reader` fully to a `String`, then parse it in the given
56    /// `format`.
57    ///
58    /// Reads only from the supplied `reader` — never touches the process's
59    /// own stdin.
60    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    /// Borrow the parsed value.
70    pub fn value(&self) -> &Value {
71        &self.value
72    }
73
74    /// Mutably borrow the parsed value.
75    pub fn value_mut(&mut self) -> &mut Value {
76        &mut self.value
77    }
78
79    /// The format this document was parsed from.
80    pub fn format(&self) -> Format {
81        self.format
82    }
83
84    /// Re-render the current value in its format via [`Format::save`].
85    ///
86    /// This is a fresh, non-source-preserving render: comments and original
87    /// formatting are not retained. Use [`DocumentFile`] when the original
88    /// source's formatting must survive an edit.
89    pub fn encode(&self) -> DocumentResult<String> {
90        self.format.save(&self.value)
91    }
92}
93
94/// A file-backed document: owns the path, format, original source text, and
95/// parsed value.
96///
97/// Reading (`open`) is always allowed. Mutation methods guard against unsafe
98/// targets (symlinks, hardlinked files) and write through a same-directory
99/// temp file that is atomically renamed over the original — see
100/// [`DocumentFile::save_atomic`].
101#[derive(Debug)]
102pub struct DocumentFile {
103    path: PathBuf,
104    format: Format,
105    source: String,
106    value: Value,
107}
108
109impl DocumentFile {
110    /// Open and parse `path`.
111    ///
112    /// `format_override` takes precedence; otherwise the format is detected
113    /// from the file extension via [`Format::detect`]. Reading is always
114    /// allowed — this does not run the mutation guard.
115    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    /// Open and parse `path` like [`DocumentFile::open`], but first reject any
143    /// non-regular file, or any file larger than `max_bytes`, without reading
144    /// its contents.
145    ///
146    /// Use this over [`open`](DocumentFile::open) when reading untrusted or
147    /// secret-bearing config, where an unbounded read of an arbitrary path is
148    /// a denial-of-service risk.
149    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    /// The file path this document was opened from.
175    pub fn path(&self) -> &Path {
176        &self.path
177    }
178
179    /// Borrow the currently parsed value (reflects the last successful
180    /// edit, if any).
181    pub fn value(&self) -> &Value {
182        &self.value
183    }
184
185    /// Resolve a dotted `path` against the parsed document and return the value
186    /// at that address.
187    ///
188    /// One-call read counterpart to [`set`](DocumentFile::set): equivalent to
189    /// [`value`](DocumentFile::value) followed by
190    /// [`crate::document::get_path`], for callers that only need to read one
191    /// address out of a file.
192    pub fn value_at(&self, path: &str) -> DocumentResult<Value> {
193        crate::document::get_path(&self.value, path, &[])
194    }
195
196    /// The format this file was opened as.
197    pub fn format(&self) -> Format {
198        self.format
199    }
200
201    /// Borrow the current source text (reflects the last successful edit,
202    /// if any).
203    pub fn source(&self) -> &str {
204        &self.source
205    }
206
207    /// Preflight-check that this file is safe to mutate — not a symlink, and
208    /// on unix not hardlinked — without performing any write.
209    ///
210    /// Every mutation method ([`DocumentFile::set`] and friends) already
211    /// runs this same guard itself before writing, so calling it directly is
212    /// only useful when a caller must front-run a *separate* side effect with
213    /// the same guarantee — for example, a CLI that reads a secret from stdin
214    /// for `set` should refuse an unsafe target before consuming that input,
215    /// not after.
216    pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
217        guard_mutation(&self.path, operation)?;
218        Ok(())
219    }
220
221    /// Set `key` to the typed `value`, preserving the rest of the source
222    /// document.
223    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    /// Add a new element to the keyed list at `key`, identified by
285    /// `slug`/`slug_field`, with the given `fields`. Preserves the rest of
286    /// the source document.
287    ///
288    /// Only JSON and YAML backends implement a source-preserving
289    /// keyed-collection editor today; other formats return
290    /// [`DocumentError::UnsupportedOperation`].
291    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    /// Remove the element identified by `slug`/`slug_field` from the keyed
356    /// list at `key`. Preserves the rest of the source document.
357    ///
358    /// Only JSON and YAML backends implement a source-preserving
359    /// keyed-collection editor today; other formats return
360    /// [`DocumentError::UnsupportedOperation`].
361    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    /// Remove the entry at `key` entirely. Preserves the rest of the source
413    /// document.
414    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    /// Atomically replace the file's contents with `new_source`: guard
459    /// against symlinks/hardlinked files, write to a same-directory temp
460    /// file, fsync it, re-apply the original file's permissions, then
461    /// `rename` it over the target. No partial write is ever observable —
462    /// on any failure the temp file is removed and the original file is
463    /// untouched.
464    ///
465    /// This does not update the in-memory [`DocumentFile::source`] /
466    /// [`DocumentFile::value`] — the mutation methods do that themselves
467    /// after a successful write.
468    ///
469    /// Crate-internal: this is the raw-string write seam the typed verbs
470    /// (`set`/`unset`/`add`/`remove`) route through after computing a
471    /// source-preserving rendering; it is not part of the public API, so
472    /// callers cannot bypass the typed verbs to write arbitrary raw text.
473    pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
474        write_atomic(&self.path, new_source.as_bytes(), "write")
475    }
476}
477
478/// Reject mutation of a symlink or (on unix) a hardlinked file. Returns the
479/// target's metadata on success so callers that also need to write can reuse
480/// it (e.g. to preserve permissions) without a second syscall.
481fn 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
506/// Write `bytes` to `path` atomically: guard, same-directory temp file,
507/// fsync, permission preservation, then rename over the target.
508fn 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        // Within the cap: opens normally.
643        assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
644
645        // Over the cap: rejected without parsing, as an io failure.
646        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        // A directory is not a regular file.
651        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        // Reading through the symlink is fine.
717        let mut doc = DocumentFile::open(&link, None).unwrap();
718
719        // Mutating through it is not.
720        let err = doc.set("port", Value::Integer(1024)).unwrap_err();
721        assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
722
723        // The target file was never touched.
724        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}