Skip to main content

codeberg_cli/types/
config.rs

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