mcmodbuild 0.1.1

Format for describing how mods should be built and automatically building them from a file.
Documentation
use std::{
    fs,
    io::Write,
    path::{Path, PathBuf},
    process::Command,
};

use crate::structs::{BuildType, ExcludeType, ModBuild};
use anyhow::{bail, Result};
use directories::ProjectDirs;

pub struct Installer {
    pub build: ModBuild,
    pub cache_dir: PathBuf,
    pub build_path: PathBuf,
}

impl Installer {
    pub fn new(build: ModBuild) -> Result<Self> {
        let dirs = ProjectDirs::from("com", "siesque", "mcmodbuild")
            .ok_or_else(|| anyhow::anyhow!("Could not determine cache directory"))?;

        let cache_dir = dirs.cache_dir().to_path_buf();
        let build_path = cache_dir.join(format!("{}-{}", build.id, build.branch));

        Ok(Self {
            build,
            cache_dir,
            build_path,
        })
    }

    #[allow(dead_code)]
    pub fn install(&self, destination: PathBuf) -> Result<()> {
        println!("🚀 Starting installation...");

        let steps: Vec<(&str, &str, &str, Box<dyn FnOnce() -> Result<()>>)> = vec![
            (
                "Preparing cache...\n",
                "Created build directory\n",
                "Failed to create build directory\n",
                Box::new(|| self.ensure_cache_directory()),
            ),
            (
                "Updating repository...\n",
                "Repository synced\n",
                "Failed to sync recpository\n",
                Box::new(|| self.clone_or_update_repository()),
            ),
            (
                "Building mod...\n",
                "Mod built\n",
                "Failed to build mod\n",
                Box::new(|| self.build_project()),
            ),
            (
                "Copying files...\n",
                "Files copied\n",
                "Failed to copy files\n",
                Box::new(move || self.copy_built_files(&destination)),
            ),
        ];

        for (i, (msg, success, failure, step)) in steps.into_iter().enumerate() {
            print!("[{}/{}] {}", i + 1, 4, msg);
            std::io::stdout().flush()?;

            match step() {
                Ok(_) => println!("{success}"),
                Err(e) => {
                    println!("{failure}");
                    return Err(e);
                }
            }
        }

        println!("🎉 Installation complete!");
        Ok(())
    }

    pub fn ensure_cache_directory(&self) -> Result<()> {
        if !self.cache_dir.exists() {
            fs::create_dir_all(&self.cache_dir)?;
        }
        Ok(())
    }

    pub fn clone_or_update_repository(&self) -> Result<()> {
        let exists = self.build_path.exists();

        if !exists {
            fs::create_dir(&self.build_path)?;
        }

        let mut git_command = if exists {
            self.create_git_pull_command()
        } else {
            self.create_git_clone_command()
        };

        git_command.spawn()?.wait()?;
        Ok(())
    }

    pub fn build_project(&self) -> Result<()> {
        let mut build_command = self.create_build_command()?;
        build_command.spawn()?.wait()?;
        Ok(())
    }

    fn create_git_clone_command(&self) -> Command {
        let mut cmd = Command::new("git");
        cmd.arg("clone")
            .arg(&self.build.git)
            .arg(&self.build_path)
            .arg("-b")
            .arg(&self.build.branch)
            .arg("--depth")
            .arg("1")
            .stdout(std::process::Stdio::inherit())
            .stderr(std::process::Stdio::inherit());
        cmd
    }

    fn create_git_pull_command(&self) -> Command {
        let mut cmd = Command::new("git");
        cmd.arg("pull")
            .current_dir(&self.build_path)
            .stdout(std::process::Stdio::inherit())
            .stderr(std::process::Stdio::inherit());
        cmd
    }

    fn create_build_command(&self) -> Result<Command> {
        let cmd_str = match self.build.build {
            BuildType::Std => "./gradlew build".to_string(),
            BuildType::Cmd => self
                .build
                .cmd
                .clone()
                .ok_or_else(|| anyhow::anyhow!("Custom command not specified"))?,
        };

        let parts: Vec<&str> = cmd_str.split_whitespace().collect();
        let (program, args) = parts
            .split_first()
            .ok_or_else(|| anyhow::anyhow!("Invalid command given"))?;

        let mut cmd = Command::new(program);
        cmd.args(args)
            .current_dir(&self.build_path)
            .stdout(std::process::Stdio::inherit())
            .stderr(std::process::Stdio::inherit());

        Ok(cmd)
    }

    pub fn get_built_files(&self) -> Result<PathBuf> {
        let real_out = self
            .build
            .out
            .replace('@', self.build_path.to_str().unwrap());

        let path_type;

        let out_path = if real_out.starts_with("file:") {
            path_type = PathType::File;
            real_out.strip_prefix("file:").unwrap()
        } else {
            path_type = PathType::Dir;
            real_out.strip_prefix("dir:").unwrap()
        };

        let out_path = Path::new(&out_path);

        let path: PathBuf = match path_type {
            PathType::File => out_path.to_path_buf(),
            PathType::Dir => {
                let matching_files = self.find_matching_files(out_path)?;

                if matching_files.len() != 1 {
                    bail!("Matched more or less than one result files");
                }

                matching_files[0].clone()
            }
        };

        Ok(path)
    }

    #[allow(dead_code)]
    fn copy_built_files(&self, destination: &PathBuf) -> Result<()> {
        let path = self.get_built_files()?;
        fs::copy(path, destination)?;

        Ok(())
    }

    fn find_matching_files(&self, out_path: &Path) -> Result<Vec<PathBuf>> {
        let mut matching_files = Vec::new();

        for entry in fs::read_dir(out_path)? {
            let file = entry?;
            if !self.should_exclude_file(&file.file_name().to_string_lossy()) {
                matching_files.push(file.path());
            }
        }

        Ok(matching_files)
    }

    fn should_exclude_file(&self, filename: &str) -> bool {
        for filter in &self.build.exclude {
            match filter.type_name {
                ExcludeType::Starts => {
                    if filename.starts_with(&filter.value) {
                        return true;
                    }
                }
                ExcludeType::Ends => {
                    if filename.ends_with(&filter.value) {
                        return true;
                    }
                }
                ExcludeType::Contains => {
                    if filename.contains(&filter.value) {
                        return true;
                    }
                }
            }
        }
        false
    }
}

enum PathType {
    File,
    Dir,
}