codeberg-cli 0.5.5

CLI Tool for codeberg similar to gh and glab
Documentation
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
use std::path::PathBuf;

use crate::actions::GlobalArgs;
use crate::client::BergClient;
use crate::paths::token_path;
use crate::render::spinner::spin_until_ready;
use crate::render::ui::confirm_with_prompt;
use crate::types::config::BergConfig;
use crate::types::token::Token;
use inquire::CustomUserError;
use inquire::validator::Validation;
use miette::{Context, IntoDiagnostic};

use crate::actions::text_manipulation::input_prompt_for;

use clap::Parser;

/// Login via generating authentication token
#[derive(Parser, Debug)]
pub struct LoginArgs {
    /// Access Token
    #[arg(short, long)]
    pub token: Option<String>,
}

impl LoginArgs {
    pub async fn run(self, _global_args: GlobalArgs) -> miette::Result<()> {
        let config = BergConfig::new()?;
        let token = self
            .token
            .map(Token)
            .map(Ok)
            .unwrap_or_else(prompt_for_token)?;

        // verify
        spin_until_ready(verify_setup(&token)).await?;

        // this is where the token gets stored
        let token_path = create_token_storage_path(config.base_url)?;

        // save the token
        std::fs::write(token_path.as_path(), token.as_str())
            .into_diagnostic()
            .context("Failed to save token")?;
        // make the token secret
        std::fs::set_permissions(token_path.as_path(), Permissions::from_mode(0o0600))
            .into_diagnostic()?;

        Ok(())
    }
}

fn prompt_for_token() -> miette::Result<Token> {
    let config = BergConfig::new()?;
    let url = config.url()?;
    let token_generation_url = url.join("/user/settings/applications").into_diagnostic()?;
    let token_generation_url = token_generation_url.as_str();
    // ask for usage of browser
    if confirm_with_prompt("Authenticating. Open Browser to generate token for `berg`?")? {
        println!(
            "\nOpening {token_generation_url:?} in the browser.\n\nPlease log in, generate a token and provide it after the following prompt:\n\n"
        );
        webbrowser::open(token_generation_url).into_diagnostic()?;
    } else {
        println!(
            "\nYou chose not to authenticate via browser. Visit\n\n\t{}\n\nto generate a token.\n",
            token_generation_url
        );
    }

    // get token from user
    let token = ask_for_token()?;

    Ok(token)
}

async fn verify_setup(token: &Token) -> miette::Result<()> {
    let config = BergConfig::new()?;
    let base_url = config.url()?;
    tracing::debug!("URL: {base_url}");
    tracing::debug!("TOKEN: {token:?}");
    let client = BergClient::new(token, base_url).context("Couldn't create `berg` client.")?;

    _ = client.user_get_current().await.map_err(|e| {
        miette::miette!("Verification API call didn't contain expected information.\n\n{e}")
    })?;

    println!("\nAuthentication success!");

    Ok(())
}

fn create_token_storage_path(instance: impl AsRef<str>) -> miette::Result<PathBuf> {
    let token_path = token_path(instance)?;
    let token_dir = token_path
        .parent()
        .context("Parent directory of token path '{token_path}' should exist")?;
    std::fs::create_dir_all(token_dir)
        .into_diagnostic()
        .context("Couldn't create directory for saving the token.")?;
    Ok(token_path)
}

fn validate_token(input: &str) -> Result<Validation, CustomUserError> {
    let v = validate_word_count(input);
    if let Validation::Invalid(_) = v {
        return Ok(v);
    }
    Ok(validate_token_length(input))
}

fn validate_word_count(input: &str) -> Validation {
    let words = input.split_whitespace().collect::<Vec<_>>();
    if words.len() != 1 {
        Validation::Invalid(
            format!(
                "Token is just one word. Your input words were\n{}",
                words
                    .iter()
                    .map(|word| format!("  - {word}"))
                    .collect::<Vec<_>>()
                    .join("\n")
            )
            .into(),
        )
    } else {
        Validation::Valid
    }
}

fn validate_token_length(token: &str) -> Validation {
    if token.len() != 40 {
        Validation::Invalid(
            format!(
                "Usual token length is 40. Token\n\n\t{token:?}\n\nhas length {}",
                token.len()
            )
            .into(),
        )
    } else {
        Validation::Valid
    }
}

fn ask_for_token() -> miette::Result<Token> {
    inquire::Text::new(input_prompt_for("Token").as_str())
        .with_validator(validate_token)
        .prompt()
        .map(Token::new)
        .into_diagnostic()
}