manatsu 0.7.1

CLI tools for Manatsu
mod template;

use crate::prelude::*;
use semver::Version;
use std::io::Cursor;
use taplo::formatter;
pub use template::Template;
use walkdir::WalkDir;
use zip::ZipArchive;

pub struct Project {
  pub name: String,
  pub description: Option<String>,
  pub force: bool,
  pub template: Template,
  pub version: Version,
}

impl Project {
  /// <https://regex101.com/r/9dSatE>
  const NAME_REGEX: &'static str = r"^(?:@[a-z0-9-*~][a-z0-9-*._~]*/)?[a-z0-9-~][a-z0-9-._~]*$";

  /// Create a new Manatsu project from a template.
  ///
  /// Tauri: <https://github.com/tsukilabs/manatsu-template-tauri>
  ///
  /// Vue: <https://github.com/tsukilabs/manatsu-template-vue>
  pub async fn create(self) -> Result<()> {
    let start = Instant::now();

    if !Self::is_valid(&self.name) {
      bail!("invalid project name: {}", self.name);
    }

    let path = env::current_dir()?.join(&self.name);

    if path.try_exists()? {
      if self.force {
        fs::remove_dir_all(&path)?;
      } else {
        bail!("directory already exists: {}", path.display());
      }
    }

    println!("downloading template...");
    let bytes = self.template.download().await?;

    println!("building project...");
    fs::create_dir_all(&path).with_context(|| "could not create project dir")?;

    let cursor = Cursor::new(bytes);
    let mut zip = ZipArchive::new(cursor)?;
    zip.extract(&path)?;

    self.hoist_extracted_files(&path)?;
    self.update_project_metadata(&path)?;

    println!("built {} in {:?}", self.name, start.elapsed());

    Ok(())
  }

  fn hoist_extracted_files(&self, path: &Path) -> Result<()> {
    let globset = build_globset();
    let dir = self.find_extracted_dir(path)?;

    for entry in fs::read_dir(&dir)?.flatten() {
      let entry_path = entry.path();
      if globset.is_match(&entry_path) {
        remove_entry(entry_path)?;
      } else {
        let target_path = path.join(entry.file_name());
        fs::rename(entry_path, target_path)?;
      }
    }

    fs::remove_dir_all(dir)?;

    Ok(())
  }

  fn find_extracted_dir(&self, path: &Path) -> Result<PathBuf> {
    let template_name = self.template.to_string();
    for entry in fs::read_dir(path)?.flatten() {
      let entry_path = entry.path();
      if entry.metadata()?.is_dir() {
        let file_name = entry.file_name();
        if matches!(file_name.to_str(), Some(n) if n.contains(&template_name)) {
          return Ok(entry_path);
        }
      }

      remove_entry(&entry_path)?;
    }

    Err(anyhow!("could not find extracted folder"))
  }

  fn update_project_metadata(&self, path: &Path) -> Result<()> {
    self.update_package_json(path)?;

    if self.template.is_tauri() {
      self.update_cargo_toml(path)?;
      self.update_tauri_conf(path)?;
    }

    self.update_index_html(path)?;

    Ok(())
  }

  fn update_package_json<P: AsRef<Path>>(&self, dir_path: P) -> Result<()> {
    let path = dir_path.as_ref().join("package.json");
    let package_json = fs::read_to_string(&path)?;
    let mut package_json: serde_json::Value = serde_json::from_str(&package_json)?;

    macro_rules! update {
      ($key:literal, $value:expr) => {
        package_json[$key] = serde_json::Value::String($value);
      };
    }

    update!("name", self.name.clone());
    update!("version", self.version.to_string());
    update!("description", self.description.clone().unwrap_or_default());

    let json = serde_json::to_string_pretty(&package_json)?;
    fs::write(path, json)?;

    Ok(())
  }

  fn update_cargo_toml<P: AsRef<Path>>(&self, dir_path: P) -> Result<()> {
    let glob = Glob::new("**/Cargo.toml")?.compile_matcher();
    let entries = WalkDir::new(dir_path)
      .into_iter()
      .filter_map(std::result::Result::ok)
      .filter(|e| glob.is_match(e.path()));

    for entry in entries {
      let path = entry.path();
      let cargo_toml = fs::read_to_string(path)?;
      let mut cargo_toml: toml::Value = toml::from_str(&cargo_toml)?;

      macro_rules! update {
        ($key:literal, $value:expr) => {
          if cargo_toml["package"].get($key).is_some() {
            cargo_toml["package"][$key] = toml::Value::String($value);
          }
        };
      }

      if cargo_toml.get("package").is_some() {
        update!("name", self.name.clone());
        update!("version", self.version.to_string());
        update!("description", self.description.clone().unwrap_or_default());
      }

      let options = formatter::Options::default();
      let cargo_toml = toml::to_string(&cargo_toml)?;
      let cargo_toml = formatter::format(&cargo_toml, options);

      fs::write(path, cargo_toml)?;
    }

    Ok(())
  }

  fn update_tauri_conf<P: AsRef<Path>>(&self, dir_path: P) -> Result<()> {
    let path = dir_path.as_ref().join("src-tauri/tauri.conf.json");
    let tauri_conf = fs::read_to_string(&path)?;
    let mut tauri_conf: serde_json::Value = serde_json::from_str(&tauri_conf)?;

    macro_rules! update {
      ($key:literal, $value:expr) => {
        tauri_conf[$key] = serde_json::Value::String($value);
      };
    }

    update!("productName", self.name.clone());
    update!("version", self.version.to_string());

    let title = self.name.to_case(Case::Title);
    tauri_conf["app"]["windows"][0]["title"] = serde_json::Value::String(title);

    let tauri_conf = serde_json::to_string_pretty(&tauri_conf)?;
    fs::write(path, tauri_conf)?;

    Ok(())
  }

  fn update_index_html<P: AsRef<Path>>(&self, dir_path: P) -> Result<()> {
    let path = dir_path.as_ref().join("index.html");
    let index_html = fs::read_to_string(&path)?;
    let index_html = index_html.replace("Manatsu", &self.name);

    fs::write(path, index_html)?;

    Ok(())
  }

  pub fn is_valid<T: AsRef<str>>(name: T) -> bool {
    let regex = Regex::new(Project::NAME_REGEX).unwrap();
    regex.is_match(name.as_ref())
  }
}

/// Build a globset to match files and directories to remove from the extracted template.
fn build_globset() -> GlobSet {
  let mut builder = GlobSetBuilder::new();

  macro_rules! add {
    ($glob:expr) => {
      builder.add(Glob::new($glob).unwrap());
    };
  }

  // Directories
  add!("**/dist");
  add!("**/target");
  add!("**/node_modules");
  add!("**/.github");

  // Files
  add!("**/LICENSE");
  add!("**/pnpm-lock.yaml");
  add!("**/*.lock");
  add!("**/*.log");
  add!("**/config.json");

  builder.build().unwrap()
}

fn remove_entry<P: AsRef<Path>>(path: P) -> Result<()> {
  let path = path.as_ref();
  let metadata = path.metadata()?;

  if metadata.is_dir() {
    fs::remove_dir_all(path)?;
  } else if metadata.is_file() {
    fs::remove_file(path)?;
  }

  Ok(())
}