Skip to main content

agent_first_data/document/
file.rs

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