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    /// Detect format from file extension.
53    pub fn detect(path: &Path) -> Option<Self> {
54        let file_name = path.file_name().and_then(|name| name.to_str())?;
55        let file_name_lower = file_name.to_lowercase();
56        if file_name_lower == ".env"
57            || file_name_lower.starts_with(".env.")
58            || path
59                .extension()
60                .and_then(|ext| ext.to_str())
61                .is_some_and(|ext| ext.eq_ignore_ascii_case("env"))
62        {
63            return Some(Format::Dotenv);
64        }
65
66        path.extension().and_then(|ext| ext.to_str()).and_then(|s| {
67            match s.to_lowercase().as_str() {
68                "json" => Some(Format::Json),
69                "toml" => Some(Format::Toml),
70                "yaml" | "yml" => Some(Format::Yaml),
71                "ini" => Some(Format::Ini),
72                _ => None,
73            }
74        })
75    }
76
77    /// Load a config file in the detected format.
78    pub fn load(&self, content: &str) -> DocumentResult<Value> {
79        match self {
80            Format::Json => json::load(content),
81
82            #[cfg(feature = "toml")]
83            Format::Toml => toml::load(content),
84            #[cfg(not(feature = "toml"))]
85            Format::Toml => Err(DocumentError::UnsupportedOperation {
86                format: "TOML".to_string(),
87                operation: "load".to_string(),
88                detail: "requires Cargo feature `toml`".to_string(),
89            }),
90
91            #[cfg(feature = "yaml")]
92            Format::Yaml => yaml::load(content),
93            #[cfg(not(feature = "yaml"))]
94            Format::Yaml => Err(DocumentError::UnsupportedOperation {
95                format: "YAML".to_string(),
96                operation: "load".to_string(),
97                detail: "requires Cargo feature `yaml`".to_string(),
98            }),
99
100            #[cfg(feature = "dotenv")]
101            Format::Dotenv => dotenv::load(content),
102            #[cfg(not(feature = "dotenv"))]
103            Format::Dotenv => Err(DocumentError::UnsupportedOperation {
104                format: "dotenv".to_string(),
105                operation: "load".to_string(),
106                detail: "requires Cargo feature `dotenv`".to_string(),
107            }),
108
109            #[cfg(feature = "ini")]
110            Format::Ini => ini::load(content),
111            #[cfg(not(feature = "ini"))]
112            Format::Ini => Err(DocumentError::UnsupportedOperation {
113                format: "INI".to_string(),
114                operation: "load".to_string(),
115                detail: "requires Cargo feature `ini`".to_string(),
116            }),
117
118            #[cfg(feature = "toml")]
119            Format::TomlFrontmatter => {
120                toml::load(frontmatter::split(content, frontmatter::Delimiter::Plus)?.frontmatter)
121            }
122            #[cfg(not(feature = "toml"))]
123            Format::TomlFrontmatter => Err(DocumentError::UnsupportedOperation {
124                format: "TOML frontmatter".to_string(),
125                operation: "load".to_string(),
126                detail: "requires Cargo feature `toml`".to_string(),
127            }),
128
129            #[cfg(feature = "yaml")]
130            Format::YamlFrontmatter => {
131                yaml::load(frontmatter::split(content, frontmatter::Delimiter::Dash)?.frontmatter)
132            }
133            #[cfg(not(feature = "yaml"))]
134            Format::YamlFrontmatter => Err(DocumentError::UnsupportedOperation {
135                format: "YAML frontmatter".to_string(),
136                operation: "load".to_string(),
137                detail: "requires Cargo feature `yaml`".to_string(),
138            }),
139        }
140    }
141
142    /// Save a config in the target format.
143    pub fn save(&self, value: &Value) -> DocumentResult<String> {
144        match self {
145            Format::Json => json::save(value),
146
147            #[cfg(feature = "toml")]
148            Format::Toml => toml::save(value),
149            #[cfg(not(feature = "toml"))]
150            Format::Toml => Err(DocumentError::UnsupportedOperation {
151                format: "TOML".to_string(),
152                operation: "save".to_string(),
153                detail: "requires Cargo feature `toml`".to_string(),
154            }),
155
156            #[cfg(feature = "yaml")]
157            Format::Yaml => yaml::save(value),
158            #[cfg(not(feature = "yaml"))]
159            Format::Yaml => Err(DocumentError::UnsupportedOperation {
160                format: "YAML".to_string(),
161                operation: "save".to_string(),
162                detail: "requires Cargo feature `yaml`".to_string(),
163            }),
164
165            #[cfg(feature = "dotenv")]
166            Format::Dotenv => dotenv::save(value),
167            #[cfg(not(feature = "dotenv"))]
168            Format::Dotenv => Err(DocumentError::UnsupportedOperation {
169                format: "dotenv".to_string(),
170                operation: "save".to_string(),
171                detail: "requires Cargo feature `dotenv`".to_string(),
172            }),
173
174            #[cfg(feature = "ini")]
175            Format::Ini => ini::save(value),
176            #[cfg(not(feature = "ini"))]
177            Format::Ini => Err(DocumentError::UnsupportedOperation {
178                format: "INI".to_string(),
179                operation: "save".to_string(),
180                detail: "requires Cargo feature `ini`".to_string(),
181            }),
182
183            // Frontmatter has no whole-document re-render: the Markdown body is
184            // frozen source, not part of the parsed value, so a fresh render
185            // cannot reconstruct the file. Edits go through the source-preserving
186            // set/unset seam (see `DocumentFile`), never here.
187            Format::TomlFrontmatter | Format::YamlFrontmatter => {
188                Err(DocumentError::UnsupportedOperation {
189                    format: "frontmatter".to_string(),
190                    operation: "save".to_string(),
191                    detail:
192                        "frontmatter mode has no whole-document re-render; the Markdown body is \
193                             not part of the parsed value — use source-preserving set/unset"
194                            .to_string(),
195                })
196            }
197        }
198    }
199
200    /// Reject mutation before a backend-specific value is changed or written.
201    pub fn ensure_writable(&self, _operation: &str) -> DocumentResult<()> {
202        Ok(())
203    }
204}
205
206#[cfg(feature = "dotenv")]
207pub use dotenv::load as load_dotenv;
208pub use json::{load as load_json, save as save_json};
209#[cfg(feature = "toml")]
210pub use toml::{load as load_toml, save as save_toml};
211#[cfg(feature = "yaml")]
212pub use yaml::{load as load_yaml, save as save_yaml};
213
214#[cfg(test)]
215mod tests {
216    use super::Format;
217
218    #[test]
219    fn format_names_are_stable() {
220        let cases = [
221            (Format::Json, "JSON"),
222            (Format::Toml, "TOML"),
223            (Format::Yaml, "YAML"),
224            (Format::Dotenv, "dotenv"),
225            (Format::Ini, "INI"),
226            (Format::TomlFrontmatter, "TOML frontmatter"),
227            (Format::YamlFrontmatter, "YAML frontmatter"),
228        ];
229
230        for (format, expected) in cases {
231            assert_eq!(format.name(), expected);
232        }
233    }
234}