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            // Same editor as `set` reaches through frontmatter: the delimited
415            // block is an ordinary YAML document, and the body is spliced back
416            // untouched. Without this arm a keyed list was editable in a `.yaml`
417            // file and refused in the frontmatter of a `.md` one.
418            #[cfg(feature = "yaml")]
419            Format::YamlFrontmatter => {
420                let parts = crate::document::format::frontmatter::split(
421                    &self.source,
422                    crate::document::format::frontmatter::Delimiter::Dash,
423                )?;
424                let new_fm = crate::document::format::yaml::append_array_item_preserving(
425                    parts.frontmatter,
426                    key,
427                    item,
428                )?;
429                format!("{}{}{}", parts.pre, new_fm, parts.post)
430            }
431            _ => {
432                return Err(DocumentError::UnsupportedOperation {
433                    format: self.format.name().to_string(),
434                    operation: "add".to_string(),
435                    detail: "keyed collection source editor is not implemented for this backend"
436                        .to_string(),
437                });
438            }
439        };
440        self.source = output;
441        self.value = value;
442        Ok(())
443    }
444
445    /// Remove the element identified by `slug`/`slug_field` from the keyed
446    /// list at `key`. Preserves the rest of the source document. An empty
447    /// `key` targets the document root when the root is itself the keyed array.
448    ///
449    /// Only JSON and YAML backends implement a source-preserving
450    /// keyed-collection editor today; other formats return
451    /// [`DocumentError::UnsupportedOperation`].
452    pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
453        self.ensure_writable("remove")?;
454        let mut value = self.value.clone();
455        let keyed_lists = [KeyedList {
456            prefix: key,
457            slug_field,
458        }];
459        let removed_index = crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
460        // The catch-all covers the formats with no source-preserving keyed
461        // editor. A build with only JSON has none left to catch, which makes it
462        // unreachable there and reachable everywhere else — the arm stays.
463        #[allow(unreachable_patterns)]
464        let output: String = match self.format {
465            Format::Json => crate::document::format::json::remove_array_item_preserving(
466                &self.source,
467                key,
468                removed_index,
469            )?,
470            #[cfg(feature = "yaml")]
471            Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
472                &self.source,
473                key,
474                removed_index,
475            )?,
476            #[cfg(feature = "yaml")]
477            Format::YamlFrontmatter => {
478                let parts = crate::document::format::frontmatter::split(
479                    &self.source,
480                    crate::document::format::frontmatter::Delimiter::Dash,
481                )?;
482                let new_fm = crate::document::format::yaml::remove_array_item_preserving(
483                    parts.frontmatter,
484                    key,
485                    removed_index,
486                )?;
487                format!("{}{}{}", parts.pre, new_fm, parts.post)
488            }
489            _ => {
490                return Err(DocumentError::UnsupportedOperation {
491                    format: self.format.name().to_string(),
492                    operation: "remove".to_string(),
493                    detail: "keyed collection source editor is not implemented for this backend"
494                        .to_string(),
495                });
496            }
497        };
498        self.source = output;
499        self.value = value;
500        Ok(())
501    }
502
503    /// Remove the entry at `key` entirely, preserving the rest of the source
504    /// document. The edit is staged in memory — call
505    /// [`DocumentFile::save`] to persist it.
506    ///
507    /// Idempotent, like [`HashSet::remove`](std::collections::HashSet::remove):
508    /// returns `Ok(false)` when there was nothing at `key` to remove (nothing
509    /// is staged), and `Ok(true)` when it was removed.
510    ///
511    /// "Nothing there" does not depend on how deep the path is. A missing leaf
512    /// and a missing ancestor are the same fact — `a.b.c` is absent whether
513    /// `a.b` exists or not — so both answer `Ok(false)`. Only a path that is
514    /// *malformed* stays an error: bad syntax, an index into a non-array, or a
515    /// segment that tries to traverse through a scalar. Those describe a caller
516    /// asking something incoherent, not a document that already lacks the key.
517    ///
518    /// A read-only format is the one case that errors *before* the idempotent
519    /// answer: "nothing to remove" would report success for a document this
520    /// verb can never edit.
521    ///
522    /// A content-addressed segment that matches no element is also an error
523    /// ([`DocumentError::SlugNotFound`]), not `Ok(false)`. It is not the same
524    /// fact as an absent key: the caller named an element and the document has
525    /// none by that name, which is how a mistyped slug looks, and answering
526    /// "removed nothing, all good" would swallow it.
527    pub fn unset(&mut self, key: &str) -> DocumentResult<bool> {
528        let addressing = self.addressing();
529        self.unset_addressed(key, addressing)
530    }
531
532    /// [`unset`](Document::unset) with the caller's own addressing, so an
533    /// element named by its content can be removed and not merely read. See
534    /// [`set_addressed`](Document::set_addressed) for why the address is
535    /// canonicalized before anything below sees it.
536    pub fn unset_addressed(
537        &mut self,
538        key: &str,
539        addressing: Addressing<'_>,
540    ) -> DocumentResult<bool> {
541        self.ensure_writable("unset")?;
542        let key = &crate::document::resolve_path(&self.value, key, addressing)?;
543        let segments = crate::document::parse_path(key)?;
544        let (leaf, parents) = segments.split_last().ok_or(DocumentError::EmptyPath)?;
545        let parent = if parents.is_empty() {
546            &self.value
547        } else {
548            let parent_path = crate::document::join_path(parents);
549            match crate::document::get_path_ref(&self.value, &parent_path, Addressing::INDEX_ONLY) {
550                Ok(parent) => parent,
551                // The ancestor is absent, so the leaf below it is too.
552                Err(DocumentError::UnknownSegment { .. }) => return Ok(false),
553                Err(error) => return Err(error),
554            }
555        };
556        match parent {
557            Value::Object(object) => {
558                if !object.contains_key(leaf) {
559                    return Ok(false);
560                }
561            }
562            Value::Array(array) => {
563                let index =
564                    leaf.parse::<usize>()
565                        .map_err(|_| DocumentError::UnregisteredArray {
566                            path: crate::document::join_path(parents),
567                        })?;
568                if index >= array.len() {
569                    return Err(DocumentError::IndexOutOfBounds {
570                        path: crate::document::join_path(parents),
571                        index,
572                        len: array.len(),
573                    });
574                }
575            }
576            value => {
577                return Err(DocumentError::NotTraversable {
578                    path: crate::document::join_path(parents),
579                    got: value.kind_name().to_string(),
580                });
581            }
582        }
583        let mut value = self.value.clone();
584        crate::document::unset_path(&mut value, key)?;
585        #[allow(unreachable_patterns)]
586        let output = match self.format {
587            Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
588            #[cfg(feature = "toml")]
589            Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
590            #[cfg(feature = "yaml")]
591            Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
592            #[cfg(feature = "dotenv")]
593            Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
594            #[cfg(feature = "ini")]
595            Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
596            #[cfg(feature = "toml")]
597            Format::TomlFrontmatter => {
598                let parts = crate::document::format::frontmatter::split(
599                    &self.source,
600                    crate::document::format::frontmatter::Delimiter::Plus,
601                )?;
602                let new_fm =
603                    crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
604                format!("{}{}{}", parts.pre, new_fm, parts.post)
605            }
606            #[cfg(feature = "yaml")]
607            Format::YamlFrontmatter => {
608                let parts = crate::document::format::frontmatter::split(
609                    &self.source,
610                    crate::document::format::frontmatter::Delimiter::Dash,
611                )?;
612                let new_fm =
613                    crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
614                format!("{}{}{}", parts.pre, new_fm, parts.post)
615            }
616            _ => self.format.save(&value)?,
617        };
618        self.source = output;
619        self.value = value;
620        Ok(true)
621    }
622}
623
624impl DocumentFile {
625    /// Run `edit` against the in-memory [`Document`], then commit once with
626    /// [`save`](DocumentFile::save). The single-call form of stage-then-save:
627    /// the edits either all land (on `Ok`) or none reach disk (on `Err`,
628    /// nothing is written), and the commit can't be forgotten.
629    pub fn edit<F>(&mut self, edit: F) -> DocumentResult<()>
630    where
631        F: FnOnce(&mut Document) -> DocumentResult<()>,
632    {
633        edit(&mut self.doc)?;
634        self.save()
635    }
636
637    /// Persist the document — every edit staged since [`open`](DocumentFile::open)
638    /// — to its path in a single atomic write.
639    ///
640    /// The mutation verbs (`set`/`unset`/`add`/`remove`) stage their
641    /// source-preserving edit in memory and do **not** touch disk; this is the
642    /// one commit point. That lets a caller apply several edits and inspect the
643    /// result via [`value`](Document::value) (e.g. deserialize-and-validate)
644    /// before any bytes are written, and makes a multi-edit change atomic —
645    /// all edits land together or none do.
646    pub fn save(&self) -> DocumentResult<()> {
647        self.save_atomic(self.doc.source())
648    }
649
650    /// Atomically replace the file's contents with `new_source`: guard
651    /// against symlinks/hardlinked files, write to a same-directory temp
652    /// file, fsync it, re-apply the original file's permissions, then
653    /// `rename` it over the target. No partial write is ever observable —
654    /// on any failure the temp file is removed and the original file is
655    /// untouched.
656    ///
657    /// Crate-internal write seam behind the public [`save`](DocumentFile::save);
658    /// it is not exported, so callers cannot write arbitrary raw text that
659    /// bypasses the parse/edit path.
660    pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
661        // A read-only format never reaches disk, even when nothing was staged
662        // and the bytes would be identical: a `save` that succeeds is a claim
663        // this file is under afdata's control for writing, and it is not.
664        self.ensure_writable("save")?;
665        // Read back what is about to be written, before writing it. A
666        // source-preserving edit splices text, and a splice that lands in the
667        // wrong place can produce a file this very parser rejects — an INI key
668        // added to a section that had already closed, for one. Of the three
669        // possible outcomes, reporting success and leaving an unreadable file
670        // behind is the only unrecoverable one.
671        Document::parse(new_source, self.format).map_err(|error| {
672            DocumentError::WriteWouldCorrupt {
673                format: self.format.name().to_string(),
674                detail: error.redacted_message(),
675            }
676        })?;
677        write_atomic(&self.path, new_source.as_bytes(), "write")
678    }
679}
680
681impl std::ops::Deref for DocumentFile {
682    type Target = Document;
683
684    fn deref(&self) -> &Document {
685        &self.doc
686    }
687}
688
689impl std::ops::DerefMut for DocumentFile {
690    fn deref_mut(&mut self) -> &mut Document {
691        &mut self.doc
692    }
693}
694
695/// Reject mutation of a symlink or (on unix) a hardlinked file. Returns the
696/// target's metadata on success so callers that also need to write can reuse
697/// it (e.g. to preserve permissions) without a second syscall.
698fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
699    let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
700        detail: format!("{operation} preflight `{}`: {error}", path.display()),
701    })?;
702    if metadata.file_type().is_symlink() {
703        return Err(DocumentError::UnsupportedOperation {
704            format: "filesystem".to_string(),
705            operation: operation.to_string(),
706            detail: format!("refusing to mutate symlink `{}`", path.display()),
707        });
708    }
709    #[cfg(unix)]
710    {
711        use std::os::unix::fs::MetadataExt;
712        if metadata.nlink() > 1 {
713            return Err(DocumentError::UnsupportedOperation {
714                format: "filesystem".to_string(),
715                operation: operation.to_string(),
716                detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
717            });
718        }
719    }
720    Ok(metadata)
721}
722
723/// Write `bytes` to `path` atomically: guard, same-directory temp file,
724/// fsync, permission preservation, then rename over the target.
725fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
726    let metadata = guard_mutation(path, operation)?;
727
728    let parent = path.parent().ok_or_else(|| DocumentError::IoError {
729        detail: format!(
730            "{operation} has no parent directory for `{}`",
731            path.display()
732        ),
733    })?;
734    let file_name = path
735        .file_name()
736        .and_then(|name| name.to_str())
737        .ok_or_else(|| DocumentError::IoError {
738            detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
739        })?;
740    let pid = std::process::id();
741    let mut temp_path = None;
742    let mut temp_file = None;
743    for attempt in 0..32_u32 {
744        let candidate = parent.join(format!(".{file_name}.afdata-document.{pid}.{attempt}.tmp"));
745        match OpenOptions::new()
746            .write(true)
747            .create_new(true)
748            .open(&candidate)
749        {
750            Ok(file) => {
751                temp_path = Some(candidate);
752                temp_file = Some(file);
753                break;
754            }
755            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
756            Err(error) => {
757                return Err(DocumentError::IoError {
758                    detail: format!(
759                        "{operation} create temporary file in `{}`: {error}",
760                        parent.display()
761                    ),
762                });
763            }
764        }
765    }
766    let temp_path = temp_path.ok_or_else(|| DocumentError::IoError {
767        detail: format!(
768            "{operation} could not allocate temporary file in `{}`",
769            parent.display()
770        ),
771    })?;
772    let mut temp_file = temp_file.ok_or_else(|| DocumentError::IoError {
773        detail: format!("{operation} temporary file handle missing"),
774    })?;
775    let result = (|| -> DocumentResult<()> {
776        temp_file
777            .write_all(bytes)
778            .map_err(|error| DocumentError::IoError {
779                detail: format!("{operation} write `{}`: {error}", path.display()),
780            })?;
781        temp_file
782            .sync_all()
783            .map_err(|error| DocumentError::IoError {
784                detail: format!("{operation} fsync `{}`: {error}", path.display()),
785            })?;
786        drop(temp_file);
787        fs::set_permissions(&temp_path, metadata.permissions()).map_err(|error| {
788            DocumentError::IoError {
789                detail: format!(
790                    "{operation} preserve permissions `{}`: {error}",
791                    path.display()
792                ),
793            }
794        })?;
795        fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
796            detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
797        })?;
798        Ok(())
799    })();
800    if result.is_err() {
801        let _ = fs::remove_file(&temp_path);
802    }
803    result
804}
805
806#[cfg(test)]
807mod tests {
808    #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
809    use super::*;
810    use std::io::Cursor;
811
812    fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
813        let path = dir.join(name);
814        fs::write(&path, contents).unwrap();
815        path
816    }
817
818    #[test]
819    fn round_trip_open_json() {
820        let dir = tempfile::tempdir().unwrap();
821        let contents = r#"{"host": "example.com", "port": 993}"#;
822        let path = write_temp(dir.path(), "config.json", contents);
823
824        let doc = DocumentFile::open(&path, None).unwrap();
825
826        assert_eq!(doc.format(), Format::Json);
827        assert_eq!(
828            doc.value().get("host").and_then(Value::as_str),
829            Some("example.com")
830        );
831        assert_eq!(doc.source(), contents);
832    }
833
834    #[test]
835    fn value_at_reads_a_nested_address() {
836        let dir = tempfile::tempdir().unwrap();
837        let path = write_temp(
838            dir.path(),
839            "config.json",
840            r#"{"database": {"url": "postgres://x"}}"#,
841        );
842        let doc = DocumentFile::open(&path, None).unwrap();
843
844        assert_eq!(
845            doc.value_at("database.url").unwrap(),
846            Value::String("postgres://x".to_string())
847        );
848        assert_eq!(
849            doc.value_at("database.missing").unwrap_err().code(),
850            "document_path_not_found"
851        );
852    }
853
854    #[test]
855    fn open_capped_enforces_size_and_regular_file() {
856        let dir = tempfile::tempdir().unwrap();
857        let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
858
859        // Within the cap: opens normally.
860        assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
861
862        // Over the cap: rejected without parsing, as an io failure.
863        let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
864        assert_eq!(err.code(), "document_io_failed");
865        assert!(err.to_string().contains("read limit"));
866
867        // A directory is not a regular file.
868        let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
869        assert_eq!(dir_err.code(), "document_io_failed");
870    }
871
872    #[test]
873    fn typed_get_and_set_enforce_the_stated_type() {
874        use crate::document::ValueType;
875        let dir = tempfile::tempdir().unwrap();
876        let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
877        let mut doc = DocumentFile::open(&path, None).unwrap();
878
879        // Typed get: matching type returns, wrong type is a caught error, Json
880        // matches anything.
881        assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
882        assert_eq!(
883            doc.value_at_typed("port", ValueType::String)
884                .unwrap_err()
885                .code(),
886            "document_type_mismatch"
887        );
888        assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
889
890        // Typed set: the literal is validated against the stated type.
891        doc.set_typed("port", Some("9090"), ValueType::Number)
892            .unwrap();
893        assert_eq!(
894            doc.value_at("port").unwrap(),
895            Value::from(serde_json::json!(9090))
896        );
897        assert_eq!(
898            doc.set_typed("port", Some("not-a-number"), ValueType::Number)
899                .unwrap_err()
900                .code(),
901            "document_parse_failed"
902        );
903    }
904
905    #[cfg(feature = "toml")]
906    #[test]
907    fn round_trip_open_toml() {
908        let dir = tempfile::tempdir().unwrap();
909        let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
910        let path = write_temp(dir.path(), "config.toml", contents);
911
912        let doc = DocumentFile::open(&path, None).unwrap();
913
914        assert_eq!(doc.format(), Format::Toml);
915        assert_eq!(
916            doc.value().get("host").and_then(Value::as_str),
917            Some("example.com")
918        );
919        assert_eq!(doc.source(), contents);
920    }
921
922    #[cfg(feature = "toml")]
923    #[test]
924    fn set_scalar_preserves_toml_comments_and_formatting() {
925        let dir = tempfile::tempdir().unwrap();
926        let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
927        let path = write_temp(dir.path(), "config.toml", contents);
928        let mut doc = DocumentFile::open(&path, None).unwrap();
929
930        doc.set("port", Value::Integer(1024)).unwrap();
931        doc.save().unwrap();
932
933        let saved = fs::read_to_string(&path).unwrap();
934        assert!(saved.contains("# leading comment"));
935        assert!(saved.contains("port = 1024"));
936        assert_eq!(
937            doc.value().get("port").and_then(Value::as_integer),
938            Some(1024)
939        );
940        assert_eq!(doc.source(), saved);
941    }
942
943    #[test]
944    fn save_refuses_source_its_own_parser_rejects() {
945        // The read-back guard, exercised directly: no backend should be able to
946        // splice text into this shape any more, so the corrupt source is
947        // supplied by hand rather than provoked through a verb.
948        let dir = tempfile::tempdir().unwrap();
949        let original = "[db]\nhost=localhost\n";
950        let path = write_temp(dir.path(), "config.ini", original);
951        let doc = DocumentFile::open(&path, None).unwrap();
952
953        let error = doc
954            .save_atomic("[db]\nhost=localhost\n\n[db]\nport=5432\n")
955            .unwrap_err();
956        assert_eq!(error.code(), "document_write_would_corrupt");
957        // The whole point is that the guard runs before any bytes land.
958        assert_eq!(fs::read_to_string(&path).unwrap(), original);
959    }
960
961    #[test]
962    fn save_writes_source_the_parser_accepts() {
963        let dir = tempfile::tempdir().unwrap();
964        let path = write_temp(dir.path(), "config.ini", "[db]\nhost=localhost\n");
965        let mut doc = DocumentFile::open(&path, None).unwrap();
966
967        doc.set("db.port", Value::String("5432".to_string()))
968            .unwrap();
969        doc.save().unwrap();
970
971        // A key added to a section that is not the file's last one used to be
972        // appended at end of file, re-opening a section that had closed.
973        assert_eq!(
974            fs::read_to_string(&path).unwrap(),
975            "[db]\nhost=localhost\nport=5432\n"
976        );
977        assert!(DocumentFile::open(&path, None).is_ok());
978    }
979
980    #[cfg(unix)]
981    #[test]
982    fn atomic_save_preserves_file_mode() {
983        use std::os::unix::fs::PermissionsExt;
984
985        let dir = tempfile::tempdir().unwrap();
986        let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
987        fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
988        let mut doc = DocumentFile::open(&path, None).unwrap();
989
990        doc.set("port", Value::Integer(1024)).unwrap();
991        doc.save().unwrap();
992
993        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
994        assert_eq!(mode, 0o640);
995    }
996
997    #[cfg(unix)]
998    #[test]
999    fn symlink_target_is_rejected_for_mutation() {
1000        let dir = tempfile::tempdir().unwrap();
1001        let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
1002        let link = dir.path().join("link.json");
1003        std::os::unix::fs::symlink(&target, &link).unwrap();
1004
1005        // Reading through the symlink is fine.
1006        let mut doc = DocumentFile::open(&link, None).unwrap();
1007
1008        // Editing in memory is fine; committing through the symlink is not.
1009        doc.set("port", Value::Integer(1024)).unwrap();
1010        let err = doc.save().unwrap_err();
1011        assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
1012
1013        // The target file was never touched.
1014        let target_contents = fs::read_to_string(&target).unwrap();
1015        assert_eq!(target_contents, r#"{"port": 993}"#);
1016    }
1017
1018    #[test]
1019    fn from_reader_parses_in_memory_cursor() {
1020        let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
1021
1022        let doc = Document::from_reader(cursor, Format::Json).unwrap();
1023
1024        assert_eq!(
1025            doc.value().get("host").and_then(Value::as_str),
1026            Some("example.com")
1027        );
1028    }
1029
1030    #[test]
1031    fn document_from_str_encode_round_trip() {
1032        let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
1033        let encoded = doc.encode().unwrap();
1034        let reparsed = Document::parse(&encoded, Format::Json).unwrap();
1035        assert_eq!(
1036            reparsed.value().get("a").and_then(Value::as_integer),
1037            Some(1)
1038        );
1039    }
1040
1041    #[test]
1042    fn document_edits_source_in_memory_without_a_file() {
1043        // The point of the Document/DocumentFile split: source-preserving
1044        // editing with no file, no I/O, no guards.
1045        let mut doc = Document::parse("{\n  \"host\": \"old\"\n}\n", Format::Json).unwrap();
1046        doc.set("host", Value::String("new".to_string())).unwrap();
1047        doc.set("imap.port", Value::Integer(993)).unwrap(); // creates the parent
1048
1049        assert_eq!(
1050            doc.source(),
1051            "{\n  \"host\": \"new\",\n  \"imap\": {\n    \"port\": 993\n  }\n}\n"
1052        );
1053        assert_eq!(
1054            doc.value_at("imap.port").unwrap(),
1055            Value::from(serde_json::json!(993))
1056        );
1057    }
1058
1059    #[test]
1060    fn unset_is_false_for_anything_already_absent() {
1061        let mut doc = Document::parse(
1062            r#"{"service":{"host":"example","ports":[80]}}"#,
1063            Format::Json,
1064        )
1065        .unwrap();
1066
1067        // Absent is absent, at any depth — the answer must not change with the
1068        // number of segments the caller had to write to name the same nothing.
1069        assert!(!doc.unset("service.missing").unwrap());
1070        assert!(!doc.unset("missing.parent").unwrap());
1071        assert!(!doc.unset("missing.deeply.nested").unwrap());
1072
1073        // Still errors: these describe a path no document could satisfy, not a
1074        // document that happens not to carry the key.
1075        assert!(doc.unset("service.host.child").is_err()); // through a scalar
1076        assert!(doc.unset("service.ports.9").is_err()); // index out of range
1077        assert!(doc.unset(r"service\q").is_err()); // malformed syntax
1078    }
1079
1080    #[cfg(feature = "markdown")]
1081    #[test]
1082    fn every_markdown_write_verb_is_refused() {
1083        let source = "# Title\n\nThe lead.\n";
1084        let mut doc = Document::parse(source, Format::Markdown).unwrap();
1085
1086        // Reading is the whole point, and works — including by content, which
1087        // `value_at` gets from the format's own rule.
1088        assert_eq!(
1089            doc.value_at("h1.0.text").unwrap(),
1090            Value::String("Title".to_string())
1091        );
1092        assert_eq!(
1093            doc.value_at("h1.Tit.paragraph.0.text").unwrap(),
1094            Value::String("The lead.".to_string())
1095        );
1096
1097        // Every mutating verb refuses, including the two that would otherwise
1098        // answer before reaching a backend: `unset` on an absent path (which
1099        // is `Ok(false)` for a writable format) and `encode`.
1100        let refusals: Vec<DocumentError> = vec![
1101            doc.set("h1.0.text", Value::String("New".to_string()))
1102                .unwrap_err(),
1103            doc.add("preamble", "x", "type", &[]).unwrap_err(),
1104            doc.remove("preamble", "x", "type").unwrap_err(),
1105            doc.unset("h1.0").unwrap_err(),
1106            doc.unset("nothing.here").unwrap_err(),
1107            doc.encode().unwrap_err(),
1108        ];
1109        for error in refusals {
1110            assert_eq!(error.code(), "document_unsupported_operation");
1111            assert!(
1112                error.to_string().contains("read-only"),
1113                "refusal must name the reason: {error}"
1114            );
1115        }
1116
1117        // Nothing was staged by any of them.
1118        assert_eq!(doc.source(), source);
1119    }
1120
1121    #[cfg(feature = "markdown")]
1122    #[test]
1123    fn markdown_save_never_reaches_disk() {
1124        let dir = tempfile::tempdir().unwrap();
1125        let path = write_temp(dir.path(), "README.md", "# Title\n");
1126        // `.md` resolves to no format, so the reading has to be named.
1127        assert!(DocumentFile::open(&path, None).is_err());
1128
1129        let doc = DocumentFile::open(&path, Some(Format::Markdown)).unwrap();
1130        // Even an unmodified save, whose bytes would be identical, is refused.
1131        let error = doc.save().unwrap_err();
1132        assert_eq!(error.code(), "document_unsupported_operation");
1133        assert_eq!(fs::read_to_string(&path).unwrap(), "# Title\n");
1134    }
1135
1136    #[cfg(feature = "yaml")]
1137    #[test]
1138    fn yaml_write_rejects_cst_ambiguous_mapping_segments() {
1139        let mut numeric = Document::parse("\"123\": value\n", Format::Yaml).unwrap();
1140        assert!(
1141            numeric
1142                .set("123", Value::String("changed".to_string()))
1143                .is_err()
1144        );
1145        assert!(numeric.unset("123").is_err());
1146
1147        let mut bracketed = Document::parse("\"a[0]\": value\n", Format::Yaml).unwrap();
1148        assert!(
1149            bracketed
1150                .set("a[0]", Value::String("changed".to_string()))
1151                .is_err()
1152        );
1153    }
1154}