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