synd_term/config/
resolver.rs

1use std::{
2    io::{self, ErrorKind},
3    path::PathBuf,
4    time::Duration,
5};
6
7use synd_stdx::{
8    conf::Entry,
9    fs::{fsimpl, FileSystem},
10};
11use thiserror::Error;
12use url::Url;
13
14use crate::{
15    cli::{self, ApiOptions, FeedOptions, GithubOptions},
16    config::{
17        self,
18        file::{ConfigFile, ConfigFileError},
19        Categories,
20    },
21    ui::theme::Palette,
22};
23
24/// `ConfigResolver` is responsible for resolving the application's configration
25/// while taking priority into account.
26/// Specifically, it takes the following elements into account
27/// with the first elements having the highest priority
28/// * command line arguments
29/// * environment variables
30/// * configuration file
31/// * default values
32#[derive(Debug)]
33pub struct ConfigResolver {
34    config_file: PathBuf,
35    log_file: Entry<PathBuf>,
36    cache_dir: Entry<PathBuf>,
37    api_endpoint: Entry<Url>,
38    api_timeout: Entry<Duration>,
39    feed_entries_limit: Entry<usize>,
40    feed_browser_command: Entry<PathBuf>,
41    feed_browser_args: Entry<Vec<String>>,
42    github_enable: Entry<bool>,
43    github_pat: Entry<String>,
44    palette: Entry<Palette>,
45    categories: Categories,
46}
47
48impl ConfigResolver {
49    pub fn builder() -> ConfigResolverBuilder {
50        ConfigResolverBuilder::default()
51    }
52
53    pub fn config_file(&self) -> PathBuf {
54        self.config_file.clone()
55    }
56
57    pub fn log_file(&self) -> PathBuf {
58        self.log_file.resolve_ref().clone()
59    }
60
61    pub fn cache_dir(&self) -> PathBuf {
62        self.cache_dir.resolve_ref().clone()
63    }
64
65    pub fn api_endpoint(&self) -> Url {
66        self.api_endpoint.resolve_ref().clone()
67    }
68
69    pub fn api_timeout(&self) -> Duration {
70        self.api_timeout.resolve()
71    }
72
73    pub fn feed_entries_limit(&self) -> usize {
74        self.feed_entries_limit.resolve()
75    }
76
77    pub fn feed_browser_command(&self) -> PathBuf {
78        self.feed_browser_command.resolve_ref().clone()
79    }
80
81    pub fn feed_browser_args(&self) -> Vec<String> {
82        self.feed_browser_args.resolve_ref().clone()
83    }
84
85    pub fn is_github_enable(&self) -> bool {
86        self.github_enable.resolve()
87    }
88
89    pub fn github_pat(&self) -> String {
90        self.github_pat.resolve_ref().clone()
91    }
92
93    pub fn palette(&self) -> Palette {
94        self.palette.resolve_ref().clone()
95    }
96
97    pub fn categories(&self) -> Categories {
98        self.categories.clone()
99    }
100}
101
102impl ConfigResolver {
103    /// performs validation based on the relationshsips between the various settings.
104    fn validate(self) -> Result<Self, ConfigResolverBuildError> {
105        if self.github_enable.resolve() && self.github_pat.resolve_ref().is_empty() {
106            return Err(ConfigResolverBuildError::ValidateConfigFile(
107                "github pat is required for github feature".into(),
108            ));
109        }
110        Ok(self)
111    }
112}
113
114#[derive(Error, Debug)]
115pub enum ConfigResolverBuildError {
116    #[error("failed to open {path} {err}")]
117    ConfigFileOpen { path: String, err: io::Error },
118    #[error(transparent)]
119    ConfigFileLoad(#[from] ConfigFileError),
120    #[error("invalid configration: {0}")]
121    ValidateConfigFile(String),
122}
123
124#[derive(Default)]
125pub struct ConfigResolverBuilder<FS = fsimpl::FileSystem> {
126    config_file: Option<PathBuf>,
127    log_file_flag: Option<PathBuf>,
128    cache_dir_flag: Option<PathBuf>,
129    api_flags: Option<ApiOptions>,
130    feed_flags: Option<FeedOptions>,
131    github_flags: Option<GithubOptions>,
132    palette_flag: Option<cli::Palette>,
133    fs: FS,
134}
135
136impl ConfigResolverBuilder {
137    #[must_use]
138    pub fn config_file(self, config_file: Option<PathBuf>) -> Self {
139        Self {
140            config_file,
141            ..self
142        }
143    }
144
145    #[must_use]
146    pub fn log_file(self, log_file_flag: Option<PathBuf>) -> Self {
147        Self {
148            log_file_flag,
149            ..self
150        }
151    }
152
153    #[must_use]
154    pub fn cache_dir(self, cache_dir_flag: Option<PathBuf>) -> Self {
155        Self {
156            cache_dir_flag,
157            ..self
158        }
159    }
160
161    #[must_use]
162    pub fn api_options(self, api_options: ApiOptions) -> Self {
163        Self {
164            api_flags: Some(api_options),
165            ..self
166        }
167    }
168
169    #[must_use]
170    pub fn feed_options(self, feed_options: FeedOptions) -> Self {
171        Self {
172            feed_flags: Some(feed_options),
173            ..self
174        }
175    }
176
177    #[must_use]
178    pub fn github_options(self, github_options: GithubOptions) -> Self {
179        Self {
180            github_flags: Some(github_options),
181            ..self
182        }
183    }
184
185    #[must_use]
186    pub fn palette(self, palette: Option<cli::Palette>) -> Self {
187        Self {
188            palette_flag: palette,
189            ..self
190        }
191    }
192
193    pub fn build(self) -> ConfigResolver {
194        self.try_build().expect("failed to build config resolver")
195    }
196
197    pub fn try_build(self) -> Result<ConfigResolver, ConfigResolverBuildError> {
198        let (mut config_file, config_path) = if let Some(path) = self.config_file {
199            // If a configuration file path is explicitly specified, search for that file
200            // and return an error if it is not found.
201            match self.fs.open_file(&path) {
202                Ok(f) => (Some(ConfigFile::new(f)?), path),
203                Err(err) => {
204                    return Err(ConfigResolverBuildError::ConfigFileOpen {
205                        path: path.display().to_string(),
206                        err,
207                    })
208                }
209            }
210        // If the path is not specified, builder search for the default path
211        // but will not return an error even if it is not found.
212        } else {
213            let default_path = config::config_path();
214            match self.fs.open_file(&default_path) {
215                Ok(f) => (Some(ConfigFile::new(f)?), default_path),
216                Err(err) => match err.kind() {
217                    ErrorKind::NotFound => {
218                        tracing::debug!(path = %default_path.display(), "default config file not found");
219                        (None, default_path)
220                    }
221                    _ => {
222                        return Err(ConfigResolverBuildError::ConfigFileOpen {
223                            path: default_path.display().to_string(),
224                            err,
225                        })
226                    }
227                },
228            }
229        };
230
231        // construct categories
232        let mut categories = Categories::default_toml();
233        if let Some(user_defined) = config_file.as_mut().and_then(|c| c.categories.take()) {
234            categories.merge(user_defined);
235        }
236
237        let ConfigResolverBuilder {
238            api_flags:
239                Some(ApiOptions {
240                    endpoint,
241                    client_timeout,
242                }),
243            feed_flags:
244                Some(FeedOptions {
245                    entries_limit,
246                    browser,
247                    browser_args,
248                }),
249            github_flags:
250                Some(GithubOptions {
251                    enable_github_notification,
252                    github_pat,
253                }),
254            log_file_flag,
255            cache_dir_flag,
256            palette_flag,
257            ..
258        } = self
259        else {
260            panic!()
261        };
262
263        let resolver = ConfigResolver {
264            config_file: config_path,
265            log_file: Entry::with_default(config::log_path())
266                .with_file(
267                    config_file
268                        .as_mut()
269                        .and_then(|c| c.log.as_mut())
270                        .and_then(|log| log.path.take()),
271                )
272                .with_flag(log_file_flag),
273            cache_dir: Entry::with_default(config::cache::dir().to_owned())
274                .with_file(
275                    config_file
276                        .as_mut()
277                        .and_then(|c| c.cache.as_mut())
278                        .and_then(|cache| cache.directory.take()),
279                )
280                .with_flag(cache_dir_flag),
281            api_endpoint: Entry::with_default(Url::parse(config::api::ENDPOINT).unwrap())
282                .with_file(
283                    config_file
284                        .as_mut()
285                        .and_then(|c| c.api.as_mut())
286                        .and_then(|api| api.endpoint.take()),
287                )
288                .with_flag(endpoint),
289            api_timeout: Entry::with_default(config::client::DEFAULT_TIMEOUT)
290                .with_file(
291                    config_file
292                        .as_mut()
293                        .and_then(|c| c.api.as_mut())
294                        .and_then(|api| api.timeout.take()),
295                )
296                .with_flag(client_timeout),
297
298            feed_entries_limit: Entry::with_default(config::feed::DEFAULT_ENTRIES_LIMIT)
299                .with_file(
300                    config_file
301                        .as_mut()
302                        .and_then(|c| c.feed.as_mut())
303                        .and_then(|feed| feed.entries_limit),
304                )
305                .with_flag(entries_limit),
306            feed_browser_command: Entry::with_default(config::feed::default_brower_command())
307                .with_file(
308                    config_file
309                        .as_mut()
310                        .and_then(|c| c.feed.as_mut())
311                        .and_then(|feed| feed.browser.as_mut())
312                        .and_then(|brower| brower.command.take()),
313                )
314                .with_flag(browser),
315
316            feed_browser_args: Entry::with_default(Vec::new())
317                .with_file(
318                    config_file
319                        .as_mut()
320                        .and_then(|c| c.feed.as_mut())
321                        .and_then(|feed| feed.browser.as_mut())
322                        .and_then(|brower| brower.args.take()),
323                )
324                .with_flag(browser_args),
325
326            github_enable: Entry::with_default(false)
327                .with_file(
328                    config_file
329                        .as_mut()
330                        .and_then(|c| c.github.as_mut())
331                        .and_then(|gh| gh.enable.take()),
332                )
333                .with_flag(enable_github_notification),
334            github_pat: Entry::with_default(String::new())
335                .with_file(
336                    config_file
337                        .as_mut()
338                        .and_then(|c| c.github.as_mut())
339                        .and_then(|gh| gh.pat.take()),
340                )
341                .with_flag(github_pat),
342            palette: Entry::with_default(config::theme::DEFAULT_PALETTE.into())
343                .with_file(
344                    config_file
345                        .as_mut()
346                        .and_then(|c| c.theme.as_mut())
347                        .and_then(|theme| theme.name.take())
348                        .map(Into::into),
349                )
350                .with_flag(palette_flag.map(Into::into)),
351            categories,
352        };
353
354        resolver.validate()
355    }
356}