Skip to main content

agent_first_data/document/
file.rs

1//! Format-neutral document editing: an in-memory [`Document`] with
2//! source-preserving edits, and a [`DocumentFile`] that adds the file boundary.
3//!
4//! [`Document`] holds the source text, its parsed [`Value`], and the
5//! [`Format`]. Its verbs — [`set`](Document::set) / [`unset`](Document::unset)
6//! / [`add`](Document::add) / [`remove`](Document::remove) — edit the source in
7//! place (comments, ordering, and untouched formatting survive) and update the
8//! parsed value alongside; [`source`](Document::source) reads the result and
9//! [`encode`](Document::encode) re-renders a fresh, non-preserving copy from the
10//! value. No file, no I/O, no guards — just editing.
11//!
12//! [`DocumentFile`] is a [`Document`] plus a path, reachable through
13//! [`Deref`](std::ops::Deref): read and edit exactly as above, then commit with
14//! [`save`](DocumentFile::save) or the [`edit`](DocumentFile::edit) closure.
15//! Every write refuses a symlink/hardlinked target and goes to a
16//! same-directory temp file that is fsynced, has the original permissions
17//! re-applied, and is atomically renamed over the target — so a crash mid-write
18//! never leaves a partial file.
19//!
20//! This module never redacts values — it reads and writes raw values as-is;
21//! redaction is the caller's responsibility.
22
23use 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/// A format-neutral in-memory document: the original source text, its parsed
30/// [`Value`], and the [`Format`] both came from. Has no file coupling —
31/// construct it from a string or any [`std::io::Read`] the caller supplies,
32/// edit it source-preservingly with [`set`](Document::set) /
33/// [`unset`](Document::unset) / [`add`](Document::add) /
34/// [`remove`](Document::remove), and read the result back with
35/// [`source`](Document::source). [`DocumentFile`] is just this plus a path.
36#[derive(Debug, Clone)]
37pub struct Document {
38    source: String,
39    value: Value,
40    format: Format,
41}
42
43impl Document {
44    /// Parse `source` in the given `format`.
45    ///
46    /// Named `parse` (not `from_str`) deliberately: this takes an explicit
47    /// `format` argument, so it is not the single-argument `std::str::FromStr`
48    /// contract that `from_str` would imply.
49    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    /// Read `reader` fully to a `String`, then parse it in the given
59    /// `format`.
60    ///
61    /// Reads only from the supplied `reader` — never touches the process's
62    /// own stdin.
63    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    /// Borrow the parsed value (reflects the last successful edit).
73    pub fn value(&self) -> &Value {
74        &self.value
75    }
76
77    /// Borrow the current source text — the original bytes with every
78    /// source-preserving edit applied. This is what [`DocumentFile::save`]
79    /// writes.
80    pub fn source(&self) -> &str {
81        &self.source
82    }
83
84    /// The format this document was parsed from.
85    pub fn format(&self) -> Format {
86        self.format
87    }
88
89    /// Resolve a dotted `path` against the parsed document and return the value
90    /// at that address.
91    pub fn value_at(&self, path: &str) -> DocumentResult<Value> {
92        crate::document::get_path(&self.value, path, &[])
93    }
94
95    /// [`value_at`](Document::value_at) that also asserts the value at `path`
96    /// satisfies `expected`, returning a [`DocumentError::TypeMismatch`]
97    /// otherwise.
98    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    /// Build a value from the CLI string `raw` per an explicit
117    /// [`ValueType`](crate::document::ValueType) and [`set`](Document::set) it.
118    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    /// Re-render the current value in its format via [`Format::save`].
129    ///
130    /// This is a fresh, non-source-preserving render: comments and original
131    /// formatting are not retained. Use [`source`](Document::source) after
132    /// source-preserving edits to keep the original formatting.
133    pub fn encode(&self) -> DocumentResult<String> {
134        self.format.save(&self.value)
135    }
136}
137
138/// A file-backed [`Document`]: the in-memory document plus the path it was
139/// read from.
140///
141/// All reads and source-preserving edits come from [`Document`] through
142/// [`Deref`]/[`DerefMut`]; `DocumentFile` adds only the file boundary — reading
143/// on [`open`](DocumentFile::open) and an atomic, symlink-guarded commit on
144/// [`save`](DocumentFile::save) / [`edit`](DocumentFile::edit).
145#[derive(Debug, Clone)]
146pub struct DocumentFile {
147    doc: Document,
148    path: PathBuf,
149}
150
151impl DocumentFile {
152    /// Open and parse `path`.
153    ///
154    /// `format_override` takes precedence; otherwise the format is detected
155    /// from the file extension via [`Format::detect`]. Reading is always
156    /// allowed — this does not run the mutation guard.
157    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::FormatUnknown {
165                path: path.display().to_string(),
166            })?,
167        };
168        let source = fs::read_to_string(&path).map_err(|error| DocumentError::IoError {
169            detail: format!("read `{}`: {error}", path.display()),
170        })?;
171        Ok(DocumentFile {
172            doc: Document::parse(&source, format)?,
173            path,
174        })
175    }
176
177    /// Open and parse `path` like [`DocumentFile::open`], but first reject any
178    /// non-regular file, or any file larger than `max_bytes`, without reading
179    /// its contents.
180    ///
181    /// Use this over [`open`](DocumentFile::open) when reading untrusted or
182    /// secret-bearing config, where an unbounded read of an arbitrary path is
183    /// a denial-of-service risk.
184    pub fn open_capped(
185        path: impl AsRef<Path>,
186        format_override: Option<Format>,
187        max_bytes: u64,
188    ) -> DocumentResult<DocumentFile> {
189        let path = path.as_ref();
190        let metadata = fs::metadata(path).map_err(|error| DocumentError::IoError {
191            detail: format!("read `{}`: {error}", path.display()),
192        })?;
193        if !metadata.is_file() {
194            return Err(DocumentError::IoError {
195                detail: format!("`{}` is not a regular file", path.display()),
196            });
197        }
198        if metadata.len() > max_bytes {
199            return Err(DocumentError::IoError {
200                detail: format!(
201                    "`{}` exceeds the {max_bytes}-byte read limit",
202                    path.display()
203                ),
204            });
205        }
206        DocumentFile::open(path, format_override)
207    }
208
209    /// The file path this document was opened from.
210    pub fn path(&self) -> &Path {
211        &self.path
212    }
213
214    /// Preflight-check that this file is safe to mutate — not a symlink, and
215    /// on unix not hardlinked — without performing any write.
216    ///
217    /// [`save`](DocumentFile::save) runs this same guard before it writes, so
218    /// calling it directly is only useful to front-run a *separate* side effect
219    /// with the same guarantee — e.g. a CLI reading a secret from stdin for a
220    /// `set` should refuse an unsafe target before consuming that input.
221    pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
222        guard_mutation(&self.path, operation)?;
223        Ok(())
224    }
225}
226
227impl Document {
228    /// Set `key` to the typed `value`, preserving the rest of the source
229    /// document. The edit is staged in memory — call
230    /// [`save`](DocumentFile::save) to persist it.
231    ///
232    /// Backend capability mirrors [`crate::document::set_path`] where the
233    /// source editor allows it: the JSON backend replaces an existing value
234    /// (scalar or collection) and creates missing intermediate parent objects;
235    /// the TOML backend creates missing parent tables. Backends that cannot
236    /// express an edit source-preserving (e.g. YAML collection mutation) return
237    /// [`DocumentError::UnsupportedOperation`].
238    pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
239        let mut new_doc = self.value.clone();
240        crate::document::set_path(&mut new_doc, key, &value, &[])?;
241        let target = crate::document::get_path(&new_doc, key, &[])?;
242        #[allow(unreachable_patterns)]
243        let output = match self.format {
244            #[cfg(feature = "toml")]
245            Format::Toml => {
246                crate::document::format::toml::set_preserving(&self.source, key, &target)?
247            }
248            #[cfg(feature = "yaml")]
249            Format::Yaml => {
250                crate::document::format::yaml::set_preserving(&self.source, key, &target)?
251            }
252            Format::Json => {
253                crate::document::format::json::set_preserving(&self.source, key, &target)?
254            }
255            #[cfg(feature = "dotenv")]
256            Format::Dotenv => {
257                crate::document::format::dotenv::set_preserving(&self.source, key, &target)?
258            }
259            #[cfg(feature = "ini")]
260            Format::Ini => {
261                crate::document::format::ini::set_preserving(&self.source, key, &target)?
262            }
263            #[cfg(feature = "toml")]
264            Format::TomlFrontmatter => {
265                let parts = crate::document::format::frontmatter::split(
266                    &self.source,
267                    crate::document::format::frontmatter::Delimiter::Plus,
268                )?;
269                let new_fm =
270                    crate::document::format::toml::set_preserving(parts.frontmatter, key, &target)?;
271                format!("{}{}{}", parts.pre, new_fm, parts.post)
272            }
273            #[cfg(feature = "yaml")]
274            Format::YamlFrontmatter => {
275                let parts = crate::document::format::frontmatter::split(
276                    &self.source,
277                    crate::document::format::frontmatter::Delimiter::Dash,
278                )?;
279                let new_fm =
280                    crate::document::format::yaml::set_preserving(parts.frontmatter, key, &target)?;
281                format!("{}{}{}", parts.pre, new_fm, parts.post)
282            }
283            _ => self.format.save(&new_doc)?,
284        };
285        self.source = output;
286        self.value = new_doc;
287        Ok(())
288    }
289
290    /// Add a new element to the keyed list at `key`, identified by
291    /// `slug`/`slug_field`, with the given `fields`. Preserves the rest of
292    /// the source document.
293    ///
294    /// Only JSON and YAML backends implement a source-preserving
295    /// keyed-collection editor today; other formats return
296    /// [`DocumentError::UnsupportedOperation`].
297    pub fn add(
298        &mut self,
299        key: &str,
300        slug: &str,
301        slug_field: &str,
302        fields: &[(String, Value)],
303    ) -> DocumentResult<()> {
304        let mut value = self.value.clone();
305        let keyed_lists = [KeyedList {
306            prefix: key,
307            slug_field,
308        }];
309        crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
310        let output: String = match self.format {
311            Format::Json => {
312                let array = crate::document::get_path(&value, key, &keyed_lists)?;
313                let item = array
314                    .as_array()
315                    .and_then(|items| items.last())
316                    .ok_or_else(|| DocumentError::UnsupportedOperation {
317                        format: "JSON".to_string(),
318                        operation: "add".to_string(),
319                        detail: "keyed list did not produce an array item".to_string(),
320                    })?;
321                crate::document::format::json::append_array_item_preserving(
322                    &self.source,
323                    key,
324                    item,
325                )?
326            }
327            #[cfg(feature = "yaml")]
328            Format::Yaml => {
329                let array = crate::document::get_path(&value, key, &keyed_lists)?;
330                let item = array
331                    .as_array()
332                    .and_then(|items| items.last())
333                    .ok_or_else(|| DocumentError::UnsupportedOperation {
334                        format: "YAML".to_string(),
335                        operation: "add".to_string(),
336                        detail: "keyed list did not produce an array item".to_string(),
337                    })?;
338                crate::document::format::yaml::append_array_item_preserving(
339                    &self.source,
340                    key,
341                    item,
342                )?
343            }
344            _ => {
345                return Err(DocumentError::UnsupportedOperation {
346                    format: self.format.name().to_string(),
347                    operation: "add".to_string(),
348                    detail: "keyed collection source editor is not implemented for this backend"
349                        .to_string(),
350                });
351            }
352        };
353        self.source = output;
354        self.value = value;
355        Ok(())
356    }
357
358    /// Remove the element identified by `slug`/`slug_field` from the keyed
359    /// list at `key`. Preserves the rest of the source document.
360    ///
361    /// Only JSON and YAML backends implement a source-preserving
362    /// keyed-collection editor today; other formats return
363    /// [`DocumentError::UnsupportedOperation`].
364    pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
365        let mut value = self.value.clone();
366        let keyed_lists = [KeyedList {
367            prefix: key,
368            slug_field,
369        }];
370        let original_array = crate::document::get_path(&value, key, &keyed_lists)?;
371        let removed_index = original_array
372            .as_array()
373            .and_then(|items| {
374                items
375                    .iter()
376                    .position(|item| item.get(slug_field).and_then(Value::as_str) == Some(slug))
377            })
378            .ok_or_else(|| DocumentError::SlugNotFound {
379                prefix: key.to_string(),
380                slug: slug.to_string(),
381            })?;
382        #[cfg(not(feature = "yaml"))]
383        let _ = removed_index;
384        crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
385        let output: String = match self.format {
386            Format::Json => crate::document::format::json::remove_array_item_preserving(
387                &self.source,
388                key,
389                slug,
390                slug_field,
391            )?,
392            #[cfg(feature = "yaml")]
393            Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
394                &self.source,
395                key,
396                removed_index,
397            )?,
398            _ => {
399                return Err(DocumentError::UnsupportedOperation {
400                    format: self.format.name().to_string(),
401                    operation: "remove".to_string(),
402                    detail: "keyed collection source editor is not implemented for this backend"
403                        .to_string(),
404                });
405            }
406        };
407        self.source = output;
408        self.value = value;
409        Ok(())
410    }
411
412    /// Remove the entry at `key` entirely, preserving the rest of the source
413    /// document. The edit is staged in memory — call
414    /// [`DocumentFile::save`] to persist it.
415    ///
416    /// Idempotent, like [`HashSet::remove`](std::collections::HashSet::remove):
417    /// returns `Ok(false)` when there was nothing at `key` to remove (nothing
418    /// is staged), and `Ok(true)` when it was removed.
419    ///
420    /// "Nothing there" does not depend on how deep the path is. A missing leaf
421    /// and a missing ancestor are the same fact — `a.b.c` is absent whether
422    /// `a.b` exists or not — so both answer `Ok(false)`. Only a path that is
423    /// *malformed* stays an error: bad syntax, an index into a non-array, or a
424    /// segment that tries to traverse through a scalar. Those describe a caller
425    /// asking something incoherent, not a document that already lacks the key.
426    pub fn unset(&mut self, key: &str) -> DocumentResult<bool> {
427        let segments = crate::document::parse_path(key)?;
428        let (leaf, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
429        let parent = if parents.is_empty() {
430            &self.value
431        } else {
432            let parent_path = crate::document::join_path(parents);
433            match crate::document::get_path_ref(&self.value, &parent_path, &[]) {
434                Ok(parent) => parent,
435                // The ancestor is absent, so the leaf below it is too.
436                Err(DocumentError::UnknownSegment { .. }) => return Ok(false),
437                Err(error) => return Err(error),
438            }
439        };
440        match parent {
441            Value::Object(object) => {
442                if !object.contains_key(leaf) {
443                    return Ok(false);
444                }
445            }
446            Value::Array(array) => {
447                let index =
448                    leaf.parse::<usize>()
449                        .map_err(|_| DocumentError::UnregisteredArray {
450                            path: crate::document::join_path(parents),
451                        })?;
452                if index >= array.len() {
453                    return Err(DocumentError::IndexOutOfBounds {
454                        path: crate::document::join_path(parents),
455                        index,
456                        len: array.len(),
457                    });
458                }
459            }
460            value => {
461                return Err(DocumentError::NotTraversable {
462                    path: crate::document::join_path(parents),
463                    got: value.kind_name().to_string(),
464                });
465            }
466        }
467        let mut value = self.value.clone();
468        crate::document::unset_path(&mut value, key)?;
469        #[allow(unreachable_patterns)]
470        let output = match self.format {
471            Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
472            #[cfg(feature = "toml")]
473            Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
474            #[cfg(feature = "yaml")]
475            Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
476            #[cfg(feature = "dotenv")]
477            Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
478            #[cfg(feature = "ini")]
479            Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
480            #[cfg(feature = "toml")]
481            Format::TomlFrontmatter => {
482                let parts = crate::document::format::frontmatter::split(
483                    &self.source,
484                    crate::document::format::frontmatter::Delimiter::Plus,
485                )?;
486                let new_fm =
487                    crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
488                format!("{}{}{}", parts.pre, new_fm, parts.post)
489            }
490            #[cfg(feature = "yaml")]
491            Format::YamlFrontmatter => {
492                let parts = crate::document::format::frontmatter::split(
493                    &self.source,
494                    crate::document::format::frontmatter::Delimiter::Dash,
495                )?;
496                let new_fm =
497                    crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
498                format!("{}{}{}", parts.pre, new_fm, parts.post)
499            }
500            _ => self.format.save(&value)?,
501        };
502        self.source = output;
503        self.value = value;
504        Ok(true)
505    }
506}
507
508impl DocumentFile {
509    /// Run `edit` against the in-memory [`Document`], then commit once with
510    /// [`save`](DocumentFile::save). The single-call form of stage-then-save:
511    /// the edits either all land (on `Ok`) or none reach disk (on `Err`,
512    /// nothing is written), and the commit can't be forgotten.
513    pub fn edit<F>(&mut self, edit: F) -> DocumentResult<()>
514    where
515        F: FnOnce(&mut Document) -> DocumentResult<()>,
516    {
517        edit(&mut self.doc)?;
518        self.save()
519    }
520
521    /// Persist the document — every edit staged since [`open`](DocumentFile::open)
522    /// — to its path in a single atomic write.
523    ///
524    /// The mutation verbs (`set`/`unset`/`add`/`remove`) stage their
525    /// source-preserving edit in memory and do **not** touch disk; this is the
526    /// one commit point. That lets a caller apply several edits and inspect the
527    /// result via [`value`](Document::value) (e.g. deserialize-and-validate)
528    /// before any bytes are written, and makes a multi-edit change atomic —
529    /// all edits land together or none do.
530    pub fn save(&self) -> DocumentResult<()> {
531        self.save_atomic(self.doc.source())
532    }
533
534    /// Atomically replace the file's contents with `new_source`: guard
535    /// against symlinks/hardlinked files, write to a same-directory temp
536    /// file, fsync it, re-apply the original file's permissions, then
537    /// `rename` it over the target. No partial write is ever observable —
538    /// on any failure the temp file is removed and the original file is
539    /// untouched.
540    ///
541    /// Crate-internal write seam behind the public [`save`](DocumentFile::save);
542    /// it is not exported, so callers cannot write arbitrary raw text that
543    /// bypasses the parse/edit path.
544    pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
545        // Read back what is about to be written, before writing it. A
546        // source-preserving edit splices text, and a splice that lands in the
547        // wrong place can produce a file this very parser rejects — an INI key
548        // added to a section that had already closed, for one. Of the three
549        // possible outcomes, reporting success and leaving an unreadable file
550        // behind is the only unrecoverable one.
551        Document::parse(new_source, self.format).map_err(|error| {
552            DocumentError::WriteWouldCorrupt {
553                format: self.format.name().to_string(),
554                detail: error.redacted_message(),
555            }
556        })?;
557        write_atomic(&self.path, new_source.as_bytes(), "write")
558    }
559}
560
561impl std::ops::Deref for DocumentFile {
562    type Target = Document;
563
564    fn deref(&self) -> &Document {
565        &self.doc
566    }
567}
568
569impl std::ops::DerefMut for DocumentFile {
570    fn deref_mut(&mut self) -> &mut Document {
571        &mut self.doc
572    }
573}
574
575/// Reject mutation of a symlink or (on unix) a hardlinked file. Returns the
576/// target's metadata on success so callers that also need to write can reuse
577/// it (e.g. to preserve permissions) without a second syscall.
578fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
579    let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
580        detail: format!("{operation} preflight `{}`: {error}", path.display()),
581    })?;
582    if metadata.file_type().is_symlink() {
583        return Err(DocumentError::UnsupportedOperation {
584            format: "filesystem".to_string(),
585            operation: operation.to_string(),
586            detail: format!("refusing to mutate symlink `{}`", path.display()),
587        });
588    }
589    #[cfg(unix)]
590    {
591        use std::os::unix::fs::MetadataExt;
592        if metadata.nlink() > 1 {
593            return Err(DocumentError::UnsupportedOperation {
594                format: "filesystem".to_string(),
595                operation: operation.to_string(),
596                detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
597            });
598        }
599    }
600    Ok(metadata)
601}
602
603/// Write `bytes` to `path` atomically: guard, same-directory temp file,
604/// fsync, permission preservation, then rename over the target.
605fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
606    let metadata = guard_mutation(path, operation)?;
607
608    let parent = path.parent().ok_or_else(|| DocumentError::IoError {
609        detail: format!(
610            "{operation} has no parent directory for `{}`",
611            path.display()
612        ),
613    })?;
614    let file_name = path
615        .file_name()
616        .and_then(|name| name.to_str())
617        .ok_or_else(|| DocumentError::IoError {
618            detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
619        })?;
620    let pid = std::process::id();
621    let mut temp_path = None;
622    let mut temp_file = None;
623    for attempt in 0..32_u32 {
624        let candidate = parent.join(format!(".{file_name}.afdata-document.{pid}.{attempt}.tmp"));
625        match OpenOptions::new()
626            .write(true)
627            .create_new(true)
628            .open(&candidate)
629        {
630            Ok(file) => {
631                temp_path = Some(candidate);
632                temp_file = Some(file);
633                break;
634            }
635            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
636            Err(error) => {
637                return Err(DocumentError::IoError {
638                    detail: format!(
639                        "{operation} create temporary file in `{}`: {error}",
640                        parent.display()
641                    ),
642                });
643            }
644        }
645    }
646    let temp_path = temp_path.ok_or_else(|| DocumentError::IoError {
647        detail: format!(
648            "{operation} could not allocate temporary file in `{}`",
649            parent.display()
650        ),
651    })?;
652    let mut temp_file = temp_file.ok_or_else(|| DocumentError::IoError {
653        detail: format!("{operation} temporary file handle missing"),
654    })?;
655    let result = (|| -> DocumentResult<()> {
656        temp_file
657            .write_all(bytes)
658            .map_err(|error| DocumentError::IoError {
659                detail: format!("{operation} write `{}`: {error}", path.display()),
660            })?;
661        temp_file
662            .sync_all()
663            .map_err(|error| DocumentError::IoError {
664                detail: format!("{operation} fsync `{}`: {error}", path.display()),
665            })?;
666        drop(temp_file);
667        fs::set_permissions(&temp_path, metadata.permissions()).map_err(|error| {
668            DocumentError::IoError {
669                detail: format!(
670                    "{operation} preserve permissions `{}`: {error}",
671                    path.display()
672                ),
673            }
674        })?;
675        fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
676            detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
677        })?;
678        Ok(())
679    })();
680    if result.is_err() {
681        let _ = fs::remove_file(&temp_path);
682    }
683    result
684}
685
686#[cfg(test)]
687mod tests {
688    #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
689    use super::*;
690    use std::io::Cursor;
691
692    fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
693        let path = dir.join(name);
694        fs::write(&path, contents).unwrap();
695        path
696    }
697
698    #[test]
699    fn round_trip_open_json() {
700        let dir = tempfile::tempdir().unwrap();
701        let contents = r#"{"host": "example.com", "port": 993}"#;
702        let path = write_temp(dir.path(), "config.json", contents);
703
704        let doc = DocumentFile::open(&path, None).unwrap();
705
706        assert_eq!(doc.format(), Format::Json);
707        assert_eq!(
708            doc.value().get("host").and_then(Value::as_str),
709            Some("example.com")
710        );
711        assert_eq!(doc.source(), contents);
712    }
713
714    #[test]
715    fn value_at_reads_a_nested_address() {
716        let dir = tempfile::tempdir().unwrap();
717        let path = write_temp(
718            dir.path(),
719            "config.json",
720            r#"{"database": {"url": "postgres://x"}}"#,
721        );
722        let doc = DocumentFile::open(&path, None).unwrap();
723
724        assert_eq!(
725            doc.value_at("database.url").unwrap(),
726            Value::String("postgres://x".to_string())
727        );
728        assert_eq!(
729            doc.value_at("database.missing").unwrap_err().code(),
730            "document_path_not_found"
731        );
732    }
733
734    #[test]
735    fn open_capped_enforces_size_and_regular_file() {
736        let dir = tempfile::tempdir().unwrap();
737        let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
738
739        // Within the cap: opens normally.
740        assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
741
742        // Over the cap: rejected without parsing, as an io failure.
743        let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
744        assert_eq!(err.code(), "document_io_failed");
745        assert!(err.to_string().contains("read limit"));
746
747        // A directory is not a regular file.
748        let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
749        assert_eq!(dir_err.code(), "document_io_failed");
750    }
751
752    #[test]
753    fn typed_get_and_set_enforce_the_stated_type() {
754        use crate::document::ValueType;
755        let dir = tempfile::tempdir().unwrap();
756        let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
757        let mut doc = DocumentFile::open(&path, None).unwrap();
758
759        // Typed get: matching type returns, wrong type is a caught error, Json
760        // matches anything.
761        assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
762        assert_eq!(
763            doc.value_at_typed("port", ValueType::String)
764                .unwrap_err()
765                .code(),
766            "document_type_mismatch"
767        );
768        assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
769
770        // Typed set: the literal is validated against the stated type.
771        doc.set_typed("port", Some("9090"), ValueType::Number)
772            .unwrap();
773        assert_eq!(
774            doc.value_at("port").unwrap(),
775            Value::from(serde_json::json!(9090))
776        );
777        assert_eq!(
778            doc.set_typed("port", Some("not-a-number"), ValueType::Number)
779                .unwrap_err()
780                .code(),
781            "document_parse_failed"
782        );
783    }
784
785    #[cfg(feature = "toml")]
786    #[test]
787    fn round_trip_open_toml() {
788        let dir = tempfile::tempdir().unwrap();
789        let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
790        let path = write_temp(dir.path(), "config.toml", contents);
791
792        let doc = DocumentFile::open(&path, None).unwrap();
793
794        assert_eq!(doc.format(), Format::Toml);
795        assert_eq!(
796            doc.value().get("host").and_then(Value::as_str),
797            Some("example.com")
798        );
799        assert_eq!(doc.source(), contents);
800    }
801
802    #[cfg(feature = "toml")]
803    #[test]
804    fn set_scalar_preserves_toml_comments_and_formatting() {
805        let dir = tempfile::tempdir().unwrap();
806        let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
807        let path = write_temp(dir.path(), "config.toml", contents);
808        let mut doc = DocumentFile::open(&path, None).unwrap();
809
810        doc.set("port", Value::Integer(1024)).unwrap();
811        doc.save().unwrap();
812
813        let saved = fs::read_to_string(&path).unwrap();
814        assert!(saved.contains("# leading comment"));
815        assert!(saved.contains("port = 1024"));
816        assert_eq!(
817            doc.value().get("port").and_then(Value::as_integer),
818            Some(1024)
819        );
820        assert_eq!(doc.source(), saved);
821    }
822
823    #[test]
824    fn save_refuses_source_its_own_parser_rejects() {
825        // The read-back guard, exercised directly: no backend should be able to
826        // splice text into this shape any more, so the corrupt source is
827        // supplied by hand rather than provoked through a verb.
828        let dir = tempfile::tempdir().unwrap();
829        let original = "[db]\nhost=localhost\n";
830        let path = write_temp(dir.path(), "config.ini", original);
831        let doc = DocumentFile::open(&path, None).unwrap();
832
833        let error = doc
834            .save_atomic("[db]\nhost=localhost\n\n[db]\nport=5432\n")
835            .unwrap_err();
836        assert_eq!(error.code(), "document_write_would_corrupt");
837        // The whole point is that the guard runs before any bytes land.
838        assert_eq!(fs::read_to_string(&path).unwrap(), original);
839    }
840
841    #[test]
842    fn save_writes_source_the_parser_accepts() {
843        let dir = tempfile::tempdir().unwrap();
844        let path = write_temp(dir.path(), "config.ini", "[db]\nhost=localhost\n");
845        let mut doc = DocumentFile::open(&path, None).unwrap();
846
847        doc.set("db.port", Value::String("5432".to_string()))
848            .unwrap();
849        doc.save().unwrap();
850
851        // A key added to a section that is not the file's last one used to be
852        // appended at end of file, re-opening a section that had closed.
853        assert_eq!(
854            fs::read_to_string(&path).unwrap(),
855            "[db]\nhost=localhost\nport=5432\n"
856        );
857        assert!(DocumentFile::open(&path, None).is_ok());
858    }
859
860    #[cfg(unix)]
861    #[test]
862    fn atomic_save_preserves_file_mode() {
863        use std::os::unix::fs::PermissionsExt;
864
865        let dir = tempfile::tempdir().unwrap();
866        let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
867        fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
868        let mut doc = DocumentFile::open(&path, None).unwrap();
869
870        doc.set("port", Value::Integer(1024)).unwrap();
871        doc.save().unwrap();
872
873        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
874        assert_eq!(mode, 0o640);
875    }
876
877    #[cfg(unix)]
878    #[test]
879    fn symlink_target_is_rejected_for_mutation() {
880        let dir = tempfile::tempdir().unwrap();
881        let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
882        let link = dir.path().join("link.json");
883        std::os::unix::fs::symlink(&target, &link).unwrap();
884
885        // Reading through the symlink is fine.
886        let mut doc = DocumentFile::open(&link, None).unwrap();
887
888        // Editing in memory is fine; committing through the symlink is not.
889        doc.set("port", Value::Integer(1024)).unwrap();
890        let err = doc.save().unwrap_err();
891        assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
892
893        // The target file was never touched.
894        let target_contents = fs::read_to_string(&target).unwrap();
895        assert_eq!(target_contents, r#"{"port": 993}"#);
896    }
897
898    #[test]
899    fn from_reader_parses_in_memory_cursor() {
900        let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
901
902        let doc = Document::from_reader(cursor, Format::Json).unwrap();
903
904        assert_eq!(
905            doc.value().get("host").and_then(Value::as_str),
906            Some("example.com")
907        );
908    }
909
910    #[test]
911    fn document_from_str_encode_round_trip() {
912        let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
913        let encoded = doc.encode().unwrap();
914        let reparsed = Document::parse(&encoded, Format::Json).unwrap();
915        assert_eq!(
916            reparsed.value().get("a").and_then(Value::as_integer),
917            Some(1)
918        );
919    }
920
921    #[test]
922    fn document_edits_source_in_memory_without_a_file() {
923        // The point of the Document/DocumentFile split: source-preserving
924        // editing with no file, no I/O, no guards.
925        let mut doc = Document::parse("{\n  \"host\": \"old\"\n}\n", Format::Json).unwrap();
926        doc.set("host", Value::String("new".to_string())).unwrap();
927        doc.set("imap.port", Value::Integer(993)).unwrap(); // creates the parent
928
929        assert_eq!(
930            doc.source(),
931            "{\n  \"host\": \"new\",\n  \"imap\": {\n    \"port\": 993\n  }\n}\n"
932        );
933        assert_eq!(
934            doc.value_at("imap.port").unwrap(),
935            Value::from(serde_json::json!(993))
936        );
937    }
938
939    #[test]
940    fn unset_is_false_for_anything_already_absent() {
941        let mut doc = Document::parse(
942            r#"{"service":{"host":"example","ports":[80]}}"#,
943            Format::Json,
944        )
945        .unwrap();
946
947        // Absent is absent, at any depth — the answer must not change with the
948        // number of segments the caller had to write to name the same nothing.
949        assert!(!doc.unset("service.missing").unwrap());
950        assert!(!doc.unset("missing.parent").unwrap());
951        assert!(!doc.unset("missing.deeply.nested").unwrap());
952
953        // Still errors: these describe a path no document could satisfy, not a
954        // document that happens not to carry the key.
955        assert!(doc.unset("service.host.child").is_err()); // through a scalar
956        assert!(doc.unset("service.ports.9").is_err()); // index out of range
957        assert!(doc.unset(r"service\q").is_err()); // malformed syntax
958    }
959
960    #[cfg(feature = "yaml")]
961    #[test]
962    fn yaml_write_rejects_cst_ambiguous_mapping_segments() {
963        let mut numeric = Document::parse("\"123\": value\n", Format::Yaml).unwrap();
964        assert!(
965            numeric
966                .set("123", Value::String("changed".to_string()))
967                .is_err()
968        );
969        assert!(numeric.unset("123").is_err());
970
971        let mut bracketed = Document::parse("\"a[0]\": value\n", Format::Yaml).unwrap();
972        assert!(
973            bracketed
974                .set("a[0]", Value::String("changed".to_string()))
975                .is_err()
976        );
977    }
978}