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