system-hook 0.2.1

shook: webhook server to automatically update production servers
use std::{
    fs::{self, File},
    io::{Read, Write},
    path::{Path, PathBuf},
    process::Command,
    str::FromStr,
};

use color_eyre::eyre::{eyre, Context};
use dialoguer::{theme::ColorfulTheme, Completion, Confirm, Input};
use github_webhook_extract::EventDiscriminants;
use text_completions::{EnvCompletion, MultiCompletion, PathCompletion};

use crate::config::{parse_multiple_events, Init, InitConfig, TcpOrUnix};

const SERVICE_TEMPLATE: &str = include_str!("shook.service");
const SERVICE_DIR: &str = "/etc/systemd/system/";

pub fn init_project(args: Init) -> color_eyre::Result<()> {
    tracing::info!("creating project");

    let completion = MultiCompletion::default()
        .with(EnvCompletion::default())
        .with(PathCompletion);
    let repo_path = get_input_pathbuf("path to the repository", args.repo_path, &completion)?;
    let config_path = repo_path.join("shook.toml");
    if config_path.try_exists()? {
        tracing::warn!("config already exists");
        let source = Confirm::with_theme(&ColorfulTheme::default())
            .with_prompt("source existing shook.toml?")
            .interact()?;
        if source {
            let mut file = File::open(&config_path).context("opening shook config")?;
            let mut buf = String::new();
            file.read_to_string(&mut buf)
                .context("reading shook config")?;
            let mut config = toml::from_str(&buf).context("parsing shook config")?;
            return install(&mut config);
        }
    }
    let username = get_input("the linux user to run git as", args.username)?;
    let remote = get_input_default(
        "the remote to track for changes",
        args.remote,
        "origin".to_string(),
    )?;
    let branch = get_input_default(
        "the branch to track for changes",
        args.branch,
        "main".to_string(),
    )?;
    let system_name = get_input(
        "name of systemd service to update on github events",
        args.system_name,
    )?;
    let update_events = get_input_events("github events to update", args.update_events)?;
    let addr = get_input_default(
        "address to serve on (unix socket path or tcp address) ensure doesn't overlap with other shook instances",
        args.addr,
        TcpOrUnix::Unix("/var/run/shook.sock".into()),
    )?;
    let (socket_group, socket_user) = if let TcpOrUnix::Unix(_) = addr {
        let group = get_input_default(
            "group to put socket under",
            args.socket_group,
            "www-data".to_string(),
        )?;
        let user = get_input_default(
            "user to put socket under",
            args.socket_user,
            "www-data".to_string(),
        )?;

        (group, user)
    } else {
        ("".to_string(), "".to_string())
    };

    let pre_restart_command = get_input_default(
        "Command to run before restarting",
        args.pre_restart_command,
        ":".to_string(),
    )?;

    let mut config = InitConfig {
        username,
        repo_path,
        remote,
        branch,
        system_name,
        update_events,
        addr,
        socket_group,
        socket_user,
        pre_restart_command,
        shook_service_name: args
            .shook_service_name
            .unwrap_or_else(|| "shook.service".to_string()),
    };

    tracing::debug!(?config);

    if !Path::try_exists(&config.repo_path)? {
        tracing::warn!("repository could not be found");

        let should_clone = Confirm::with_theme(&ColorfulTheme::default())
            .with_prompt("clone the repository?")
            .interact()?;

        if should_clone {
            let url: String = Input::with_theme(&ColorfulTheme::default())
                .with_prompt("repository url")
                .interact_text()?;

            tracing::info!("cloning repository into {:?}", config.repo_path);
            let parent = config
                .repo_path
                .parent()
                .ok_or_else(|| eyre!("repo-path has no parent"))?;
            if !Path::try_exists(parent)? {
                tracing::info!("creating {:?}", parent);
                fs::create_dir_all(parent)?;
            }

            let mut handle = Command::new("su")
                .arg(&config.username)
                .arg("-c")
                .arg(format!(
                    "git clone '{}' '{}'",
                    url,
                    config.repo_path.to_string_lossy()
                ))
                .current_dir(parent)
                .spawn()?;
            let exit_code = handle.wait()?;
            tracing::debug!("git exited with exit code {:?}", exit_code.code());
            if exit_code.code().unwrap_or(1) != 0 {
                tracing::error!("could not clone repository");
                return Err(eyre!("could not clone repository"));
            }
        }
    }

    let config_path = config.repo_path.join("shook.toml");
    if Path::exists(&config_path) {
        tracing::warn!("{:?} already exists", config_path);

        let should_replace = Confirm::with_theme(&ColorfulTheme::default())
            .with_prompt("replace existing shook.toml?")
            .interact()?;
        if !should_replace {
            tracing::info!("aborting init process");
            return Err(eyre!("aborting due to existing config"));
        }
    }

    install(&mut config)?;
    let toml = toml::to_string_pretty(&config).context("serializing config to toml")?;
    let mut file = File::create(&config_path).context("creating shook.toml")?;
    file.write_all(toml.as_bytes())
        .context("writing shook.toml")?;
    tracing::info!("finished writing shook.toml");

    Ok(())
}

fn install(config: &mut InitConfig) -> color_eyre::Result<()> {
    let systemd = SERVICE_TEMPLATE.replace(
        "{REPO_PATH}",
        config
            .repo_path
            .to_str()
            .ok_or_else(|| eyre!("repo path is not vaid utf8"))?,
    );

    tracing::info!("installing systemd config");
    tracing::debug!("systemd file:\n{}", systemd);
    let mut service_path = PathBuf::from(SERVICE_DIR);
    service_path.push(&config.shook_service_name);
    if Path::exists(&service_path) {
        tracing::warn!("shook.service already exists");

        let should_replace = Confirm::with_theme(&ColorfulTheme::default())
            .with_prompt("replace existing service file?")
            .interact()?;
        if !should_replace {
            tracing::info!("not replacing service file");
            let skip_installing = Confirm::with_theme(&ColorfulTheme::default())
                .with_prompt("skip installing service file?")
                .interact()?;
            if skip_installing {
                tracing::info!("skipping installing service file");
                return Ok(());
            }

            tracing::info!("finding alternative name for service file");
            loop {
                let new_name = get_input::<String>("input service name", None)?;
                if !new_name.ends_with(".service") {
                    tracing::info!("end input with .service");
                }
                service_path.pop();
                service_path.push(new_name.clone());
                if !Path::exists(&service_path) {
                    config.shook_service_name = new_name;
                    break;
                }
                tracing::info!("path already exists");
            }
        }
    }

    let mut file = File::create(&service_path).context("creating service file")?;
    file.write_all(systemd.as_bytes())
        .context("writing service file")?;

    tracing::info!("finished creating project");

    Ok(())
}

fn get_input<T>(prompt: &str, initial: Option<T>) -> color_eyre::Result<T>
where
    T: Clone + ToString + FromStr,
    <T as FromStr>::Err: std::fmt::Debug + ToString,
{
    if let Some(v) = initial {
        return Ok(v);
    }
    let res = Input::with_theme(&ColorfulTheme::default())
        .with_prompt(prompt)
        .interact_text()?;

    Ok(res)
}

fn get_input_default<T>(prompt: &str, initial: Option<T>, default: T) -> color_eyre::Result<T>
where
    T: Clone + ToString + FromStr,
    <T as FromStr>::Err: std::fmt::Debug + ToString,
{
    if let Some(v) = initial {
        return Ok(v);
    }
    let res = Input::with_theme(&ColorfulTheme::default())
        .with_prompt(prompt)
        .default(default)
        .interact_text()?;

    Ok(res)
}

fn get_input_pathbuf(
    prompt: &str,
    initial: Option<PathBuf>,
    completion: &MultiCompletion,
) -> color_eyre::Result<PathBuf> {
    if let Some(v) = initial {
        return Ok(v);
    }

    let path = loop {
        let res: String = Input::with_theme(&ColorfulTheme::default())
            .with_prompt(prompt)
            .completion_with(completion)
            .interact_text()?;

        let res = completion.get(&res).unwrap_or(res);
        if let Ok(p) = PathBuf::from(res).canonicalize() {
            break p;
        }
        tracing::warn!("type in a valid path");
    };

    Ok(path)
}

fn get_input_events(
    prompt: &str,
    initial: Option<Vec<EventDiscriminants>>,
) -> color_eyre::Result<Vec<EventDiscriminants>> {
    if let Some(v) = initial {
        return Ok(v);
    }
    let res = loop {
        let str: String = Input::with_theme(&ColorfulTheme::default())
            .with_prompt(prompt)
            .default("push".into())
            .interact_text()?;

        if let Ok(e) = parse_multiple_events(&str) {
            break e;
        }
        tracing::warn!("type in a comma delimited list of valid events, refer to https://docs.github.com/en/webhooks-and-events/webhooks/webhook-events-and-payloads");
    };

    Ok(res)
}