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