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