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