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::{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    /// Resolve a dotted `path` against the parsed document and return the value
90    /// at that address.
91    pub fn value_at(&self, path: &str) -> DocumentResult<Value> {
92        crate::document::get_path(&self.value, path, &[])
93    }
94
95    /// [`value_at`](Document::value_at) that also asserts the value at `path`
96    /// satisfies `expected`, returning a [`DocumentError::TypeMismatch`]
97    /// otherwise.
98    pub fn value_at_typed(
99        &self,
100        path: &str,
101        expected: crate::document::ValueType,
102    ) -> DocumentResult<Value> {
103        let value = self.value_at(path)?;
104        if crate::document::value_matches_type(&value, expected) {
105            Ok(value)
106        } else {
107            Err(DocumentError::TypeMismatch {
108                path: path.to_string(),
109                expected: expected.name().to_string(),
110                got: value.kind_name().to_string(),
111                hint: None,
112            })
113        }
114    }
115
116    /// Build a value from the CLI string `raw` per an explicit
117    /// [`ValueType`](crate::document::ValueType) and [`set`](Document::set) it.
118    pub fn set_typed(
119        &mut self,
120        key: &str,
121        raw: Option<&str>,
122        value_type: crate::document::ValueType,
123    ) -> DocumentResult<()> {
124        let value = crate::document::value_from_type(value_type, raw)?;
125        self.set(key, value)
126    }
127
128    /// Re-render the current value in its format via [`Format::save`].
129    ///
130    /// This is a fresh, non-source-preserving render: comments and original
131    /// formatting are not retained. Use [`source`](Document::source) after
132    /// source-preserving edits to keep the original formatting.
133    pub fn encode(&self) -> DocumentResult<String> {
134        self.format.save(&self.value)
135    }
136}
137
138/// A file-backed [`Document`]: the in-memory document plus the path it was
139/// read from.
140///
141/// All reads and source-preserving edits come from [`Document`] through
142/// [`Deref`]/[`DerefMut`]; `DocumentFile` adds only the file boundary — reading
143/// on [`open`](DocumentFile::open) and an atomic, symlink-guarded commit on
144/// [`save`](DocumentFile::save) / [`edit`](DocumentFile::edit).
145#[derive(Debug, Clone)]
146pub struct DocumentFile {
147    doc: Document,
148    path: PathBuf,
149}
150
151impl DocumentFile {
152    /// Open and parse `path`.
153    ///
154    /// `format_override` takes precedence; otherwise the format is detected
155    /// from the file extension via [`Format::detect`]. Reading is always
156    /// allowed — this does not run the mutation guard.
157    pub fn open(
158        path: impl AsRef<Path>,
159        format_override: Option<Format>,
160    ) -> DocumentResult<DocumentFile> {
161        let path = path.as_ref().to_path_buf();
162        let format = match format_override {
163            Some(format) => format,
164            None => Format::detect(&path).ok_or_else(|| DocumentError::ParseError {
165                format: "format".to_string(),
166                detail: format!(
167                    "cannot detect format from file extension `{}`; pass an explicit format",
168                    path.display()
169                ),
170            })?,
171        };
172        let source = fs::read_to_string(&path).map_err(|error| DocumentError::IoError {
173            detail: format!("read `{}`: {error}", path.display()),
174        })?;
175        Ok(DocumentFile {
176            doc: Document::parse(&source, format)?,
177            path,
178        })
179    }
180
181    /// Open and parse `path` like [`DocumentFile::open`], but first reject any
182    /// non-regular file, or any file larger than `max_bytes`, without reading
183    /// its contents.
184    ///
185    /// Use this over [`open`](DocumentFile::open) when reading untrusted or
186    /// secret-bearing config, where an unbounded read of an arbitrary path is
187    /// a denial-of-service risk.
188    pub fn open_capped(
189        path: impl AsRef<Path>,
190        format_override: Option<Format>,
191        max_bytes: u64,
192    ) -> DocumentResult<DocumentFile> {
193        let path = path.as_ref();
194        let metadata = fs::metadata(path).map_err(|error| DocumentError::IoError {
195            detail: format!("read `{}`: {error}", path.display()),
196        })?;
197        if !metadata.is_file() {
198            return Err(DocumentError::IoError {
199                detail: format!("`{}` is not a regular file", path.display()),
200            });
201        }
202        if metadata.len() > max_bytes {
203            return Err(DocumentError::IoError {
204                detail: format!(
205                    "`{}` exceeds the {max_bytes}-byte read limit",
206                    path.display()
207                ),
208            });
209        }
210        DocumentFile::open(path, format_override)
211    }
212
213    /// The file path this document was opened from.
214    pub fn path(&self) -> &Path {
215        &self.path
216    }
217
218    /// Preflight-check that this file is safe to mutate — not a symlink, and
219    /// on unix not hardlinked — without performing any write.
220    ///
221    /// [`save`](DocumentFile::save) runs this same guard before it writes, so
222    /// calling it directly is only useful to front-run a *separate* side effect
223    /// with the same guarantee — e.g. a CLI reading a secret from stdin for a
224    /// `set` should refuse an unsafe target before consuming that input.
225    pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
226        guard_mutation(&self.path, operation)?;
227        Ok(())
228    }
229}
230
231impl Document {
232    /// Set `key` to the typed `value`, preserving the rest of the source
233    /// document. The edit is staged in memory — call
234    /// [`save`](DocumentFile::save) to persist it.
235    ///
236    /// Backend capability mirrors [`crate::document::set_path`] where the
237    /// source editor allows it: the JSON backend replaces an existing value
238    /// (scalar or collection) and creates missing intermediate parent objects;
239    /// the TOML backend creates missing parent tables. Backends that cannot
240    /// express an edit source-preserving (e.g. YAML collection mutation) return
241    /// [`DocumentError::UnsupportedOperation`].
242    pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
243        let mut new_doc = self.value.clone();
244        self.format.ensure_writable("set")?;
245        crate::document::set_path(&mut new_doc, key, &value, &[])?;
246        let target = crate::document::get_path(&new_doc, key, &[])?;
247        #[allow(unreachable_patterns)]
248        let output = match self.format {
249            #[cfg(feature = "toml")]
250            Format::Toml => {
251                crate::document::format::toml::set_preserving(&self.source, key, &target)?
252            }
253            #[cfg(feature = "yaml")]
254            Format::Yaml => {
255                crate::document::format::yaml::set_preserving(&self.source, key, &target)?
256            }
257            Format::Json => {
258                crate::document::format::json::set_preserving(&self.source, key, &target)?
259            }
260            #[cfg(feature = "dotenv")]
261            Format::Dotenv => {
262                crate::document::format::dotenv::set_preserving(&self.source, key, &target)?
263            }
264            #[cfg(feature = "ini")]
265            Format::Ini => {
266                crate::document::format::ini::set_preserving(&self.source, key, &target)?
267            }
268            #[cfg(feature = "toml")]
269            Format::TomlFrontmatter => {
270                let parts = crate::document::format::frontmatter::split(
271                    &self.source,
272                    crate::document::format::frontmatter::Delimiter::Plus,
273                )?;
274                let new_fm =
275                    crate::document::format::toml::set_preserving(parts.frontmatter, key, &target)?;
276                format!("{}{}{}", parts.pre, new_fm, parts.post)
277            }
278            #[cfg(feature = "yaml")]
279            Format::YamlFrontmatter => {
280                let parts = crate::document::format::frontmatter::split(
281                    &self.source,
282                    crate::document::format::frontmatter::Delimiter::Dash,
283                )?;
284                let new_fm =
285                    crate::document::format::yaml::set_preserving(parts.frontmatter, key, &target)?;
286                format!("{}{}{}", parts.pre, new_fm, parts.post)
287            }
288            _ => self.format.save(&new_doc)?,
289        };
290        self.source = output;
291        self.value = new_doc;
292        Ok(())
293    }
294
295    /// Add a new element to the keyed list at `key`, identified by
296    /// `slug`/`slug_field`, with the given `fields`. Preserves the rest of
297    /// the source document.
298    ///
299    /// Only JSON and YAML backends implement a source-preserving
300    /// keyed-collection editor today; other formats return
301    /// [`DocumentError::UnsupportedOperation`].
302    pub fn add(
303        &mut self,
304        key: &str,
305        slug: &str,
306        slug_field: &str,
307        fields: &[(String, Value)],
308    ) -> DocumentResult<()> {
309        let mut value = self.value.clone();
310        self.format.ensure_writable("add")?;
311        let keyed_lists = [KeyedList {
312            prefix: key,
313            slug_field,
314        }];
315        crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
316        let output: String = match self.format {
317            Format::Json => {
318                let array = crate::document::get_path(&value, key, &keyed_lists)?;
319                let item = array
320                    .as_array()
321                    .and_then(|items| items.last())
322                    .ok_or_else(|| DocumentError::UnsupportedOperation {
323                        format: "JSON".to_string(),
324                        operation: "add".to_string(),
325                        detail: "keyed list did not produce an array item".to_string(),
326                    })?;
327                crate::document::format::json::append_array_item_preserving(
328                    &self.source,
329                    key,
330                    item,
331                )?
332            }
333            #[cfg(feature = "yaml")]
334            Format::Yaml => {
335                let array = crate::document::get_path(&value, key, &keyed_lists)?;
336                let item = array
337                    .as_array()
338                    .and_then(|items| items.last())
339                    .ok_or_else(|| DocumentError::UnsupportedOperation {
340                        format: "YAML".to_string(),
341                        operation: "add".to_string(),
342                        detail: "keyed list did not produce an array item".to_string(),
343                    })?;
344                crate::document::format::yaml::append_array_item_preserving(
345                    &self.source,
346                    key,
347                    item,
348                )?
349            }
350            _ => {
351                return Err(DocumentError::UnsupportedOperation {
352                    format: self.format.name().to_string(),
353                    operation: "add".to_string(),
354                    detail: "keyed collection source editor is not implemented for this backend"
355                        .to_string(),
356                });
357            }
358        };
359        self.source = output;
360        self.value = value;
361        Ok(())
362    }
363
364    /// Remove the element identified by `slug`/`slug_field` from the keyed
365    /// list at `key`. Preserves the rest of the source document.
366    ///
367    /// Only JSON and YAML backends implement a source-preserving
368    /// keyed-collection editor today; other formats return
369    /// [`DocumentError::UnsupportedOperation`].
370    pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
371        let mut value = self.value.clone();
372        self.format.ensure_writable("remove")?;
373        let keyed_lists = [KeyedList {
374            prefix: key,
375            slug_field,
376        }];
377        let original_array = crate::document::get_path(&value, key, &keyed_lists)?;
378        let removed_index = original_array
379            .as_array()
380            .and_then(|items| {
381                items
382                    .iter()
383                    .position(|item| item.get(slug_field).and_then(Value::as_str) == Some(slug))
384            })
385            .ok_or_else(|| DocumentError::SlugNotFound {
386                prefix: key.to_string(),
387                slug: slug.to_string(),
388            })?;
389        #[cfg(not(feature = "yaml"))]
390        let _ = removed_index;
391        crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
392        let output: String = match self.format {
393            Format::Json => crate::document::format::json::remove_array_item_preserving(
394                &self.source,
395                key,
396                slug,
397                slug_field,
398            )?,
399            #[cfg(feature = "yaml")]
400            Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
401                &self.source,
402                key,
403                removed_index,
404            )?,
405            _ => {
406                return Err(DocumentError::UnsupportedOperation {
407                    format: self.format.name().to_string(),
408                    operation: "remove".to_string(),
409                    detail: "keyed collection source editor is not implemented for this backend"
410                        .to_string(),
411                });
412            }
413        };
414        self.source = output;
415        self.value = value;
416        Ok(())
417    }
418
419    /// Remove the entry at `key` entirely, preserving the rest of the source
420    /// document. The edit is staged in memory — call
421    /// [`DocumentFile::save`] to persist it.
422    ///
423    /// Idempotent, like [`HashSet::remove`](std::collections::HashSet::remove):
424    /// returns `Ok(false)` when `key` is already absent (nothing is staged) and
425    /// `Ok(true)` when it was removed.
426    pub fn unset(&mut self, key: &str) -> DocumentResult<bool> {
427        if self.value_at(key).is_err() {
428            return Ok(false);
429        }
430        let mut value = self.value.clone();
431        self.format.ensure_writable("unset")?;
432        crate::document::unset_path(&mut value, key)?;
433        #[allow(unreachable_patterns)]
434        let output = match self.format {
435            Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
436            #[cfg(feature = "toml")]
437            Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
438            #[cfg(feature = "yaml")]
439            Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
440            #[cfg(feature = "dotenv")]
441            Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
442            #[cfg(feature = "ini")]
443            Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
444            #[cfg(feature = "toml")]
445            Format::TomlFrontmatter => {
446                let parts = crate::document::format::frontmatter::split(
447                    &self.source,
448                    crate::document::format::frontmatter::Delimiter::Plus,
449                )?;
450                let new_fm =
451                    crate::document::format::toml::unset_preserving(parts.frontmatter, key)?;
452                format!("{}{}{}", parts.pre, new_fm, parts.post)
453            }
454            #[cfg(feature = "yaml")]
455            Format::YamlFrontmatter => {
456                let parts = crate::document::format::frontmatter::split(
457                    &self.source,
458                    crate::document::format::frontmatter::Delimiter::Dash,
459                )?;
460                let new_fm =
461                    crate::document::format::yaml::unset_preserving(parts.frontmatter, key)?;
462                format!("{}{}{}", parts.pre, new_fm, parts.post)
463            }
464            _ => self.format.save(&value)?,
465        };
466        self.source = output;
467        self.value = value;
468        Ok(true)
469    }
470}
471
472impl DocumentFile {
473    /// Run `edit` against the in-memory [`Document`], then commit once with
474    /// [`save`](DocumentFile::save). The single-call form of stage-then-save:
475    /// the edits either all land (on `Ok`) or none reach disk (on `Err`,
476    /// nothing is written), and the commit can't be forgotten.
477    pub fn edit<F>(&mut self, edit: F) -> DocumentResult<()>
478    where
479        F: FnOnce(&mut Document) -> DocumentResult<()>,
480    {
481        edit(&mut self.doc)?;
482        self.save()
483    }
484
485    /// Persist the document — every edit staged since [`open`](DocumentFile::open)
486    /// — to its path in a single atomic write.
487    ///
488    /// The mutation verbs (`set`/`unset`/`add`/`remove`) stage their
489    /// source-preserving edit in memory and do **not** touch disk; this is the
490    /// one commit point. That lets a caller apply several edits and inspect the
491    /// result via [`value`](Document::value) (e.g. deserialize-and-validate)
492    /// before any bytes are written, and makes a multi-edit change atomic —
493    /// all edits land together or none do.
494    pub fn save(&self) -> DocumentResult<()> {
495        self.save_atomic(self.doc.source())
496    }
497
498    /// Atomically replace the file's contents with `new_source`: guard
499    /// against symlinks/hardlinked files, write to a same-directory temp
500    /// file, fsync it, re-apply the original file's permissions, then
501    /// `rename` it over the target. No partial write is ever observable —
502    /// on any failure the temp file is removed and the original file is
503    /// untouched.
504    ///
505    /// Crate-internal write seam behind the public [`save`](DocumentFile::save);
506    /// it is not exported, so callers cannot write arbitrary raw text that
507    /// bypasses the parse/edit path.
508    pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
509        write_atomic(&self.path, new_source.as_bytes(), "write")
510    }
511}
512
513impl std::ops::Deref for DocumentFile {
514    type Target = Document;
515
516    fn deref(&self) -> &Document {
517        &self.doc
518    }
519}
520
521impl std::ops::DerefMut for DocumentFile {
522    fn deref_mut(&mut self) -> &mut Document {
523        &mut self.doc
524    }
525}
526
527/// Reject mutation of a symlink or (on unix) a hardlinked file. Returns the
528/// target's metadata on success so callers that also need to write can reuse
529/// it (e.g. to preserve permissions) without a second syscall.
530fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
531    let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
532        detail: format!("{operation} preflight `{}`: {error}", path.display()),
533    })?;
534    if metadata.file_type().is_symlink() {
535        return Err(DocumentError::UnsupportedOperation {
536            format: "filesystem".to_string(),
537            operation: operation.to_string(),
538            detail: format!("refusing to mutate symlink `{}`", path.display()),
539        });
540    }
541    #[cfg(unix)]
542    {
543        use std::os::unix::fs::MetadataExt;
544        if metadata.nlink() > 1 {
545            return Err(DocumentError::UnsupportedOperation {
546                format: "filesystem".to_string(),
547                operation: operation.to_string(),
548                detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
549            });
550        }
551    }
552    Ok(metadata)
553}
554
555/// Write `bytes` to `path` atomically: guard, same-directory temp file,
556/// fsync, permission preservation, then rename over the target.
557fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
558    let metadata = guard_mutation(path, operation)?;
559
560    let parent = path.parent().ok_or_else(|| DocumentError::IoError {
561        detail: format!(
562            "{operation} has no parent directory for `{}`",
563            path.display()
564        ),
565    })?;
566    let file_name = path
567        .file_name()
568        .and_then(|name| name.to_str())
569        .ok_or_else(|| DocumentError::IoError {
570            detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
571        })?;
572    let pid = std::process::id();
573    let mut temp_path = None;
574    let mut temp_file = None;
575    for attempt in 0..32_u32 {
576        let candidate = parent.join(format!(".{file_name}.afdata-document.{pid}.{attempt}.tmp"));
577        match OpenOptions::new()
578            .write(true)
579            .create_new(true)
580            .open(&candidate)
581        {
582            Ok(file) => {
583                temp_path = Some(candidate);
584                temp_file = Some(file);
585                break;
586            }
587            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
588            Err(error) => {
589                return Err(DocumentError::IoError {
590                    detail: format!(
591                        "{operation} create temporary file in `{}`: {error}",
592                        parent.display()
593                    ),
594                });
595            }
596        }
597    }
598    let temp_path = temp_path.ok_or_else(|| DocumentError::IoError {
599        detail: format!(
600            "{operation} could not allocate temporary file in `{}`",
601            parent.display()
602        ),
603    })?;
604    let mut temp_file = temp_file.ok_or_else(|| DocumentError::IoError {
605        detail: format!("{operation} temporary file handle missing"),
606    })?;
607    let result = (|| -> DocumentResult<()> {
608        temp_file
609            .write_all(bytes)
610            .map_err(|error| DocumentError::IoError {
611                detail: format!("{operation} write `{}`: {error}", path.display()),
612            })?;
613        temp_file
614            .sync_all()
615            .map_err(|error| DocumentError::IoError {
616                detail: format!("{operation} fsync `{}`: {error}", path.display()),
617            })?;
618        drop(temp_file);
619        fs::set_permissions(&temp_path, metadata.permissions()).map_err(|error| {
620            DocumentError::IoError {
621                detail: format!(
622                    "{operation} preserve permissions `{}`: {error}",
623                    path.display()
624                ),
625            }
626        })?;
627        fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
628            detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
629        })?;
630        Ok(())
631    })();
632    if result.is_err() {
633        let _ = fs::remove_file(&temp_path);
634    }
635    result
636}
637
638#[cfg(test)]
639mod tests {
640    #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
641    use super::*;
642    use std::io::Cursor;
643
644    fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
645        let path = dir.join(name);
646        fs::write(&path, contents).unwrap();
647        path
648    }
649
650    #[test]
651    fn round_trip_open_json() {
652        let dir = tempfile::tempdir().unwrap();
653        let contents = r#"{"host": "example.com", "port": 993}"#;
654        let path = write_temp(dir.path(), "config.json", contents);
655
656        let doc = DocumentFile::open(&path, None).unwrap();
657
658        assert_eq!(doc.format(), Format::Json);
659        assert_eq!(
660            doc.value().get("host").and_then(Value::as_str),
661            Some("example.com")
662        );
663        assert_eq!(doc.source(), contents);
664    }
665
666    #[test]
667    fn value_at_reads_a_nested_address() {
668        let dir = tempfile::tempdir().unwrap();
669        let path = write_temp(
670            dir.path(),
671            "config.json",
672            r#"{"database": {"url": "postgres://x"}}"#,
673        );
674        let doc = DocumentFile::open(&path, None).unwrap();
675
676        assert_eq!(
677            doc.value_at("database.url").unwrap(),
678            Value::String("postgres://x".to_string())
679        );
680        assert_eq!(
681            doc.value_at("database.missing").unwrap_err().code(),
682            "document_path_not_found"
683        );
684    }
685
686    #[test]
687    fn open_capped_enforces_size_and_regular_file() {
688        let dir = tempfile::tempdir().unwrap();
689        let path = write_temp(dir.path(), "config.json", r#"{"k": "v"}"#);
690
691        // Within the cap: opens normally.
692        assert!(DocumentFile::open_capped(&path, None, 1024).is_ok());
693
694        // Over the cap: rejected without parsing, as an io failure.
695        let err = DocumentFile::open_capped(&path, None, 4).unwrap_err();
696        assert_eq!(err.code(), "document_io_failed");
697        assert!(err.to_string().contains("read limit"));
698
699        // A directory is not a regular file.
700        let dir_err = DocumentFile::open_capped(dir.path(), Some(Format::Json), 1024).unwrap_err();
701        assert_eq!(dir_err.code(), "document_io_failed");
702    }
703
704    #[test]
705    fn typed_get_and_set_enforce_the_stated_type() {
706        use crate::document::ValueType;
707        let dir = tempfile::tempdir().unwrap();
708        let path = write_temp(dir.path(), "config.json", r#"{"port": 8080, "host": "x"}"#);
709        let mut doc = DocumentFile::open(&path, None).unwrap();
710
711        // Typed get: matching type returns, wrong type is a caught error, Json
712        // matches anything.
713        assert!(doc.value_at_typed("port", ValueType::Number).is_ok());
714        assert_eq!(
715            doc.value_at_typed("port", ValueType::String)
716                .unwrap_err()
717                .code(),
718            "document_type_mismatch"
719        );
720        assert!(doc.value_at_typed("host", ValueType::Json).is_ok());
721
722        // Typed set: the literal is validated against the stated type.
723        doc.set_typed("port", Some("9090"), ValueType::Number)
724            .unwrap();
725        assert_eq!(
726            doc.value_at("port").unwrap(),
727            Value::from(serde_json::json!(9090))
728        );
729        assert_eq!(
730            doc.set_typed("port", Some("not-a-number"), ValueType::Number)
731                .unwrap_err()
732                .code(),
733            "document_parse_failed"
734        );
735    }
736
737    #[cfg(feature = "toml")]
738    #[test]
739    fn round_trip_open_toml() {
740        let dir = tempfile::tempdir().unwrap();
741        let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
742        let path = write_temp(dir.path(), "config.toml", contents);
743
744        let doc = DocumentFile::open(&path, None).unwrap();
745
746        assert_eq!(doc.format(), Format::Toml);
747        assert_eq!(
748            doc.value().get("host").and_then(Value::as_str),
749            Some("example.com")
750        );
751        assert_eq!(doc.source(), contents);
752    }
753
754    #[cfg(feature = "toml")]
755    #[test]
756    fn set_scalar_preserves_toml_comments_and_formatting() {
757        let dir = tempfile::tempdir().unwrap();
758        let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
759        let path = write_temp(dir.path(), "config.toml", contents);
760        let mut doc = DocumentFile::open(&path, None).unwrap();
761
762        doc.set("port", Value::Integer(1024)).unwrap();
763        doc.save().unwrap();
764
765        let saved = fs::read_to_string(&path).unwrap();
766        assert!(saved.contains("# leading comment"));
767        assert!(saved.contains("port = 1024"));
768        assert_eq!(
769            doc.value().get("port").and_then(Value::as_integer),
770            Some(1024)
771        );
772        assert_eq!(doc.source(), saved);
773    }
774
775    #[cfg(unix)]
776    #[test]
777    fn atomic_save_preserves_file_mode() {
778        use std::os::unix::fs::PermissionsExt;
779
780        let dir = tempfile::tempdir().unwrap();
781        let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
782        fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
783        let mut doc = DocumentFile::open(&path, None).unwrap();
784
785        doc.set("port", Value::Integer(1024)).unwrap();
786        doc.save().unwrap();
787
788        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
789        assert_eq!(mode, 0o640);
790    }
791
792    #[cfg(unix)]
793    #[test]
794    fn symlink_target_is_rejected_for_mutation() {
795        let dir = tempfile::tempdir().unwrap();
796        let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
797        let link = dir.path().join("link.json");
798        std::os::unix::fs::symlink(&target, &link).unwrap();
799
800        // Reading through the symlink is fine.
801        let mut doc = DocumentFile::open(&link, None).unwrap();
802
803        // Editing in memory is fine; committing through the symlink is not.
804        doc.set("port", Value::Integer(1024)).unwrap();
805        let err = doc.save().unwrap_err();
806        assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
807
808        // The target file was never touched.
809        let target_contents = fs::read_to_string(&target).unwrap();
810        assert_eq!(target_contents, r#"{"port": 993}"#);
811    }
812
813    #[test]
814    fn from_reader_parses_in_memory_cursor() {
815        let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
816
817        let doc = Document::from_reader(cursor, Format::Json).unwrap();
818
819        assert_eq!(
820            doc.value().get("host").and_then(Value::as_str),
821            Some("example.com")
822        );
823    }
824
825    #[test]
826    fn document_from_str_encode_round_trip() {
827        let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
828        let encoded = doc.encode().unwrap();
829        let reparsed = Document::parse(&encoded, Format::Json).unwrap();
830        assert_eq!(
831            reparsed.value().get("a").and_then(Value::as_integer),
832            Some(1)
833        );
834    }
835
836    #[test]
837    fn document_edits_source_in_memory_without_a_file() {
838        // The point of the Document/DocumentFile split: source-preserving
839        // editing with no file, no I/O, no guards.
840        let mut doc = Document::parse("{\n  \"host\": \"old\"\n}\n", Format::Json).unwrap();
841        doc.set("host", Value::String("new".to_string())).unwrap();
842        doc.set("imap.port", Value::Integer(993)).unwrap(); // creates the parent
843
844        assert_eq!(
845            doc.source(),
846            "{\n  \"host\": \"new\",\n  \"imap\": {\n    \"port\": 993\n  }\n}\n"
847        );
848        assert_eq!(
849            doc.value_at("imap.port").unwrap(),
850            Value::from(serde_json::json!(993))
851        );
852    }
853}