rotz 1.2.1

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

use directories::BaseDirs;
use handlebars::{Context, Handlebars, Helper, HelperDef, HelperResult, Output, RenderContext, RenderError, RenderErrorReason, Renderable, ScopedJson};
use itertools::Itertools;
use miette::Diagnostic;
use serde::Serialize;
use tap::{Conv, Pipe};
#[cfg(feature = "profiling")]
use tracing::instrument;
use velcro::hash_map;

use crate::{
  USER_DIRS,
  cli::Cli,
  config::Config,
  helpers::{self, os},
};

pub static ENV: LazyLock<HashMap<String, String>> = LazyLock::new(|| std::env::vars().collect());

#[derive(thiserror::Error, Diagnostic, Debug)]
pub enum Error {
  #[error("Could not render templeate")]
  #[diagnostic(code(template::render))]
  RenderingTemplate(#[source] handlebars::RenderError),

  #[error("Could not parse eval command")]
  #[diagnostic(code(template::eval::parse))]
  ParseEvalCommand(#[source] shellwords::MismatchedQuotes),

  #[error("Eval command did not run successfully")]
  #[diagnostic(code(template::eval::run))]
  RunEvalCommand(
    #[source]
    #[diagnostic_source]
    helpers::RunError,
  ),
}

#[derive(Serialize, Debug)]
pub struct Parameters<'a> {
  pub config: &'a Config,
  pub name: &'a str,
}

#[derive(Serialize, Debug)]
pub struct WhoamiPrameters {
  pub realname: String,
  pub username: String,
  pub lang: Vec<String>,
  pub devicename: String,
  pub hostname: Option<String>,
  pub platform: String,
  pub distro: String,
  pub desktop_env: String,
  pub arch: String,
}

pub static WHOAMI_PRAMETERS: LazyLock<WhoamiPrameters> = LazyLock::new(|| WhoamiPrameters {
  realname: whoami::realname(),
  username: whoami::username(),
  lang: whoami::langs().map(|l| l.map(|l| l.to_string()).collect_vec()).unwrap_or_default(),
  devicename: whoami::devicename(),
  hostname: whoami::fallible::hostname().ok(),
  platform: whoami::platform().to_string(),
  distro: whoami::distro(),
  desktop_env: whoami::desktop_env().to_string(),
  arch: whoami::arch().to_string(),
});

#[derive(Serialize, Debug)]
pub struct DirectoryPrameters {
  pub base: HashMap<&'static str, PathBuf>,
  pub user: HashMap<&'static str, PathBuf>,
}

pub static DIRECTORY_PRAMETERS: LazyLock<DirectoryPrameters> = LazyLock::new(|| {
  let mut base: HashMap<&'static str, PathBuf> = HashMap::new();

  if let Some(dirs) = BaseDirs::new() {
    base.insert("cache", dirs.cache_dir().to_path_buf());
    base.insert("config", dirs.config_dir().to_path_buf());
    base.insert("data", dirs.data_dir().to_path_buf());
    base.insert("data_local", dirs.data_local_dir().to_path_buf());
    base.insert("home", dirs.home_dir().to_path_buf());
    base.insert("preference", dirs.preference_dir().to_path_buf());
    if let Some(dir) = dirs.executable_dir() {
      base.insert("executable", dir.to_path_buf());
    }
    if let Some(dir) = dirs.runtime_dir() {
      base.insert("runtime", dir.to_path_buf());
    }
    if let Some(dir) = dirs.state_dir() {
      base.insert("state", dir.to_path_buf());
    }
  }

  let mut user: HashMap<&'static str, PathBuf> = HashMap::new();

  user.insert("home", USER_DIRS.home_dir().to_path_buf());
  if let Some(dir) = USER_DIRS.audio_dir() {
    user.insert("audio", dir.to_path_buf());
  }
  if let Some(dir) = USER_DIRS.desktop_dir() {
    user.insert("desktop", dir.to_path_buf());
  }
  if let Some(dir) = USER_DIRS.document_dir() {
    user.insert("document", dir.to_path_buf());
  }
  if let Some(dir) = USER_DIRS.download_dir() {
    user.insert("download", dir.to_path_buf());
  }
  if let Some(dir) = USER_DIRS.font_dir() {
    user.insert("font", dir.to_path_buf());
  }
  if let Some(dir) = USER_DIRS.picture_dir() {
    user.insert("picture", dir.to_path_buf());
  }
  if let Some(dir) = USER_DIRS.public_dir() {
    user.insert("public", dir.to_path_buf());
  }
  if let Some(dir) = USER_DIRS.template_dir() {
    user.insert("template", dir.to_path_buf());
  }
  if let Some(dir) = USER_DIRS.video_dir() {
    user.insert("video", dir.to_path_buf());
  }

  DirectoryPrameters { base, user }
});

#[derive(Serialize, Debug)]
struct CompleteParameters<'a, T> {
  #[serde(flatten)]
  pub parameters: &'a T,
  pub env: &'a HashMap<String, String>,
  pub os: &'a str,
  pub whoami: &'static WhoamiPrameters,
  pub dirs: &'static DirectoryPrameters,
}

pub(crate) struct Engine<'a>(Handlebars<'a>);

impl<'b> Engine<'b> {
  #[cfg_attr(feature = "profiling", instrument)]
  pub fn new<'a>(config: &'a Config, cli: &'a Cli) -> Engine<'b> {
    let mut hb = handlebars_misc_helpers::new_hbs::<'b>();
    hb.set_strict_mode(false);

    hb.register_helper("windows", WindowsHelper.conv::<Box<_>>());
    hb.register_helper("linux", LinuxHelper.conv::<Box<_>>());
    hb.register_helper("darwin", DarwinHelper.conv::<Box<_>>());

    hb.register_helper(
      "eval",
      EvalHelper {
        shell_command: config.shell_command.clone(),
        dry_run: cli.dry_run,
      }
      .pipe(Box::new),
    );

    Self(hb)
  }

  #[cfg_attr(feature = "profiling", instrument(skip(self)))]
  pub fn render(&self, template: &str, parameters: &(impl Serialize + Debug)) -> Result<String, Error> {
    let complete = CompleteParameters {
      parameters,
      env: &ENV,
      whoami: &WHOAMI_PRAMETERS,
      os: &helpers::os::OS.to_string().to_ascii_lowercase(),
      dirs: &DIRECTORY_PRAMETERS,
    };
    self.render_template(template, &complete).map_err(Error::RenderingTemplate)
  }

  #[cfg_attr(feature = "profiling", instrument(skip(self)))]
  pub fn render_template(&self, template_string: &str, data: &(impl Serialize + Debug)) -> Result<String, RenderError> {
    self.0.render_template(template_string, data)
  }
}

pub struct WindowsHelper;

impl HelperDef for WindowsHelper {
  #[cfg_attr(feature = "profiling", instrument(skip(self, out)))]
  fn call<'reg: 'rc, 'rc>(&self, h: &Helper<'rc>, r: &'reg Handlebars<'reg>, ctx: &'rc Context, rc: &mut RenderContext<'reg, 'rc>, out: &mut dyn Output) -> HelperResult {
    if os::OS.is_windows() { h.template() } else { h.inverse() }.map(|t| t.render(r, ctx, rc, out)).map_or(Ok(()), |r| r)
  }
}

pub struct LinuxHelper;

impl HelperDef for LinuxHelper {
  #[cfg_attr(feature = "profiling", instrument(skip(self, out)))]
  fn call<'reg: 'rc, 'rc>(&self, h: &Helper<'rc>, r: &'reg Handlebars<'reg>, ctx: &'rc Context, rc: &mut RenderContext<'reg, 'rc>, out: &mut dyn Output) -> HelperResult {
    if os::OS.is_linux() { h.template() } else { h.inverse() }.map(|t| t.render(r, ctx, rc, out)).map_or(Ok(()), |r| r)
  }
}

pub struct DarwinHelper;

impl HelperDef for DarwinHelper {
  #[cfg_attr(feature = "profiling", instrument(skip(self, out)))]
  fn call<'reg: 'rc, 'rc>(&self, h: &Helper<'rc>, r: &'reg Handlebars<'reg>, ctx: &'rc Context, rc: &mut RenderContext<'reg, 'rc>, out: &mut dyn Output) -> HelperResult {
    if os::OS.is_darwin() { h.template() } else { h.inverse() }.map(|t| t.render(r, ctx, rc, out)).map_or(Ok(()), |r| r)
  }
}

pub struct EvalHelper {
  shell_command: Option<String>,
  dry_run: bool,
}

impl HelperDef for EvalHelper {
  #[cfg_attr(feature = "profiling", instrument(skip(self)))]
  fn call_inner<'reg: 'rc, 'rc>(&self, h: &Helper<'rc>, r: &'reg Handlebars<'reg>, _: &'rc Context, _: &mut RenderContext<'reg, 'rc>) -> Result<ScopedJson<'rc>, RenderError> {
    let cmd = h
      .param(0)
      .ok_or_else(|| RenderErrorReason::ParamNotFoundForIndex("eval", 0))?
      .value()
      .as_str()
      .ok_or_else(|| RenderErrorReason::InvalidParamType("String"))?;

    if self.dry_run {
      format!("{{{{ eval \"{cmd}\" }}}}").conv::<handlebars::JsonValue>().conv::<handlebars::ScopedJson>().pipe(Ok)
    } else {
      let cmd = if let Some(shell_command) = self.shell_command.as_ref() {
        r.render_template(shell_command, &hash_map! { "cmd": &cmd })?
      } else {
        cmd.to_owned()
      };

      let cmd = shellwords::split(&cmd).map_err(|e| RenderErrorReason::NestedError(Box::new(Error::ParseEvalCommand(e))))?;

      match helpers::run_command(&cmd[0], &cmd[1..], true, false) {
        Err(err) => RenderErrorReason::NestedError(Box::new(Error::RunEvalCommand(err))).conv::<RenderError>().pipe(Err),
        Ok(result) => result.trim().conv::<handlebars::JsonValue>().conv::<handlebars::ScopedJson>().pipe(Ok),
      }
    }
  }
}

#[cfg(test)]
pub mod test;