rotz 1.2.1

Fully cross platform dotfile manager written in rust.
use std::{
  collections::{HashMap, HashSet},
  fmt::Debug,
};

use crossterm::style::{Attribute, Stylize};
use indexmap::IndexSet;
use miette::{Diagnostic, Report, Result};
use tap::Pipe;
#[cfg(feature = "profiling")]
use tracing::instrument;
use velcro::hash_map;
use wax::{Glob, Pattern};

use super::Command;
use crate::{config::Config, dot::Installs, helpers, templating};

#[derive(thiserror::Error, Diagnostic, Debug)]
enum Error {
  #[error("{name} has a cyclic dependency")]
  #[diagnostic(code(dependency::cyclic), help("{} depends on itsself through {}", name, through))]
  CyclicDependency { name: String, through: String },

  #[error("{name} has a cyclic installation dependency")]
  #[diagnostic(code(dependency::cyclic::install), help("{} depends on itsself through {}", name, through))]
  CyclicInstallDependency { name: String, through: String },

  #[error("Dependency {1} of {0} was not found")]
  #[diagnostic(code(dependency::not_found))]
  DependencyNotFound(String, String),

  #[error("Install command for {0} did not run successfully")]
  #[diagnostic(code(install::command::run))]
  InstallExecute(
    String,
    #[source]
    #[diagnostic_source]
    helpers::RunError,
  ),

  #[error("Could not render command templeate for {0}")]
  #[diagnostic(code(install::command::render))]
  RenderingTemplate(String, #[source] Box<handlebars::RenderError>),

  #[error("Could not parse install command for {0}")]
  #[diagnostic(code(install::command::parse))]
  ParsingInstallCommand(String, #[source] shellwords::MismatchedQuotes),

  #[error("Could not spawl install command")]
  #[diagnostic(code(install::command::spawn), help("The shell_command in your config is set to \"{0}\" is that correct?"))]
  CouldNotSpawn(String),

  #[error("Could not parse dependency \"{0}\"")]
  #[diagnostic(code(glob::parse))]
  ParseGlob(String, #[source] Box<wax::BuildError>),
}

pub(crate) struct Install<'a> {
  config: Config,
  engine: templating::Engine<'a>,
}

impl Debug for Install<'_> {
  fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
    f.debug_struct("Link").field("config", &self.config).finish()
  }
}

impl<'b> Install<'b> {
  pub const fn new(config: crate::config::Config, engine: templating::Engine<'b>) -> Self {
    Self { config, engine }
  }

  #[cfg_attr(feature = "profiling", instrument)]
  fn install<'a>(
    &self,
    dots: &'a HashMap<String, InstallsDots>,
    entry: (&'a String, &'a InstallsDots),
    installed: &mut HashSet<&'a str>,
    mut stack: IndexSet<String>,
    (globals, install_command): (&crate::cli::Globals, &crate::cli::Install),
  ) -> Result<(), Error> {
    if installed.contains(entry.0.as_str()) {
      return ().pipe(Ok);
    }

    stack.insert(entry.0.clone());

    macro_rules! recurse {
      ($depends:expr, $error:ident) => {
        for dependency in $depends {
          let dependency_glob = Glob::new(dependency).map_err(|e| Error::ParseGlob(dependency.clone(), e.into()))?;

          if stack.iter().any(|d| dependency_glob.is_match(&**d)) {
            return Error::$error {
              name: dependency.clone(),
              through: entry.0.clone(),
            }
            .pipe(Err);
          }

          self.install(
            dots,
            (
              dependency,
              dots
                .iter()
                .find(|d| dependency_glob.is_match(&**d.0))
                .map(|d| d.1)
                .ok_or_else(|| Error::DependencyNotFound(entry.0.clone(), dependency.clone()))?,
            ),
            installed,
            stack.clone(),
            (globals, install_command),
          )?;
        }
      };
    }

    if let Some(installs) = &entry.1.0 {
      if !(install_command.skip_all_dependencies || install_command.skip_installation_dependencies) {
        recurse!(&installs.depends, CyclicInstallDependency);
      }

      println!("{}Installing {}{}\n", Attribute::Bold, entry.0.as_str().blue(), Attribute::Reset);

      let inner_cmd = installs.cmd.clone();

      let cmd = if let Some(shell_command) = self.config.shell_command.as_ref() {
        self
          .engine
          .render_template(shell_command, &hash_map! { "cmd": &inner_cmd })
          .map_err(|err| Error::RenderingTemplate(entry.0.clone(), err.pipe(Box::new)))?
      } else {
        #[allow(clippy::redundant_clone)]
        inner_cmd.clone()
      };

      let cmd = shellwords::split(&cmd).map_err(|err| Error::ParsingInstallCommand(entry.0.clone(), err))?;

      println!("{}{inner_cmd}{}\n", Attribute::Italic, Attribute::Reset);

      if let Err(err) = helpers::run_command(&cmd[0], &cmd[1..], false, globals.dry_run) {
        if let helpers::RunError::Spawn(err) = &err {
          if err.kind() == std::io::ErrorKind::NotFound {
            eprintln!("\n Error: {:?}", Report::new(Error::CouldNotSpawn(format!("{:?}", self.config.shell_command))));
          }
        }

        let error = Error::InstallExecute(entry.0.clone(), err);

        if install_command.continue_on_error {
          eprintln!("\n Error: {:?}", Report::new(error));
        } else {
          return error.pipe(Err);
        }
      }

      installed.insert(entry.0.as_str());
    }

    if !(install_command.skip_all_dependencies || install_command.skip_dependencies) {
      if let Some(depends) = &entry.1.1 {
        recurse!(depends, CyclicDependency);
      }
    }

    ().pipe(Ok)
  }
}

type InstallsDots = (Option<Installs>, Option<HashSet<String>>);

impl Command for Install<'_> {
  type Args = (crate::cli::Globals, crate::cli::Install);
  type Result = Result<()>;

  #[cfg_attr(feature = "profiling", instrument)]
  fn execute(&self, (globals, install_command): Self::Args) -> Self::Result {
    let dots = crate::dot::read_dots(&self.config.dotfiles, &["/**".to_owned()], &self.config, &self.engine)?
      .into_iter()
      .filter(|d| d.1.installs.is_some() || d.1.depends.is_some())
      .map(|d| (d.0, (d.1.installs, d.1.depends)))
      .collect::<HashMap<String, InstallsDots>>();

    let mut installed: HashSet<&str> = HashSet::new();
    let globs = helpers::glob_from_vec(&install_command.dots, None)?;
    for dot in &dots {
      if globs.is_match(dot.0.as_str()) {
        self.install(&dots, dot, &mut installed, IndexSet::new(), (&globals, &install_command))?;
      }
    }

    ().pipe(Ok)
  }
}