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 = "toml")]
18pub mod toml;
19#[cfg(feature = "yaml")]
20pub mod yaml;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum Format {
24    Json,
25    Toml,
26    Yaml,
27    Dotenv,
28    Ini,
29    /// A `+++`-fenced TOML frontmatter block; the Markdown body is frozen. Never
30    /// auto-detected — selected only via `--input-format toml-frontmatter`.
31    TomlFrontmatter,
32    /// A `---`-fenced YAML frontmatter block; the Markdown body is frozen. Never
33    /// auto-detected — selected only via `--input-format yaml-frontmatter`.
34    YamlFrontmatter,
35}
36
37impl Format {
38    /// Stable human-readable label used in document results and diagnostics.
39    #[must_use]
40    pub const fn name(self) -> &'static str {
41        match self {
42            Self::Json => "JSON",
43            Self::Toml => "TOML",
44            Self::Yaml => "YAML",
45            Self::Dotenv => "dotenv",
46            Self::Ini => "INI",
47            Self::TomlFrontmatter => "TOML frontmatter",
48            Self::YamlFrontmatter => "YAML frontmatter",
49        }
50    }
51
52    /// Exact CLI token accepted by `--input-format` and emitted in result
53    /// payloads. Unlike [`Self::name`], this is stable machine data rather than
54    /// a display label.
55    #[must_use]
56    pub const fn cli_name(self) -> &'static str {
57        match self {
58            Self::Json => "json",
59            Self::Toml => "toml",
60            Self::Yaml => "yaml",
61            Self::Dotenv => "dotenv",
62            Self::Ini => "ini",
63            Self::TomlFrontmatter => "toml-frontmatter",
64            Self::YamlFrontmatter => "yaml-frontmatter",
65        }
66    }
67
68    /// Detect format from file extension.
69    pub fn detect(path: &Path) -> Option<Self> {
70        let file_name = path.file_name().and_then(|name| name.to_str())?;
71        let file_name_lower = file_name.to_lowercase();
72        if file_name_lower == ".env"
73            || file_name_lower.starts_with(".env.")
74            || path
75                .extension()
76                .and_then(|ext| ext.to_str())
77                .is_some_and(|ext| ext.eq_ignore_ascii_case("env"))
78        {
79            return Some(Format::Dotenv);
80        }
81
82        path.extension().and_then(|ext| ext.to_str()).and_then(|s| {
83            match s.to_lowercase().as_str() {
84                "json" => Some(Format::Json),
85                "toml" => Some(Format::Toml),
86                "yaml" | "yml" => Some(Format::Yaml),
87                "ini" => Some(Format::Ini),
88                _ => None,
89            }
90        })
91    }
92
93    /// Load a config file in the detected format.
94    pub fn load(&self, content: &str) -> DocumentResult<Value> {
95        match self {
96            Format::Json => json::load(content),
97
98            #[cfg(feature = "toml")]
99            Format::Toml => toml::load(content),
100            #[cfg(not(feature = "toml"))]
101            Format::Toml => Err(DocumentError::UnsupportedOperation {
102                format: "TOML".to_string(),
103                operation: "load".to_string(),
104                detail: "requires Cargo feature `toml`".to_string(),
105            }),
106
107            #[cfg(feature = "yaml")]
108            Format::Yaml => yaml::load(content),
109            #[cfg(not(feature = "yaml"))]
110            Format::Yaml => Err(DocumentError::UnsupportedOperation {
111                format: "YAML".to_string(),
112                operation: "load".to_string(),
113                detail: "requires Cargo feature `yaml`".to_string(),
114            }),
115
116            #[cfg(feature = "dotenv")]
117            Format::Dotenv => dotenv::load(content),
118            #[cfg(not(feature = "dotenv"))]
119            Format::Dotenv => Err(DocumentError::UnsupportedOperation {
120                format: "dotenv".to_string(),
121                operation: "load".to_string(),
122                detail: "requires Cargo feature `dotenv`".to_string(),
123            }),
124
125            #[cfg(feature = "ini")]
126            Format::Ini => ini::load(content),
127            #[cfg(not(feature = "ini"))]
128            Format::Ini => Err(DocumentError::UnsupportedOperation {
129                format: "INI".to_string(),
130                operation: "load".to_string(),
131                detail: "requires Cargo feature `ini`".to_string(),
132            }),
133
134            #[cfg(feature = "toml")]
135            Format::TomlFrontmatter => {
136                toml::load(frontmatter::split(content, frontmatter::Delimiter::Plus)?.frontmatter)
137            }
138            #[cfg(not(feature = "toml"))]
139            Format::TomlFrontmatter => Err(DocumentError::UnsupportedOperation {
140                format: "TOML frontmatter".to_string(),
141                operation: "load".to_string(),
142                detail: "requires Cargo feature `toml`".to_string(),
143            }),
144
145            #[cfg(feature = "yaml")]
146            Format::YamlFrontmatter => {
147                yaml::load(frontmatter::split(content, frontmatter::Delimiter::Dash)?.frontmatter)
148            }
149            #[cfg(not(feature = "yaml"))]
150            Format::YamlFrontmatter => Err(DocumentError::UnsupportedOperation {
151                format: "YAML frontmatter".to_string(),
152                operation: "load".to_string(),
153                detail: "requires Cargo feature `yaml`".to_string(),
154            }),
155        }
156    }
157
158    /// Save a config in the target format.
159    pub fn save(&self, value: &Value) -> DocumentResult<String> {
160        match self {
161            Format::Json => json::save(value),
162
163            #[cfg(feature = "toml")]
164            Format::Toml => toml::save(value),
165            #[cfg(not(feature = "toml"))]
166            Format::Toml => Err(DocumentError::UnsupportedOperation {
167                format: "TOML".to_string(),
168                operation: "save".to_string(),
169                detail: "requires Cargo feature `toml`".to_string(),
170            }),
171
172            #[cfg(feature = "yaml")]
173            Format::Yaml => yaml::save(value),
174            #[cfg(not(feature = "yaml"))]
175            Format::Yaml => Err(DocumentError::UnsupportedOperation {
176                format: "YAML".to_string(),
177                operation: "save".to_string(),
178                detail: "requires Cargo feature `yaml`".to_string(),
179            }),
180
181            #[cfg(feature = "dotenv")]
182            Format::Dotenv => dotenv::save(value),
183            #[cfg(not(feature = "dotenv"))]
184            Format::Dotenv => Err(DocumentError::UnsupportedOperation {
185                format: "dotenv".to_string(),
186                operation: "save".to_string(),
187                detail: "requires Cargo feature `dotenv`".to_string(),
188            }),
189
190            #[cfg(feature = "ini")]
191            Format::Ini => ini::save(value),
192            #[cfg(not(feature = "ini"))]
193            Format::Ini => Err(DocumentError::UnsupportedOperation {
194                format: "INI".to_string(),
195                operation: "save".to_string(),
196                detail: "requires Cargo feature `ini`".to_string(),
197            }),
198
199            // Frontmatter has no whole-document re-render: the Markdown body is
200            // frozen source, not part of the parsed value, so a fresh render
201            // cannot reconstruct the file. Edits go through the source-preserving
202            // set/unset seam (see `DocumentFile`), never here.
203            Format::TomlFrontmatter | Format::YamlFrontmatter => {
204                Err(DocumentError::UnsupportedOperation {
205                    format: "frontmatter".to_string(),
206                    operation: "save".to_string(),
207                    detail:
208                        "frontmatter mode has no whole-document re-render; the Markdown body is \
209                             not part of the parsed value — use source-preserving set/unset"
210                            .to_string(),
211                })
212            }
213        }
214    }
215}
216
217#[cfg(feature = "dotenv")]
218pub use dotenv::load as load_dotenv;
219pub use json::{load as load_json, save as save_json};
220#[cfg(feature = "toml")]
221pub use toml::{load as load_toml, save as save_toml};
222#[cfg(feature = "yaml")]
223pub use yaml::{load as load_yaml, save as save_yaml};
224
225#[cfg(test)]
226mod tests {
227    use super::Format;
228
229    #[test]
230    fn format_names_are_stable() {
231        let cases = [
232            (Format::Json, "JSON"),
233            (Format::Toml, "TOML"),
234            (Format::Yaml, "YAML"),
235            (Format::Dotenv, "dotenv"),
236            (Format::Ini, "INI"),
237            (Format::TomlFrontmatter, "TOML frontmatter"),
238            (Format::YamlFrontmatter, "YAML frontmatter"),
239        ];
240
241        for (format, expected) in cases {
242            assert_eq!(format.name(), expected);
243        }
244
245        let cli_names = [
246            (Format::Json, "json"),
247            (Format::Toml, "toml"),
248            (Format::Yaml, "yaml"),
249            (Format::Dotenv, "dotenv"),
250            (Format::Ini, "ini"),
251            (Format::TomlFrontmatter, "toml-frontmatter"),
252            (Format::YamlFrontmatter, "yaml-frontmatter"),
253        ];
254        for (format, expected) in cli_names {
255            assert_eq!(format.cli_name(), expected);
256        }
257    }
258}