agent_first_data/document/format/
mod.rs1#[allow(unused_imports)]
4use crate::document::{DocumentError, DocumentResult, Value};
5use std::path::Path;
6
7#[cfg(feature = "dotenv")]
8pub mod dotenv;
9pub mod frontmatter;
12#[cfg(feature = "ini")]
13pub mod ini;
14pub mod json;
17#[cfg(feature = "markdown")]
18pub mod markdown;
19#[cfg(feature = "plist")]
20pub mod plist;
21#[cfg(feature = "toml")]
22pub mod toml;
23#[cfg(feature = "yaml")]
24pub mod yaml;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum Format {
36 Json,
37 #[cfg(feature = "toml")]
38 Toml,
39 #[cfg(feature = "yaml")]
40 Yaml,
41 #[cfg(feature = "dotenv")]
42 Dotenv,
43 #[cfg(feature = "ini")]
44 Ini,
45 #[cfg(feature = "toml")]
48 TomlFrontmatter,
49 #[cfg(feature = "yaml")]
52 YamlFrontmatter,
53 #[cfg(feature = "markdown")]
58 Markdown,
59 #[cfg(feature = "plist")]
62 Plist,
63}
64
65impl Format {
66 #[must_use]
68 pub const fn name(self) -> &'static str {
69 match self {
70 Self::Json => "JSON",
71 #[cfg(feature = "toml")]
72 Self::Toml => "TOML",
73 #[cfg(feature = "yaml")]
74 Self::Yaml => "YAML",
75 #[cfg(feature = "dotenv")]
76 Self::Dotenv => "dotenv",
77 #[cfg(feature = "ini")]
78 Self::Ini => "INI",
79 #[cfg(feature = "toml")]
80 Self::TomlFrontmatter => "TOML frontmatter",
81 #[cfg(feature = "yaml")]
82 Self::YamlFrontmatter => "YAML frontmatter",
83 #[cfg(feature = "markdown")]
84 Self::Markdown => "Markdown",
85 #[cfg(feature = "plist")]
86 Self::Plist => "plist",
87 }
88 }
89
90 #[must_use]
98 pub const fn is_read_only(self) -> bool {
99 match self {
100 #[cfg(feature = "markdown")]
101 Self::Markdown => true,
102 #[cfg(feature = "plist")]
103 Self::Plist => true,
104 #[allow(unreachable_patterns)]
105 _ => false,
106 }
107 }
108
109 #[must_use]
121 pub const fn array_rule(self) -> Option<crate::document::ArrayRule<'static>> {
122 match self {
123 #[cfg(feature = "markdown")]
124 Self::Markdown => Some(crate::document::ArrayRule {
125 field: "text",
126 match_kind: crate::document::MatchKind::Contains,
127 }),
128 _ => None,
129 }
130 }
131
132 pub(crate) fn read_only_error(self, operation: &str) -> DocumentError {
136 DocumentError::UnsupportedOperation {
137 format: self.name().to_string(),
138 operation: operation.to_string(),
139 detail: format!(
140 "{} is a read-only format: afdata reads its structure and never writes it",
141 self.name()
142 ),
143 }
144 }
145
146 #[cfg(any(feature = "toml", feature = "yaml"))]
150 fn frontmatter_save_error() -> DocumentError {
151 DocumentError::UnsupportedOperation {
152 format: "frontmatter".to_string(),
153 operation: "save".to_string(),
154 detail: "frontmatter mode has no whole-document re-render; the Markdown body is not \
155 part of the parsed value — use source-preserving set/unset"
156 .to_string(),
157 }
158 }
159
160 #[must_use]
164 pub const fn cli_name(self) -> &'static str {
165 match self {
166 Self::Json => "json",
167 #[cfg(feature = "toml")]
168 Self::Toml => "toml",
169 #[cfg(feature = "yaml")]
170 Self::Yaml => "yaml",
171 #[cfg(feature = "dotenv")]
172 Self::Dotenv => "dotenv",
173 #[cfg(feature = "ini")]
174 Self::Ini => "ini",
175 #[cfg(feature = "toml")]
176 Self::TomlFrontmatter => "toml-frontmatter",
177 #[cfg(feature = "yaml")]
178 Self::YamlFrontmatter => "yaml-frontmatter",
179 #[cfg(feature = "markdown")]
180 Self::Markdown => "markdown",
181 #[cfg(feature = "plist")]
182 Self::Plist => "plist",
183 }
184 }
185
186 #[must_use]
194 pub fn from_cli_name(name: &str) -> Option<Self> {
195 match name.to_ascii_lowercase().as_str() {
196 "json" => Some(Self::Json),
197 #[cfg(feature = "toml")]
198 "toml" => Some(Self::Toml),
199 #[cfg(feature = "yaml")]
200 "yaml" | "yml" => Some(Self::Yaml),
201 #[cfg(feature = "dotenv")]
202 "dotenv" | "env" => Some(Self::Dotenv),
203 #[cfg(feature = "ini")]
204 "ini" => Some(Self::Ini),
205 #[cfg(feature = "toml")]
206 "toml-frontmatter" => Some(Self::TomlFrontmatter),
207 #[cfg(feature = "yaml")]
208 "yaml-frontmatter" => Some(Self::YamlFrontmatter),
209 #[cfg(feature = "markdown")]
210 "markdown" => Some(Self::Markdown),
211 #[cfg(feature = "plist")]
212 "plist" => Some(Self::Plist),
213 _ => None,
214 }
215 }
216
217 pub fn detect(path: &Path) -> Option<Self> {
219 match Self::extension_kind(path)? {
220 #[cfg(feature = "dotenv")]
221 "dotenv" => Some(Format::Dotenv),
222 "json" => Some(Format::Json),
223 #[cfg(feature = "toml")]
224 "toml" => Some(Format::Toml),
225 #[cfg(feature = "yaml")]
226 "yaml" => Some(Format::Yaml),
227 #[cfg(feature = "ini")]
228 "ini" => Some(Format::Ini),
229 #[cfg(feature = "plist")]
230 "plist" => Some(Format::Plist),
231 _ => None,
232 }
233 }
234
235 #[must_use]
243 pub fn unavailable(path: &Path) -> Option<&'static str> {
244 if Self::detect(path).is_some() {
245 return None;
246 }
247 match Self::extension_kind(path)? {
248 "dotenv" => Some("dotenv"),
249 "toml" => Some("toml"),
250 "yaml" => Some("yaml"),
251 "ini" => Some("ini"),
252 "plist" => Some("plist"),
253 _ => None,
255 }
256 }
257
258 fn extension_kind(path: &Path) -> Option<&'static str> {
263 let file_name = path.file_name().and_then(|name| name.to_str())?;
264 let file_name_lower = file_name.to_lowercase();
265 if file_name_lower == ".env"
266 || file_name_lower.starts_with(".env.")
267 || path
268 .extension()
269 .and_then(|ext| ext.to_str())
270 .is_some_and(|ext| ext.eq_ignore_ascii_case("env"))
271 {
272 return Some("dotenv");
273 }
274
275 match path
276 .extension()
277 .and_then(|ext| ext.to_str())?
278 .to_lowercase()
279 .as_str()
280 {
281 "json" => Some("json"),
282 "toml" => Some("toml"),
283 "yaml" | "yml" => Some("yaml"),
284 "ini" => Some("ini"),
285 "plist" => Some("plist"),
286 _ => None,
287 }
288 }
289
290 pub fn load(&self, content: &str) -> DocumentResult<Value> {
292 match self {
293 Format::Json => json::load(content),
294
295 #[cfg(feature = "toml")]
296 Format::Toml => toml::load(content),
297
298 #[cfg(feature = "yaml")]
299 Format::Yaml => yaml::load(content),
300
301 #[cfg(feature = "dotenv")]
302 Format::Dotenv => dotenv::load(content),
303
304 #[cfg(feature = "ini")]
305 Format::Ini => ini::load(content),
306
307 #[cfg(feature = "toml")]
308 Format::TomlFrontmatter => {
309 toml::load(frontmatter::split(content, frontmatter::Delimiter::Plus)?.frontmatter)
310 }
311
312 #[cfg(feature = "yaml")]
313 Format::YamlFrontmatter => {
314 yaml::load(frontmatter::split(content, frontmatter::Delimiter::Dash)?.frontmatter)
315 }
316
317 #[cfg(feature = "markdown")]
318 Format::Markdown => markdown::load(content),
319
320 #[cfg(feature = "plist")]
321 Format::Plist => plist::load(content),
322 }
323 }
324
325 pub fn save(&self, value: &Value) -> DocumentResult<String> {
327 match self {
328 Format::Json => json::save(value),
329
330 #[cfg(feature = "toml")]
331 Format::Toml => toml::save(value),
332
333 #[cfg(feature = "yaml")]
334 Format::Yaml => yaml::save(value),
335
336 #[cfg(feature = "dotenv")]
337 Format::Dotenv => dotenv::save(value),
338
339 #[cfg(feature = "ini")]
340 Format::Ini => ini::save(value),
341
342 #[cfg(feature = "toml")]
347 Format::TomlFrontmatter => Err(Self::frontmatter_save_error()),
348 #[cfg(feature = "yaml")]
349 Format::YamlFrontmatter => Err(Self::frontmatter_save_error()),
350
351 #[cfg(feature = "markdown")]
354 Format::Markdown => Err(self.read_only_error("save")),
355 #[cfg(feature = "plist")]
356 Format::Plist => Err(self.read_only_error("save")),
357 }
358 }
359}
360
361#[cfg(feature = "dotenv")]
362pub use dotenv::load as load_dotenv;
363pub use json::{load as load_json, save as save_json};
364#[cfg(feature = "markdown")]
365pub use markdown::load as load_markdown;
366#[cfg(feature = "plist")]
367pub use plist::load as load_plist;
368#[cfg(feature = "toml")]
369pub use toml::{load as load_toml, save as save_toml};
370#[cfg(feature = "yaml")]
371pub use yaml::{load as load_yaml, save as save_yaml};
372
373#[cfg(all(
377 test,
378 feature = "toml",
379 feature = "yaml",
380 feature = "dotenv",
381 feature = "ini",
382 feature = "markdown"
383))]
384mod tests {
385 use super::Format;
386 use std::path::Path;
387
388 #[test]
389 fn format_names_are_stable() {
390 let cases = [
391 (Format::Json, "JSON"),
392 (Format::Toml, "TOML"),
393 (Format::Yaml, "YAML"),
394 (Format::Dotenv, "dotenv"),
395 (Format::Ini, "INI"),
396 (Format::TomlFrontmatter, "TOML frontmatter"),
397 (Format::YamlFrontmatter, "YAML frontmatter"),
398 (Format::Markdown, "Markdown"),
399 ];
400
401 for (format, expected) in cases {
402 assert_eq!(format.name(), expected);
403 }
404
405 let cli_names = [
406 (Format::Json, "json"),
407 (Format::Toml, "toml"),
408 (Format::Yaml, "yaml"),
409 (Format::Dotenv, "dotenv"),
410 (Format::Ini, "ini"),
411 (Format::TomlFrontmatter, "toml-frontmatter"),
412 (Format::YamlFrontmatter, "yaml-frontmatter"),
413 (Format::Markdown, "markdown"),
414 ];
415 for (format, expected) in cli_names {
416 assert_eq!(format.cli_name(), expected);
417 }
418 }
419
420 #[test]
421 fn markdown_is_the_only_read_only_format() {
422 for format in [
423 Format::Json,
424 Format::Toml,
425 Format::Yaml,
426 Format::Dotenv,
427 Format::Ini,
428 Format::TomlFrontmatter,
429 Format::YamlFrontmatter,
430 ] {
431 let name = format.name();
432 assert!(!format.is_read_only(), "{name} must stay writable");
433 }
434 assert!(Format::Markdown.is_read_only());
435 }
436
437 #[test]
438 fn markdown_is_never_detected_from_an_extension() {
439 assert_eq!(Format::detect(Path::new("README.md")), None);
443 assert_eq!(Format::detect(Path::new("README.markdown")), None);
444 }
445}