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