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