tbump 1.0.3

Bump software releases
Documentation
#![allow(clippy::print_literal)]
use std::env;
use std::io;
use std::io::prelude::*;
use std::path::{Path, PathBuf};
use std::process;

use anyhow::{anyhow, Context, Result};
use colored::*;
use regex::Regex;

pub use cli::Command;

pub use config::Config;

use cli::SubCommand;
use files::PatchGroup;
use git::GitGroup;
use hooks::HooksGroup;

pub mod cli;
pub mod config;
mod files;
mod git;
mod hooks;

#[derive(Debug)]
pub struct BumpContext {
    new_version: String,
    dry_run: bool,
    interactive: bool,
    patch_only: bool,
    project_path: PathBuf,
    config: Config,
    git_state: git::State,
}

impl BumpContext {
    pub fn from_command(cmd: &Command) -> Result<Self> {
        let project_path = match &cmd.cwd {
            None => env::current_dir()
                .with_context(|| "Could not get current working directory".to_string())?,
            Some(p) => p.to_path_buf(),
        };
        let cfg_path = project_path.join("tbump.toml");
        let config = crate::config::parse(&cfg_path)
            .with_context(|| format!("Could not parse configuration in {:?}", &cfg_path))?;

        // new_version cannot be None if cmd.sub_cmd is None
        // see check at the beginning of run()
        let new_version = cmd.new_version.as_deref().unwrap();

        let git_state = git::get_state(&project_path)?;

        Ok(BumpContext {
            project_path,
            new_version: new_version.to_string(),
            dry_run: cmd.dry_run,
            interactive: !cmd.non_interactive,
            patch_only: cmd.patch_only,
            config,
            git_state,
        })
    }

    fn current_version(&self) -> &str {
        &self.config.current_version
    }

    fn new_version(&self) -> &str {
        &self.new_version
    }

    fn project_path(&self) -> &Path {
        &self.project_path
    }

    fn version_regex(&self) -> &Regex {
        &self.config.version_regex
    }
}

fn ask_for_confirmation() -> Result<bool> {
    println!("{} Looking good? (y/N)", "::".yellow());
    print!("{}", "> ".yellow());
    io::stdout().flush()?;
    let mut answer = String::new();
    io::stdin().read_line(&mut answer)?;
    match answer.to_ascii_lowercase().trim_end() {
        "yes" | "y" => Ok(true),
        _ => Ok(false),
    }
}

fn init(current_version: &str) -> Result<()> {
    let template = include_str!("tbump.in.toml");
    let to_write = template.replace("@current_version@", current_version);
    std::fs::write("tbump.toml", to_write)
        .with_context(|| "Could not write file tbump.toml".to_string())?;
    println!("Generated tbump.toml");
    Ok(())
}

pub fn run(command: &Command) -> Result<()> {
    if command.sub_cmd.is_none() && command.new_version.is_none() {
        eprintln!("Usage:\n  tbump init <current-version>\n  tbump <new-version>");
        process::exit(1);
    };

    if let Some(sub_cmd) = &command.sub_cmd {
        let SubCommand::Init { current_version } = sub_cmd;
        init(current_version)
    } else {
        let context = BumpContext::from_command(&command)?;
        bump(&context)
    }
}

pub fn bump(context: &BumpContext) -> Result<()> {
    let current_version = &context.current_version();
    let new_version = &context.new_version();
    println!(
        "{} {} {} {} {}",
        "::".blue(),
        "Bumping from",
        current_version.bold(),
        "to",
        new_version.bold(),
    );

    let groups = get_groups(&context)?;

    if context.interactive {
        for group in &groups {
            group.dry_run();
        }
    }

    if context.dry_run {
        return Ok(());
    }

    if context.interactive {
        let confirmed = ask_for_confirmation()?;
        if !confirmed {
            return Err(anyhow!("Cancelled by user"));
        }
    }

    for group in &groups {
        group.run()?;
    }

    Ok(())
}

fn get_groups(context: &BumpContext) -> Result<Vec<Box<dyn Group>>> {
    let mut groups: Vec<Box<dyn Group>> = vec![];
    let add_git_ops = !context.patch_only;

    let patch_group = PatchGroup::new(context)?;
    groups.push(Box::new(patch_group));

    if add_git_ops {
        let before_commit = HooksGroup::before_commit(&context);
        if !before_commit.is_empty() {
            groups.push(Box::new(before_commit));
        }
    }

    if add_git_ops {
        let git_group = GitGroup::new(context)?;
        groups.push(Box::new(git_group));
    }

    let after_push = HooksGroup::after_push(context);
    if !after_push.is_empty() {
        groups.push(Box::new(after_push));
    }

    Ok(groups)
}

pub trait Operation {
    fn run(&self) -> Result<()>;
    fn dry_run(&self);
}

trait Group {
    fn operations(&self) -> &[Box<dyn Operation>];
    fn description(&self) -> String;
}

impl dyn Group {
    fn dry_run(&self) {
        println!(
            "{} {} {}",
            "=>".blue(),
            &self.description(),
            "(dry run)".dimmed()
        );
        for operation in self.operations() {
            operation.dry_run();
        }
    }

    fn run(&self) -> Result<()> {
        println!("{} {}", "=>".blue(), &self.description());
        for operation in self.operations() {
            operation.run()?;
        }
        Ok(())
    }
}