Skip to main content

agent_first_data/document/
file.rs

1//! Format-neutral document editing: an in-memory [`Document`] with
2//! source-preserving edits, and a [`DocumentFile`] that adds the file boundary.
3//!
4//! [`Document`] holds the source text, its parsed [`Value`], and the
5//! [`Format`]. Its verbs — [`set`](Document::set) / [`unset`](Document::unset)
6//! / [`add`](Document::add) / [`remove`](Document::remove) — edit the source in
7//! place (comments, ordering, and untouched formatting survive) and update the
8//! parsed value alongside; [`source`](Document::source) reads the result and
9//! [`encode`](Document::encode) re-renders a fresh, non-preserving copy from the
10//! value. No file, no I/O, no guards — just editing.
11//!
12//! [`DocumentFile`] is a [`Document`] plus a path, reachable through
13//! [`Deref`](std::ops::Deref): read and edit exactly as above, then commit with
14//! [`save`](DocumentFile::save) or the [`edit`](DocumentFile::edit) closure.
15//! Every write refuses a symlink/hardlinked target and goes to a
16//! same-directory temp file that is fsynced, has the original permissions
17//! re-applied, and is atomically renamed over the target — so a crash mid-write
18//! never leaves a partial file.
19//!
20//! This module never redacts values — it reads and writes raw values as-is;
21//! redaction is the caller's responsibility.
22
23use std::fs::{self, OpenOptions};
24use std::io::Write as _;
25use std::path::{Path, PathBuf};
26
27use crate::document::{Addressing, DocumentError, DocumentResult, Format, KeyedList, Value};
28
29/// A format-neutral in-memory document: the original source text, its parsed
30/// [`Value`], and the [`Format`] both came from. Has no file coupling —
31/// construct it from a string or any [`std::io::Read`] the caller supplies,
32/// edit it source-preservingly with [`set`](Document::set) /
33/// [`unset`](Document::unset) / [`add`](Document::add) /
34/// [`remove`](Document::remove), and read the result back with
35/// [`source`](Document::source). [`DocumentFile`] is just this plus a path.
36#[derive(Debug, Clone)]
37pub struct Document {
38    source: String,
39    value: Value,
40    format: Format,
41}
42
43impl Document {
44    /// Parse `source` in the given `format`.
45    ///
46    /// Named `parse` (not `from_str`) deliberately: this takes an explicit
47    /// `format` argument, so it is not the single-argument `std::str::FromStr`
48    /// contract that `from_str` would imply.
49    pub fn parse(source: &str, format: Format) -> DocumentResult<Document> {
50        let value = format.load(source)?;
51        Ok(Document {
52            source: source.to_string(),
53            value,
54            format,
55        })
56    }
57
58    /// Read `reader` fully to a `String`, then parse it in the given
59    /// `format`.
60    ///
61    /// Reads only from the supplied `reader` — never touches the process's
62    /// own stdin.
63    pub fn from_reader<R: std::io::Read>(
64        mut reader: R,
65        format: Format,
66    ) -> DocumentResult<Document> {
67        let mut source = String::new();
68        reader.read_to_string(&mut source)?;
69        Document::parse(&source, format)
70    }
71
72    /// Borrow the parsed value (reflects the last successful edit).
73    pub fn value(&self) -> &Value {
74        &self.value
75    }
76
77    /// Borrow the current source text — the original bytes with every
78    /// source-preserving edit applied. This is what [`DocumentFile::save`]
79    /// writes.
80    pub fn source(&self) -> &str {
81        &self.source
82    }
83
84    /// The format this document was parsed from.
85    pub fn format(&self) -> Format {
86        self.format
87    }
88
89    /// How a non-numeric path segment resolves against an array in this
90    /// document: whatever its format declares (see [`Format::array_rule`]),
91    /// with no caller-declared keyed lists.
92    ///
93    /// Use [`addressing_keyed`](Document::addressing_keyed) to add those.
94    #[must_use]
95    pub fn addressing(&self) -> Addressing<'static> {
96        Addressing::INDEX_ONLY.with_array_rule(self.format.array_rule())
97    }
98
99    /// [`addressing`](Document::addressing) plus the caller's keyed lists,
100    /// which take precedence over the format rule for the arrays they name.
101    #[must_use]
102    pub fn addressing_keyed<'a>(&self, keyed_lists: &'a [KeyedList<'a>]) -> Addressing<'a> {
103        Addressing::keyed(keyed_lists).with_array_rule(self.format.array_rule())
104    }
105
106    /// Resolve a dotted `path` against the parsed document and return the value
107    /// at that address.
108    ///
109    /// A non-empty ASCII-decimal segment against an array is an index;
110    /// anything else goes through the format's own rule, so a Markdown document answers
111    /// `h2.look` while a JSON one refuses `deps.foo`. See
112    /// [`addressing`](Document::addressing).
113    pub fn value_at(&self, path: &str) -> DocumentResult<Value> {
114        crate::document::get_path(&self.value, path, self.addressing())
115    }
116
117    /// [`value_at`](Document::value_at) that also asserts the value at `path`
118    /// satisfies `expected`, returning a [`DocumentError::TypeMismatch`]
119    /// otherwise.
120    pub fn value_at_typed(
121        &self,
122        path: &str,
123        expected: crate::document::ValueType,
124    ) -> DocumentResult<Value> {
125        let value = self.value_at(path)?;
126        if crate::document::value_matches_type(&value, expected) {
127            Ok(value)
128        } else {
129            Err(DocumentError::TypeMismatch {
130                path: path.to_string(),
131                expected: expected.name().to_string(),
132                got: value.kind_name().to_string(),
133                hint: None,
134            })
135        }
136    }
137
138    /// Build a value from the CLI string `raw` per an explicit
139    /// [`ValueType`](crate::document::ValueType) and [`set`](Document::set) it.
140    pub fn set_typed(
141        &mut self,
142        key: &str,
143        raw: Option<&str>,
144        value_type: crate::document::ValueType,
145    ) -> DocumentResult<()> {
146        let value = crate::document::value_from_type(value_type, raw)?;
147        self.set(key, value)
148    }
149
150    /// Refuse `operation` when this document's format has no writer at all.
151    ///
152    /// A read-only format (see [`Format::is_read_only`]) is refused here, at
153    /// the top of every mutating verb, rather than at the backend dispatch
154    /// below it. The verbs stage an edit against the parsed value first, so a
155    /// later refusal would report whichever backend step happened to notice —
156    /// naming the wrong reason for a document that was never writable.
157    fn ensure_writable(&self, operation: &str) -> DocumentResult<()> {
158        if self.format.is_read_only() {
159            return Err(self.format.read_only_error(operation));
160        }
161        Ok(())
162    }
163
164    /// Re-render the current value in its format via [`Format::save`].
165    ///
166    /// This is a fresh, non-source-preserving render: comments and original
167    /// formatting are not retained. Use [`source`](Document::source) after
168    /// source-preserving edits to keep the original formatting.
169    pub fn encode(&self) -> DocumentResult<String> {
170        self.format.save(&self.value)
171    }
172}
173
174/// A file-backed [`Document`]: the in-memory document plus the path it was
175/// read from.
176///
177/// All reads and source-preserving edits come from [`Document`] through
178/// [`Deref`]/[`DerefMut`]; `DocumentFile` adds only the file boundary — reading
179/// on [`open`](DocumentFile::open) and an atomic, symlink-guarded commit on
180/// [`save`](DocumentFile::save) / [`edit`](DocumentFile::edit).
181#[derive(Debug, Clone)]
182pub struct DocumentFile {
183    doc: Document,
184    path: PathBuf,
185}
186
187impl DocumentFile {
188    /// Open and parse `path`.
189    ///
190    /// `format_override` takes precedence; otherwise the format is detected
191    /// from the file extension via [`Format::detect`]. Reading is always
192    /// allowed — this does not run the mutation guard.
193    pub fn open(
194        path: impl AsRef<Path>,
195        format_override: Option<Format>,
196    ) -> DocumentResult<DocumentFile> {
197        let path = path.as_ref().to_path_buf();
198        let format = match format_override {
199            Some(format) => format,
200            // A `.toml` file read by a build without the `toml` feature is not
201            // an unknown format — it is a known one this binary cannot read, and
202            // saying so names the fix. Only `Format::unavailable` can tell the
203            // two apart, because `detect` answers `None` for both.
204            None => match Format::detect(&path) {
205                Some(format) => format,
206                None => {
207                    return Err(match Format::unavailable(&path) {
208                        Some(feature) => DocumentError::UnsupportedOperation {
209                            format: feature.to_string(),
210                            operation: "open".to_string(),
211                            detail: format!("requires Cargo feature `{feature}`"),
212                        },
213                        None => DocumentError::FormatUnknown {
214                            path: path.display().to_string(),
215                        },
216                    });
217                }
218            },
219        };
220        let source = fs::read_to_string(&path).map_err(|error| DocumentError::IoError {
221            detail: format!("read `{}`: {error}", path.display()),
222        })?;
223        Ok(DocumentFile {
224            doc: Document::parse(&source, format)?,
225            path,
226        })
227    }
228
229    /// Open and parse `path` like [`DocumentFile::open`], but first reject any
230    /// non-regular file, or any file larger than `max_bytes`, without reading
231    /// its contents.
232    ///
233    /// Use this over [`open`](DocumentFile::open) when reading untrusted or
234    /// secret-bearing config, where an unbounded read of an arbitrary path is
235    /// a denial-of-service risk.
236    pub fn open_capped(
237        path: impl AsRef<Path>,
238        format_override: Option<Format>,
239        max_bytes: u64,
240    ) -> DocumentResult<DocumentFile> {
241        let path = path.as_ref();
242        let metadata = fs::metadata(path).map_err(|error| DocumentError::IoError {
243            detail: format!("read `{}`: {error}", path.display()),
244        })?;
245        if !metadata.is_file() {
246            return Err(DocumentError::IoError {
247                detail: format!("`{}` is not a regular file", path.display()),
248            });
249        }
250        if metadata.len() > max_bytes {
251            return Err(DocumentError::IoError {
252                detail: format!(
253                    "`{}` exceeds the {max_bytes}-byte read limit",
254                    path.display()
255                ),
256            });
257        }
258        DocumentFile::open(path, format_override)
259    }
260
261    /// The file path this document was opened from.
262    pub fn path(&self) -> &Path {
263        &self.path
264    }
265
266    /// Preflight-check that this document format is writable and this file is
267    /// safe to mutate — not a symlink, and on unix not hardlinked — without
268    /// performing any write.
269    ///
270    /// [`save`](DocumentFile::save) runs this same guard before it writes, so
271    /// calling it directly is only useful to front-run a *separate* side effect
272    /// with the same guarantee — e.g. a CLI reading a secret from stdin for a
273    /// `set` should refuse an unsafe target before consuming that input.
274    pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
275        self.doc.ensure_writable(operation)?;
276        guard_mutation(&self.path, operation)?;
277        Ok(())
278    }
279}
280
281impl Document {
282    /// Set `key` to the typed `value`, preserving the rest of the source
283    /// document. The edit is staged in memory — call
284    /// [`save`](DocumentFile::save) to persist it.
285    ///
286    /// Backend capability mirrors [`crate::document::set_path`] where the
287    /// source editor allows it: the JSON backend replaces an existing value
288    /// (scalar or collection) and creates missing intermediate parent objects;
289    /// the TOML backend creates missing parent tables. Backends that cannot
290    /// express an edit source-preserving (e.g. YAML collection mutation) return
291    /// [`DocumentError::UnsupportedOperation`].
292    pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
293        let addressing = self.addressing();
294        self.set_addressed(key, value, addressing)
295    }
296
297    /// [`set`](Document::set) with the caller's own addressing, so a path that
298    /// names an array element by its content — `identities.me.email` — can be
299    /// written and not merely read.
300    ///
301    /// The address is canonicalized to indices first (see
302    /// [`crate::document::resolve_path`]); everything below this point, the
303    /// source-preserving backends included, sees only `identities.0.email`.
304    pub fn set_addressed(
305        &mut self,
306        key: &str,
307        value: Value,
308        addressing: Addressing<'_>,
309    ) -> DocumentResult<()> {
310        self.ensure_writable("set")?;
311        let key = &crate::document::resolve_path(&self.value, key, addressing)?;
312        let mut new_doc = self.value.clone();
313        crate::document::set_path(&mut new_doc, key, &value, Addressing::INDEX_ONLY)?;
314        let target = crate::document::get_path(&new_doc, key, Addressing::INDEX_ONLY)?;
315        #[allow(unreachable_patterns)]
316        let output = match self.format {
317            #[cfg(feature = "toml")]
318            Format::Toml => {
319                crate::document::format::toml::set_preserving(&self.source, key, &target)?
320            }
321            #[cfg(feature = "yaml")]
322            Format::Yaml => {
323                crate::document::format::yaml::set_preserving(&self.source, key, &target)?
324            }
325            Format::Json => {
326                crate::document::format::json::set_preserving(&self.source, key, &target)?
327            }
328            #[cfg(feature = "dotenv")]
329            Format::Dotenv => {
330                crate::document::format::dotenv::set_preserving(&self.source, key, &target)?
331            }
332            #[cfg(feature = "ini")]
333            Format::Ini => {
334                crate::document::format::ini::set_preserving(&self.source, key, &target)?
335            }
336            #[cfg(feature = "toml")]
337            Format::TomlFrontmatter => {
338                let parts = crate::document::format::frontmatter::split(
339                    &self.source,
340                    crate::document::format::frontmatter::Delimiter::Plus,
341                )?;
342                let new_fm =
343                    crate::document::format::toml::set_preserving(parts.frontmatter, key, &target)?;
344                format!("{}{}{}", parts.pre, new_fm, parts.post)
345            }
346            #[cfg(feature = "yaml")]
347            Format::YamlFrontmatter => {
348                let parts = crate::document::format::frontmatter::split(
349                    &self.source,
350                    crate::document::format::frontmatter::Delimiter::Dash,
351                )?;
352                let new_fm =
353                    crate::document::format::yaml::set_preserving(parts.frontmatter, key, &target)?;
354                format!("{}{}{}", parts.pre, new_fm, parts.post)
355            }
356            _ => self.format.save(&new_doc)?,
357        };
358        self.source = output;
359        self.value = new_doc;
360        Ok(())
361    }
362
363    /// Add a new element to the keyed list at `key`, identified by
364    /// `slug`/`slug_field`, with the given `fields`. Preserves the rest of
365    /// the source document. An empty `key` targets the document root when the
366    /// root is itself the keyed array.
367    ///
368    /// Only JSON and YAML backends implement a source-preserving
369    /// keyed-collection editor today; other formats return
370    /// [`DocumentError::UnsupportedOperation`].
371    pub fn add(
372        &mut self,
373        key: &str,
374        slug: &str,
375        slug_field: &str,
376        fields: &[(String, Value)],
377    ) -> DocumentResult<()> {
378        self.ensure_writable("add")?;
379        let mut value = self.value.clone();
380        let keyed_lists = [KeyedList {
381            prefix: key,
382            slug_field,
383        }];
384        crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
385        let array = if key.is_empty() {
386            &value
387        } else {
388            crate::document::get_path_ref(&value, key, self.addressing_keyed(&keyed_lists))?
389        };
390        let item = array
391            .as_array()
392            .and_then(|items| items.last())
393            .ok_or_else(|| DocumentError::UnsupportedOperation {
394                format: self.format.name().to_string(),
395                operation: "add".to_string(),
396                detail: "keyed list did not produce an array item".to_string(),
397            })?;
398        // The catch-all covers the formats with no source-preserving keyed
399        // editor. A build with only JSON has none left to catch, which makes it
400        // unreachable there and reachable everywhere else — the arm stays.
401        #[allow(unreachable_patterns)]
402        let output: String = match self.format {
403            Format::Json => crate::document::format::json::append_array_item_preserving(
404                &self.source,
405                key,
406                item,
407            )?,
408            #[cfg(feature = "yaml")]
409            Format::Yaml => crate::document::format::yaml::append_array_item_preserving(
410                &self.source,
411                key,
412                item,
413            )?,
414            _ => {
415                return Err(DocumentError::UnsupportedOperation {
416                    format: self.format.name().to_string(),
417                    operation: "add".to_string(),
418                    detail: "keyed collection source editor is not implemented for this backend"
419                        .to_string(),
420                });
421            }
422        };
423        self.source = output;
424        self.value = value;
425        Ok(())
426    }
427
428    /// Remove the element identified by `slug`/`slug_field` from the keyed
429    /// list at `key`. Preserves the rest of the source document. An empty
430    /// `key` targets the document root when the root is itself the keyed array.
431    ///
432    /// Only JSON and YAML backends implement a source-preserving
433    /// keyed-collection editor today; other formats return
434    /// [`DocumentError::UnsupportedOperation`].
435    pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
436        self.ensure_writable("remove")?;
437        let mut value = self.value.clone();
438        let keyed_lists = [KeyedList {
439            prefix: key,
440            slug_field,
441        }];
442        let removed_index = crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
443        // The catch-all covers the formats with no source-preserving keyed
444        // editor. A build with only JSON has none left to catch, which makes it
445        // unreachable there and reachable everywhere else — the arm stays.
446        #[allow(unreachable_patterns)]
447        let output: String = match self.format {
448            Format::Json => crate::document::format::json::remove_array_item_preserving(
449                &self.source,
450                key,
451                removed_index,
452            )?,
453            #[cfg(feature = "yaml")]
454            Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
455                &self.source,
456                key,
457                removed_index,
458            )?,
459            _ => {
460                return Err(DocumentError::UnsupportedOperation {
461                    format: self.format.name().to_string(),
462                    operation: "remove".to_string(),
463                    detail: "keyed collection source editor is not implemented for this backend"
464                        .to_string(),
465                });
466            }
467        };
468        self.source = output;
469        self.value = value;
470        Ok(())
471    }
472
473    /// Remove the entry at `key` entirely, preserving the rest of the source
474    /// document. The edit is staged in memory — call
475    /// [`DocumentFile::save`] to persist it.
476    ///
477    /// Idempotent, like [`HashSet::remove`](std::collections::HashSet::remove):
478    /// returns `Ok(false)` when there was nothing at `key` to remove (nothing
479    /// is staged), and `Ok(true)` when it was removed.
480    ///
481    /// "Nothing there" does not depend on how deep the path is. A missing leaf
482    /// and a missing ancestor are the same fact — `a.b.c` is absent whether
483    /// `a.b` exists or not — so both answer `Ok(false)`. Only a path that is
484    /// *malformed* stays an error: bad syntax, an index into a non-array, or a
485    /// segment that tries to traverse through a scalar. Those describe a caller
486    /// asking something incoherent, not a document that already lacks the key.
487    ///
488    /// A read-only format is the one case that errors *before* the idempotent
489    /// answer: "nothing to remove" would report success for a document this
490    /// verb can never edit.
491    ///
492    /// A content-addressed segment that matches no element is also an error
493    /// ([`DocumentError::SlugNotFound`]), not `Ok(false)`. It is not the same
494    /// fact as an absent key: the caller named an element and the document has
495    /// none by that name, which is how a mistyped slug looks, and answering
496    /// "removed nothing, all good" would swallow it.
497    pub fn unset(&mut self, key: &str) -> DocumentResult<bool> {
498        let addressing = self.addressing();
499        self.unset_addressed(key, addressing)
500    }
501
502    /// [`unset`](Document::unset) with the caller's own addressing, so an
503    /// element named by its content can be removed and not merely read. See
504    /// [`set_addressed`](Document::set_addressed) for why the address is
505    /// canonicalized before anything below sees it.
506    pub fn unset_addressed(
507        &mut self,
508        key: &str,
509        addressing: Addressing<'_>,
510    ) -> DocumentResult<bool> {
511        self.ensure_writable("unset")?;
512        let key = &crate::document::resolve_path(&self.value, key, addressing)?;
513        let segments = crate::document::parse_path(key)?;
514        let (leaf, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
515        let parent = if parents.is_empty() {
516            &self.value
517        } else {
518            let parent_path = crate::document::join_path(parents);
519            match crate::document::get_path_ref(&self.value, &parent_path, Addressing::INDEX_ONLY) {
520                Ok(parent) => parent,
521                // The ancestor is absent, so the leaf below it is too.
522                Err(DocumentError::UnknownSegment { .. }) => return Ok(false),
523                Err(error) => return Err(error),
524            }
525        };
526        match parent {
527            Value::Object(object) => {
528                if !object.contains_key(leaf) {
529                    return Ok(false);
530                }
531            }
532            Value::Array(array) => {
533                let index =
534                    leaf.parse::<usize>()
535                        .map_err(|_| DocumentError::UnregisteredArray {
536                            path: crate::document::join_path(parents),
537                        })?;
538                if index >= array.len() {
539                    return Err(DocumentError::IndexOutOfBounds {
540                        path: crate::document::join_path(parents),
541                        index,
542                        len: array.len(),
543                    });
544                }
545            }
546            value => {
547                return Err(DocumentError::NotTraversable {
548                    path: crate::document::join_path(parents),
549                    got: value.kind_name().to_string(),
550                });
551            }
552        }
553        let mut value = self.value.clone();
554        crate::document::unset_path(&mut value, key)?;
555        #[allow(unreachable_patterns)]
556        let output = match self.format {
557            Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
558            #[cfg(feature = "toml")]
559            Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
560            #[cfg(feature = "yaml")]
561            Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
562            #[cfg(feature = "dotenv")]
563            Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
564            #[cfg(feature = "ini")]
565            Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
566            #[cfg(feature = "toml")]
567            Format::TomlFrontmatter => {
568                let parts = crate::document::format::frontmatter::split(
569                    &self.source,
570                    crate::document::format::frontmatter::Delimiter::Plus,
571                )?;
572                let new_fm =
573                    crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
574                format!("{}{}{}", parts.pre, new_fm, parts.post)
575            }
576            #[cfg(feature = "yaml")]
577            Format::YamlFrontmatter => {
578                let parts = crate::document::format::frontmatter::split(
579                    &self.source,
580                    crate::document::format::frontmatter::Delimiter::Dash,
581                )?;
582                let new_fm =
583                    crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
584                format!("{}{}{}", parts.pre, new_fm, parts.post)
585            }
586            _ => self.format.save(&value)?,
587        };
588        self.source = output;
589        self.value = value;
590        Ok(true)
591    }
592}
593
594impl DocumentFile {
595    /// Run `edit` against the in-memory [`Document`], then commit once with
596    /// [`save`](DocumentFile::save). The single-call form of stage-then-save:
597    /// the edits either all land (on `Ok`) or none reach disk (on `Err`,
598    /// nothing is written), and the commit can't be forgotten.
599    pub fn edit<F>(&mut self, edit: F) -> DocumentResult<()>
600    where
601        F: FnOnce(&mut Document) -> DocumentResult<()>,
602    {
603        edit(&mut self.doc)?;
604        self.save()
605    }
606
607    /// Persist the document — every edit staged since [`open`](DocumentFile::open)
608    /// — to its path in a single atomic write.
609    ///
610    /// The mutation verbs (`set`/`unset`/`add`/`remove`) stage their
611    /// source-preserving edit in memory and do **not** touch disk; this is the
612    /// one commit point. That lets a caller apply several edits and inspect the
613    /// result via [`value`](Document::value) (e.g. deserialize-and-validate)
614    /// before any bytes are written, and makes a multi-edit change atomic —
615    /// all edits land together or none do.
616    pub fn save(&self) -> DocumentResult<()> {
617        self.save_atomic(self.doc.source())
618    }
619
620    /// Atomically replace the file's contents with `new_source`: guard
621    /// against symlinks/hardlinked files, write to a same-directory temp
622    /// file, fsync it, re-apply the original file's permissions, then
623    /// `rename` it over the target. No partial write is ever observable —
624    /// on any failure the temp file is removed and the original file is
625    /// untouched.
626    ///
627    /// Crate-internal write seam behind the public [`save`](DocumentFile::save);
628    /// it is not exported, so callers cannot write arbitrary raw text that
629    /// bypasses the parse/edit path.
630    pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
631        // A read-only format never reaches disk, even when nothing was staged
632        // and the bytes would be identical: a `save` that succeeds is a claim
633        // this file is under afdata's control for writing, and it is not.
634        self.ensure_writable("save")?;
635        // Read back what is about to be written, before writing it. A
636        // source-preserving edit splices text, and a splice that lands in the
637        // wrong place can produce a file this very parser rejects — an INI key
638        // added to a section that had already closed, for one. Of the three
639        // possible outcomes, reporting success and leaving an unreadable file
640        // behind is the only unrecoverable one.
641        Document::parse(new_source, self.format).map_err(|error| {
642            DocumentError::WriteWouldCorrupt {
643                format: self.format.name().to_string(),
644                detail: error.redacted_message(),
645            }
646        })?;
647        write_atomic(&self.path, new_source.as_bytes(), "write")
648    }
649}
650
651impl std::ops::Deref for DocumentFile {
652    type Target = Document;
653
654    fn deref(&self) -> &Document {
655        &self.doc
656    }
657}
658
659impl std::ops::DerefMut for DocumentFile {
660    fn deref_mut(&mut self) -> &mut Document {
661        &mut self.doc
662    }
663}
664
665/// Reject mutation of a symlink or (on unix) a hardlinked file. Returns the
666/// target's metadata on success so callers that also need to write can reuse
667/// it (e.g. to preserve permissions) without a second syscall.
668fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
669    let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
670        detail: format!("{operation} preflight `{}`: {error}", path.display()),
671    })?;
672    if metadata.file_type().is_symlink() {
673        return Err(DocumentError::UnsupportedOperation {
674            format: "filesystem".to_string(),
675            operation: operation.to_string(),
676            detail: format!("refusing to mutate symlink `{}`", path.display()),
677        });
678    }
679    #[cfg(unix)]
680    {
681        use std::os::unix::fs::MetadataExt;
682        if metadata.nlink() > 1 {
683            return Err(DocumentError::UnsupportedOperation {
684                format: "filesystem".to_string(),
685                operation: operation.to_string(),
686                detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
687            });
688        }
689    }
690    Ok(metadata)
691}
692
693/// Write `bytes` to `path` atomically: guard, same-directory temp file,
694/// fsync, permission preservation, then rename over the target.
695fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
696    let metadata = guard_mutation(path, operation)?;
697
698    let parent = path.parent().ok_or_else(|| DocumentError::IoError {
699        detail: format!(
700            "{operation} has no parent directory for `{}`",
701            path.display()
702        ),
703    })?;
704    let file_name = path
705        .file_name()
706        .and_then(|name| name.to_str())
707        .ok_or_else(|| DocumentError::IoError {
708            detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
709        })?;
710    let pid = std::process::id();
711    let mut temp_path = None;
712    let mut temp_file = None;
713    for attempt in 0..32_u32 {
714        let candidate = parent.join(format!(".{file_name}.afdata-document.{pid}.{attempt}.tmp"));
715        match OpenOptions::new()
716            .write(true)
717            .create_new(true)
718            .open(&candidate)
719        {
720            Ok(file) => {
721                temp_path = Some(candidate);
722                temp_file = Some(file);
723                break;
724            }
725            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
726            Err(error) => {
727                return Err(DocumentError::IoError {
728                    detail: format!(
729                        "{operation} create temporary file in `{}`: {error}",
730                        parent.display()
731                    ),
732                });
733            }
734        }
735    }
736    let temp_path = temp_path.ok_or_else(|| DocumentError::IoError {
737        detail: format!(
738            "{operation} could not allocate temporary file in `{}`",
739            parent.display()
740        ),
741    })?;
742    let mut temp_file = temp_file.ok_or_else(|| DocumentError::IoError {
743        detail: format!("{operation} temporary file handle missing"),
744    })?;
745    let result = (|| -> DocumentResult<()> {
746        temp_file
747            .write_all(bytes)
748            .map_err(|error| DocumentError::IoError {
749                detail: format!("{operation} write `{}`: {error}", path.display()),
750            })?;
751        temp_file
752            .sync_all()
753            .map_err(|error| DocumentError::IoError {
754                detail: format!("{operation} fsync `{}`: {error}", path.display()),
755            })?;
756        drop(temp_file);
757        fs::set_permissions(&temp_path, metadata.permissions()).map_err(|error| {
758            DocumentError::IoError {
759                detail: format!(
760                    "{operation} preserve permissions `{}`: {error}",
761                    path.display()
762                ),
763            }
764        })?;
765        fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
766            detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
767        })?;
768        Ok(())
769    })();
770    if result.is_err() {
771        let _ = fs::remove_file(&temp_path);
772    }
773    result
774}
775
776#[cfg(test)]
777mod tests {
778    #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
779    use super::*;
780    use std::io::Cursor;
781
782    fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
783        let path = dir.join(name);
784        fs::write(&path, contents).unwrap();
785        path
786    }
787
788    #[test]
789    fn round_trip_open_json() {
790        let dir = tempfile::tempdir().unwrap();
791        let contents = r#"{"host": "example.com", "port": 993}"#;
792        let path = write_temp(dir.path(), "config.json", contents);
793
794        let doc = DocumentFile::open(&path, None).unwrap();
795
796        assert_eq!(doc.format(), Format::Json);
797        assert_eq!(
798            doc.value().get("host").and_then(Value::as_str),
799            Some("example.com")
800        );
801        assert_eq!(doc.source(), contents);
802    }
803
804    #[test]
805    fn value_at_reads_a_nested_address() {
806        let dir = tempfile::tempdir().unwrap();
807        let path = write_temp(
808            dir.path(),
809            "config.json",
810            r#"{"database": {"url": "postgres://x"}}"#,
811        );
812        let doc = DocumentFile::open(&path, None).unwrap();
813
814        assert_eq!(
815            doc.value_at("database.url").unwrap(),
816            Value::String("postgres://x".to_string())
817        );
818        assert_eq!(
819            doc.value_at("database.missing").unwrap_err().code(),
820            "document_path_not_found"
821        );
822    }
823
824    #[test]
825    fn open_capped_enforces_size_and_regular_file() {
826        let dir = tempfile::tempdir().unwrap();
827        let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
828
829        // Within the cap: opens normally.
830        assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
831
832        // Over the cap: rejected without parsing, as an io failure.
833        let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
834        assert_eq!(err.code(), "document_io_failed");
835        assert!(err.to_string().contains("read limit"));
836
837        // A directory is not a regular file.
838        let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
839        assert_eq!(dir_err.code(), "document_io_failed");
840    }
841
842    #[test]
843    fn typed_get_and_set_enforce_the_stated_type() {
844        use crate::document::ValueType;
845        let dir = tempfile::tempdir().unwrap();
846        let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
847        let mut doc = DocumentFile::open(&path, None).unwrap();
848
849        // Typed get: matching type returns, wrong type is a caught error, Json
850        // matches anything.
851        assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
852        assert_eq!(
853            doc.value_at_typed("port", ValueType::String)
854                .unwrap_err()
855                .code(),
856            "document_type_mismatch"
857        );
858        assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
859
860        // Typed set: the literal is validated against the stated type.
861        doc.set_typed("port", Some("9090"), ValueType::Number)
862            .unwrap();
863        assert_eq!(
864            doc.value_at("port").unwrap(),
865            Value::from(serde_json::json!(9090))
866        );
867        assert_eq!(
868            doc.set_typed("port", Some("not-a-number"), ValueType::Number)
869                .unwrap_err()
870                .code(),
871            "document_parse_failed"
872        );
873    }
874
875    #[cfg(feature = "toml")]
876    #[test]
877    fn round_trip_open_toml() {
878        let dir = tempfile::tempdir().unwrap();
879        let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
880        let path = write_temp(dir.path(), "config.toml", contents);
881
882        let doc = DocumentFile::open(&path, None).unwrap();
883
884        assert_eq!(doc.format(), Format::Toml);
885        assert_eq!(
886            doc.value().get("host").and_then(Value::as_str),
887            Some("example.com")
888        );
889        assert_eq!(doc.source(), contents);
890    }
891
892    #[cfg(feature = "toml")]
893    #[test]
894    fn set_scalar_preserves_toml_comments_and_formatting() {
895        let dir = tempfile::tempdir().unwrap();
896        let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
897        let path = write_temp(dir.path(), "config.toml", contents);
898        let mut doc = DocumentFile::open(&path, None).unwrap();
899
900        doc.set("port", Value::Integer(1024)).unwrap();
901        doc.save().unwrap();
902
903        let saved = fs::read_to_string(&path).unwrap();
904        assert!(saved.contains("# leading comment"));
905        assert!(saved.contains("port = 1024"));
906        assert_eq!(
907            doc.value().get("port").and_then(Value::as_integer),
908            Some(1024)
909        );
910        assert_eq!(doc.source(), saved);
911    }
912
913    #[test]
914    fn save_refuses_source_its_own_parser_rejects() {
915        // The read-back guard, exercised directly: no backend should be able to
916        // splice text into this shape any more, so the corrupt source is
917        // supplied by hand rather than provoked through a verb.
918        let dir = tempfile::tempdir().unwrap();
919        let original = "[db]\nhost=localhost\n";
920        let path = write_temp(dir.path(), "config.ini", original);
921        let doc = DocumentFile::open(&path, None).unwrap();
922
923        let error = doc
924            .save_atomic("[db]\nhost=localhost\n\n[db]\nport=5432\n")
925            .unwrap_err();
926        assert_eq!(error.code(), "document_write_would_corrupt");
927        // The whole point is that the guard runs before any bytes land.
928        assert_eq!(fs::read_to_string(&path).unwrap(), original);
929    }
930
931    #[test]
932    fn save_writes_source_the_parser_accepts() {
933        let dir = tempfile::tempdir().unwrap();
934        let path = write_temp(dir.path(), "config.ini", "[db]\nhost=localhost\n");
935        let mut doc = DocumentFile::open(&path, None).unwrap();
936
937        doc.set("db.port", Value::String("5432".to_string()))
938            .unwrap();
939        doc.save().unwrap();
940
941        // A key added to a section that is not the file's last one used to be
942        // appended at end of file, re-opening a section that had closed.
943        assert_eq!(
944            fs::read_to_string(&path).unwrap(),
945            "[db]\nhost=localhost\nport=5432\n"
946        );
947        assert!(DocumentFile::open(&path, None).is_ok());
948    }
949
950    #[cfg(unix)]
951    #[test]
952    fn atomic_save_preserves_file_mode() {
953        use std::os::unix::fs::PermissionsExt;
954
955        let dir = tempfile::tempdir().unwrap();
956        let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
957        fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
958        let mut doc = DocumentFile::open(&path, None).unwrap();
959
960        doc.set("port", Value::Integer(1024)).unwrap();
961        doc.save().unwrap();
962
963        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
964        assert_eq!(mode, 0o640);
965    }
966
967    #[cfg(unix)]
968    #[test]
969    fn symlink_target_is_rejected_for_mutation() {
970        let dir = tempfile::tempdir().unwrap();
971        let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
972        let link = dir.path().join("link.json");
973        std::os::unix::fs::symlink(&target, &link).unwrap();
974
975        // Reading through the symlink is fine.
976        let mut doc = DocumentFile::open(&link, None).unwrap();
977
978        // Editing in memory is fine; committing through the symlink is not.
979        doc.set("port", Value::Integer(1024)).unwrap();
980        let err = doc.save().unwrap_err();
981        assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
982
983        // The target file was never touched.
984        let target_contents = fs::read_to_string(&target).unwrap();
985        assert_eq!(target_contents, r#"{"port": 993}"#);
986    }
987
988    #[test]
989    fn from_reader_parses_in_memory_cursor() {
990        let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
991
992        let doc = Document::from_reader(cursor, Format::Json).unwrap();
993
994        assert_eq!(
995            doc.value().get("host").and_then(Value::as_str),
996            Some("example.com")
997        );
998    }
999
1000    #[test]
1001    fn document_from_str_encode_round_trip() {
1002        let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1003        let encoded = doc.encode().unwrap();
1004        let reparsed = Document::parse(&encoded, Format::Json).unwrap();
1005        assert_eq!(
1006            reparsed.value().get("a").and_then(Value::as_integer),
1007            Some(1)
1008        );
1009    }
1010
1011    #[test]
1012    fn document_edits_source_in_memory_without_a_file() {
1013        // The point of the Document/DocumentFile split: source-preserving
1014        // editing with no file, no I/O, no guards.
1015        let mut doc = Document::parse("{\n  \"host\": \"old\"\n}\n", Format::Json).unwrap();
1016        doc.set("host", Value::String("new".to_string())).unwrap();
1017        doc.set("imap.port", Value::Integer(993)).unwrap(); // creates the parent
1018
1019        assert_eq!(
1020            doc.source(),
1021            "{\n  \"host\": \"new\",\n  \"imap\": {\n    \"port\": 993\n  }\n}\n"
1022        );
1023        assert_eq!(
1024            doc.value_at("imap.port").unwrap(),
1025            Value::from(serde_json::json!(993))
1026        );
1027    }
1028
1029    #[test]
1030    fn unset_is_false_for_anything_already_absent() {
1031        let mut doc = Document::parse(
1032            r#"{"service":{"host":"example","ports":[80]}}"#,
1033            Format::Json,
1034        )
1035        .unwrap();
1036
1037        // Absent is absent, at any depth — the answer must not change with the
1038        // number of segments the caller had to write to name the same nothing.
1039        assert!(!doc.unset("service.missing").unwrap());
1040        assert!(!doc.unset("missing.parent").unwrap());
1041        assert!(!doc.unset("missing.deeply.nested").unwrap());
1042
1043        // Still errors: these describe a path no document could satisfy, not a
1044        // document that happens not to carry the key.
1045        assert!(doc.unset("service.host.child").is_err()); // through a scalar
1046        assert!(doc.unset("service.ports.9").is_err()); // index out of range
1047        assert!(doc.unset(r"service\q").is_err()); // malformed syntax
1048    }
1049
1050    #[cfg(feature = "markdown")]
1051    #[test]
1052    fn every_markdown_write_verb_is_refused() {
1053        let source = "# Title\n\nThe lead.\n";
1054        let mut doc = Document::parse(source, Format::Markdown).unwrap();
1055
1056        // Reading is the whole point, and works — including by content, which
1057        // `value_at` gets from the format's own rule.
1058        assert_eq!(
1059            doc.value_at("h1.0.text").unwrap(),
1060            Value::String("Title".to_string())
1061        );
1062        assert_eq!(
1063            doc.value_at("h1.Tit.paragraph.0.text").unwrap(),
1064            Value::String("The lead.".to_string())
1065        );
1066
1067        // Every mutating verb refuses, including the two that would otherwise
1068        // answer before reaching a backend: `unset` on an absent path (which
1069        // is `Ok(false)` for a writable format) and `encode`.
1070        let refusals: Vec<DocumentError> = vec![
1071            doc.set("h1.0.text", Value::String("New".to_string()))
1072                .unwrap_err(),
1073            doc.add("preamble", "x", "type", &[]).unwrap_err(),
1074            doc.remove("preamble", "x", "type").unwrap_err(),
1075            doc.unset("h1.0").unwrap_err(),
1076            doc.unset("nothing.here").unwrap_err(),
1077            doc.encode().unwrap_err(),
1078        ];
1079        for error in refusals {
1080            assert_eq!(error.code(), "document_unsupported_operation");
1081            assert!(
1082                error.to_string().contains("read-only"),
1083                "refusal must name the reason: {error}"
1084            );
1085        }
1086
1087        // Nothing was staged by any of them.
1088        assert_eq!(doc.source(), source);
1089    }
1090
1091    #[cfg(feature = "markdown")]
1092    #[test]
1093    fn markdown_save_never_reaches_disk() {
1094        let dir = tempfile::tempdir().unwrap();
1095        let path = write_temp(dir.path(), "README.md", "# Title\n");
1096        // `.md` resolves to no format, so the reading has to be named.
1097        assert!(DocumentFile::open(&path, None).is_err());
1098
1099        let doc = DocumentFile::open(&path, Some(Format::Markdown)).unwrap();
1100        // Even an unmodified save, whose bytes would be identical, is refused.
1101        let error = doc.save().unwrap_err();
1102        assert_eq!(error.code(), "document_unsupported_operation");
1103        assert_eq!(fs::read_to_string(&path).unwrap(), "# Title\n");
1104    }
1105
1106    #[cfg(feature = "yaml")]
1107    #[test]
1108    fn yaml_write_rejects_cst_ambiguous_mapping_segments() {
1109        let mut numeric = Document::parse("\"123\": value\n", Format::Yaml).unwrap();
1110        assert!(
1111            numeric
1112                .set("123", Value::String("changed".to_string()))
1113                .is_err()
1114        );
1115        assert!(numeric.unset("123").is_err());
1116
1117        let mut bracketed = Document::parse("\"a[0]\": value\n", Format::Yaml).unwrap();
1118        assert!(
1119            bracketed
1120                .set("a[0]", Value::String("changed".to_string()))
1121                .is_err()
1122        );
1123    }
1124}