Skip to main content

agent_first_data/document/format/
mod.rs

1//! Format detection and backend selection.
2
3#[allow(unused_imports)]
4use crate::document::{DocumentError, DocumentResult, Value};
5use std::path::Path;
6
7#[cfg(feature = "dotenv")]
8pub mod dotenv;
9// The frontmatter splitter has no format dependency, so it always compiles; the
10// inner TOML/YAML backends it delegates to are gated at the call sites below.
11pub mod frontmatter;
12#[cfg(feature = "ini")]
13pub mod ini;
14// JSON is a core (non-optional) dependency of agent-first-data, so this
15// backend always compiles — unlike toml/yaml/dotenv/ini below.
16pub mod json;
17#[cfg(feature = "markdown")]
18pub mod markdown;
19#[cfg(feature = "plist")]
20pub mod plist;
21#[cfg(feature = "toml")]
22pub mod toml;
23#[cfg(feature = "yaml")]
24pub mod yaml;
25
26/// A format this build can actually read.
27///
28/// The optional backends gate their variants, not just their bodies: in a build
29/// without `toml` there is no `Format::Toml` at all, so code naming a format it
30/// did not enable fails to compile instead of returning a runtime refusal from
31/// deep inside `load`. Detection still answers for those files — see
32/// [`Format::unavailable`] — because a file's format is a fact about the file,
33/// not about this build.
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Format {
36    Json,
37    #[cfg(feature = "toml")]
38    Toml,
39    #[cfg(feature = "yaml")]
40    Yaml,
41    #[cfg(feature = "dotenv")]
42    Dotenv,
43    #[cfg(feature = "ini")]
44    Ini,
45    /// A `+++`-fenced TOML frontmatter block; the Markdown body is frozen. Never
46    /// auto-detected — selected only via `--input-format toml-frontmatter`.
47    #[cfg(feature = "toml")]
48    TomlFrontmatter,
49    /// A `---`-fenced YAML frontmatter block; the Markdown body is frozen. Never
50    /// auto-detected — selected only via `--input-format yaml-frontmatter`.
51    #[cfg(feature = "yaml")]
52    YamlFrontmatter,
53    /// A CommonMark document read as a tree of heading sections (`preamble`,
54    /// `h1`, `h1.0.h2`, …). Read-only, and never auto-detected — the same `.md`
55    /// file is legitimately readable as frontmatter, and choosing between two
56    /// valid readings is not something an extension can decide.
57    #[cfg(feature = "markdown")]
58    Markdown,
59    /// An XML or binary property list. Read-only: afdata reads the value and
60    /// never writes a plist back. Detected from the `.plist` extension.
61    #[cfg(feature = "plist")]
62    Plist,
63}
64
65impl Format {
66    /// Stable human-readable label used in document results and diagnostics.
67    #[must_use]
68    pub const fn name(self) -> &'static str {
69        match self {
70            Self::Json => "JSON",
71            #[cfg(feature = "toml")]
72            Self::Toml => "TOML",
73            #[cfg(feature = "yaml")]
74            Self::Yaml => "YAML",
75            #[cfg(feature = "dotenv")]
76            Self::Dotenv => "dotenv",
77            #[cfg(feature = "ini")]
78            Self::Ini => "INI",
79            #[cfg(feature = "toml")]
80            Self::TomlFrontmatter => "TOML frontmatter",
81            #[cfg(feature = "yaml")]
82            Self::YamlFrontmatter => "YAML frontmatter",
83            #[cfg(feature = "markdown")]
84            Self::Markdown => "Markdown",
85            #[cfg(feature = "plist")]
86            Self::Plist => "plist",
87        }
88    }
89
90    /// Whether this format can be written at all.
91    ///
92    /// Markdown and plist are reader-only. Markdown's parsed value is a
93    /// flattened view of prose, so re-rendering a document from it would
94    /// discard everything the flattening dropped; a plist value has no
95    /// source-preserving XML writer behind it. Every mutating verb refuses up
96    /// front rather than producing that file.
97    #[must_use]
98    pub const fn is_read_only(self) -> bool {
99        match self {
100            #[cfg(feature = "markdown")]
101            Self::Markdown => true,
102            #[cfg(feature = "plist")]
103            Self::Plist => true,
104            #[allow(unreachable_patterns)]
105            _ => false,
106        }
107    }
108
109    /// This format's own rule for resolving a **non-numeric** path segment
110    /// against an array, or `None` when only a caller-declared keyed list can.
111    ///
112    /// A format earns a rule by owning the shape of its own value. Markdown's
113    /// value is a tree afdata synthesized — every node carries `text` because
114    /// this crate put it there — so `h2.look` can be answered from the format
115    /// alone. JSON, TOML, YAML, dotenv, and INI hand back whatever the file
116    /// said; nothing in `deps.foo` tells afdata which field of a `deps` element
117    /// `foo` is supposed to match, and inventing one is the shape-guessing this
118    /// crate exists to refuse. Those keep needing an explicit
119    /// [`KeyedList`](crate::document::KeyedList).
120    #[must_use]
121    pub const fn array_rule(self) -> Option<crate::document::ArrayRule<'static>> {
122        match self {
123            #[cfg(feature = "markdown")]
124            Self::Markdown => Some(crate::document::ArrayRule {
125                field: "text",
126                match_kind: crate::document::MatchKind::Contains,
127            }),
128            _ => None,
129        }
130    }
131
132    /// The refusal every mutating operation answers with for a read-only
133    /// format. One constructor so `save` and the document verbs cannot drift
134    /// into giving two different reasons for the same fact.
135    pub(crate) fn read_only_error(self, operation: &str) -> DocumentError {
136        DocumentError::UnsupportedOperation {
137            format: self.name().to_string(),
138            operation: operation.to_string(),
139            detail: format!(
140                "{} is a read-only format: afdata reads its structure and never writes it",
141                self.name()
142            ),
143        }
144    }
145
146    /// Frontmatter has no whole-document re-render, and both frontmatter
147    /// variants say so identically — one constructor so the two gated arms
148    /// cannot drift into two different reasons for the same fact.
149    #[cfg(any(feature = "toml", feature = "yaml"))]
150    fn frontmatter_save_error() -> DocumentError {
151        DocumentError::UnsupportedOperation {
152            format: "frontmatter".to_string(),
153            operation: "save".to_string(),
154            detail: "frontmatter mode has no whole-document re-render; the Markdown body is not \
155                     part of the parsed value — use source-preserving set/unset"
156                .to_string(),
157        }
158    }
159
160    /// Exact CLI token accepted by `--input-format` and emitted in result
161    /// payloads. Unlike [`Self::name`], this is stable machine data rather than
162    /// a display label.
163    #[must_use]
164    pub const fn cli_name(self) -> &'static str {
165        match self {
166            Self::Json => "json",
167            #[cfg(feature = "toml")]
168            Self::Toml => "toml",
169            #[cfg(feature = "yaml")]
170            Self::Yaml => "yaml",
171            #[cfg(feature = "dotenv")]
172            Self::Dotenv => "dotenv",
173            #[cfg(feature = "ini")]
174            Self::Ini => "ini",
175            #[cfg(feature = "toml")]
176            Self::TomlFrontmatter => "toml-frontmatter",
177            #[cfg(feature = "yaml")]
178            Self::YamlFrontmatter => "yaml-frontmatter",
179            #[cfg(feature = "markdown")]
180            Self::Markdown => "markdown",
181            #[cfg(feature = "plist")]
182            Self::Plist => "plist",
183        }
184    }
185
186    /// The format a caller named, when this build can read it.
187    ///
188    /// The inverse of [`Format::cli_name`], plus the spellings a person is
189    /// likely to type for the same thing (`yml`, `env`). `None` covers both an
190    /// unknown name and a known one this build lacks a parser for — the caller
191    /// says which, since only it knows whether that distinction is worth a
192    /// different message.
193    #[must_use]
194    pub fn from_cli_name(name: &str) -> Option<Self> {
195        match name.to_ascii_lowercase().as_str() {
196            "json" => Some(Self::Json),
197            #[cfg(feature = "toml")]
198            "toml" => Some(Self::Toml),
199            #[cfg(feature = "yaml")]
200            "yaml" | "yml" => Some(Self::Yaml),
201            #[cfg(feature = "dotenv")]
202            "dotenv" | "env" => Some(Self::Dotenv),
203            #[cfg(feature = "ini")]
204            "ini" => Some(Self::Ini),
205            #[cfg(feature = "toml")]
206            "toml-frontmatter" => Some(Self::TomlFrontmatter),
207            #[cfg(feature = "yaml")]
208            "yaml-frontmatter" => Some(Self::YamlFrontmatter),
209            #[cfg(feature = "markdown")]
210            "markdown" => Some(Self::Markdown),
211            #[cfg(feature = "plist")]
212            "plist" => Some(Self::Plist),
213            _ => None,
214        }
215    }
216
217    /// Detect format from file extension, when this build can read it.
218    pub fn detect(path: &Path) -> Option<Self> {
219        match Self::extension_kind(path)? {
220            #[cfg(feature = "dotenv")]
221            "dotenv" => Some(Format::Dotenv),
222            "json" => Some(Format::Json),
223            #[cfg(feature = "toml")]
224            "toml" => Some(Format::Toml),
225            #[cfg(feature = "yaml")]
226            "yaml" => Some(Format::Yaml),
227            #[cfg(feature = "ini")]
228            "ini" => Some(Format::Ini),
229            #[cfg(feature = "plist")]
230            "plist" => Some(Format::Plist),
231            _ => None,
232        }
233    }
234
235    /// The Cargo feature a path's format needs, when this build lacks it.
236    ///
237    /// `detect` answers `None` both for a file this crate has never heard of
238    /// and for one it knows perfectly well but was not built to read. Only the
239    /// second is worth a different message, and only this can tell them apart,
240    /// so the caller reports the missing feature instead of calling a `.toml`
241    /// file's format unknown.
242    #[must_use]
243    pub fn unavailable(path: &Path) -> Option<&'static str> {
244        if Self::detect(path).is_some() {
245            return None;
246        }
247        match Self::extension_kind(path)? {
248            "dotenv" => Some("dotenv"),
249            "toml" => Some("toml"),
250            "yaml" => Some("yaml"),
251            "ini" => Some("ini"),
252            "plist" => Some("plist"),
253            // JSON is a core dependency; there is no feature to be missing.
254            _ => None,
255        }
256    }
257
258    /// The format family a path's name implies, independent of this build.
259    ///
260    /// Always compiled, for exactly the reason the enum is not: which format a
261    /// file is written in does not change with the features this binary chose.
262    fn extension_kind(path: &Path) -> Option<&'static str> {
263        let file_name = path.file_name().and_then(|name| name.to_str())?;
264        let file_name_lower = file_name.to_lowercase();
265        if file_name_lower == ".env"
266            || file_name_lower.starts_with(".env.")
267            || path
268                .extension()
269                .and_then(|ext| ext.to_str())
270                .is_some_and(|ext| ext.eq_ignore_ascii_case("env"))
271        {
272            return Some("dotenv");
273        }
274
275        match path
276            .extension()
277            .and_then(|ext| ext.to_str())?
278            .to_lowercase()
279            .as_str()
280        {
281            "json" => Some("json"),
282            "toml" => Some("toml"),
283            "yaml" | "yml" => Some("yaml"),
284            "ini" => Some("ini"),
285            "plist" => Some("plist"),
286            _ => None,
287        }
288    }
289
290    /// Load a config file in the detected format.
291    pub fn load(&self, content: &str) -> DocumentResult<Value> {
292        match self {
293            Format::Json => json::load(content),
294
295            #[cfg(feature = "toml")]
296            Format::Toml => toml::load(content),
297
298            #[cfg(feature = "yaml")]
299            Format::Yaml => yaml::load(content),
300
301            #[cfg(feature = "dotenv")]
302            Format::Dotenv => dotenv::load(content),
303
304            #[cfg(feature = "ini")]
305            Format::Ini => ini::load(content),
306
307            #[cfg(feature = "toml")]
308            Format::TomlFrontmatter => {
309                toml::load(frontmatter::split(content, frontmatter::Delimiter::Plus)?.frontmatter)
310            }
311
312            #[cfg(feature = "yaml")]
313            Format::YamlFrontmatter => {
314                yaml::load(frontmatter::split(content, frontmatter::Delimiter::Dash)?.frontmatter)
315            }
316
317            #[cfg(feature = "markdown")]
318            Format::Markdown => markdown::load(content),
319
320            #[cfg(feature = "plist")]
321            Format::Plist => plist::load(content),
322        }
323    }
324
325    /// Save a config in the target format.
326    pub fn save(&self, value: &Value) -> DocumentResult<String> {
327        match self {
328            Format::Json => json::save(value),
329
330            #[cfg(feature = "toml")]
331            Format::Toml => toml::save(value),
332
333            #[cfg(feature = "yaml")]
334            Format::Yaml => yaml::save(value),
335
336            #[cfg(feature = "dotenv")]
337            Format::Dotenv => dotenv::save(value),
338
339            #[cfg(feature = "ini")]
340            Format::Ini => ini::save(value),
341
342            // Frontmatter has no whole-document re-render: the Markdown body is
343            // frozen source, not part of the parsed value, so a fresh render
344            // cannot reconstruct the file. Edits go through the source-preserving
345            // set/unset seam (see `DocumentFile`), never here.
346            #[cfg(feature = "toml")]
347            Format::TomlFrontmatter => Err(Self::frontmatter_save_error()),
348            #[cfg(feature = "yaml")]
349            Format::YamlFrontmatter => Err(Self::frontmatter_save_error()),
350
351            // A read-only format has no writer at all — not even a
352            // non-preserving one. See `Format::is_read_only`.
353            #[cfg(feature = "markdown")]
354            Format::Markdown => Err(self.read_only_error("save")),
355            #[cfg(feature = "plist")]
356            Format::Plist => Err(self.read_only_error("save")),
357        }
358    }
359}
360
361#[cfg(feature = "dotenv")]
362pub use dotenv::load as load_dotenv;
363pub use json::{load as load_json, save as save_json};
364#[cfg(feature = "markdown")]
365pub use markdown::load as load_markdown;
366#[cfg(feature = "plist")]
367pub use plist::load as load_plist;
368#[cfg(feature = "toml")]
369pub use toml::{load as load_toml, save as save_toml};
370#[cfg(feature = "yaml")]
371pub use yaml::{load as load_yaml, save as save_yaml};
372
373// Every test here enumerates the whole format table, so the module says which
374// build it is describing rather than each test repeating the condition. A
375// narrowed build has fewer variants by design; the gate exercises the full one.
376#[cfg(all(
377    test,
378    feature = "toml",
379    feature = "yaml",
380    feature = "dotenv",
381    feature = "ini",
382    feature = "markdown"
383))]
384mod tests {
385    use super::Format;
386    use std::path::Path;
387
388    #[test]
389    fn format_names_are_stable() {
390        let cases = [
391            (Format::Json, "JSON"),
392            (Format::Toml, "TOML"),
393            (Format::Yaml, "YAML"),
394            (Format::Dotenv, "dotenv"),
395            (Format::Ini, "INI"),
396            (Format::TomlFrontmatter, "TOML frontmatter"),
397            (Format::YamlFrontmatter, "YAML frontmatter"),
398            (Format::Markdown, "Markdown"),
399        ];
400
401        for (format, expected) in cases {
402            assert_eq!(format.name(), expected);
403        }
404
405        let cli_names = [
406            (Format::Json, "json"),
407            (Format::Toml, "toml"),
408            (Format::Yaml, "yaml"),
409            (Format::Dotenv, "dotenv"),
410            (Format::Ini, "ini"),
411            (Format::TomlFrontmatter, "toml-frontmatter"),
412            (Format::YamlFrontmatter, "yaml-frontmatter"),
413            (Format::Markdown, "markdown"),
414        ];
415        for (format, expected) in cli_names {
416            assert_eq!(format.cli_name(), expected);
417        }
418    }
419
420    #[test]
421    fn markdown_is_the_only_read_only_format() {
422        for format in [
423            Format::Json,
424            Format::Toml,
425            Format::Yaml,
426            Format::Dotenv,
427            Format::Ini,
428            Format::TomlFrontmatter,
429            Format::YamlFrontmatter,
430        ] {
431            let name = format.name();
432            assert!(!format.is_read_only(), "{name} must stay writable");
433        }
434        assert!(Format::Markdown.is_read_only());
435    }
436
437    #[test]
438    fn markdown_is_never_detected_from_an_extension() {
439        // `.md` is genuinely ambiguous — the same file is readable as
440        // frontmatter — so it resolves to no format and the caller must say
441        // which reading it wants.
442        assert_eq!(Format::detect(Path::new("README.md")), None);
443        assert_eq!(Format::detect(Path::new("README.markdown")), None);
444    }
445}