codeberg-cli 0.5.5

CLI Tool for codeberg similar to gh and glab
Documentation
use std::path::PathBuf;
use std::{env::current_dir, path::Path};

use clap::ValueEnum;
use config::Config;
use miette::{Context, IntoDiagnostic};
use serde::{Deserialize, Serialize};
use url::Url;

use crate::paths::{berg_config_dir, config_path};
use crate::render::table::TableWrapper;
use crate::types::git::Git;

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, ValueEnum)]
pub enum ConfigLocation {
    Global,
    Local,
    Stdout,
}

impl ConfigLocation {
    pub fn path(self) -> miette::Result<PathBuf> {
        match self {
            ConfigLocation::Global => {
                let global_path = config_path()?;
                let global_path_dir = global_path
                    .parent()
                    .context("config path has parent directory")?;
                Ok(global_path_dir.to_path_buf())
            }
            ConfigLocation::Local => current_dir()
                .into_diagnostic()
                .context("Getting current directory failed!"),
            ConfigLocation::Stdout => unreachable!(
                "Path of Stdout doesn't make sense. Impossible codepath. Please report this as an issue!"
            ),
        }
    }
}

/// Parsed configuration of the `berg` client
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BergConfig {
    /// base url for the forgejo instance that were going to talk to
    pub base_url: String,
    /// whether or not to use HTTPS
    ///
    /// - true -> HTTPS
    /// - false -> HTTP
    ///
    /// Note that in some places (e.g. cloning a repo) other protocols like SSH are utilized, but
    /// generally we stick with good old HTTP(S) for most of the work
    pub enable_https: bool,
    /// the output of some actions is displayed in a table format.
    /// This can optionally be disabled to make the output more machine readable
    pub fancy_tables: bool,
    /// the output of some actions is displayed with extra colors in the terminal.
    /// This can optionally be disabled in case of e.g. color blindness
    ///
    /// WIP this does nothing yet since we don't have colors anyways yet
    pub no_color: bool,
    /// The editor to be used when doing operations such as writing a comment
    pub editor: String,
    /// Maximum with of the stdout output,
    ///
    /// - negative numbers indicate using 'infinite' width per line
    /// - zero indicates using the terminals width
    /// - positive numbers are interpreted as max width. You may specify
    ///   widths that can lead to weird linebreaks. This is a feature for tools
    ///   which process stdout output line by line. You may also just negative
    ///   widths in this case.
    ///
    /// Falls back to `max_width` value in config or defaults to 80 otherwise.
    pub max_width: i32,
}

impl BergConfig {
    fn iter_default_field_value() -> impl Iterator<Item = (&'static str, config::Value)> {
        let BergConfig {
            base_url,
            enable_https,
            fancy_tables,
            no_color,
            editor,
            max_width,
        } = Default::default();
        [
            ("base_url", config::Value::from(base_url)),
            ("enable_https", config::Value::from(enable_https)),
            ("fancy_tables", config::Value::from(fancy_tables)),
            ("no_color", config::Value::from(no_color)),
            ("editor", config::Value::from(editor)),
            ("max_width", config::Value::from(max_width)),
        ]
        .into_iter()
    }
}

impl Default for BergConfig {
    fn default() -> Self {
        let base_url = Git::default()
            .origin()
            .and_then(|origin| origin.url().map(strip_base_url));
        Self {
            base_url: base_url.unwrap_or(String::from("codeberg.org/")),
            enable_https: true,
            fancy_tables: true,
            no_color: false,
            editor: default_editor(),
            max_width: 80,
        }
    }
}

impl BergConfig {
    /// tries to read berg config from a known set of locations:
    ///
    /// The following list is ordered with descending priority
    ///
    /// - environment variables of the form "BERG_CONFIG_FIELD_NAME"
    /// - current directory + "berg.toml"
    /// - data dir + ".berg-cli/berg.toml""
    ///
    /// Note that config options are overridden with these priorities. This implies that if both
    /// global and local configs exist, all existing options from the local config override the
    /// global configs options. On the other hand, if some options are not overridden, then the
    /// global ones are used in this scenario.
    pub fn new() -> miette::Result<Self> {
        let config = Self::raw()?
            .try_deserialize::<BergConfig>()
            .into_diagnostic()?;

        if !config.enable_https {
            eprintln!(
                "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!"
            );
        }

        Ok(config)
    }

    pub fn raw() -> miette::Result<Config> {
        let local_config_path = current_dir().map(add_berg_config_file).into_diagnostic()?;
        let global_config_path = berg_config_dir().map(add_berg_config_file)?;
        let mut config_builder = Config::builder();

        // adding sources starting with least significant location
        //
        // - global
        // - local path uppermost parent
        // - local path walking down to pwd
        // - pwd
        // - env variable with BERG_ prefix

        config_builder = config_builder.add_source(file_from_path(global_config_path.as_path()));
        tracing::debug!("config search in: {global_config_path:?}");

        let mut walk_up = local_config_path.clone();
        let walking_up = std::iter::from_fn(move || {
            walk_up
                .parent()
                .and_then(|parent| parent.parent())
                .map(add_berg_config_file)
                .inspect(|parent| {
                    walk_up = parent.clone();
                })
        });

        let pwd = std::iter::once(local_config_path);
        let local_paths = pwd.chain(walking_up).collect::<Vec<_>>();

        for path in local_paths.iter().rev() {
            tracing::debug!("config search in: {path:?}");
            config_builder = config_builder.add_source(file_from_path(path));
        }

        config_builder = config_builder.add_source(config::Environment::with_prefix("BERG"));
        // add default values if no source has the value set

        for (field_name, default_value) in BergConfig::iter_default_field_value() {
            config_builder = config_builder
                .set_default(field_name, default_value)
                .into_diagnostic()?;
        }

        config_builder.build().into_diagnostic()
    }
}

impl BergConfig {
    pub fn url(&self) -> miette::Result<Url> {
        let url = format!(
            "{protoc}://{url}",
            protoc = if self.enable_https { "https" } else { "http" },
            url = self.base_url
        );
        Url::parse(url.as_str())
            .into_diagnostic()
            .context("The protocol + base url in the config don't add up to a valid url")
    }

    pub fn make_table(&self) -> TableWrapper {
        let mut table = TableWrapper::default();

        table.max_width = match self.max_width.cmp(&0) {
            std::cmp::Ordering::Less => None,
            std::cmp::Ordering::Equal => termsize::get().map(|size| size.cols - 2),
            std::cmp::Ordering::Greater => Some(self.max_width as u16),
        };

        let preset = if self.fancy_tables {
            comfy_table::presets::UTF8_FULL
        } else {
            comfy_table::presets::NOTHING
        };

        table.load_preset(preset)
    }
}

fn file_from_path(
    path: impl AsRef<Path>,
) -> config::File<config::FileSourceFile, config::FileFormat> {
    config::File::new(
        path.as_ref().to_str().unwrap_or_default(),
        config::FileFormat::Toml,
    )
    .required(false)
}

fn add_berg_config_file(dir: impl AsRef<Path>) -> PathBuf {
    dir.as_ref().join("berg.toml")
}

fn default_editor() -> String {
    std::env::var("EDITOR")
        .or(std::env::var("VISUAL"))
        .unwrap_or_else(|_| {
            let os_native_editor = if cfg!(target_os = "windows") {
                // For windows, this is guaranteed to be there
                "notepad"
            } else if cfg!(target_os = "macos") {
                // https://wiki.c2.com/?TextEdit
                "textedit"
            } else {
                // For most POSIX systems, this is available
                "vi"
            };
            String::from(os_native_editor)
        })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn make_config(path: PathBuf, config: BergConfig) -> miette::Result<()> {
        let config_path = add_berg_config_file(path);
        let toml = toml::to_string(&config)
            .into_diagnostic()
            .context("Failed to create config string!")?;
        std::fs::write(config_path, toml)
            .into_diagnostic()
            .context("Failed to write config file!")?;
        Ok(())
    }

    fn delete_config(path: PathBuf) -> miette::Result<()> {
        let config_path = add_berg_config_file(path);
        std::fs::remove_file(config_path)
            .into_diagnostic()
            .context("Failed to remove file")?;
        Ok(())
    }

    #[test]
    #[ignore = "doesn't work on nix in 'ci' because of no r/w permissions on the system"]
    fn berg_config_integration_test() -> miette::Result<()> {
        // ensure the directories are there while testing
        // (otherwise CI will fail)
        let local_dir = current_dir().into_diagnostic()?;
        std::fs::create_dir_all(local_dir).into_diagnostic()?;
        let global_dir = berg_config_dir()?;
        std::fs::create_dir_all(global_dir).into_diagnostic()?;

        // test local config
        let config = BergConfig {
            base_url: String::from("local"),
            ..Default::default()
        };
        make_config(current_dir().into_diagnostic()?, config)?;
        let config = BergConfig::new();
        assert!(config.is_ok(), "{config:?}");
        let config = config.unwrap();
        assert_eq!(config.base_url.as_str(), "local");
        delete_config(current_dir().into_diagnostic()?)?;

        // test global config
        let config = BergConfig {
            base_url: String::from("global"),
            ..Default::default()
        };
        make_config(berg_config_dir()?, config)?;
        let config = BergConfig::new();
        assert!(config.is_ok(), "{config:?}");
        let config = config.unwrap();
        assert_eq!(config.base_url.as_str(), "global", "{0:?}", config.base_url);
        delete_config(berg_config_dir()?)?;

        // test creating template global config works
        let config = BergConfig::new();
        assert!(config.is_ok(), "{config:?}");
        let config = config.unwrap();
        assert_eq!(config.base_url.as_str(), "codeberg.org/");

        // testing behavior if both configs exist
        {
            // local
            let config = BergConfig {
                base_url: String::from("local"),
                ..Default::default()
            };
            make_config(current_dir().into_diagnostic()?, config)?;
        }
        {
            // global
            let config = BergConfig {
                base_url: String::from("global"),
                ..Default::default()
            };
            make_config(berg_config_dir()?, config)?;
        }
        let config = BergConfig::new();
        assert!(config.is_ok(), "{config:?}");
        let config = config.unwrap();
        assert_eq!(config.base_url.as_str(), "local");
        delete_config(current_dir().into_diagnostic()?)?;
        delete_config(berg_config_dir()?)?;

        Ok(())
    }
}

/// small helper function to get the base url from a full git remote url
///
/// There are different formats of remote urls that are all taken into account here, e.g.
///
/// - https
/// - ssh of various kinds of detail
fn strip_base_url(url: impl AsRef<str>) -> String {
    let url = url.as_ref();
    let url = url.split_once("//").map(|(_proto, url)| url).unwrap_or(url);
    let url = url.split_once("@").map(|(_user, url)| url).unwrap_or(url);
    let url = url.split_once("/").map(|(base, _path)| base).unwrap_or(url);
    let url = url.split_once(":").map(|(base, _user)| base).unwrap_or(url);
    format!("{url}/")
}

#[cfg(test)]
mod strip_base_url {
    use super::strip_base_url;
    #[test]
    fn https_works() {
        assert_eq!(
            strip_base_url("https://codeberg.org/example.com"),
            "codeberg.org/"
        );
    }

    #[test]
    fn ssh_works() {
        assert_eq!(
            strip_base_url("git@codeberg.org:Aviac/codeberg-cli.git"),
            "codeberg.org/"
        );
    }

    #[test]
    fn ssh_detailed_works() {
        assert_eq!(
            strip_base_url("ssh://git@example.com:1312/foo/bar.git"),
            "example.com/"
        );
    }

    #[test]
    fn domain_and_port_no_protoc() {
        assert_eq!(strip_base_url("example.com:1312"), "example.com/");
    }
}