tari-ootle-cli 0.10.0

Tari Ootle Template Development CLI
// Copyright 2024 The Tari Project
// SPDX-License-Identifier: BSD-3-Clause

use crate::cli::commands::build::BuildArgs;
use crate::cli::commands::config::ConfigCommand;
use crate::cli::commands::create::CreateArgs;
use crate::cli::commands::metadata::MetadataCommand;
use crate::cli::commands::publish;
use crate::cli::commands::publish::PublishArgs;
use crate::cli::commands::template::TemplateCommand;
use crate::{
    cli::{
        commands::{build, config as config_cmd, create, metadata, template, wizard},
        config::{Config, TemplateRepository},
        util,
    },
    git::repository::GitRepository,
    loading,
};
use anyhow::anyhow;
use clap::{
    Parser, Subcommand,
    builder::{Styles, styling::AnsiColor},
};
use convert_case::{Case, Casing};
use std::{env, path::PathBuf};

const DEFAULT_DATA_FOLDER_NAME: &str = "tari_cli";
const TEMPLATE_REPOS_FOLDER_NAME: &str = "template_repositories";
const DEFAULT_CONFIG_FILE_NAME: &str = "tari.config.toml";

pub fn cli_styles() -> Styles {
    Styles::styled()
        .header(AnsiColor::BrightMagenta.on_default())
        .usage(AnsiColor::BrightMagenta.on_default())
        .literal(AnsiColor::BrightGreen.on_default())
        .placeholder(AnsiColor::BrightMagenta.on_default())
        .error(AnsiColor::BrightRed.on_default())
        .invalid(AnsiColor::BrightRed.on_default())
        .valid(AnsiColor::BrightGreen.on_default())
}

pub fn default_base_dir() -> PathBuf {
    dirs_next::data_dir()
        .unwrap_or_else(|| env::current_dir().unwrap())
        .join(DEFAULT_DATA_FOLDER_NAME)
}

pub fn default_output_dir() -> PathBuf {
    env::current_dir().unwrap()
}

pub fn default_config_file() -> PathBuf {
    dirs_next::config_dir()
        .unwrap_or_else(|| env::current_dir().unwrap())
        .join(DEFAULT_DATA_FOLDER_NAME)
        .join(DEFAULT_CONFIG_FILE_NAME)
}

pub fn config_override_parser(config_override: &str) -> Result<ConfigOverride, String> {
    if config_override.is_empty() {
        return Err(String::from("Override cannot be empty!"));
    }

    let split: Vec<&str> = config_override.split("=").collect();
    if split.len() != 2 {
        return Err(String::from("Invalid override!"));
    }
    let (key, value) = (split.first().unwrap(), split.get(1).unwrap());

    if !Config::is_override_key_valid(key) {
        return Err(format!("Override key invalid: {key}"));
    }

    Ok(ConfigOverride {
        key: key.to_string(),
        value: value.to_string(),
    })
}

pub fn project_name_parser(project_name: &str) -> Result<String, String> {
    Ok(project_name.to_case(Case::Snake))
}

#[derive(Clone, Debug)]
pub struct ConfigOverride {
    pub key: String,
    pub value: String,
}

#[derive(Clone, Parser, Debug)]
pub struct CommonArguments {
    /// Base directory, where all the CLI data will be saved
    #[arg(short = 'b', long, value_name = "PATH", default_value = default_base_dir().into_os_string())]
    base_dir: PathBuf,

    /// Config file location
    #[arg(short = 'c', long, value_name = "PATH", default_value = default_config_file().into_os_string())]
    config_file_path: PathBuf,

    /// Config file overrides
    #[arg(short = 'e', long, value_name = "KEY=VALUE", value_parser = config_override_parser)]
    config_overrides: Vec<ConfigOverride>,
}

#[derive(Clone, Parser)]
#[command(styles = cli_styles())]
#[command(
    version,
    about = "🚀 Tari CLI 🚀",
    long_about = "🚀 Tari Ootle CLI 🚀\nDevelop and publish Tari templates."
)]
pub struct Cli {
    #[clap(flatten)]
    args: CommonArguments,

    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(Clone, Subcommand)]
pub enum Command {
    /// Create a new Tari template crate from a starter template.
    #[clap(alias = "new")]
    Create {
        #[clap(flatten)]
        args: CreateArgs,
    },
    /// Build the template WASM binary.
    Build {
        #[clap(flatten)]
        args: BuildArgs,
    },
    /// Publish a Tari template to a network.
    #[clap(alias = "deploy")]
    Publish {
        #[clap(flatten)]
        args: PublishArgs,
    },
    /// Template metadata tooling (init, inspect, publish with metadata).
    Template {
        #[command(subcommand)]
        command: TemplateCommand,
    },
    /// Template metadata operations (inspect, publish to server).
    Metadata {
        #[command(subcommand)]
        command: MetadataCommand,
    },
    /// Manage project configuration (tari.config.toml).
    Config {
        #[command(subcommand)]
        command: ConfigCommand,
    },
}

impl Cli {
    async fn init_base_dir_and_config(&self) -> anyhow::Result<Config> {
        // make sure we have all the directories set up
        util::create_dir(&self.args.base_dir).await?;

        // create config file dir if not exists
        util::create_dir(
            &self
                .args
                .config_file_path
                .parent()
                .ok_or(anyhow!("Can't find folder of configuration file!"))?
                .to_path_buf(),
        )
        .await?;

        // loading/creating config
        let path = &self.args.config_file_path;
        let mut config = if !util::file_exists(path).await? {
            println!("Existing config not found. Creating a new config at {}", path.display());
            let cfg = Config::default();
            cfg.write_to_file(path).await?;
            cfg
        } else {
            match Config::open(path).await {
                Ok(cfg) => cfg,
                Err(error) => {
                    println!("Failed to open config file: {error:?}, creating default...");
                    let cfg = Config::default();
                    cfg.write_to_file(path).await?;
                    cfg
                },
            }
        };

        // apply config overrides
        for config_override in &self.args.config_overrides {
            config.override_data(config_override.key.as_str(), config_override.value.as_str())?;
        }

        Ok(config)
    }

    async fn refresh_template_repository(&self, template_repo: &TemplateRepository) -> anyhow::Result<GitRepository> {
        util::create_dir(&self.args.base_dir.join(TEMPLATE_REPOS_FOLDER_NAME)).await?;
        let repo_url_splitted: Vec<&str> = template_repo.url.split("/").collect();
        let repo_name = repo_url_splitted
            .last()
            .ok_or(anyhow!("Failed to get repository name from URL!"))?;
        let repo_user = repo_url_splitted
            .get(repo_url_splitted.len() - 2)
            .ok_or(anyhow!("Failed to get repository owner from URL!"))?;
        let repo_folder_path = self
            .args
            .base_dir
            .join(TEMPLATE_REPOS_FOLDER_NAME)
            .join(repo_user)
            .join(repo_name);
        let mut repo = GitRepository::new(repo_folder_path.clone());

        if util::dir_exists(&repo_folder_path).await? {
            repo.load()?;
            let current_branch = repo.current_branch_name()?;
            if current_branch != template_repo.branch {
                repo.pull_changes(Some(template_repo.branch.clone()))?;
            } else {
                repo.pull_changes(None)?;
            }
        } else {
            repo.clone_and_checkout(template_repo.url.as_str(), template_repo.branch.as_str())?;
        }

        Ok(repo)
    }

    pub async fn handle_command(mut self) -> anyhow::Result<()> {
        let Some(command) = self.command.take() else {
            return wizard::handle().await;
        };

        // Config command operates on project config, not CLI config
        if let Command::Config { command } = command {
            return config_cmd::handle(command).await;
        }

        if let Command::Build { args } = command {
            return build::handle(args).await;
        }

        if let Command::Metadata {
            command: MetadataCommand::Inspect { args },
        } = command
        {
            return template::inspect_metadata::handle(args).await;
        }

        // init config and dirs
        let config = loading!(
            "Init configuration and directories",
            self.init_base_dir_and_config().await
        )?;

        // Commands that don't need template repository refresh
        match &command {
            Command::Template { .. } | Command::Publish { .. } | Command::Metadata { .. } => {
                return match command {
                    Command::Template { command } => match command {
                        TemplateCommand::Init { args } => template::init_metadata::handle(args).await,
                        TemplateCommand::Inspect { args } => template::inspect_metadata::handle(args).await,
                        TemplateCommand::Publish { args } => template::publish::handle(config, args).await,
                    },
                    Command::Publish { args } => publish::handle(config, args).await,
                    Command::Metadata { command } => match command {
                        MetadataCommand::Publish { args } => metadata::publish::handle(config, args).await,
                        MetadataCommand::Inspect { .. } => unreachable!(),
                    },
                    _ => unreachable!(),
                };
            },
            _ => {},
        }

        // Refresh template repository (only needed for `create`)
        let template_repo = loading!(
            "Refresh templates repository",
            self.refresh_template_repository(&config.template_repository).await
        )?;

        match command {
            Command::Create { args } => create::handle(config, template_repo.local_folder().clone(), args).await,
            _ => unreachable!(),
        }
    }
}