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