manatsu 0.2.7

CLI tools for Manatsu
Documentation
mod template;

use anyhow::{bail, Context, Result};
use globset::{Glob, GlobSet, GlobSetBuilder};
use regex::Regex;
use std::io::Cursor;
use std::path::{Path, PathBuf};
use std::time::Instant;
use std::{env, fs};
pub use template::Template;
use zip::ZipArchive;

/// <https://regex101.com/r/9dSatE>
pub(crate) const PROJECT_NAME_REGEX: &str =
  r"^(?:@[a-z0-9-*~][a-z0-9-*._~]*/)?[a-z0-9-~][a-z0-9-._~]*$";

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

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

    if !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()?;

    println!("Building project...");
    fs::create_dir_all(&path).with_context(|| "Could not create project folder")?;

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

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

fn hoist_extracted_files(path: &Path, template: Template) -> Result<()> {
  let globset = build_globset()?;
  let extracted_folder = find_extracted_folder(path, template)?;

  for entry in fs::read_dir(&extracted_folder)?.flatten() {
    let src_path = entry.path();
    let target_path = path.join(entry.file_name());

    if globset.is_match(&src_path) {
      remove_entry(&src_path)?;
    } else {
      fs::rename(&src_path, target_path)?;
    }
  }

  fs::remove_dir_all(extracted_folder)?;
  Ok(())
}

fn find_extracted_folder(path: &Path, template: Template) -> Result<PathBuf> {
  let template_name: &str = template.into();
  for entry in fs::read_dir(path)? {
    let entry = entry?;
    let metadata = entry.metadata()?;
    let entry_path = entry.path();

    if 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)?;
  }

  bail!("Could not find extracted folder")
}

fn build_globset() -> Result<GlobSet> {
  let mut builder = GlobSetBuilder::new();

  // Directories
  builder.add(Glob::new("**/dist")?);
  builder.add(Glob::new("**/target")?);
  builder.add(Glob::new("**/node_modules")?);
  builder.add(Glob::new("**/.github")?);

  // Files
  builder.add(Glob::new("**/LICENSE")?);
  builder.add(Glob::new("**/README.md")?);
  builder.add(Glob::new("**/pnpm-lock.yaml")?);
  builder.add(Glob::new("**/Cargo.lock")?);
  builder.add(Glob::new("**/*.log")?);
  builder.add(Glob::new("**/taze.config.js")?);
  builder.add(Glob::new("**/config.json")?);

  let globset = builder.build()?;
  Ok(globset)
}

fn remove_entry(path: &Path) -> Result<()> {
  let metadata = path.metadata()?;
  if metadata.is_dir() {
    fs::remove_dir_all(path)?;
  } else if metadata.is_file() {
    fs::remove_file(path)?;
  }

  Ok(())
}

/// Determines whether the project name is valid.
pub fn is_valid<T: AsRef<str>>(project_name: T) -> Result<bool> {
  let project_name = project_name.as_ref();
  let regex = Regex::new(PROJECT_NAME_REGEX)?;
  Ok(regex.is_match(project_name))
}

#[cfg(test)]
mod tests {
  use super::template::Template;
  use super::*;

  #[test]
  fn should_determine_if_name_is_valid() {
    let name = "my-project";
    assert!(is_valid(name).unwrap());

    let name = "真夏";
    assert!(!is_valid(name).unwrap());
  }

  #[test]
  fn should_return_status_200() {
    let template_url = Template::Vue.url();
    let response = ureq::get(&template_url).call().unwrap();
    assert_eq!(200, response.status());
  }
}