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