1use std::fmt;
6use std::path::{Path, PathBuf};
7
8use crate::ConfigValue;
9use anyhow::{Context, bail};
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum Format {
14 Json,
15 Toml,
16}
17
18impl Format {
19 pub fn from_path(path: &Path) -> Option<Self> {
22 let ext = path.extension()?.to_str()?;
23 if ext.eq_ignore_ascii_case("json") {
24 Some(Self::Json)
25 } else if ext.eq_ignore_ascii_case("toml") {
26 Some(Self::Toml)
27 } else {
28 None
29 }
30 }
31
32 pub fn extension(self) -> &'static str {
34 match self {
35 Self::Json => "json",
36 Self::Toml => "toml",
37 }
38 }
39}
40
41impl fmt::Display for Format {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 f.write_str(self.extension())
44 }
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
49pub enum SourceName {
50 File(PathBuf),
51 Stdin,
52}
53
54impl fmt::Display for SourceName {
55 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
56 match self {
57 Self::File(p) => write!(f, "{}", p.display()),
58 Self::Stdin => f.write_str("<stdin>"),
59 }
60 }
61}
62
63mod private {
64 pub trait Sealed {}
65 impl Sealed for serde_json::Value {}
66 impl Sealed for toml::Value {}
67}
68
69pub trait ConfigFormat: ConfigValue + private::Sealed {
73 const FORMAT: Format;
75 fn parse_document(text: &str) -> anyhow::Result<Self>;
77 fn parse_inline(text: String) -> Self;
79 fn serialize(&self, pretty: bool) -> anyhow::Result<String>;
81}
82
83impl ConfigFormat for serde_json::Value {
84 const FORMAT: Format = Format::Json;
85 fn parse_document(text: &str) -> anyhow::Result<Self> {
86 Ok(serde_json::from_str(text)?)
87 }
88 fn parse_inline(text: String) -> Self {
89 crate::inline::json_or_string(text)
90 }
91 fn serialize(&self, pretty: bool) -> anyhow::Result<String> {
92 Ok(if pretty {
93 serde_json::to_string_pretty(self)?
94 } else {
95 serde_json::to_string(self)?
96 })
97 }
98}
99
100impl ConfigFormat for toml::Value {
101 const FORMAT: Format = Format::Toml;
102 fn parse_document(text: &str) -> anyhow::Result<Self> {
103 Ok(toml::from_str(text)?)
104 }
105 fn parse_inline(text: String) -> Self {
106 crate::inline::toml_or_string(text)
107 }
108 fn serialize(&self, pretty: bool) -> anyhow::Result<String> {
109 Ok(if pretty {
110 toml::to_string_pretty(self)?
111 } else {
112 toml::to_string(self)?
113 })
114 }
115}
116
117pub fn parse<V: ConfigFormat>(text: &str, source: &SourceName) -> anyhow::Result<V> {
119 let label = match V::FORMAT {
120 Format::Json => "JSON",
121 Format::Toml => "TOML",
122 };
123 let value = V::parse_document(text).with_context(|| format!("{source}: invalid {label}"))?;
124 if value.as_object().is_none() {
125 bail!(
126 "{source}: expected an object at the top level, found {}",
127 value.kind()
128 );
129 }
130 Ok(value)
131}
132
133pub fn emit<V: ConfigFormat>(value: V, pretty: bool) -> anyhow::Result<String> {
135 Ok(ensure_trailing_newline(value.serialize(pretty)?))
136}
137
138fn ensure_trailing_newline(mut s: String) -> String {
139 if !s.ends_with('\n') {
140 s.push('\n');
141 }
142 s
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn extension_inference() {
151 assert_eq!(Format::from_path(Path::new("a.json")), Some(Format::Json));
152 assert_eq!(Format::from_path(Path::new("a.TOML")), Some(Format::Toml));
153 assert_eq!(Format::from_path(Path::new("a.yaml")), None);
154 assert_eq!(Format::from_path(Path::new("a")), None);
155 }
156
157 #[test]
158 fn top_level_must_be_an_object() {
159 let err = parse::<serde_json::Value>("[1,2]", &SourceName::Stdin).unwrap_err();
160 assert!(err.to_string().contains("found array"), "{err}");
161 }
162}