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