kignore 0.4.0

kignore is a tool for easily adding patterns to .gitignore and cleaning up afterwards
use clap::{Arg, Command};
use console::style;
use eyre::{Context, ContextCompat, OptionExt};
use std::io::prelude::*;
use std::os::unix::fs::PermissionsExt;
use std::{env::current_dir, io::Read, path::PathBuf};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;

const ZSH_FILE_CONTENTS: &[u8] = b"#!/usr/bin/env zsh
set -e

kignore $@
";

const SH_FILE_CONTENTS: &[u8] = b"#!/usr/bin/env sh
set -e

kignore $@
";

const BASH_FILE_CONTENTS: &[u8] = b"#!/usr/bin/env bash
set -e

kignore $@
";

pub fn main() -> eyre::Result<()> {
    let matches = Command::new("gitignore")
        .version("0.1")
        .author("Kasper J. Hermansen <contact@kjuulh.io>")
        .about("Easily ignore items and remove from git state")
        .long_about(
            "git ignore is a utility tool for easily adding patterns to your .gitignore file.
Easily add patterns using `git ignore <pattern>` this will by default
also help you remove committed code violating these patterns
                    ",
        )
        .propagate_version(true)
        .arg(
            Arg::new("pattern")
                .help("the pattern you want to ignore")
                .long_help("the pattern you want to ignore in the nearest .gitignore file"),
        )
        .arg(
            Arg::new("log-level")
                .long("log-level")
                .default_value("warn")
                .help("choose a log level and get more messages")
                .long_help("Choose a log level and get more message, defaults to [warn]"),
        )
        .subcommand(
            clap::Command::new("init")
                .subcommand_required(true)
                .subcommand(Command::new("zsh"))
                .subcommand(Command::new("sh"))
                .subcommand(Command::new("bash")),
        )
        .get_matches();

    match matches.subcommand() {
        Some(("init", args)) => match args
            .subcommand()
            .expect("should never be able to call on init")
        {
            ("zsh", _) => init_script(ShellType::Zsh),
            ("bash", _) => init_script(ShellType::Bash),
            ("sh", _) => init_script(ShellType::Shell),
            (subcommand, _) => {
                panic!("cannot call on subcommand: {}", subcommand);
            }
        },
        _ => {
            let log_level = match matches.get_one::<String>("log-level").map(|f| f.as_str()) {
                Some("off") => "off",
                Some("info") => "info",
                Some("debug") => "debug",
                Some("warn") => "warn",
                Some("error") => "error",
                _ => "error",
            };

            tracing_subscriber::registry()
                .with(tracing_subscriber::EnvFilter::new(format!(
                    "gitignore={}",
                    log_level
                )))
                .with(tracing_subscriber::fmt::layer())
                .init();

            let term = console::Term::stdout();

            let pattern = matches
                .get_one::<String>("pattern")
                .context("missing [pattern]")?;

            add_gitignore_pattern(term, pattern)
        }
    }
}

enum GitActions {
    AddPattern { gitignore_path: PathBuf },
    CreateIgnoreAndAddPattern { git_path: PathBuf },
}

fn add_gitignore_pattern(term: console::Term, pattern: &String) -> eyre::Result<()> {
    println!("git ignore: Add pattern");
    let curdir = current_dir().context(
        "could not find current_dir, you may not have permission to access that directory",
    )?;
    let actions = match search_for_dotgitignore(&curdir)? {
        // If we have an ignore path, make sure it is in a git repo as well
        GitSearchResult::GitIgnore(ignorepath) => match search_for_git_root(&curdir)? {
            GitSearchResult::Git(_gitpath) => GitActions::AddPattern {
                gitignore_path: ignorepath,
            },
            _ => return Err(eyre::anyhow!("could not find parent git directory")),
        },
        // Find the nearest git repo
        GitSearchResult::Git(gitpath) => {
            GitActions::CreateIgnoreAndAddPattern { git_path: gitpath }
        } // We will always have either above, or an error so we have no default arm
    };

    match actions {
        GitActions::AddPattern { gitignore_path } => {
            println!("Found existing {}", style(".gitignore").green());
            let mut gitignore_file = open_gitignore_file(&gitignore_path)?;
            let mut gitignore_content = String::new();
            gitignore_file
                .read_to_string(&mut gitignore_content)
                .context(format!(
                    "could not read file: {}",
                    gitignore_path.to_string_lossy()
                ))?;
            if gitignore_content.contains(pattern) {
                println!(
                    ".gitignore already contains pattern, {}",
                    style("skipping...").blue()
                );
                return Ok(());
            }

            println!("adding pattern to file");
            writeln!(gitignore_file, "{}", pattern).context("could not write contents to file")?;
            gitignore_file
                .sync_all()
                .context("failed to write data to disk")?;
        }
        GitActions::CreateIgnoreAndAddPattern { git_path } => {
            println!(
                "could not find {} file, creating one in the root of the git repository",
                style(".gitignore").yellow()
            );
            let mut gitignore_file = create_gitignore_file(git_path)?;
            println!("adding pattern to file");
            writeln!(gitignore_file, "{}", pattern).context("could not write contents to file")?;
            gitignore_file
                .sync_all()
                .context("failed to write data to disk")?;
        }
    }

    // TODO: Run git rm -r --cached --pathspec <pattern> on the .git root
    let output = std::process::Command::new("git")
        .arg("rm")
        .arg("-r")
        .arg("--cached")
        .arg("-f")
        .arg("--ignore-unmatch")
        .arg(pattern)
        .output()
        .context("could not process git remove from index command")?;
    String::from_utf8(output.stdout)?
        .lines()
        .chain(String::from_utf8(output.stderr)?.lines())
        .map(|l| {
            // make rm 'path' look nice
            if l.contains("rm") {
                if let Some((_, pruned_first)) = l.split_once("'") {
                    if let Some((content, _)) = pruned_first.rsplit_once("'") {
                        return content;
                    }
                }
            }

            l
        })
        .for_each(|l| println!("removed from git history: {}", style(l).yellow()));

    if !output.status.success() {
        return Err(eyre::anyhow!("failed to run git index command"));
    }

    term.write_line("git successfully removed files")?;

    Ok(())
}

fn create_gitignore_file(gitroot: PathBuf) -> eyre::Result<std::fs::File> {
    let mut ignore_path = gitroot.clone();
    if !ignore_path.pop() {
        return Err(eyre::anyhow!("could not open parent dir"));
    }
    ignore_path.push(".gitignore");
    let file = std::fs::File::create(ignore_path.clone()).context(format!(
        "could not create file at path: {}",
        ignore_path.to_string_lossy()
    ))?;

    Ok(file)
}

fn open_gitignore_file(gitignore: &PathBuf) -> eyre::Result<std::fs::File> {
    let file = std::fs::OpenOptions::new()
        .read(true)
        .write(true)
        .open(gitignore)
        .context(format!(
            "could not create file at path: {}",
            gitignore.to_string_lossy()
        ))?;

    Ok(file)
}

enum GitSearchResult {
    GitIgnore(PathBuf),
    Git(PathBuf),
}

fn search_for_git_root(path: &PathBuf) -> eyre::Result<GitSearchResult> {
    if !path.is_dir() {
        return Err(eyre::anyhow!(
            "path is not a dir: {}",
            path.to_string_lossy()
        ));
    }

    let direntries = std::fs::read_dir(path)
        .context(format!("could not open dir: {}", path.to_string_lossy()))?;
    for direntry in direntries {
        let entry = direntry.context("could not access file")?;

        let file_name = entry.file_name().to_os_string();
        if file_name.to_str().context("could not convert to str")? == ".git" {
            return Ok(GitSearchResult::Git(entry.path()));
        }
    }

    let mut upwards_par = path.clone();
    if !upwards_par.pop() {
        return Err(eyre::anyhow!(
            "no parent exists, cannot check further, you may not be in a git repository"
        ));
    }

    search_for_git_root(&upwards_par)
}

fn search_for_dotgitignore(path: &PathBuf) -> eyre::Result<GitSearchResult> {
    if !path.is_dir() {
        return Err(eyre::anyhow!(
            "path is not a dir: {}",
            path.to_string_lossy()
        ));
    }

    let direntries = std::fs::read_dir(path)
        .context(format!("could not open dir: {}", path.to_string_lossy()))?;
    for direntry in direntries {
        let entry = direntry.context("could not access file")?;

        let file_name = entry.file_name().to_os_string();

        if file_name.to_str().context("could not convert to str")? == ".gitignore" {
            return Ok(GitSearchResult::GitIgnore(entry.path()));
        }
    }

    let direntries = std::fs::read_dir(path)
        .context(format!("could not open dir: {}", path.to_string_lossy()))?;
    for direntry in direntries {
        let entry = direntry.context("could not access file")?;

        let file_name = entry.file_name().to_os_string();

        if file_name.to_str().context("could not convert to str")? == ".git" {
            return Ok(GitSearchResult::Git(entry.path()));
        }
    }

    let mut upwards_par = path.clone();
    if !upwards_par.pop() {
        return Err(eyre::anyhow!(
            "no parent exists, cannot check further, you may not be in a git repository"
        ));
    }

    search_for_dotgitignore(&upwards_par)
}

enum ShellType {
    Bash,
    Shell,
    Zsh,
}

fn init_script(shell: ShellType) -> eyre::Result<()> {
    let bin_dir = dirs::executable_dir().ok_or_eyre("failed to find executable dir")?;

    let script = match shell {
        ShellType::Bash => BASH_FILE_CONTENTS,
        ShellType::Shell => SH_FILE_CONTENTS,
        ShellType::Zsh => ZSH_FILE_CONTENTS,
    };

    let alias_script = bin_dir.join("git-ignore");
    if let Ok(existing_file) = std::fs::read(&alias_script) {
        if existing_file == script {
            return Ok(());
        }
    } else {
        std::fs::create_dir_all(&bin_dir).context("failed to create bin dir")?;
    }

    let mut file = std::fs::OpenOptions::new()
        .write(true)
        .create(true)
        .truncate(true)
        .open(&alias_script)?;

    file.write_all(script)?;
    file.flush()?;

    // Set the file to be executable
    let metadata = file.metadata()?;
    let mut permissions = metadata.permissions();
    permissions.set_mode(0o755); // rwxr-xr-x
    file.set_permissions(permissions)?;

    println!(
        "successfully wrote alias to {}",
        style(alias_script.display()).green()
    );

    Ok(())
}