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, File, OpenOptions};
24use std::io::{Read as _, Write as _};
25use std::path::{Path, PathBuf};
26
27use crate::document::{Addressing, DocumentError, DocumentResult, Format, KeyedList, Value};
28
29/// Whether a capped file read may follow a symbolic link.
30///
31/// [`NoFollow`](SymlinkPolicy::NoFollow) is implemented with the operating
32/// system's atomic `O_NOFOLLOW` open on unix when Cargo feature `libc` is
33/// enabled. Builds without that feature, and other platforms, return
34/// [`DocumentError::UnsupportedOperation`] rather than pretending a
35/// check-before-open sequence provides the same guarantee.
36#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
37pub enum SymlinkPolicy {
38    /// Follow a symbolic link in the same way as [`File::open`].
39    #[default]
40    Follow,
41    /// Refuse a symbolic-link final path component atomically.
42    ///
43    /// This requires Cargo feature `libc` on unix.
44    NoFollow,
45}
46
47/// Whether [`DocumentFile::create_atomic`] may replace an existing target.
48#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
49pub enum CreateMode {
50    /// Fail with `document_target_exists` if the target already exists.
51    #[default]
52    NewOnly,
53    /// Atomically replace an existing target, or create it when absent.
54    Replace,
55}
56
57/// Options for a safe first commit with [`DocumentFile::create_atomic`].
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub struct CreateOptions {
60    mode: CreateMode,
61    unix_mode: Option<u32>,
62}
63
64impl CreateOptions {
65    /// Create with no-clobber semantics and unix mode `0o600` for a new file.
66    ///
67    /// Replacing an existing target preserves that target's mode unless
68    /// [`unix_mode`](Self::unix_mode) asks for a specific one.
69    #[must_use]
70    pub const fn new() -> Self {
71        Self {
72            mode: CreateMode::NewOnly,
73            unix_mode: None,
74        }
75    }
76
77    /// Set the target's unix permission bits.
78    ///
79    /// The value must contain only the low `0o777` permission bits. It is
80    /// ignored on non-unix platforms. Setting it explicitly also applies the
81    /// mode when replacing an existing file, which otherwise keeps its own.
82    #[must_use]
83    pub const fn unix_mode(mut self, unix_mode: u32) -> Self {
84        self.unix_mode = Some(unix_mode);
85        self
86    }
87
88    /// Explicitly allow an existing target to be atomically replaced.
89    #[must_use]
90    pub const fn replace(mut self) -> Self {
91        self.mode = CreateMode::Replace;
92        self
93    }
94
95    /// The configured target-existence behavior.
96    #[must_use]
97    pub const fn mode(&self) -> CreateMode {
98        self.mode
99    }
100
101    /// The explicitly configured unix permission bits, if any.
102    ///
103    /// `None` means "`0o600` when creating, and keep the existing mode when
104    /// replacing".
105    #[must_use]
106    pub const fn configured_unix_mode(&self) -> Option<u32> {
107        self.unix_mode
108    }
109
110    /// The mode to apply, given whether the target already exists.
111    const fn effective_unix_mode(&self, target_exists: bool) -> Option<u32> {
112        match self.unix_mode {
113            Some(mode) => Some(mode),
114            // Replacing must not silently re-permission a file the caller did
115            // not ask about: `save()` preserves the original, and a second
116            // write path with the opposite rule would quietly widen a 0600
117            // secrets file to the create default, or narrow a shared one.
118            None if target_exists => None,
119            None => Some(0o600),
120        }
121    }
122}
123
124impl Default for CreateOptions {
125    fn default() -> Self {
126        Self::new()
127    }
128}
129
130/// A format-neutral in-memory document: the original source text, its parsed
131/// [`Value`], and the [`Format`] both came from. Has no file coupling —
132/// construct it from a string or any [`std::io::Read`] the caller supplies,
133/// edit it source-preservingly with [`set`](Document::set) /
134/// [`unset`](Document::unset) / [`add`](Document::add) /
135/// [`remove`](Document::remove), and read the result back with
136/// [`source`](Document::source). [`DocumentFile`] is just this plus a path.
137#[derive(Debug, Clone)]
138pub struct Document {
139    source: String,
140    value: Value,
141    format: Format,
142}
143
144impl Document {
145    /// Parse `source` in the given `format`.
146    ///
147    /// Named `parse` (not `from_str`) deliberately: this takes an explicit
148    /// `format` argument, so it is not the single-argument `std::str::FromStr`
149    /// contract that `from_str` would imply.
150    pub fn parse(source: &str, format: Format) -> DocumentResult<Document> {
151        let value = format.load(source)?;
152        Ok(Document {
153            source: source.to_string(),
154            value,
155            format,
156        })
157    }
158
159    /// Read `reader` fully to a `String`, then parse it in the given
160    /// `format`.
161    ///
162    /// Reads only from the supplied `reader` — never touches the process's
163    /// own stdin.
164    pub fn from_reader<R: std::io::Read>(
165        mut reader: R,
166        format: Format,
167    ) -> DocumentResult<Document> {
168        let mut source = String::new();
169        reader.read_to_string(&mut source)?;
170        Document::parse(&source, format)
171    }
172
173    /// Borrow the parsed value (reflects the last successful edit).
174    pub fn value(&self) -> &Value {
175        &self.value
176    }
177
178    /// Borrow the current source text — the original bytes with every
179    /// source-preserving edit applied. This is what [`DocumentFile::save`]
180    /// writes.
181    pub fn source(&self) -> &str {
182        &self.source
183    }
184
185    /// The format this document was parsed from.
186    pub fn format(&self) -> Format {
187        self.format
188    }
189
190    /// How a non-numeric path segment resolves against an array in this
191    /// document: whatever its format declares (see [`Format::array_rule`]),
192    /// with no caller-declared keyed lists.
193    ///
194    /// Use [`addressing_keyed`](Document::addressing_keyed) to add those.
195    #[must_use]
196    pub fn addressing(&self) -> Addressing<'static> {
197        Addressing::INDEX_ONLY.with_array_rule(self.format.array_rule())
198    }
199
200    /// [`addressing`](Document::addressing) plus the caller's keyed lists,
201    /// which take precedence over the format rule for the arrays they name.
202    #[must_use]
203    pub fn addressing_keyed<'a>(&self, keyed_lists: &'a [KeyedList<'a>]) -> Addressing<'a> {
204        Addressing::keyed(keyed_lists).with_array_rule(self.format.array_rule())
205    }
206
207    /// Resolve a dotted `path` against the parsed document and return the value
208    /// at that address.
209    ///
210    /// A non-empty ASCII-decimal segment against an array is an index;
211    /// anything else goes through the format's own rule, so a Markdown document answers
212    /// `h2.look` while a JSON one refuses `deps.foo`. See
213    /// [`addressing`](Document::addressing).
214    pub fn value_at(&self, path: &str) -> DocumentResult<Value> {
215        crate::document::get_path(&self.value, path, self.addressing())
216    }
217
218    /// [`value_at`](Document::value_at) that also asserts the value at `path`
219    /// satisfies `expected`, returning a [`DocumentError::TypeMismatch`]
220    /// otherwise.
221    pub fn value_at_typed(
222        &self,
223        path: &str,
224        expected: crate::document::ValueType,
225    ) -> DocumentResult<Value> {
226        let value = self.value_at(path)?;
227        if crate::document::value_matches_type(&value, expected) {
228            Ok(value)
229        } else {
230            Err(DocumentError::TypeMismatch {
231                path: path.to_string(),
232                expected: expected.name().to_string(),
233                got: value.kind_name().to_string(),
234                hint: None,
235            })
236        }
237    }
238
239    /// Deserialize the complete document into a typed serde model.
240    ///
241    /// This is the convenience form of
242    /// [`crate::document::from_value(self.value(), "")`](crate::document::from_value).
243    /// Type errors are returned as content-redactable [`DocumentError`] values.
244    pub fn decode<T: serde::de::DeserializeOwned>(&self) -> DocumentResult<T> {
245        crate::document::from_value(self.value(), "")
246    }
247
248    /// Build a value from the CLI string `raw` per an explicit
249    /// [`ValueType`](crate::document::ValueType) and [`set`](Document::set) it.
250    pub fn set_typed(
251        &mut self,
252        key: &str,
253        raw: Option<&str>,
254        value_type: crate::document::ValueType,
255    ) -> DocumentResult<()> {
256        let value = crate::document::value_from_type(value_type, raw)?;
257        self.set(key, value)
258    }
259
260    /// Refuse `operation` when this document's format has no writer at all.
261    ///
262    /// A read-only format (see [`Format::is_read_only`]) is refused here, at
263    /// the top of every mutating verb, rather than at the backend dispatch
264    /// below it. The verbs stage an edit against the parsed value first, so a
265    /// later refusal would report whichever backend step happened to notice —
266    /// naming the wrong reason for a document that was never writable.
267    fn ensure_writable(&self, operation: &str) -> DocumentResult<()> {
268        if self.format.is_read_only() {
269            return Err(self.format.read_only_error(operation));
270        }
271        Ok(())
272    }
273
274    /// Re-render the current value in its format via [`Format::save`].
275    ///
276    /// This is a fresh, non-source-preserving render: comments and original
277    /// formatting are not retained. Use [`source`](Document::source) after
278    /// source-preserving edits to keep the original formatting.
279    pub fn encode(&self) -> DocumentResult<String> {
280        self.format.save(&self.value)
281    }
282}
283
284/// A file-backed [`Document`]: the in-memory document plus the path it was
285/// read from.
286///
287/// All reads and source-preserving edits come from [`Document`] through
288/// [`Deref`]/[`DerefMut`]; `DocumentFile` adds only the file boundary — reading
289/// on [`open`](DocumentFile::open) and an atomic, symlink-guarded commit on
290/// [`save`](DocumentFile::save) / [`edit`](DocumentFile::edit).
291#[derive(Debug, Clone)]
292pub struct DocumentFile {
293    doc: Document,
294    path: PathBuf,
295}
296
297impl DocumentFile {
298    /// Open and parse `path`.
299    ///
300    /// `format_override` takes precedence; otherwise the format is detected
301    /// from the file extension via [`Format::detect`]. Reading is always
302    /// allowed — this does not run the mutation guard.
303    pub fn open(
304        path: impl AsRef<Path>,
305        format_override: Option<Format>,
306    ) -> DocumentResult<DocumentFile> {
307        let path = path.as_ref().to_path_buf();
308        let format = resolve_format(&path, format_override)?;
309        let source = fs::read_to_string(&path).map_err(|error| DocumentError::IoError {
310            detail: format!("read `{}`: {error}", path.display()),
311        })?;
312        Ok(DocumentFile {
313            doc: Document::parse(&source, format)?,
314            path,
315        })
316    }
317
318    /// Open and parse `path` like [`DocumentFile::open`], but reject any
319    /// non-regular file and limit the actual read to `max_bytes + 1`.
320    ///
321    /// Use this over [`open`](DocumentFile::open) when reading untrusted or
322    /// secret-bearing config, where an unbounded read of an arbitrary path is
323    /// a denial-of-service risk. The file is opened exactly once; both metadata
324    /// and contents come from that same handle, so replacing or growing the
325    /// path cannot bypass the cap.
326    ///
327    /// On unix, Cargo feature `libc` also opens with `O_NONBLOCK`, so a special
328    /// file such as a FIFO cannot block before metadata rejects it. Without
329    /// that feature, the single-handle and byte-cap guarantees still hold, but
330    /// opening a special file can block before its type is inspected.
331    pub fn open_capped(
332        path: impl AsRef<Path>,
333        format_override: Option<Format>,
334        max_bytes: u64,
335    ) -> DocumentResult<DocumentFile> {
336        Self::open_capped_with_policy(path, format_override, max_bytes, SymlinkPolicy::Follow)
337    }
338
339    /// [`open_capped`](DocumentFile::open_capped) with an explicit symbolic-link
340    /// policy.
341    ///
342    /// [`SymlinkPolicy::NoFollow`] requires Cargo feature `libc` on unix and is
343    /// unsupported on other platforms.
344    pub fn open_capped_with_policy(
345        path: impl AsRef<Path>,
346        format_override: Option<Format>,
347        max_bytes: u64,
348        symlink_policy: SymlinkPolicy,
349    ) -> DocumentResult<DocumentFile> {
350        let path = path.as_ref().to_path_buf();
351        let format = resolve_format(&path, format_override)?;
352        let file = open_read_handle(&path, symlink_policy)?;
353        let source = read_capped_source(file, &path, max_bytes)?;
354        Ok(DocumentFile {
355            doc: Document::parse(&source, format)?,
356            path,
357        })
358    }
359
360    /// Safely create and commit a new file-backed document.
361    ///
362    /// Safely create and commit a new file-backed document.
363    ///
364    /// The document must already have passed a supported parser through
365    /// [`Document::parse`]. Its source is parsed once more before any write,
366    /// then written to a private same-directory temporary file, fsynced, and
367    /// atomically installed. The default [`CreateOptions`] never replaces an
368    /// existing path; replacement must be explicitly requested.
369    ///
370    /// The document's format must match what the path resolves to, so a commit
371    /// cannot produce a file [`open`](DocumentFile::open) would then reject.
372    pub fn create_atomic(
373        path: impl AsRef<Path>,
374        document: Document,
375        options: CreateOptions,
376    ) -> DocumentResult<DocumentFile> {
377        let path = path.as_ref().to_path_buf();
378        document.ensure_writable("create")?;
379        // Resolving the path's own format is what `open` does; disagreeing here
380        // would install a document that only this call can read back.
381        let path_format = resolve_format(&path, None)?;
382        if path_format != document.format() {
383            return Err(DocumentError::UnsupportedOperation {
384                format: document.format().name().to_string(),
385                operation: "create".to_string(),
386                detail: format!(
387                    "path resolves to {}, so the created file could not be reopened",
388                    path_format.name()
389                ),
390            });
391        }
392        validate_source_for_write(&document)?;
393        validate_create_options(options)?;
394        write_atomic_create(&path, document.source().as_bytes(), options)?;
395        Ok(DocumentFile {
396            doc: document,
397            path,
398        })
399    }
400
401    /// The file path this document was opened from.
402    pub fn path(&self) -> &Path {
403        &self.path
404    }
405
406    /// Preflight-check that this document format is writable and this file is
407    /// safe to mutate — not a symlink, and on unix not hardlinked — without
408    /// performing any write.
409    ///
410    /// [`save`](DocumentFile::save) runs this same guard before it writes, so
411    /// calling it directly is only useful to front-run a *separate* side effect
412    /// with the same guarantee — e.g. a CLI reading a secret from stdin for a
413    /// `set` should refuse an unsafe target before consuming that input.
414    pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
415        self.doc.ensure_writable(operation)?;
416        guard_mutation(&self.path, operation)?;
417        Ok(())
418    }
419}
420
421impl Document {
422    /// Set `key` to the typed `value`, preserving the rest of the source
423    /// document. The edit is staged in memory — call
424    /// [`save`](DocumentFile::save) to persist it.
425    ///
426    /// Backend capability mirrors [`crate::document::set_path`] where the
427    /// source editor allows it: the JSON backend replaces an existing value
428    /// (scalar or collection) and creates missing intermediate parent objects;
429    /// YAML replaces collection blocks while preserving bytes outside the
430    /// replaced block; TOML updates arrays, inline tables, and ordinary tables
431    /// in place. Arrays of tables are refused because this editor has no
432    /// explicit element-identity policy. Backends return
433    /// [`DocumentError::UnsupportedOperation`] when an edit cannot be expressed
434    /// source-preservingly.
435    pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
436        let addressing = self.addressing();
437        self.set_addressed(key, value, addressing)
438    }
439
440    /// [`set`](Document::set) with the caller's own addressing, so a path that
441    /// names an array element by its content — `identities.me.email` — can be
442    /// written and not merely read.
443    ///
444    /// The address is canonicalized to indices first (see
445    /// [`crate::document::resolve_path`]); everything below this point, the
446    /// source-preserving backends included, sees only `identities.0.email`.
447    pub fn set_addressed(
448        &mut self,
449        key: &str,
450        value: Value,
451        addressing: Addressing<'_>,
452    ) -> DocumentResult<()> {
453        self.ensure_writable("set")?;
454        let key = &crate::document::resolve_path(&self.value, key, addressing)?;
455        let mut new_doc = self.value.clone();
456        crate::document::set_path(&mut new_doc, key, &value, Addressing::INDEX_ONLY)?;
457        let target = crate::document::get_path(&new_doc, key, Addressing::INDEX_ONLY)?;
458        #[allow(unreachable_patterns)]
459        let output = match self.format {
460            #[cfg(feature = "toml")]
461            Format::Toml => {
462                crate::document::format::toml::set_preserving(&self.source, key, &target)?
463            }
464            #[cfg(feature = "yaml")]
465            Format::Yaml => {
466                crate::document::format::yaml::set_preserving(&self.source, key, &target)?
467            }
468            Format::Json => {
469                crate::document::format::json::set_preserving(&self.source, key, &target)?
470            }
471            #[cfg(feature = "dotenv")]
472            Format::Dotenv => {
473                crate::document::format::dotenv::set_preserving(&self.source, key, &target)?
474            }
475            #[cfg(feature = "ini")]
476            Format::Ini => {
477                crate::document::format::ini::set_preserving(&self.source, key, &target)?
478            }
479            #[cfg(feature = "toml")]
480            Format::TomlFrontmatter => {
481                let parts = crate::document::format::frontmatter::split(
482                    &self.source,
483                    crate::document::format::frontmatter::Delimiter::Plus,
484                )?;
485                let new_fm =
486                    crate::document::format::toml::set_preserving(parts.frontmatter, key, &target)?;
487                format!("{}{}{}", parts.pre, new_fm, parts.post)
488            }
489            #[cfg(feature = "yaml")]
490            Format::YamlFrontmatter => {
491                let parts = crate::document::format::frontmatter::split(
492                    &self.source,
493                    crate::document::format::frontmatter::Delimiter::Dash,
494                )?;
495                let new_fm =
496                    crate::document::format::yaml::set_preserving(parts.frontmatter, key, &target)?;
497                format!("{}{}{}", parts.pre, new_fm, parts.post)
498            }
499            _ => self.format.save(&new_doc)?,
500        };
501        self.source = output;
502        self.value = new_doc;
503        Ok(())
504    }
505
506    /// Add a new element to the keyed list at `key`, identified by
507    /// `slug`/`slug_field`, with the given `fields`. Preserves the rest of
508    /// the source document. An empty `key` targets the document root when the
509    /// root is itself the keyed array.
510    ///
511    /// Only JSON and YAML backends implement a source-preserving
512    /// keyed-collection editor today; other formats return
513    /// [`DocumentError::UnsupportedOperation`].
514    pub fn add(
515        &mut self,
516        key: &str,
517        slug: &str,
518        slug_field: &str,
519        fields: &[(String, Value)],
520    ) -> DocumentResult<()> {
521        self.ensure_writable("add")?;
522        let mut value = self.value.clone();
523        let keyed_lists = [KeyedList {
524            prefix: key,
525            slug_field,
526        }];
527        crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
528        let array = if key.is_empty() {
529            &value
530        } else {
531            crate::document::get_path_ref(&value, key, self.addressing_keyed(&keyed_lists))?
532        };
533        let item = array
534            .as_array()
535            .and_then(|items| items.last())
536            .ok_or_else(|| DocumentError::UnsupportedOperation {
537                format: self.format.name().to_string(),
538                operation: "add".to_string(),
539                detail: "keyed list did not produce an array item".to_string(),
540            })?;
541        // The catch-all covers the formats with no source-preserving keyed
542        // editor. A build with only JSON has none left to catch, which makes it
543        // unreachable there and reachable everywhere else — the arm stays.
544        #[allow(unreachable_patterns)]
545        let output: String = match self.format {
546            Format::Json => crate::document::format::json::append_array_item_preserving(
547                &self.source,
548                key,
549                item,
550            )?,
551            #[cfg(feature = "yaml")]
552            Format::Yaml => crate::document::format::yaml::append_array_item_preserving(
553                &self.source,
554                key,
555                item,
556            )?,
557            // Same editor as `set` reaches through frontmatter: the delimited
558            // block is an ordinary YAML document, and the body is spliced back
559            // untouched. Without this arm a keyed list was editable in a `.yaml`
560            // file and refused in the frontmatter of a `.md` one.
561            #[cfg(feature = "yaml")]
562            Format::YamlFrontmatter => {
563                let parts = crate::document::format::frontmatter::split(
564                    &self.source,
565                    crate::document::format::frontmatter::Delimiter::Dash,
566                )?;
567                let new_fm = crate::document::format::yaml::append_array_item_preserving(
568                    parts.frontmatter,
569                    key,
570                    item,
571                )?;
572                format!("{}{}{}", parts.pre, new_fm, parts.post)
573            }
574            _ => {
575                return Err(DocumentError::UnsupportedOperation {
576                    format: self.format.name().to_string(),
577                    operation: "add".to_string(),
578                    detail: "keyed collection source editor is not implemented for this backend"
579                        .to_string(),
580                });
581            }
582        };
583        self.source = output;
584        self.value = value;
585        Ok(())
586    }
587
588    /// Remove the element identified by `slug`/`slug_field` from the keyed
589    /// list at `key`. Preserves the rest of the source document. An empty
590    /// `key` targets the document root when the root is itself the keyed array.
591    ///
592    /// Only JSON and YAML backends implement a source-preserving
593    /// keyed-collection editor today; other formats return
594    /// [`DocumentError::UnsupportedOperation`].
595    pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
596        self.ensure_writable("remove")?;
597        let mut value = self.value.clone();
598        let keyed_lists = [KeyedList {
599            prefix: key,
600            slug_field,
601        }];
602        let removed_index = crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
603        // The catch-all covers the formats with no source-preserving keyed
604        // editor. A build with only JSON has none left to catch, which makes it
605        // unreachable there and reachable everywhere else — the arm stays.
606        #[allow(unreachable_patterns)]
607        let output: String = match self.format {
608            Format::Json => crate::document::format::json::remove_array_item_preserving(
609                &self.source,
610                key,
611                removed_index,
612            )?,
613            #[cfg(feature = "yaml")]
614            Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
615                &self.source,
616                key,
617                removed_index,
618            )?,
619            #[cfg(feature = "yaml")]
620            Format::YamlFrontmatter => {
621                let parts = crate::document::format::frontmatter::split(
622                    &self.source,
623                    crate::document::format::frontmatter::Delimiter::Dash,
624                )?;
625                let new_fm = crate::document::format::yaml::remove_array_item_preserving(
626                    parts.frontmatter,
627                    key,
628                    removed_index,
629                )?;
630                format!("{}{}{}", parts.pre, new_fm, parts.post)
631            }
632            _ => {
633                return Err(DocumentError::UnsupportedOperation {
634                    format: self.format.name().to_string(),
635                    operation: "remove".to_string(),
636                    detail: "keyed collection source editor is not implemented for this backend"
637                        .to_string(),
638                });
639            }
640        };
641        self.source = output;
642        self.value = value;
643        Ok(())
644    }
645
646    /// Remove the entry at `key` entirely, preserving the rest of the source
647    /// document. The edit is staged in memory — call
648    /// [`DocumentFile::save`] to persist it.
649    ///
650    /// Idempotent, like [`HashSet::remove`](std::collections::HashSet::remove):
651    /// returns `Ok(false)` when there was nothing at `key` to remove (nothing
652    /// is staged), and `Ok(true)` when it was removed.
653    ///
654    /// "Nothing there" does not depend on how deep the path is. A missing leaf
655    /// and a missing ancestor are the same fact — `a.b.c` is absent whether
656    /// `a.b` exists or not — so both answer `Ok(false)`. Only a path that is
657    /// *malformed* stays an error: bad syntax, an index into a non-array, or a
658    /// segment that tries to traverse through a scalar. Those describe a caller
659    /// asking something incoherent, not a document that already lacks the key.
660    ///
661    /// A read-only format is the one case that errors *before* the idempotent
662    /// answer: "nothing to remove" would report success for a document this
663    /// verb can never edit.
664    ///
665    /// A content-addressed segment that matches no element is also an error
666    /// ([`DocumentError::SlugNotFound`]), not `Ok(false)`. It is not the same
667    /// fact as an absent key: the caller named an element and the document has
668    /// none by that name, which is how a mistyped slug looks, and answering
669    /// "removed nothing, all good" would swallow it.
670    pub fn unset(&mut self, key: &str) -> DocumentResult<bool> {
671        let addressing = self.addressing();
672        self.unset_addressed(key, addressing)
673    }
674
675    /// [`unset`](Document::unset) with the caller's own addressing, so an
676    /// element named by its content can be removed and not merely read. See
677    /// [`set_addressed`](Document::set_addressed) for why the address is
678    /// canonicalized before anything below sees it.
679    pub fn unset_addressed(
680        &mut self,
681        key: &str,
682        addressing: Addressing<'_>,
683    ) -> DocumentResult<bool> {
684        self.ensure_writable("unset")?;
685        let key = &crate::document::resolve_path(&self.value, key, addressing)?;
686        let segments = crate::document::parse_path(key)?;
687        let (leaf, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
688        let parent = if parents.is_empty() {
689            &self.value
690        } else {
691            let parent_path = crate::document::join_path(parents);
692            match crate::document::get_path_ref(&self.value, &parent_path, Addressing::INDEX_ONLY) {
693                Ok(parent) => parent,
694                // The ancestor is absent, so the leaf below it is too.
695                Err(DocumentError::UnknownSegment { .. }) => return Ok(false),
696                Err(error) => return Err(error),
697            }
698        };
699        match parent {
700            Value::Object(object) => {
701                if !object.contains_key(leaf) {
702                    return Ok(false);
703                }
704            }
705            Value::Array(array) => {
706                let index =
707                    leaf.parse::<usize>()
708                        .map_err(|_| DocumentError::UnregisteredArray {
709                            path: crate::document::join_path(parents),
710                        })?;
711                if index >= array.len() {
712                    return Err(DocumentError::IndexOutOfBounds {
713                        path: crate::document::join_path(parents),
714                        index,
715                        len: array.len(),
716                    });
717                }
718            }
719            value => {
720                return Err(DocumentError::NotTraversable {
721                    path: crate::document::join_path(parents),
722                    got: value.kind_name().to_string(),
723                });
724            }
725        }
726        let mut value = self.value.clone();
727        crate::document::unset_path(&mut value, key)?;
728        #[allow(unreachable_patterns)]
729        let output = match self.format {
730            Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
731            #[cfg(feature = "toml")]
732            Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
733            #[cfg(feature = "yaml")]
734            Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
735            #[cfg(feature = "dotenv")]
736            Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
737            #[cfg(feature = "ini")]
738            Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
739            #[cfg(feature = "toml")]
740            Format::TomlFrontmatter => {
741                let parts = crate::document::format::frontmatter::split(
742                    &self.source,
743                    crate::document::format::frontmatter::Delimiter::Plus,
744                )?;
745                let new_fm =
746                    crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
747                format!("{}{}{}", parts.pre, new_fm, parts.post)
748            }
749            #[cfg(feature = "yaml")]
750            Format::YamlFrontmatter => {
751                let parts = crate::document::format::frontmatter::split(
752                    &self.source,
753                    crate::document::format::frontmatter::Delimiter::Dash,
754                )?;
755                let new_fm =
756                    crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
757                format!("{}{}{}", parts.pre, new_fm, parts.post)
758            }
759            _ => self.format.save(&value)?,
760        };
761        self.source = output;
762        self.value = value;
763        Ok(true)
764    }
765}
766
767impl DocumentFile {
768    /// Run `edit` against the in-memory [`Document`], then commit once with
769    /// [`save`](DocumentFile::save). The single-call form of stage-then-save:
770    /// closure and pre-install failures reach neither this handle nor disk,
771    /// while a successful commit updates both. As with [`save`](Self::save), a
772    /// parent-directory fsync error after installation is commit-uncertain and
773    /// callers should reopen the path.
774    pub fn edit<F>(&mut self, edit: F) -> DocumentResult<()>
775    where
776        F: FnOnce(&mut Document) -> DocumentResult<()>,
777    {
778        let mut draft = self.doc.clone();
779        edit(&mut draft)?;
780        self.save_document(&draft)?;
781        self.doc = draft;
782        Ok(())
783    }
784
785    /// Transactionally edit, deserialize, and validate the complete document
786    /// before committing it.
787    ///
788    /// The closure works on a clone. Editing, typed decoding, and every
789    /// pre-install write failure leave both this handle and the file in their
790    /// original state. No partial file is observable. If the final parent
791    /// directory fsync fails after the rename, a complete new file may already
792    /// be installed even though durability could not be confirmed; reopen the
793    /// file after that error. On success the decoded model is returned so
794    /// callers need not deserialize a second time.
795    pub fn edit_and_validate<T>(
796        &mut self,
797        edit: impl FnOnce(&mut Document) -> DocumentResult<()>,
798    ) -> DocumentResult<T>
799    where
800        T: serde::de::DeserializeOwned,
801    {
802        let mut draft = self.doc.clone();
803        edit(&mut draft)?;
804        let decoded = draft.decode::<T>()?;
805        self.save_document(&draft)?;
806        self.doc = draft;
807        Ok(decoded)
808    }
809
810    /// Persist the document — every edit staged since [`open`](DocumentFile::open)
811    /// — to its path in a single atomic write.
812    ///
813    /// The mutation verbs (`set`/`unset`/`add`/`remove`) stage their
814    /// source-preserving edit in memory and do **not** touch disk; this is the
815    /// one commit point. That lets a caller apply several edits and inspect the
816    /// result via [`value`](Document::value) (e.g. deserialize-and-validate)
817    /// before any bytes are written, and makes a multi-edit change atomic —
818    /// all edits land together or none do.
819    pub fn save(&self) -> DocumentResult<()> {
820        self.save_atomic(self.doc.source())
821    }
822
823    /// Atomically replace the file's contents with `new_source`: guard
824    /// against symlinks/hardlinked files, write to a same-directory temp
825    /// file, fsync it, re-apply the original file's permissions, then
826    /// `rename` it over the target. No partial write is ever observable.
827    /// Failures before the rename leave the original untouched. A failure
828    /// syncing the parent directory after the rename means the complete new
829    /// file may already be installed, but its crash durability could not be
830    /// confirmed.
831    ///
832    /// Crate-internal write seam behind the public [`save`](DocumentFile::save);
833    /// it is not exported, so callers cannot write arbitrary raw text that
834    /// bypasses the parse/edit path.
835    pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
836        // A read-only format never reaches disk, even when nothing was staged
837        // and the bytes would be identical: a `save` that succeeds is a claim
838        // this file is under afdata's control for writing, and it is not.
839        self.ensure_writable("save")?;
840        // Read back what is about to be written, before writing it. A
841        // source-preserving edit splices text, and a splice that lands in the
842        // wrong place can produce a file this very parser rejects — an INI key
843        // added to a section that had already closed, for one. Of the three
844        // possible outcomes, reporting success and leaving an unreadable file
845        // behind is the only unrecoverable one.
846        validate_source_text_for_write(new_source, self.format)?;
847        write_atomic(&self.path, new_source.as_bytes(), "write")
848    }
849
850    fn save_document(&self, document: &Document) -> DocumentResult<()> {
851        document.ensure_writable("save")?;
852        validate_source_for_write(document)?;
853        write_atomic(&self.path, document.source().as_bytes(), "write")
854    }
855}
856
857impl std::ops::Deref for DocumentFile {
858    type Target = Document;
859
860    fn deref(&self) -> &Document {
861        &self.doc
862    }
863}
864
865impl std::ops::DerefMut for DocumentFile {
866    fn deref_mut(&mut self) -> &mut Document {
867        &mut self.doc
868    }
869}
870
871fn resolve_format(path: &Path, format_override: Option<Format>) -> DocumentResult<Format> {
872    match format_override {
873        Some(format) => Ok(format),
874        // A `.toml` file read by a build without the `toml` feature is not an
875        // unknown format — it is a known one this binary cannot read, and
876        // saying so names the fix. Only `Format::unavailable` can tell the two
877        // apart, because `detect` answers `None` for both.
878        None => match Format::detect(path) {
879            Some(format) => Ok(format),
880            None => Err(match Format::unavailable(path) {
881                Some(feature) => DocumentError::UnsupportedOperation {
882                    format: feature.to_string(),
883                    operation: "open".to_string(),
884                    detail: format!("requires Cargo feature `{feature}`"),
885                },
886                None => DocumentError::FormatUnknown {
887                    path: path.display().to_string(),
888                },
889            }),
890        },
891    }
892}
893
894fn open_read_handle(path: &Path, symlink_policy: SymlinkPolicy) -> DocumentResult<File> {
895    let mut options = OpenOptions::new();
896    options.read(true);
897    #[cfg(all(unix, feature = "libc"))]
898    {
899        use std::os::unix::fs::OpenOptionsExt as _;
900        // Opening a FIFO for reading can otherwise block before handle
901        // metadata gets the chance to reject it as non-regular. O_NONBLOCK has
902        // no effect on ordinary regular-file reads.
903        let mut flags = libc::O_NONBLOCK;
904        if symlink_policy == SymlinkPolicy::NoFollow {
905            flags |= libc::O_NOFOLLOW;
906        }
907        options.custom_flags(flags);
908    }
909    #[cfg(all(unix, not(feature = "libc")))]
910    if symlink_policy == SymlinkPolicy::NoFollow {
911        return Err(DocumentError::UnsupportedOperation {
912            format: "filesystem".to_string(),
913            operation: "open".to_string(),
914            detail: "atomic no-follow reads require Cargo feature `libc` on unix".to_string(),
915        });
916    }
917    #[cfg(not(unix))]
918    if symlink_policy == SymlinkPolicy::NoFollow {
919        return Err(DocumentError::UnsupportedOperation {
920            format: "filesystem".to_string(),
921            operation: "open".to_string(),
922            detail: "atomic no-follow reads are unavailable on this platform".to_string(),
923        });
924    }
925    options.open(path).map_err(|error| DocumentError::IoError {
926        detail: format!("read `{}`: {error}", path.display()),
927    })
928}
929
930fn read_capped_source(file: File, path: &Path, max_bytes: u64) -> DocumentResult<String> {
931    inspect_capped_source(&file, path, max_bytes)?;
932    read_capped_contents(file, path, max_bytes)
933}
934
935fn inspect_capped_source(file: &File, path: &Path, max_bytes: u64) -> DocumentResult<()> {
936    let metadata = file.metadata().map_err(|error| DocumentError::IoError {
937        detail: format!("inspect `{}`: {error}", path.display()),
938    })?;
939    if !metadata.is_file() {
940        return Err(DocumentError::IoError {
941            detail: format!("`{}` is not a regular file", path.display()),
942        });
943    }
944    if metadata.len() > max_bytes {
945        return Err(DocumentError::TooLarge {
946            path: path.display().to_string(),
947            max_bytes,
948        });
949    }
950    Ok(())
951}
952
953fn read_capped_contents(file: File, path: &Path, max_bytes: u64) -> DocumentResult<String> {
954    let read_limit = max_bytes.saturating_add(1);
955    let initial_capacity = usize::try_from(max_bytes.min(1024 * 1024)).unwrap_or(1024 * 1024);
956    let mut bytes = Vec::with_capacity(initial_capacity);
957    file.take(read_limit)
958        .read_to_end(&mut bytes)
959        .map_err(|error| DocumentError::IoError {
960            detail: format!("read `{}`: {error}", path.display()),
961        })?;
962    if u64::try_from(bytes.len()).unwrap_or(u64::MAX) > max_bytes {
963        return Err(DocumentError::TooLarge {
964            path: path.display().to_string(),
965            max_bytes,
966        });
967    }
968    String::from_utf8(bytes).map_err(|error| DocumentError::IoError {
969        detail: format!(
970            "read `{}`: document is not UTF-8 (valid through byte {})",
971            path.display(),
972            error.utf8_error().valid_up_to()
973        ),
974    })
975}
976
977fn validate_source_for_write(document: &Document) -> DocumentResult<()> {
978    validate_source_text_for_write(document.source(), document.format())
979}
980
981fn validate_source_text_for_write(source: &str, format: Format) -> DocumentResult<()> {
982    Document::parse(source, format).map_err(|error| DocumentError::WriteWouldCorrupt {
983        format: format.name().to_string(),
984        detail: error.redacted_message(),
985    })?;
986    Ok(())
987}
988
989fn validate_create_options(options: CreateOptions) -> DocumentResult<()> {
990    if let Some(unix_mode) = options.unix_mode
991        && unix_mode & !0o777 != 0
992    {
993        return Err(DocumentError::InvalidArgument {
994            detail: format!("unix mode {unix_mode:o} contains bits outside 0o777"),
995        });
996    }
997    Ok(())
998}
999
1000/// Reject mutation of a symlink or (on unix) a hardlinked file. Returns the
1001/// target's metadata on success so callers that also need to write can reuse
1002/// it (e.g. to preserve permissions) without a second syscall.
1003fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
1004    let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
1005        detail: format!("{operation} preflight `{}`: {error}", path.display()),
1006    })?;
1007    if metadata.file_type().is_symlink() {
1008        return Err(DocumentError::UnsupportedOperation {
1009            format: "filesystem".to_string(),
1010            operation: operation.to_string(),
1011            detail: format!("refusing to mutate symlink `{}`", path.display()),
1012        });
1013    }
1014    #[cfg(unix)]
1015    {
1016        use std::os::unix::fs::MetadataExt;
1017        if metadata.nlink() > 1 {
1018            return Err(DocumentError::UnsupportedOperation {
1019                format: "filesystem".to_string(),
1020                operation: operation.to_string(),
1021                detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
1022            });
1023        }
1024    }
1025    Ok(metadata)
1026}
1027
1028/// Compose a temporary file name that stays within one path component's limit.
1029///
1030/// The marker, pid, and attempt add about 35 bytes. A target whose own name is
1031/// close to `NAME_MAX` would push the composed name past it, and the failure
1032/// would surface at `save()` — after the caller had already made their edits, on
1033/// a file they opened successfully. Truncating the stem keeps the write
1034/// possible; uniqueness still comes from the pid and attempt, with `create_new`
1035/// retrying the rare collision.
1036fn temp_file_name(file_name: &str, pid: u32, attempt: u32) -> String {
1037    // The smallest NAME_MAX across the platforms this runs on.
1038    const MAX_NAME_BYTES: usize = 255;
1039    let suffix = format!(".afdata-document.{pid}.{attempt}.tmp");
1040    // One byte for the leading dot that hides the temporary file.
1041    let budget = MAX_NAME_BYTES.saturating_sub(suffix.len() + 1);
1042    let mut stem = file_name;
1043    if stem.len() > budget {
1044        let mut cut = budget;
1045        while cut > 0 && !stem.is_char_boundary(cut) {
1046            cut -= 1;
1047        }
1048        stem = &stem[..cut];
1049    }
1050    format!(".{stem}{suffix}")
1051}
1052
1053fn allocate_private_temp(
1054    parent: &Path,
1055    file_name: &str,
1056    operation: &str,
1057) -> DocumentResult<(PathBuf, File)> {
1058    let pid = std::process::id();
1059    for attempt in 0..32_u32 {
1060        let candidate = parent.join(temp_file_name(file_name, pid, attempt));
1061        let mut options = OpenOptions::new();
1062        options.write(true).create_new(true);
1063        #[cfg(unix)]
1064        {
1065            use std::os::unix::fs::OpenOptionsExt as _;
1066            options.mode(0o600);
1067        }
1068        match options.open(&candidate) {
1069            Ok(file) => return Ok((candidate, file)),
1070            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
1071            Err(error) => {
1072                return Err(DocumentError::IoError {
1073                    detail: format!(
1074                        "{operation} temporary file in `{}`: {error}",
1075                        parent.display()
1076                    ),
1077                });
1078            }
1079        }
1080    }
1081    Err(DocumentError::IoError {
1082        detail: format!(
1083            "{operation} could not allocate temporary file in `{}`",
1084            parent.display()
1085        ),
1086    })
1087}
1088
1089fn atomic_parent_and_name<'a>(
1090    path: &'a Path,
1091    operation: &str,
1092) -> DocumentResult<(&'a Path, String)> {
1093    let parent = match path.parent() {
1094        Some(parent) if parent.as_os_str().is_empty() => Path::new("."),
1095        Some(parent) => parent,
1096        None => {
1097            return Err(DocumentError::IoError {
1098                detail: format!(
1099                    "{operation} has no parent directory for `{}`",
1100                    path.display()
1101                ),
1102            });
1103        }
1104    };
1105    let file_name = path
1106        .file_name()
1107        .and_then(|name| name.to_str())
1108        .ok_or_else(|| DocumentError::IoError {
1109            detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
1110        })?
1111        .to_string();
1112    Ok((parent, file_name))
1113}
1114
1115#[cfg(unix)]
1116fn sync_parent(parent: &Path, operation: &str) -> DocumentResult<()> {
1117    File::open(parent)
1118        .and_then(|directory| directory.sync_all())
1119        .map_err(|error| DocumentError::IoError {
1120            detail: format!(
1121                "{operation} fsync parent directory `{}`: {error}",
1122                parent.display()
1123            ),
1124        })
1125}
1126
1127#[cfg(not(unix))]
1128fn sync_parent(_parent: &Path, _operation: &str) -> DocumentResult<()> {
1129    // Rust does not expose a portable directory handle on non-unix targets.
1130    // The file itself is still synced before its atomic installation.
1131    Ok(())
1132}
1133
1134fn write_temp_bytes(
1135    mut temp_file: File,
1136    temp_path: &Path,
1137    target_path: &Path,
1138    bytes: &[u8],
1139    operation: &str,
1140    permissions: Option<fs::Permissions>,
1141    unix_mode: Option<u32>,
1142) -> DocumentResult<()> {
1143    temp_file
1144        .write_all(bytes)
1145        .map_err(|error| DocumentError::IoError {
1146            detail: format!("{operation} write `{}`: {error}", target_path.display()),
1147        })?;
1148    if let Some(permissions) = permissions {
1149        temp_file
1150            .set_permissions(permissions)
1151            .map_err(|error| DocumentError::IoError {
1152                detail: format!(
1153                    "{operation} preserve permissions `{}`: {error}",
1154                    target_path.display()
1155                ),
1156            })?;
1157    }
1158    #[cfg(unix)]
1159    if let Some(unix_mode) = unix_mode {
1160        use std::os::unix::fs::PermissionsExt as _;
1161        temp_file
1162            .set_permissions(fs::Permissions::from_mode(unix_mode))
1163            .map_err(|error| DocumentError::IoError {
1164                detail: format!(
1165                    "{operation} set permissions on `{}`: {error}",
1166                    target_path.display()
1167                ),
1168            })?;
1169    }
1170    #[cfg(not(unix))]
1171    let _ = unix_mode;
1172    temp_file
1173        .sync_all()
1174        .map_err(|error| DocumentError::IoError {
1175            detail: format!("{operation} fsync `{}`: {error}", temp_path.display()),
1176        })
1177}
1178
1179/// Write `bytes` to `path` atomically: guard, same-directory temp file,
1180/// fsync, permission preservation, then rename over the target.
1181fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
1182    let metadata = guard_mutation(path, operation)?;
1183    let (parent, file_name) = atomic_parent_and_name(path, operation)?;
1184    let (temp_path, temp_file) = allocate_private_temp(parent, &file_name, operation)?;
1185    let result = (|| -> DocumentResult<()> {
1186        write_temp_bytes(
1187            temp_file,
1188            &temp_path,
1189            path,
1190            bytes,
1191            operation,
1192            Some(metadata.permissions()),
1193            None,
1194        )?;
1195        fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
1196            detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
1197        })?;
1198        sync_parent(parent, operation)?;
1199        Ok(())
1200    })();
1201    if result.is_err() {
1202        let _ = fs::remove_file(&temp_path);
1203    }
1204    result
1205}
1206
1207fn write_atomic_create(path: &Path, bytes: &[u8], options: CreateOptions) -> DocumentResult<()> {
1208    let operation = "create";
1209    let mut existing_permissions = None;
1210    match fs::symlink_metadata(path) {
1211        Ok(_) => match options.mode {
1212            CreateMode::NewOnly => {
1213                return Err(DocumentError::AlreadyExists {
1214                    path: path.display().to_string(),
1215                });
1216            }
1217            CreateMode::Replace => {
1218                let metadata = guard_mutation(path, operation)?;
1219                existing_permissions = Some(metadata.permissions());
1220            }
1221        },
1222        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
1223        Err(error) => {
1224            return Err(DocumentError::IoError {
1225                detail: format!("create preflight `{}`: {error}", path.display()),
1226            });
1227        }
1228    }
1229
1230    let (parent, file_name) = atomic_parent_and_name(path, operation)?;
1231    let (temp_path, temp_file) = allocate_private_temp(parent, &file_name, operation)?;
1232    let result = (|| -> DocumentResult<()> {
1233        // A replace with no explicit mode keeps the target's own permissions,
1234        // exactly as `save()` does; only a first creation falls back to 0o600.
1235        let unix_mode = options.effective_unix_mode(existing_permissions.is_some());
1236        let preserved = unix_mode
1237            .is_none()
1238            .then(|| existing_permissions.clone())
1239            .flatten();
1240        write_temp_bytes(
1241            temp_file, &temp_path, path, bytes, operation, preserved, unix_mode,
1242        )?;
1243        match options.mode {
1244            CreateMode::NewOnly => match fs::hard_link(&temp_path, path) {
1245                Ok(()) => {
1246                    // The document is installed from here on. Failing to unlink
1247                    // the temporary link leaves a stray file, but reporting an
1248                    // error would tell the caller the commit did not happen —
1249                    // and a retry would then get `document_target_exists` for a
1250                    // write that in fact succeeded.
1251                    let _ = fs::remove_file(&temp_path);
1252                }
1253                Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
1254                    return Err(DocumentError::AlreadyExists {
1255                        path: path.display().to_string(),
1256                    });
1257                }
1258                Err(error) => {
1259                    return Err(DocumentError::IoError {
1260                        detail: format!("create install `{}`: {error}", path.display()),
1261                    });
1262                }
1263            },
1264            CreateMode::Replace => {
1265                fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
1266                    detail: format!("create atomic replace `{}`: {error}", path.display()),
1267                })?;
1268            }
1269        }
1270        sync_parent(parent, operation)?;
1271        Ok(())
1272    })();
1273    if result.is_err() {
1274        let _ = fs::remove_file(&temp_path);
1275    }
1276    result
1277}
1278
1279#[cfg(test)]
1280mod tests {
1281    #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
1282    use super::*;
1283    use std::io::Cursor;
1284
1285    fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
1286        let path = dir.join(name);
1287        fs::write(&path, contents).unwrap();
1288        path
1289    }
1290
1291    #[test]
1292    fn round_trip_open_json() {
1293        let dir = tempfile::tempdir().unwrap();
1294        let contents = r#"{"host": "example.com", "port": 993}"#;
1295        let path = write_temp(dir.path(), "config.json", contents);
1296
1297        let doc = DocumentFile::open(&path, None).unwrap();
1298
1299        assert_eq!(doc.format(), Format::Json);
1300        assert_eq!(
1301            doc.value().get("host").and_then(Value::as_str),
1302            Some("example.com")
1303        );
1304        assert_eq!(doc.source(), contents);
1305    }
1306
1307    #[test]
1308    fn value_at_reads_a_nested_address() {
1309        let dir = tempfile::tempdir().unwrap();
1310        let path = write_temp(
1311            dir.path(),
1312            "config.json",
1313            r#"{"database": {"url": "postgres://x"}}"#,
1314        );
1315        let doc = DocumentFile::open(&path, None).unwrap();
1316
1317        assert_eq!(
1318            doc.value_at("database.url").unwrap(),
1319            Value::String("postgres://x".to_string())
1320        );
1321        assert_eq!(
1322            doc.value_at("database.missing").unwrap_err().code(),
1323            "document_path_not_found"
1324        );
1325    }
1326
1327    #[test]
1328    fn open_capped_enforces_size_and_regular_file() {
1329        let dir = tempfile::tempdir().unwrap();
1330        let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
1331
1332        // Within the cap: opens normally.
1333        assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
1334
1335        // Over the cap: rejected without parsing, under its own code so a
1336        // caller enforcing a size budget need not match on the message.
1337        let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
1338        assert_eq!(err.code(), "document_too_large");
1339
1340        // A directory is not a regular file, and that is a different failure.
1341        let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
1342        assert_eq!(dir_err.code(), "document_io_failed");
1343
1344        // Missing is different again — the three must stay distinguishable.
1345        let missing =
1346            DocumentFile::open_capped(dir.path().join("absent.json"), None, 1024).unwrap_err();
1347        assert_ne!(missing.code(), "document_too_large");
1348    }
1349
1350    #[cfg(unix)]
1351    #[test]
1352    fn capped_read_uses_the_open_handle_when_the_path_is_replaced() {
1353        let dir = tempfile::tempdir().unwrap();
1354        let original = r#"{"source":"original"}"#;
1355        let path = write_temp(dir.path(), "config.json", original);
1356        let handle = open_read_handle(&path, SymlinkPolicy::Follow).unwrap();
1357        inspect_capped_source(&handle, &path, 64).unwrap();
1358
1359        fs::rename(&path, dir.path().join("original.json")).unwrap();
1360        fs::write(&path, r#"{"source":"replacement"}"#).unwrap();
1361
1362        let source = read_capped_contents(handle, &path, 64).unwrap();
1363        assert_eq!(source, original);
1364    }
1365
1366    #[cfg(unix)]
1367    #[test]
1368    fn capped_read_rechecks_the_actual_bytes_after_metadata() {
1369        let dir = tempfile::tempdir().unwrap();
1370        let path = write_temp(dir.path(), "config.json", "{}");
1371        let handle = open_read_handle(&path, SymlinkPolicy::Follow).unwrap();
1372        inspect_capped_source(&handle, &path, 4).unwrap();
1373
1374        let mut writer = OpenOptions::new().append(true).open(&path).unwrap();
1375        writer.write_all(b"123").unwrap();
1376        writer.sync_all().unwrap();
1377
1378        let error = read_capped_contents(handle, &path, 4).unwrap_err();
1379        assert_eq!(error.code(), "document_too_large");
1380    }
1381
1382    #[cfg(all(unix, feature = "libc"))]
1383    #[test]
1384    fn open_capped_can_atomically_refuse_a_symlink() {
1385        let dir = tempfile::tempdir().unwrap();
1386        let target = write_temp(dir.path(), "target.json", r#"{"k": "v"}"#);
1387        let link = dir.path().join("link.json");
1388        std::os::unix::fs::symlink(&target, &link).unwrap();
1389
1390        assert!(
1391            DocumentFile::open_capped_with_policy(&link, None, 1024, SymlinkPolicy::Follow).is_ok()
1392        );
1393        let error =
1394            DocumentFile::open_capped_with_policy(&link, None, 1024, SymlinkPolicy::NoFollow)
1395                .unwrap_err();
1396        assert_eq!(error.code(), "document_io_failed");
1397    }
1398
1399    #[test]
1400    fn create_atomic_is_no_clobber_and_returns_a_file_handle() {
1401        let dir = tempfile::tempdir().unwrap();
1402        let path = dir.path().join("config.json");
1403        let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1404
1405        let created = DocumentFile::create_atomic(&path, document, CreateOptions::new()).unwrap();
1406        assert_eq!(created.path(), path);
1407        assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 993}"#);
1408
1409        let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1410        let error =
1411            DocumentFile::create_atomic(&path, replacement, CreateOptions::new()).unwrap_err();
1412        assert_eq!(error.code(), "document_target_exists");
1413        assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 993}"#);
1414    }
1415
1416    #[test]
1417    fn create_atomic_requires_explicit_replace() {
1418        let dir = tempfile::tempdir().unwrap();
1419        let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
1420        let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1421
1422        let created =
1423            DocumentFile::create_atomic(&path, replacement, CreateOptions::new().replace())
1424                .unwrap();
1425
1426        assert_eq!(created.value_at("port").unwrap(), Value::Integer(1024));
1427        assert_eq!(fs::read_to_string(&path).unwrap(), r#"{"port": 1024}"#);
1428    }
1429
1430    /// Two write paths must not disagree about permissions. `save()` preserves
1431    /// the target's mode, so a replace does too unless the caller names one —
1432    /// otherwise committing through the other verb silently re-permissions a
1433    /// file, which on a secrets file means widening it.
1434    #[cfg(unix)]
1435    #[test]
1436    fn create_atomic_replace_preserves_the_targets_mode_unless_told_otherwise() {
1437        use std::os::unix::fs::PermissionsExt as _;
1438
1439        let dir = tempfile::tempdir().unwrap();
1440        let mode_of = |path: &Path| fs::metadata(path).unwrap().permissions().mode() & 0o777;
1441
1442        for original in [0o644, 0o600, 0o640] {
1443            let path = write_temp(dir.path(), &format!("m{original:o}.json"), r#"{"a": 1}"#);
1444            fs::set_permissions(&path, fs::Permissions::from_mode(original)).unwrap();
1445            let replacement = Document::parse(r#"{"a": 2}"#, Format::Json).unwrap();
1446            DocumentFile::create_atomic(&path, replacement, CreateOptions::new().replace())
1447                .unwrap();
1448            assert_eq!(
1449                mode_of(&path),
1450                original,
1451                "replace must keep the file's mode"
1452            );
1453        }
1454
1455        // An explicit mode still wins.
1456        let path = write_temp(dir.path(), "explicit.json", r#"{"a": 1}"#);
1457        fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
1458        let replacement = Document::parse(r#"{"a": 2}"#, Format::Json).unwrap();
1459        DocumentFile::create_atomic(
1460            &path,
1461            replacement,
1462            CreateOptions::new().replace().unix_mode(0o600),
1463        )
1464        .unwrap();
1465        assert_eq!(mode_of(&path), 0o600);
1466
1467        // A first creation still defaults to owner-only.
1468        let fresh = dir.path().join("fresh.json");
1469        let document = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1470        DocumentFile::create_atomic(&fresh, document, CreateOptions::new()).unwrap();
1471        assert_eq!(mode_of(&fresh), 0o600);
1472    }
1473
1474    /// A "safe first commit" that installs a file `open` would reject is not
1475    /// safe. The format the path resolves to and the document's own format have
1476    /// to agree before anything is written.
1477    #[cfg(feature = "toml")]
1478    #[test]
1479    fn create_atomic_refuses_a_document_the_path_could_not_reopen() {
1480        let dir = tempfile::tempdir().unwrap();
1481        let path = dir.path().join("mismatch.toml");
1482        let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1483
1484        let error = DocumentFile::create_atomic(&path, document, CreateOptions::new()).unwrap_err();
1485
1486        assert_eq!(error.code(), "document_unsupported_operation");
1487        assert!(
1488            !path.exists(),
1489            "nothing may be written when the check fails"
1490        );
1491    }
1492
1493    #[test]
1494    fn bare_relative_atomic_paths_use_the_current_directory() {
1495        let (parent, file_name) =
1496            atomic_parent_and_name(Path::new("config.json"), "write").unwrap();
1497
1498        assert_eq!(parent, Path::new("."));
1499        assert_eq!(file_name, "config.json");
1500    }
1501
1502    #[cfg(unix)]
1503    #[test]
1504    fn create_atomic_applies_requested_private_mode() {
1505        use std::os::unix::fs::PermissionsExt as _;
1506
1507        let dir = tempfile::tempdir().unwrap();
1508        let path = dir.path().join("config.json");
1509        let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1510
1511        DocumentFile::create_atomic(&path, document, CreateOptions::new().unix_mode(0o640))
1512            .unwrap();
1513
1514        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1515        assert_eq!(mode, 0o640);
1516    }
1517
1518    #[test]
1519    fn create_atomic_rejects_invalid_permission_bits_before_writing() {
1520        let dir = tempfile::tempdir().unwrap();
1521        let path = dir.path().join("config.json");
1522        let document = Document::parse(r#"{"port": 993}"#, Format::Json).unwrap();
1523
1524        let error =
1525            DocumentFile::create_atomic(&path, document, CreateOptions::new().unix_mode(0o1600))
1526                .unwrap_err();
1527
1528        assert_eq!(error.code(), "document_invalid_argument");
1529        assert!(!path.exists());
1530    }
1531
1532    #[cfg(unix)]
1533    #[test]
1534    fn create_atomic_replace_refuses_symlinks_and_hardlinks() {
1535        let dir = tempfile::tempdir().unwrap();
1536        let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
1537        let symlink = dir.path().join("symlink.json");
1538        std::os::unix::fs::symlink(&target, &symlink).unwrap();
1539        let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1540
1541        let symlink_error = DocumentFile::create_atomic(
1542            &symlink,
1543            replacement.clone(),
1544            CreateOptions::new().replace(),
1545        )
1546        .unwrap_err();
1547        assert_eq!(symlink_error.code(), "document_unsupported_operation");
1548        assert_eq!(fs::read_to_string(&target).unwrap(), r#"{"port": 993}"#);
1549
1550        let hardlink = dir.path().join("hardlink.json");
1551        fs::hard_link(&target, &hardlink).unwrap();
1552        let hardlink_error =
1553            DocumentFile::create_atomic(&hardlink, replacement, CreateOptions::new().replace())
1554                .unwrap_err();
1555        assert_eq!(hardlink_error.code(), "document_unsupported_operation");
1556        assert_eq!(fs::read_to_string(&target).unwrap(), r#"{"port": 993}"#);
1557    }
1558
1559    #[cfg(unix)]
1560    #[test]
1561    fn create_atomic_replace_refuses_a_dangling_symlink() {
1562        let dir = tempfile::tempdir().unwrap();
1563        let missing = dir.path().join("missing.json");
1564        let symlink = dir.path().join("dangling.json");
1565        std::os::unix::fs::symlink(&missing, &symlink).unwrap();
1566        let replacement = Document::parse(r#"{"port": 1024}"#, Format::Json).unwrap();
1567
1568        let error =
1569            DocumentFile::create_atomic(&symlink, replacement, CreateOptions::new().replace())
1570                .unwrap_err();
1571
1572        assert_eq!(error.code(), "document_unsupported_operation");
1573        assert!(
1574            fs::symlink_metadata(&symlink)
1575                .unwrap()
1576                .file_type()
1577                .is_symlink()
1578        );
1579        assert!(!missing.exists());
1580    }
1581
1582    #[test]
1583    fn edit_rolls_back_memory_and_disk_when_the_closure_fails() {
1584        let dir = tempfile::tempdir().unwrap();
1585        let original = r#"{"port": 993}"#;
1586        let path = write_temp(dir.path(), "config.json", original);
1587        let mut document = DocumentFile::open(&path, None).unwrap();
1588
1589        let error = document
1590            .edit(|draft| {
1591                draft.set("port", Value::Integer(1024))?;
1592                Err(DocumentError::InvalidArgument {
1593                    detail: "validation failed".to_string(),
1594                })
1595            })
1596            .unwrap_err();
1597
1598        assert_eq!(error.code(), "document_invalid_argument");
1599        assert_eq!(document.source(), original);
1600        assert_eq!(document.value_at("port").unwrap(), Value::Integer(993));
1601        assert_eq!(fs::read_to_string(&path).unwrap(), original);
1602    }
1603
1604    #[test]
1605    fn typed_get_and_set_enforce_the_stated_type() {
1606        use crate::document::ValueType;
1607        let dir = tempfile::tempdir().unwrap();
1608        let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
1609        let mut doc = DocumentFile::open(&path, None).unwrap();
1610
1611        // Typed get: matching type returns, wrong type is a caught error, Json
1612        // matches anything.
1613        assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
1614        assert_eq!(
1615            doc.value_at_typed("port", ValueType::String)
1616                .unwrap_err()
1617                .code(),
1618            "document_type_mismatch"
1619        );
1620        assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
1621
1622        // Typed set: the literal is validated against the stated type.
1623        doc.set_typed("port", Some("9090"), ValueType::Number)
1624            .unwrap();
1625        assert_eq!(
1626            doc.value_at("port").unwrap(),
1627            Value::from(serde_json::json!(9090))
1628        );
1629        assert_eq!(
1630            doc.set_typed("port", Some("not-a-number"), ValueType::Number)
1631                .unwrap_err()
1632                .code(),
1633            "document_parse_failed"
1634        );
1635    }
1636
1637    #[test]
1638    fn decode_and_edit_and_validate_share_one_typed_boundary() {
1639        #[derive(Debug, serde::Deserialize)]
1640        #[serde(deny_unknown_fields)]
1641        struct Config {
1642            port: u16,
1643        }
1644
1645        let dir = tempfile::tempdir().unwrap();
1646        let original = r#"{"port": 8080}"#;
1647        let path = write_temp(dir.path(), "config.json", original);
1648        let mut document = DocumentFile::open(&path, None).unwrap();
1649
1650        assert_eq!(document.decode::<Config>().unwrap().port, 8080);
1651
1652        let error = document
1653            .edit_and_validate::<Config>(|draft| {
1654                draft.set("port", Value::String("invalid".to_string()))
1655            })
1656            .unwrap_err();
1657        assert_eq!(error.code(), "document_type_mismatch");
1658        assert_eq!(document.source(), original);
1659        assert_eq!(fs::read_to_string(&path).unwrap(), original);
1660
1661        let config = document
1662            .edit_and_validate::<Config>(|draft| draft.set("port", Value::Unsigned(9090)))
1663            .unwrap();
1664        assert_eq!(config.port, 9090);
1665        assert_eq!(document.value_at("port").unwrap(), Value::Unsigned(9090));
1666    }
1667
1668    #[cfg(feature = "toml")]
1669    #[test]
1670    fn round_trip_open_toml() {
1671        let dir = tempfile::tempdir().unwrap();
1672        let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
1673        let path = write_temp(dir.path(), "config.toml", contents);
1674
1675        let doc = DocumentFile::open(&path, None).unwrap();
1676
1677        assert_eq!(doc.format(), Format::Toml);
1678        assert_eq!(
1679            doc.value().get("host").and_then(Value::as_str),
1680            Some("example.com")
1681        );
1682        assert_eq!(doc.source(), contents);
1683    }
1684
1685    #[cfg(feature = "toml")]
1686    #[test]
1687    fn set_scalar_preserves_toml_comments_and_formatting() {
1688        let dir = tempfile::tempdir().unwrap();
1689        let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
1690        let path = write_temp(dir.path(), "config.toml", contents);
1691        let mut doc = DocumentFile::open(&path, None).unwrap();
1692
1693        doc.set("port", Value::Integer(1024)).unwrap();
1694        doc.save().unwrap();
1695
1696        let saved = fs::read_to_string(&path).unwrap();
1697        assert!(saved.contains("# leading comment"));
1698        assert!(saved.contains("port = 1024"));
1699        assert_eq!(
1700            doc.value().get("port").and_then(Value::as_integer),
1701            Some(1024)
1702        );
1703        assert_eq!(doc.source(), saved);
1704    }
1705
1706    /// A comment documents the value it was written beside. Growing an array
1707    /// must not copy a neighbour's comment onto a value the author never wrote
1708    /// it for, and shrinking must not leave a removed element's comment
1709    /// documenting a survivor. Both are content this module invented.
1710    #[cfg(feature = "toml")]
1711    #[test]
1712    fn toml_array_edits_never_invent_or_misattribute_a_comment() {
1713        let contents = "paths = [\n  \"one\", # first\n  \"two\", # second\n]\n";
1714
1715        let mut grown = Document::parse(contents, Format::Toml).unwrap();
1716        grown
1717            .set(
1718                "paths",
1719                Value::Array(vec![
1720                    Value::String("a".into()),
1721                    Value::String("b".into()),
1722                    Value::String("c".into()),
1723                ]),
1724            )
1725            .unwrap();
1726        assert_eq!(
1727            grown.source(),
1728            "paths = [\n  \"a\", # first\n  \"b\",\n  \"c\", # second\n]\n",
1729            "an appended element must carry no comment of its own"
1730        );
1731
1732        let mut shrunk = Document::parse(contents, Format::Toml).unwrap();
1733        shrunk
1734            .set("paths", Value::Array(vec![Value::String("only".into())]))
1735            .unwrap();
1736        assert_eq!(
1737            shrunk.source(),
1738            "paths = [\n  \"only\", # first\n]\n",
1739            "the surviving element keeps its own comment, not the removed one's"
1740        );
1741
1742        // Same length: every comment stays exactly where the author put it.
1743        let mut replaced = Document::parse(contents, Format::Toml).unwrap();
1744        replaced
1745            .set(
1746                "paths",
1747                Value::Array(vec![Value::String("x".into()), Value::String("y".into())]),
1748            )
1749            .unwrap();
1750        assert_eq!(
1751            replaced.source(),
1752            "paths = [\n  \"x\", # first\n  \"y\", # second\n]\n"
1753        );
1754
1755        // Growing a single-line array keeps the bracket spacing.
1756        let mut inline = Document::parse("paths = [ \"one\" ]\n", Format::Toml).unwrap();
1757        inline
1758            .set(
1759                "paths",
1760                Value::Array(vec![
1761                    Value::String("one".into()),
1762                    Value::String("two".into()),
1763                ]),
1764            )
1765            .unwrap();
1766        assert_eq!(inline.source(), "paths = [ \"one\", \"two\" ]\n");
1767    }
1768
1769    #[cfg(feature = "toml")]
1770    #[test]
1771    fn set_toml_array_preserves_single_line_decor() {
1772        let contents = "# before\npaths = [ \"old\", 'second', ] # keep this\nother = 42\n";
1773        let mut document = Document::parse(contents, Format::Toml).unwrap();
1774
1775        document
1776            .set(
1777                "paths",
1778                Value::Array(vec![
1779                    Value::String("new".to_string()),
1780                    Value::String("next".to_string()),
1781                ]),
1782            )
1783            .unwrap();
1784
1785        assert_eq!(
1786            document.source(),
1787            "# before\npaths = [ \"new\", \"next\", ] # keep this\nother = 42\n"
1788        );
1789    }
1790
1791    #[cfg(feature = "toml")]
1792    #[test]
1793    fn set_toml_array_preserves_multiline_comments_and_trailing_comma() {
1794        let contents = "paths = [\n  \"one\", # first\n  \"two\", # second\n]\nother = 42\n";
1795        let mut document = Document::parse(contents, Format::Toml).unwrap();
1796
1797        document
1798            .set(
1799                "paths",
1800                Value::Array(vec![
1801                    Value::String("uno".to_string()),
1802                    Value::String("dos".to_string()),
1803                ]),
1804            )
1805            .unwrap();
1806
1807        assert_eq!(
1808            document.source(),
1809            "paths = [\n  \"uno\", # first\n  \"dos\", # second\n]\nother = 42\n"
1810        );
1811    }
1812
1813    #[cfg(feature = "toml")]
1814    #[test]
1815    fn set_toml_array_element_preserves_its_neighbors() {
1816        let contents = "paths = [\n  \"one\", # first\n  \"two\", # second\n]\n";
1817        let mut document = Document::parse(contents, Format::Toml).unwrap();
1818
1819        document
1820            .set("paths.1", Value::String("changed".to_string()))
1821            .unwrap();
1822
1823        assert_eq!(
1824            document.source(),
1825            "paths = [\n  \"one\", # first\n  \"changed\", # second\n]\n"
1826        );
1827    }
1828
1829    #[cfg(feature = "toml")]
1830    #[test]
1831    fn set_toml_array_can_become_empty_without_touching_neighbors() {
1832        let contents = "before = 1\npaths = [ \"one\", ] # list\nafter = 2\n";
1833        let mut document = Document::parse(contents, Format::Toml).unwrap();
1834
1835        document.set("paths", Value::Array(Vec::new())).unwrap();
1836
1837        assert_eq!(
1838            document.source(),
1839            "before = 1\npaths = [ ] # list\nafter = 2\n"
1840        );
1841    }
1842
1843    #[cfg(feature = "toml")]
1844    #[test]
1845    fn set_toml_inline_table_preserves_layout_and_comments() {
1846        let contents = "cache = { ttl_s = 1, enabled = true } # cache\nother = 42\n";
1847        let mut document = Document::parse(contents, Format::Toml).unwrap();
1848        let replacement = Value::from(serde_json::json!({
1849            "enabled": false,
1850            "ttl_s": 60
1851        }));
1852
1853        document.set("cache", replacement).unwrap();
1854
1855        assert_eq!(
1856            document.source(),
1857            "cache = { ttl_s = 60, enabled = false } # cache\nother = 42\n"
1858        );
1859    }
1860
1861    #[cfg(feature = "toml")]
1862    #[test]
1863    fn set_toml_ordinary_table_preserves_header_and_unrelated_section() {
1864        let contents = "# lead\n[cache] # cache header\nttl_s = 1 # ttl\nenabled = true\n\n[next]\nvalue = 9\n";
1865        let mut document = Document::parse(contents, Format::Toml).unwrap();
1866        let replacement = Value::from(serde_json::json!({
1867            "enabled": false,
1868            "ttl_s": 60
1869        }));
1870
1871        document.set("cache", replacement).unwrap();
1872
1873        assert_eq!(
1874            document.source(),
1875            "# lead\n[cache] # cache header\nttl_s = 60 # ttl\nenabled = false\n\n[next]\nvalue = 9\n"
1876        );
1877    }
1878
1879    #[cfg(feature = "toml")]
1880    #[test]
1881    fn set_toml_collection_preserves_unchanged_datetime_syntax() {
1882        let contents = "[cache]\nexpires_at = 2026-08-04T12:30:00Z\npaths = [\"one\"]\n";
1883        let mut document = Document::parse(contents, Format::Toml).unwrap();
1884        let replacement = document.value_at("cache").unwrap();
1885
1886        document.set("cache", replacement).unwrap();
1887
1888        assert_eq!(document.source(), contents);
1889    }
1890
1891    #[cfg(feature = "toml")]
1892    #[test]
1893    fn set_toml_array_of_tables_is_explicitly_refused() {
1894        let contents = "[[servers]]\nname = \"one\"\n[[servers]]\nname = \"two\"\n";
1895        let mut document = Document::parse(contents, Format::Toml).unwrap();
1896        let replacement = document.value_at("servers").unwrap();
1897
1898        let error = document.set("servers", replacement).unwrap_err();
1899
1900        assert_eq!(error.code(), "document_unsupported_operation");
1901        assert_eq!(document.source(), contents);
1902    }
1903
1904    #[cfg(feature = "ini")]
1905    #[test]
1906    fn save_refuses_source_its_own_parser_rejects() {
1907        // The read-back guard, exercised directly: no backend should be able to
1908        // splice text into this shape any more, so the corrupt source is
1909        // supplied by hand rather than provoked through a verb.
1910        let dir = tempfile::tempdir().unwrap();
1911        let original = "[db]\nhost=localhost\n";
1912        let path = write_temp(dir.path(), "config.ini", original);
1913        let doc = DocumentFile::open(&path, None).unwrap();
1914
1915        let error = doc
1916            .save_atomic("[db]\nhost=localhost\n\n[db]\nport=5432\n")
1917            .unwrap_err();
1918        assert_eq!(error.code(), "document_write_would_corrupt");
1919        // The whole point is that the guard runs before any bytes land.
1920        assert_eq!(fs::read_to_string(&path).unwrap(), original);
1921    }
1922
1923    #[cfg(feature = "ini")]
1924    #[test]
1925    fn save_writes_source_the_parser_accepts() {
1926        let dir = tempfile::tempdir().unwrap();
1927        let path = write_temp(dir.path(), "config.ini", "[db]\nhost=localhost\n");
1928        let mut doc = DocumentFile::open(&path, None).unwrap();
1929
1930        doc.set("db.port", Value::String("5432".to_string()))
1931            .unwrap();
1932        doc.save().unwrap();
1933
1934        // A key added to a section that is not the file's last one used to be
1935        // appended at end of file, re-opening a section that had closed.
1936        assert_eq!(
1937            fs::read_to_string(&path).unwrap(),
1938            "[db]\nhost=localhost\nport=5432\n"
1939        );
1940        assert!(DocumentFile::open(&path, None).is_ok());
1941    }
1942
1943    #[cfg(unix)]
1944    #[test]
1945    fn atomic_save_preserves_file_mode() {
1946        use std::os::unix::fs::PermissionsExt;
1947
1948        let dir = tempfile::tempdir().unwrap();
1949        let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
1950        fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
1951        let mut doc = DocumentFile::open(&path, None).unwrap();
1952
1953        doc.set("port", Value::Integer(1024)).unwrap();
1954        doc.save().unwrap();
1955
1956        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
1957        assert_eq!(mode, 0o640);
1958    }
1959
1960    #[cfg(unix)]
1961    #[test]
1962    fn symlink_target_is_rejected_for_mutation() {
1963        let dir = tempfile::tempdir().unwrap();
1964        let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
1965        let link = dir.path().join("link.json");
1966        std::os::unix::fs::symlink(&target, &link).unwrap();
1967
1968        // Reading through the symlink is fine.
1969        let mut doc = DocumentFile::open(&link, None).unwrap();
1970
1971        // Editing in memory is fine; committing through the symlink is not.
1972        doc.set("port", Value::Integer(1024)).unwrap();
1973        let err = doc.save().unwrap_err();
1974        assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
1975
1976        // The target file was never touched.
1977        let target_contents = fs::read_to_string(&target).unwrap();
1978        assert_eq!(target_contents, r#"{"port": 993}"#);
1979    }
1980
1981    #[test]
1982    fn from_reader_parses_in_memory_cursor() {
1983        let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
1984
1985        let doc = Document::from_reader(cursor, Format::Json).unwrap();
1986
1987        assert_eq!(
1988            doc.value().get("host").and_then(Value::as_str),
1989            Some("example.com")
1990        );
1991    }
1992
1993    #[test]
1994    fn document_from_str_encode_round_trip() {
1995        let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1996        let encoded = doc.encode().unwrap();
1997        let reparsed = Document::parse(&encoded, Format::Json).unwrap();
1998        assert_eq!(
1999            reparsed.value().get("a").and_then(Value::as_integer),
2000            Some(1)
2001        );
2002    }
2003
2004    #[test]
2005    fn document_edits_source_in_memory_without_a_file() {
2006        // The point of the Document/DocumentFile split: source-preserving
2007        // editing with no file, no I/O, no guards.
2008        let mut doc = Document::parse("{\n  \"host\": \"old\"\n}\n", Format::Json).unwrap();
2009        doc.set("host", Value::String("new".to_string())).unwrap();
2010        doc.set("imap.port", Value::Integer(993)).unwrap(); // creates the parent
2011
2012        assert_eq!(
2013            doc.source(),
2014            "{\n  \"host\": \"new\",\n  \"imap\": {\n    \"port\": 993\n  }\n}\n"
2015        );
2016        assert_eq!(
2017            doc.value_at("imap.port").unwrap(),
2018            Value::from(serde_json::json!(993))
2019        );
2020    }
2021
2022    #[test]
2023    fn unset_is_false_for_anything_already_absent() {
2024        let mut doc = Document::parse(
2025            r#"{"service":{"host":"example","ports":[80]}}"#,
2026            Format::Json,
2027        )
2028        .unwrap();
2029
2030        // Absent is absent, at any depth — the answer must not change with the
2031        // number of segments the caller had to write to name the same nothing.
2032        assert!(!doc.unset("service.missing").unwrap());
2033        assert!(!doc.unset("missing.parent").unwrap());
2034        assert!(!doc.unset("missing.deeply.nested").unwrap());
2035
2036        // Still errors: these describe a path no document could satisfy, not a
2037        // document that happens not to carry the key.
2038        assert!(doc.unset("service.host.child").is_err()); // through a scalar
2039        assert!(doc.unset("service.ports.9").is_err()); // index out of range
2040        assert!(doc.unset(r"service\q").is_err()); // malformed syntax
2041    }
2042
2043    #[cfg(feature = "markdown")]
2044    #[test]
2045    fn every_markdown_write_verb_is_refused() {
2046        let source = "# Title\n\nThe lead.\n";
2047        let mut doc = Document::parse(source, Format::Markdown).unwrap();
2048
2049        // Reading is the whole point, and works — including by content, which
2050        // `value_at` gets from the format's own rule.
2051        assert_eq!(
2052            doc.value_at("h1.0.text").unwrap(),
2053            Value::String("Title".to_string())
2054        );
2055        assert_eq!(
2056            doc.value_at("h1.Tit.paragraph.0.text").unwrap(),
2057            Value::String("The lead.".to_string())
2058        );
2059
2060        // Every mutating verb refuses, including the two that would otherwise
2061        // answer before reaching a backend: `unset` on an absent path (which
2062        // is `Ok(false)` for a writable format) and `encode`.
2063        let refusals: Vec<DocumentError> = vec![
2064            doc.set("h1.0.text", Value::String("New".to_string()))
2065                .unwrap_err(),
2066            doc.add("preamble", "x", "type", &[]).unwrap_err(),
2067            doc.remove("preamble", "x", "type").unwrap_err(),
2068            doc.unset("h1.0").unwrap_err(),
2069            doc.unset("nothing.here").unwrap_err(),
2070            doc.encode().unwrap_err(),
2071        ];
2072        for error in refusals {
2073            assert_eq!(error.code(), "document_unsupported_operation");
2074            assert!(
2075                error.to_string().contains("read-only"),
2076                "refusal must name the reason: {error}"
2077            );
2078        }
2079
2080        // Nothing was staged by any of them.
2081        assert_eq!(doc.source(), source);
2082    }
2083
2084    #[cfg(feature = "markdown")]
2085    #[test]
2086    fn markdown_save_never_reaches_disk() {
2087        let dir = tempfile::tempdir().unwrap();
2088        let path = write_temp(dir.path(), "README.md", "# Title\n");
2089        // `.md` resolves to no format, so the reading has to be named.
2090        assert!(DocumentFile::open(&path, None).is_err());
2091
2092        let doc = DocumentFile::open(&path, Some(Format::Markdown)).unwrap();
2093        // Even an unmodified save, whose bytes would be identical, is refused.
2094        let error = doc.save().unwrap_err();
2095        assert_eq!(error.code(), "document_unsupported_operation");
2096        assert_eq!(fs::read_to_string(&path).unwrap(), "# Title\n");
2097    }
2098
2099    #[cfg(feature = "yaml")]
2100    #[test]
2101    fn yaml_write_rejects_cst_ambiguous_mapping_segments() {
2102        let mut numeric = Document::parse("\"123\": value\n", Format::Yaml).unwrap();
2103        assert!(
2104            numeric
2105                .set("123", Value::String("changed".to_string()))
2106                .is_err()
2107        );
2108        assert!(numeric.unset("123").is_err());
2109
2110        let mut bracketed = Document::parse("\"a[0]\": value\n", Format::Yaml).unwrap();
2111        assert!(
2112            bracketed
2113                .set("a[0]", Value::String("changed".to_string()))
2114                .is_err()
2115        );
2116    }
2117}