1use eyre::Result;
33use serde::Deserialize;
34use std::{
35 collections::HashMap,
36 fmt,
37 path::{Path, PathBuf},
38};
39use thiserror::Error;
40use tracing::error;
41
42const CONFIG: &str = include_str!("../.config/config.yaml");
43
44#[derive(Debug, Error)]
47#[non_exhaustive]
48pub enum ConfigError {
49 #[error("failed to parse config: {}", source)]
51 Parse {
52 #[from]
54 source: config::ConfigError,
55 },
56 #[error("failed to parse config: {}", source)]
58 Builder {
59 #[from]
61 source: ConfigBuilderError,
62 },
63}
64
65impl ConfigError {
66 pub fn parse(source: config::ConfigError) -> Self {
68 ConfigError::Parse { source }
69 }
70 pub fn builder(source: ConfigBuilderError) -> Self {
72 ConfigError::Builder { source }
73 }
74}
75
76#[derive(Error)]
78#[non_exhaustive]
79pub enum ConfigBuilderError {
80 #[error("failed to parse file {path:?}: {source}")]
82 FileParse {
83 source: Box<config::ConfigError>,
85 builder: ConfigBuilder,
87 path: PathBuf,
89 },
90 #[error("failed to deserialize config {path:?}: {source}")]
92 ConfigDeserialize {
93 source: Box<config::ConfigError>,
95 builder: ConfigBuilder,
97 path: PathBuf,
99 },
100}
101#[derive(Clone, Debug, Default, Deserialize)]
106pub struct ViewConfig {
107 #[serde(default)]
109 pub default_fields: Vec<String>,
110 #[serde(default)]
112 pub fields: Vec<FieldConfig>,
113 #[serde(default)]
115 pub wide: Option<bool>,
116}
117
118#[derive(Clone, Debug, Default, Deserialize, Eq, Ord, PartialOrd, PartialEq)]
120pub struct FieldConfig {
121 pub name: String,
123 #[serde(default)]
125 pub width: Option<usize>,
126 #[serde(default)]
128 pub min_width: Option<usize>,
129 #[serde(default)]
131 pub max_width: Option<usize>,
132 #[serde(default)]
135 pub json_pointer: Option<String>,
136}
137
138const fn _default_true() -> bool {
139 true
140}
141
142#[derive(Clone, Debug, Default, Deserialize)]
144pub struct Config {
145 #[serde(default)]
148 pub views: HashMap<String, ViewConfig>,
149 #[serde(default)]
151 pub command_hints: HashMap<String, HashMap<String, Vec<String>>>,
152 #[serde(default)]
154 pub hints: Vec<String>,
155 #[serde(default = "_default_true")]
157 pub enable_hints: bool,
158}
159
160pub struct ConfigBuilder {
162 sources: Vec<config::Config>,
164}
165
166impl ConfigBuilder {
167 pub fn add_source(mut self, source: impl AsRef<Path>) -> Result<Self, ConfigBuilderError> {
172 let config = match config::Config::builder()
173 .add_source(config::File::from(source.as_ref()))
174 .build()
175 {
176 Ok(config) => config,
177 Err(error) => {
178 return Err(ConfigBuilderError::FileParse {
179 source: Box::new(error),
180 builder: self,
181 path: source.as_ref().to_owned(),
182 });
183 }
184 };
185
186 if let Err(error) = config.clone().try_deserialize::<Config>() {
187 return Err(ConfigBuilderError::ConfigDeserialize {
188 source: Box::new(error),
189 builder: self,
190 path: source.as_ref().to_owned(),
191 });
192 }
193
194 self.sources.push(config);
195 Ok(self)
196 }
197
198 pub fn build(self) -> Result<Config, ConfigError> {
201 let mut config = config::Config::builder();
202
203 for source in self.sources {
204 config = config.add_source(source);
205 }
206
207 Ok(config.build()?.try_deserialize::<Config>()?)
208 }
209}
210
211impl fmt::Debug for ConfigBuilderError {
212 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213 match self {
214 ConfigBuilderError::FileParse { source, path, .. } => f
215 .debug_struct("FileParse")
216 .field("source", source)
217 .field("path", path)
218 .finish_non_exhaustive(),
219 ConfigBuilderError::ConfigDeserialize { source, path, .. } => f
220 .debug_struct("ConfigDeserialize")
221 .field("source", source)
222 .field("path", path)
223 .finish_non_exhaustive(),
224 }
225 }
226}
227
228impl Config {
229 pub fn builder() -> ConfigBuilder {
231 let default_config: config::Config = config::Config::builder()
232 .add_source(config::File::from_str(CONFIG, config::FileFormat::Yaml))
233 .build()
234 .expect("default config must be valid");
235
236 ConfigBuilder {
237 sources: Vec::from([default_config]),
238 }
239 }
240
241 pub fn new() -> Result<Self, ConfigError> {
243 let default_config: config::Config = config::Config::builder()
244 .add_source(config::File::from_str(CONFIG, config::FileFormat::Yaml))
245 .build()?;
246
247 let config_dir = get_config_dir();
248 let mut builder = ConfigBuilder {
249 sources: Vec::from([default_config]),
250 };
251
252 let config_files = [
253 ("config.yaml", config::FileFormat::Yaml),
254 ("views.yaml", config::FileFormat::Yaml),
255 ];
256 let mut found_config = false;
257 for (file, _format) in &config_files {
258 if config_dir.join(file).exists() {
259 found_config = true;
260
261 builder = match builder.add_source(config_dir.join(file)) {
262 Ok(builder) => builder,
263 Err(ConfigBuilderError::FileParse { source, .. }) => {
264 return Err(ConfigError::parse(*source));
265 }
266 Err(ConfigBuilderError::ConfigDeserialize {
267 source,
268 builder,
269 path,
270 }) => {
271 error!(
272 "The file {path:?} could not be deserialized and will be ignored: {source}"
273 );
274 builder
275 }
276 }
277 }
278 }
279 if !found_config {
280 tracing::error!("No configuration file found. Application may not behave as expected");
281 }
282
283 builder.build()
284 }
285}
286
287fn get_config_dir() -> PathBuf {
288 dirs::config_dir()
289 .expect("Cannot determine users XDG_CONFIG_HOME")
290 .join("osc")
291}
292
293impl fmt::Display for Config {
294 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
295 write!(f, "")
296 }
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302 use std::io::Write;
303 use tempfile::Builder;
304
305 #[test]
306 fn test_parse_config() {
307 let mut config_file = Builder::new().suffix(".yaml").tempfile().unwrap();
308
309 const CONFIG_DATA: &str = r#"
310 views:
311 foo:
312 default_fields: ["a", "b", "c"]
313 bar:
314 fields:
315 - name: "b"
316 min_width: 1
317 command_hints:
318 res:
319 cmd:
320 - hint1
321 - hint2
322 hints:
323 - hint1
324 - hint2
325 enable_hints: true
326 "#;
327
328 write!(config_file, "{CONFIG_DATA}").unwrap();
329
330 let _cfg = Config::builder()
331 .add_source(config_file.path())
332 .unwrap()
333 .build();
334 }
335}