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    /// Detect format from file extension, when this build can read it.
173    pub fn detect(path: &Path) -> Option<Self> {
174        match Self::extension_kind(path)? {
175            #[cfg(feature = "dotenv")]
176            "dotenv" => Some(Format::Dotenv),
177            "json" => Some(Format::Json),
178            #[cfg(feature = "toml")]
179            "toml" => Some(Format::Toml),
180            #[cfg(feature = "yaml")]
181            "yaml" => Some(Format::Yaml),
182            #[cfg(feature = "ini")]
183            "ini" => Some(Format::Ini),
184            _ => None,
185        }
186    }
187
188    /// The Cargo feature a path's format needs, when this build lacks it.
189    ///
190    /// `detect` answers `None` both for a file this crate has never heard of
191    /// and for one it knows perfectly well but was not built to read. Only the
192    /// second is worth a different message, and only this can tell them apart,
193    /// so the caller reports the missing feature instead of calling a `.toml`
194    /// file's format unknown.
195    #[must_use]
196    pub fn unavailable(path: &Path) -> Option<&'static str> {
197        if Self::detect(path).is_some() {
198            return None;
199        }
200        match Self::extension_kind(path)? {
201            "dotenv" => Some("dotenv"),
202            "toml" => Some("toml"),
203            "yaml" => Some("yaml"),
204            "ini" => Some("ini"),
205            // JSON is a core dependency; there is no feature to be missing.
206            _ => None,
207        }
208    }
209
210    /// The format family a path's name implies, independent of this build.
211    ///
212    /// Always compiled, for exactly the reason the enum is not: which format a
213    /// file is written in does not change with the features this binary chose.
214    fn extension_kind(path: &Path) -> Option<&'static str> {
215        let file_name = path.file_name().and_then(|name| name.to_str())?;
216        let file_name_lower = file_name.to_lowercase();
217        if file_name_lower == ".env"
218            || file_name_lower.starts_with(".env.")
219            || path
220                .extension()
221                .and_then(|ext| ext.to_str())
222                .is_some_and(|ext| ext.eq_ignore_ascii_case("env"))
223        {
224            return Some("dotenv");
225        }
226
227        match path
228            .extension()
229            .and_then(|ext| ext.to_str())?
230            .to_lowercase()
231            .as_str()
232        {
233            "json" => Some("json"),
234            "toml" => Some("toml"),
235            "yaml" | "yml" => Some("yaml"),
236            "ini" => Some("ini"),
237            _ => None,
238        }
239    }
240
241    /// Load a config file in the detected format.
242    pub fn load(&self, content: &str) -> DocumentResult<Value> {
243        match self {
244            Format::Json => json::load(content),
245
246            #[cfg(feature = "toml")]
247            Format::Toml => toml::load(content),
248
249            #[cfg(feature = "yaml")]
250            Format::Yaml => yaml::load(content),
251
252            #[cfg(feature = "dotenv")]
253            Format::Dotenv => dotenv::load(content),
254
255            #[cfg(feature = "ini")]
256            Format::Ini => ini::load(content),
257
258            #[cfg(feature = "toml")]
259            Format::TomlFrontmatter => {
260                toml::load(frontmatter::split(content, frontmatter::Delimiter::Plus)?.frontmatter)
261            }
262
263            #[cfg(feature = "yaml")]
264            Format::YamlFrontmatter => {
265                yaml::load(frontmatter::split(content, frontmatter::Delimiter::Dash)?.frontmatter)
266            }
267
268            #[cfg(feature = "markdown")]
269            Format::Markdown => markdown::load(content),
270        }
271    }
272
273    /// Save a config in the target format.
274    pub fn save(&self, value: &Value) -> DocumentResult<String> {
275        match self {
276            Format::Json => json::save(value),
277
278            #[cfg(feature = "toml")]
279            Format::Toml => toml::save(value),
280
281            #[cfg(feature = "yaml")]
282            Format::Yaml => yaml::save(value),
283
284            #[cfg(feature = "dotenv")]
285            Format::Dotenv => dotenv::save(value),
286
287            #[cfg(feature = "ini")]
288            Format::Ini => ini::save(value),
289
290            // Frontmatter has no whole-document re-render: the Markdown body is
291            // frozen source, not part of the parsed value, so a fresh render
292            // cannot reconstruct the file. Edits go through the source-preserving
293            // set/unset seam (see `DocumentFile`), never here.
294            #[cfg(feature = "toml")]
295            Format::TomlFrontmatter => Err(Self::frontmatter_save_error()),
296            #[cfg(feature = "yaml")]
297            Format::YamlFrontmatter => Err(Self::frontmatter_save_error()),
298
299            // A read-only format has no writer at all — not even a
300            // non-preserving one. See `Format::is_read_only`.
301            #[cfg(feature = "markdown")]
302            Format::Markdown => Err(self.read_only_error("save")),
303        }
304    }
305}
306
307#[cfg(feature = "dotenv")]
308pub use dotenv::load as load_dotenv;
309pub use json::{load as load_json, save as save_json};
310#[cfg(feature = "markdown")]
311pub use markdown::load as load_markdown;
312#[cfg(feature = "toml")]
313pub use toml::{load as load_toml, save as save_toml};
314#[cfg(feature = "yaml")]
315pub use yaml::{load as load_yaml, save as save_yaml};
316
317// Every test here enumerates the whole format table, so the module says which
318// build it is describing rather than each test repeating the condition. A
319// narrowed build has fewer variants by design; the gate exercises the full one.
320#[cfg(all(
321    test,
322    feature = "toml",
323    feature = "yaml",
324    feature = "dotenv",
325    feature = "ini",
326    feature = "markdown"
327))]
328mod tests {
329    use super::Format;
330    use std::path::Path;
331
332    #[test]
333    fn format_names_are_stable() {
334        let cases = [
335            (Format::Json, "JSON"),
336            (Format::Toml, "TOML"),
337            (Format::Yaml, "YAML"),
338            (Format::Dotenv, "dotenv"),
339            (Format::Ini, "INI"),
340            (Format::TomlFrontmatter, "TOML frontmatter"),
341            (Format::YamlFrontmatter, "YAML frontmatter"),
342            (Format::Markdown, "Markdown"),
343        ];
344
345        for (format, expected) in cases {
346            assert_eq!(format.name(), expected);
347        }
348
349        let cli_names = [
350            (Format::Json, "json"),
351            (Format::Toml, "toml"),
352            (Format::Yaml, "yaml"),
353            (Format::Dotenv, "dotenv"),
354            (Format::Ini, "ini"),
355            (Format::TomlFrontmatter, "toml-frontmatter"),
356            (Format::YamlFrontmatter, "yaml-frontmatter"),
357            (Format::Markdown, "markdown"),
358        ];
359        for (format, expected) in cli_names {
360            assert_eq!(format.cli_name(), expected);
361        }
362    }
363
364    #[test]
365    fn markdown_is_the_only_read_only_format() {
366        for format in [
367            Format::Json,
368            Format::Toml,
369            Format::Yaml,
370            Format::Dotenv,
371            Format::Ini,
372            Format::TomlFrontmatter,
373            Format::YamlFrontmatter,
374        ] {
375            let name = format.name();
376            assert!(!format.is_read_only(), "{name} must stay writable");
377        }
378        assert!(Format::Markdown.is_read_only());
379    }
380
381    #[test]
382    fn markdown_is_never_detected_from_an_extension() {
383        // `.md` is genuinely ambiguous — the same file is readable as
384        // frontmatter — so it resolves to no format and the caller must say
385        // which reading it wants.
386        assert_eq!(Format::detect(Path::new("README.md")), None);
387        assert_eq!(Format::detect(Path::new("README.markdown")), None);
388    }
389}