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