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    /// The file path this document was opened from.
143    pub fn path(&self) -> &Path {
144        &self.path
145    }
146
147    /// Borrow the currently parsed value (reflects the last successful
148    /// edit, if any).
149    pub fn value(&self) -> &Value {
150        &self.value
151    }
152
153    /// The format this file was opened as.
154    pub fn format(&self) -> Format {
155        self.format
156    }
157
158    /// Borrow the current source text (reflects the last successful edit,
159    /// if any).
160    pub fn source(&self) -> &str {
161        &self.source
162    }
163
164    /// Preflight-check that this file is safe to mutate — not a symlink, and
165    /// on unix not hardlinked — without performing any write.
166    ///
167    /// Every mutation method ([`DocumentFile::set`] and friends) already
168    /// runs this same guard itself before writing, so calling it directly is
169    /// only useful when a caller must front-run a *separate* side effect with
170    /// the same guarantee — for example, a CLI that reads a secret from stdin
171    /// for `set` should refuse an unsafe target before consuming that input,
172    /// not after.
173    pub fn ensure_mutable(&self, operation: &str) -> DocumentResult<()> {
174        guard_mutation(&self.path, operation)?;
175        Ok(())
176    }
177
178    /// Set `key` to the typed `value`, preserving the rest of the source
179    /// document.
180    pub fn set(&mut self, key: &str, value: Value) -> DocumentResult<()> {
181        guard_mutation(&self.path, "set")?;
182        let mut new_doc = self.value.clone();
183        self.format.ensure_writable("set")?;
184        crate::document::set_path(&mut new_doc, key, &value, &[])?;
185        let target = crate::document::get_path(&new_doc, key, &[])?;
186        #[allow(unreachable_patterns)]
187        let output = match self.format {
188            #[cfg(feature = "toml")]
189            Format::Toml => {
190                crate::document::format::toml::set_scalar_preserving(&self.source, key, &target)?
191            }
192            #[cfg(feature = "yaml")]
193            Format::Yaml => {
194                crate::document::format::yaml::set_scalar_preserving(&self.source, key, &target)?
195            }
196            Format::Json => {
197                crate::document::format::json::set_scalar_preserving(&self.source, key, &target)?
198            }
199            #[cfg(feature = "dotenv")]
200            Format::Dotenv => {
201                crate::document::format::dotenv::set_scalar_preserving(&self.source, key, &target)?
202            }
203            #[cfg(feature = "ini")]
204            Format::Ini => {
205                crate::document::format::ini::set_scalar_preserving(&self.source, key, &target)?
206            }
207            _ => self.format.save(&new_doc)?,
208        };
209        self.save_atomic(&output)?;
210        self.source = output;
211        self.value = new_doc;
212        Ok(())
213    }
214
215    /// Add a new element to the keyed list at `key`, identified by
216    /// `slug`/`slug_field`, with the given `fields`. Preserves the rest of
217    /// the source document.
218    ///
219    /// Only JSON and YAML backends implement a source-preserving
220    /// keyed-collection editor today; other formats return
221    /// [`DocumentError::UnsupportedOperation`].
222    pub fn add(
223        &mut self,
224        key: &str,
225        slug: &str,
226        slug_field: &str,
227        fields: &[(String, Value)],
228    ) -> DocumentResult<()> {
229        guard_mutation(&self.path, "add")?;
230        let mut value = self.value.clone();
231        self.format.ensure_writable("add")?;
232        let keyed_lists = [KeyedList {
233            prefix: key,
234            slug_field,
235        }];
236        crate::document::add_keyed(&mut value, key, slug, &keyed_lists, None, fields)?;
237        let output: String = match self.format {
238            Format::Json => {
239                let array = crate::document::get_path(&value, key, &keyed_lists)?;
240                let item = array
241                    .as_array()
242                    .and_then(|items| items.last())
243                    .ok_or_else(|| DocumentError::UnsupportedOperation {
244                        format: "JSON".to_string(),
245                        operation: "add".to_string(),
246                        detail: "keyed list did not produce an array item".to_string(),
247                    })?;
248                crate::document::format::json::append_array_item_preserving(
249                    &self.source,
250                    key,
251                    item,
252                )?
253            }
254            #[cfg(feature = "yaml")]
255            Format::Yaml => {
256                let array = crate::document::get_path(&value, key, &keyed_lists)?;
257                let item = array
258                    .as_array()
259                    .and_then(|items| items.last())
260                    .ok_or_else(|| DocumentError::UnsupportedOperation {
261                        format: "YAML".to_string(),
262                        operation: "add".to_string(),
263                        detail: "keyed list did not produce an array item".to_string(),
264                    })?;
265                crate::document::format::yaml::append_array_item_preserving(
266                    &self.source,
267                    key,
268                    item,
269                )?
270            }
271            _ => {
272                return Err(DocumentError::UnsupportedOperation {
273                    format: format_name(self.format).to_string(),
274                    operation: "add".to_string(),
275                    detail: "keyed collection source editor is not implemented for this backend"
276                        .to_string(),
277                });
278            }
279        };
280        self.save_atomic(&output)?;
281        self.source = output;
282        self.value = value;
283        Ok(())
284    }
285
286    /// Remove the element identified by `slug`/`slug_field` from the keyed
287    /// list at `key`. Preserves the rest of the source document.
288    ///
289    /// Only JSON and YAML backends implement a source-preserving
290    /// keyed-collection editor today; other formats return
291    /// [`DocumentError::UnsupportedOperation`].
292    pub fn remove(&mut self, key: &str, slug: &str, slug_field: &str) -> DocumentResult<()> {
293        guard_mutation(&self.path, "remove")?;
294        let mut value = self.value.clone();
295        self.format.ensure_writable("remove")?;
296        let keyed_lists = [KeyedList {
297            prefix: key,
298            slug_field,
299        }];
300        let original_array = crate::document::get_path(&value, key, &keyed_lists)?;
301        let removed_index = original_array
302            .as_array()
303            .and_then(|items| {
304                items
305                    .iter()
306                    .position(|item| item.get(slug_field).and_then(Value::as_str) == Some(slug))
307            })
308            .ok_or_else(|| DocumentError::SlugNotFound {
309                prefix: key.to_string(),
310                slug: slug.to_string(),
311            })?;
312        #[cfg(not(feature = "yaml"))]
313        let _ = removed_index;
314        crate::document::remove_keyed(&mut value, key, slug, &keyed_lists)?;
315        let output: String = match self.format {
316            Format::Json => crate::document::format::json::remove_array_item_preserving(
317                &self.source,
318                key,
319                slug,
320                slug_field,
321            )?,
322            #[cfg(feature = "yaml")]
323            Format::Yaml => crate::document::format::yaml::remove_array_item_preserving(
324                &self.source,
325                key,
326                removed_index,
327            )?,
328            _ => {
329                return Err(DocumentError::UnsupportedOperation {
330                    format: format_name(self.format).to_string(),
331                    operation: "remove".to_string(),
332                    detail: "keyed collection source editor is not implemented for this backend"
333                        .to_string(),
334                });
335            }
336        };
337        self.save_atomic(&output)?;
338        self.source = output;
339        self.value = value;
340        Ok(())
341    }
342
343    /// Remove the entry at `key` entirely. Preserves the rest of the source
344    /// document.
345    pub fn unset(&mut self, key: &str) -> DocumentResult<()> {
346        guard_mutation(&self.path, "unset")?;
347        let mut value = self.value.clone();
348        self.format.ensure_writable("unset")?;
349        crate::document::unset_path(&mut value, key)?;
350        #[allow(unreachable_patterns)]
351        let output = match self.format {
352            Format::Json => crate::document::format::json::unset_preserving(&self.source, key)?,
353            #[cfg(feature = "toml")]
354            Format::Toml => crate::document::format::toml::unset_preserving(&self.source, key)?,
355            #[cfg(feature = "yaml")]
356            Format::Yaml => crate::document::format::yaml::unset_preserving(&self.source, key)?,
357            #[cfg(feature = "dotenv")]
358            Format::Dotenv => crate::document::format::dotenv::unset_preserving(&self.source, key)?,
359            #[cfg(feature = "ini")]
360            Format::Ini => crate::document::format::ini::unset_preserving(&self.source, key)?,
361            _ => self.format.save(&value)?,
362        };
363        self.save_atomic(&output)?;
364        self.source = output;
365        self.value = value;
366        Ok(())
367    }
368
369    /// Atomically replace the file's contents with `new_source`: guard
370    /// against symlinks/hardlinked files, write to a same-directory temp
371    /// file, fsync it, re-apply the original file's permissions, then
372    /// `rename` it over the target. No partial write is ever observable —
373    /// on any failure the temp file is removed and the original file is
374    /// untouched.
375    ///
376    /// This does not update the in-memory [`DocumentFile::source`] /
377    /// [`DocumentFile::value`] — the mutation methods do that themselves
378    /// after a successful write.
379    ///
380    /// Crate-internal: this is the raw-string write seam the typed verbs
381    /// (`set`/`unset`/`add`/`remove`) route through after computing a
382    /// source-preserving rendering; it is not part of the public API, so
383    /// callers cannot bypass the typed verbs to write arbitrary raw text.
384    pub(crate) fn save_atomic(&self, new_source: &str) -> DocumentResult<()> {
385        write_atomic(&self.path, new_source.as_bytes(), "write")
386    }
387}
388
389/// Reject mutation of a symlink or (on unix) a hardlinked file. Returns the
390/// target's metadata on success so callers that also need to write can reuse
391/// it (e.g. to preserve permissions) without a second syscall.
392fn guard_mutation(path: &Path, operation: &str) -> DocumentResult<fs::Metadata> {
393    let metadata = fs::symlink_metadata(path).map_err(|error| DocumentError::IoError {
394        detail: format!("{operation} preflight `{}`: {error}", path.display()),
395    })?;
396    if metadata.file_type().is_symlink() {
397        return Err(DocumentError::UnsupportedOperation {
398            format: "filesystem".to_string(),
399            operation: operation.to_string(),
400            detail: format!("refusing to mutate symlink `{}`", path.display()),
401        });
402    }
403    #[cfg(unix)]
404    {
405        use std::os::unix::fs::MetadataExt;
406        if metadata.nlink() > 1 {
407            return Err(DocumentError::UnsupportedOperation {
408                format: "filesystem".to_string(),
409                operation: operation.to_string(),
410                detail: format!("refusing to mutate hardlinked file `{}`", path.display()),
411            });
412        }
413    }
414    Ok(metadata)
415}
416
417/// Write `bytes` to `path` atomically: guard, same-directory temp file,
418/// fsync, permission preservation, then rename over the target.
419fn write_atomic(path: &Path, bytes: &[u8], operation: &str) -> DocumentResult<()> {
420    let metadata = guard_mutation(path, operation)?;
421
422    let parent = path.parent().ok_or_else(|| DocumentError::IoError {
423        detail: format!(
424            "{operation} has no parent directory for `{}`",
425            path.display()
426        ),
427    })?;
428    let file_name = path
429        .file_name()
430        .and_then(|name| name.to_str())
431        .ok_or_else(|| DocumentError::IoError {
432            detail: format!("{operation} path is not valid UTF-8: `{}`", path.display()),
433        })?;
434    let pid = std::process::id();
435    let mut temp_path = None;
436    let mut temp_file = None;
437    for attempt in 0..32_u32 {
438        let candidate = parent.join(format!(".{file_name}.afdata-document.{pid}.{attempt}.tmp"));
439        match OpenOptions::new()
440            .write(true)
441            .create_new(true)
442            .open(&candidate)
443        {
444            Ok(file) => {
445                temp_path = Some(candidate);
446                temp_file = Some(file);
447                break;
448            }
449            Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
450            Err(error) => {
451                return Err(DocumentError::IoError {
452                    detail: format!(
453                        "{operation} create temporary file in `{}`: {error}",
454                        parent.display()
455                    ),
456                });
457            }
458        }
459    }
460    let temp_path = temp_path.ok_or_else(|| DocumentError::IoError {
461        detail: format!(
462            "{operation} could not allocate temporary file in `{}`",
463            parent.display()
464        ),
465    })?;
466    let mut temp_file = temp_file.ok_or_else(|| DocumentError::IoError {
467        detail: format!("{operation} temporary file handle missing"),
468    })?;
469    let result = (|| -> DocumentResult<()> {
470        temp_file
471            .write_all(bytes)
472            .map_err(|error| DocumentError::IoError {
473                detail: format!("{operation} write `{}`: {error}", path.display()),
474            })?;
475        temp_file
476            .sync_all()
477            .map_err(|error| DocumentError::IoError {
478                detail: format!("{operation} fsync `{}`: {error}", path.display()),
479            })?;
480        drop(temp_file);
481        fs::set_permissions(&temp_path, metadata.permissions()).map_err(|error| {
482            DocumentError::IoError {
483                detail: format!(
484                    "{operation} preserve permissions `{}`: {error}",
485                    path.display()
486                ),
487            }
488        })?;
489        fs::rename(&temp_path, path).map_err(|error| DocumentError::IoError {
490            detail: format!("{operation} atomic replace `{}`: {error}", path.display()),
491        })?;
492        Ok(())
493    })();
494    if result.is_err() {
495        let _ = fs::remove_file(&temp_path);
496    }
497    result
498}
499
500fn format_name(format: Format) -> &'static str {
501    match format {
502        Format::Json => "JSON",
503        Format::Toml => "TOML",
504        Format::Yaml => "YAML",
505        Format::Dotenv => "dotenv",
506        Format::Ini => "INI",
507    }
508}
509
510#[cfg(test)]
511mod tests {
512    #![allow(clippy::unwrap_used, clippy::panic, clippy::expect_used)]
513    use super::*;
514    use std::io::Cursor;
515
516    fn write_temp(dir: &Path, name: &str, contents: &str) -> PathBuf {
517        let path = dir.join(name);
518        fs::write(&path, contents).unwrap();
519        path
520    }
521
522    #[test]
523    fn round_trip_open_json() {
524        let dir = tempfile::tempdir().unwrap();
525        let contents = r#"{"host": "example.com", "port": 993}"#;
526        let path = write_temp(dir.path(), "config.json", contents);
527
528        let doc = DocumentFile::open(&path, None).unwrap();
529
530        assert_eq!(doc.format(), Format::Json);
531        assert_eq!(
532            doc.value().get("host").and_then(Value::as_str),
533            Some("example.com")
534        );
535        assert_eq!(doc.source(), contents);
536    }
537
538    #[cfg(feature = "toml")]
539    #[test]
540    fn round_trip_open_toml() {
541        let dir = tempfile::tempdir().unwrap();
542        let contents = "# leading comment\nhost = \"example.com\"\nport = 993\n";
543        let path = write_temp(dir.path(), "config.toml", contents);
544
545        let doc = DocumentFile::open(&path, None).unwrap();
546
547        assert_eq!(doc.format(), Format::Toml);
548        assert_eq!(
549            doc.value().get("host").and_then(Value::as_str),
550            Some("example.com")
551        );
552        assert_eq!(doc.source(), contents);
553    }
554
555    #[cfg(feature = "toml")]
556    #[test]
557    fn set_scalar_preserves_toml_comments_and_formatting() {
558        let dir = tempfile::tempdir().unwrap();
559        let contents = "# leading comment\nhost = \"example.com\"\nport = 993 # inline comment\n";
560        let path = write_temp(dir.path(), "config.toml", contents);
561        let mut doc = DocumentFile::open(&path, None).unwrap();
562
563        doc.set("port", Value::Integer(1024)).unwrap();
564
565        let saved = fs::read_to_string(&path).unwrap();
566        assert!(saved.contains("# leading comment"));
567        assert!(saved.contains("port = 1024"));
568        assert_eq!(
569            doc.value().get("port").and_then(Value::as_integer),
570            Some(1024)
571        );
572        assert_eq!(doc.source(), saved);
573    }
574
575    #[cfg(unix)]
576    #[test]
577    fn atomic_save_preserves_file_mode() {
578        use std::os::unix::fs::PermissionsExt;
579
580        let dir = tempfile::tempdir().unwrap();
581        let path = write_temp(dir.path(), "config.json", r#"{"port": 993}"#);
582        fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
583        let mut doc = DocumentFile::open(&path, None).unwrap();
584
585        doc.set("port", Value::Integer(1024)).unwrap();
586
587        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
588        assert_eq!(mode, 0o640);
589    }
590
591    #[cfg(unix)]
592    #[test]
593    fn symlink_target_is_rejected_for_mutation() {
594        let dir = tempfile::tempdir().unwrap();
595        let target = write_temp(dir.path(), "target.json", r#"{"port": 993}"#);
596        let link = dir.path().join("link.json");
597        std::os::unix::fs::symlink(&target, &link).unwrap();
598
599        // Reading through the symlink is fine.
600        let mut doc = DocumentFile::open(&link, None).unwrap();
601
602        // Mutating through it is not.
603        let err = doc.set("port", Value::Integer(1024)).unwrap_err();
604        assert!(matches!(err, DocumentError::UnsupportedOperation { .. }));
605
606        // The target file was never touched.
607        let target_contents = fs::read_to_string(&target).unwrap();
608        assert_eq!(target_contents, r#"{"port": 993}"#);
609    }
610
611    #[test]
612    fn from_reader_parses_in_memory_cursor() {
613        let cursor = Cursor::new(br#"{"host": "example.com"}"#.to_vec());
614
615        let doc = Document::from_reader(cursor, Format::Json).unwrap();
616
617        assert_eq!(
618            doc.value().get("host").and_then(Value::as_str),
619            Some("example.com")
620        );
621    }
622
623    #[test]
624    fn document_from_str_encode_round_trip() {
625        let doc = Document::parse(r#"{"a": 1}"#, Format::Json).unwrap();
626        let encoded = doc.encode().unwrap();
627        let reparsed = Document::parse(&encoded, Format::Json).unwrap();
628        assert_eq!(
629            reparsed.value().get("a").and_then(Value::as_integer),
630            Some(1)
631        );
632    }
633}