codeberg_cli/types/
config.rs

1use std::path::PathBuf;
2use std::{env::current_dir, path::Path};
3
4use anyhow::Context;
5use clap::ValueEnum;
6use config::Config;
7use serde::{Deserialize, Serialize};
8use url::Url;
9
10use crate::paths::{berg_config_dir, config_path};
11use crate::render::table::TableWrapper;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
14pub enum ConfigLocation {
15    Global,
16    Local,
17    Stdout,
18}
19
20impl ConfigLocation {
21    pub fn path(self) -> anyhow::Result<PathBuf> {
22        match self {
23            ConfigLocation::Global => {
24                let global_path = config_path()?;
25                let global_path_dir = global_path
26                    .parent()
27                    .context("config path has parent directory")?;
28                Ok(global_path_dir.to_path_buf())
29            }
30            ConfigLocation::Local => current_dir().context("Getting current directory failed!"),
31            ConfigLocation::Stdout => unreachable!(
32                "Path of Stdout doesn't make sense. Impossible codepath. Please report this as an issue!"
33            ),
34        }
35    }
36}
37
38/// Parsed configuration of the `berg` client
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct BergConfig {
41    /// base url for the forgejo instance that were going to talk to
42    pub base_url: String,
43    /// protocol used, typically only a choice between http/https
44    pub protocol: String,
45    /// the output of some actions is displayed in a table format.
46    /// This can optionally be disabled to make the output more machine readable
47    pub fancy_tables: bool,
48    /// the output of some actions is displayed with extra colors in the terminal.
49    /// This can optionally be disabled in case of e.g. color blindness
50    ///
51    /// WIP this does nothing yet since we don't have colors anyways yet
52    pub no_color: bool,
53    /// The editor to be used when doing operations such as writing a comment
54    pub editor: String,
55    /// Maximum with of the stdout output,
56    ///
57    /// - negative numbers indicate using 'infinite' width per line
58    /// - zero indicates using the terminals width
59    /// - positive numbers are interpreted as max width. You may specify
60    ///   widths that can lead to weird linebreaks. This is a feature for tools
61    ///   which process stdout output line by line. You may also just negative
62    ///   widths in this case.
63    ///
64    /// Falls back to `max_width` value in config or defaults to 80 otherwise.
65    pub max_width: i32,
66}
67
68impl BergConfig {
69    fn iter_default_field_value() -> impl Iterator<Item = (&'static str, config::Value)> {
70        let BergConfig {
71            base_url,
72            protocol,
73            fancy_tables,
74            no_color,
75            editor,
76            max_width,
77        } = Default::default();
78        [
79            ("base_url", config::Value::from(base_url)),
80            ("protocol", config::Value::from(protocol)),
81            ("fancy_tables", config::Value::from(fancy_tables)),
82            ("no_color", config::Value::from(no_color)),
83            ("editor", config::Value::from(editor)),
84            ("max_width", config::Value::from(max_width)),
85        ]
86        .into_iter()
87    }
88}
89
90impl Default for BergConfig {
91    fn default() -> Self {
92        Self {
93            base_url: String::from("codeberg.org/"),
94            protocol: String::from("https"),
95            fancy_tables: true,
96            no_color: false,
97            editor: default_editor(),
98            max_width: 80,
99        }
100    }
101}
102
103impl BergConfig {
104    /// tries to read berg config from a known set of locations:
105    ///
106    /// The following list is ordered with descending priority
107    ///
108    /// - environment variables of the form "BERG_CONFIG_FIELD_NAME"
109    /// - current directory + "berg.toml"
110    /// - data dir + ".berg-cli/berg.toml""
111    ///
112    /// Note that config options are overridden with these priorities. This implies that if both
113    /// global and local configs exist, all existing options from the local config override the
114    /// global configs options. On the other hand, if some options are not overridden, then the
115    /// global ones are used in this scenario.
116    pub fn new() -> anyhow::Result<Self> {
117        let config = Self::raw()?.try_deserialize::<BergConfig>()?;
118        Ok(config)
119    }
120
121    pub fn raw() -> anyhow::Result<Config> {
122        let local_config_path = current_dir().map(add_berg_config_file)?;
123        let global_config_path = berg_config_dir().map(add_berg_config_file)?;
124        let mut config_builder = Config::builder();
125
126        // adding sources starting with least significant location
127        //
128        // - global
129        // - local path uppermost parent
130        // - local path walking down to pwd
131        // - pwd
132        // - env variable with BERG_ prefix
133
134        config_builder = config_builder.add_source(file_from_path(global_config_path.as_path()));
135        tracing::debug!("config search in: {global_config_path:?}");
136
137        let mut walk_up = local_config_path.clone();
138        let walking_up = std::iter::from_fn(move || {
139            walk_up
140                .parent()
141                .and_then(|parent| parent.parent())
142                .map(add_berg_config_file)
143                .inspect(|parent| {
144                    walk_up = parent.clone();
145                })
146        });
147
148        let pwd = std::iter::once(local_config_path);
149        let local_paths = pwd.chain(walking_up).collect::<Vec<_>>();
150
151        for path in local_paths.iter().rev() {
152            tracing::debug!("config search in: {path:?}");
153            config_builder = config_builder.add_source(file_from_path(path));
154        }
155
156        config_builder = config_builder.add_source(config::Environment::with_prefix("BERG"));
157        // add default values if no source has the value set
158
159        for (field_name, default_value) in BergConfig::iter_default_field_value() {
160            config_builder = config_builder.set_default(field_name, default_value)?;
161        }
162
163        config_builder.build().map_err(anyhow::Error::from)
164    }
165}
166
167impl BergConfig {
168    pub fn url(&self) -> anyhow::Result<Url> {
169        let url = format!(
170            "{protoc}://{url}",
171            protoc = self.protocol,
172            url = self.base_url
173        );
174        Url::parse(url.as_str())
175            .context("The protocol + base url in the config don't add up to a valid url")
176    }
177
178    pub fn make_table(&self) -> TableWrapper {
179        let mut table = TableWrapper::default();
180
181        table.max_width = match self.max_width.cmp(&0) {
182            std::cmp::Ordering::Less => None,
183            std::cmp::Ordering::Equal => termsize::get().map(|size| size.cols - 2),
184            std::cmp::Ordering::Greater => Some(self.max_width as u16),
185        };
186
187        let preset = if self.fancy_tables {
188            comfy_table::presets::UTF8_FULL
189        } else {
190            comfy_table::presets::NOTHING
191        };
192
193        table.load_preset(preset);
194
195        table
196    }
197}
198
199fn file_from_path(
200    path: impl AsRef<Path>,
201) -> config::File<config::FileSourceFile, config::FileFormat> {
202    config::File::new(
203        path.as_ref().to_str().unwrap_or_default(),
204        config::FileFormat::Toml,
205    )
206    .required(false)
207}
208
209fn add_berg_config_file(dir: impl AsRef<Path>) -> PathBuf {
210    dir.as_ref().join("berg.toml")
211}
212
213fn default_editor() -> String {
214    std::env::var("EDITOR")
215        .or(std::env::var("VISUAL"))
216        .unwrap_or_else(|_| {
217            let os_native_editor = if cfg!(target_os = "windows") {
218                // For windows, this is guaranteed to be there
219                "notepad"
220            } else if cfg!(target_os = "macos") {
221                // https://wiki.c2.com/?TextEdit
222                "textedit"
223            } else {
224                // For most POSIX systems, this is available
225                "vi"
226            };
227            String::from(os_native_editor)
228        })
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn make_config(path: PathBuf, config: BergConfig) -> anyhow::Result<()> {
236        let config_path = add_berg_config_file(path);
237        let toml = toml::to_string(&config).context("Failed to create config string!")?;
238        std::fs::write(config_path, toml).context("Failed to write config file!")?;
239        Ok(())
240    }
241
242    fn delete_config(path: PathBuf) -> anyhow::Result<()> {
243        let config_path = add_berg_config_file(path);
244        std::fs::remove_file(config_path).context("Failed to remove file")?;
245        Ok(())
246    }
247
248    #[test]
249    #[ignore = "doesn't work on nix in 'ci' because of no r/w permissions on the system"]
250    fn berg_config_integration_test() -> anyhow::Result<()> {
251        // ensure the directories are there while testing
252        // (otherwise CI will fail)
253        let local_dir = current_dir()?;
254        std::fs::create_dir_all(local_dir)?;
255        let global_dir = berg_config_dir()?;
256        std::fs::create_dir_all(global_dir)?;
257
258        // test local config
259        let config = BergConfig {
260            base_url: String::from("local"),
261            ..Default::default()
262        };
263        make_config(current_dir()?, config)?;
264        let config = BergConfig::new();
265        assert!(config.is_ok(), "{config:?}");
266        let config = config.unwrap();
267        assert_eq!(config.base_url.as_str(), "local");
268        delete_config(current_dir()?)?;
269
270        // test global config
271        let config = BergConfig {
272            base_url: String::from("global"),
273            ..Default::default()
274        };
275        make_config(berg_config_dir()?, config)?;
276        let config = BergConfig::new();
277        assert!(config.is_ok(), "{config:?}");
278        let config = config.unwrap();
279        assert_eq!(config.base_url.as_str(), "global", "{0:?}", config.base_url);
280        delete_config(berg_config_dir()?)?;
281
282        // test creating template global config works
283        let config = BergConfig::new();
284        assert!(config.is_ok(), "{config:?}");
285        let config = config.unwrap();
286        assert_eq!(config.base_url.as_str(), "codeberg.org/");
287
288        // testing behavior if both configs exist
289        {
290            // local
291            let config = BergConfig {
292                base_url: String::from("local"),
293                ..Default::default()
294            };
295            make_config(current_dir()?, config)?;
296        }
297        {
298            // global
299            let config = BergConfig {
300                base_url: String::from("global"),
301                ..Default::default()
302            };
303            make_config(berg_config_dir()?, config)?;
304        }
305        let config = BergConfig::new();
306        assert!(config.is_ok(), "{config:?}");
307        let config = config.unwrap();
308        assert_eq!(config.base_url.as_str(), "local");
309        delete_config(current_dir()?)?;
310        delete_config(berg_config_dir()?)?;
311
312        Ok(())
313    }
314}