anesis 0.12.4

CLI for scaffolding projects from remote templates and extending them with project addons
Documentation
use std::path::Path;
use std::process::Command;

use anyhow::{Context, Result, bail};

use crate::addons::manifest::PackagesStep;

use super::Rollback;

/// The package managers we know how to drive. Detection is by lock-file (JS) then
/// by `Cargo.toml` (Rust), so an addon's `packages` step works in whatever project
/// it lands in without the author naming a manager.
enum PackageManager {
  Npm,
  Bun,
  Pnpm,
  Yarn,
  Cargo,
}

impl PackageManager {
  fn program(&self) -> &'static str {
    match self {
      PackageManager::Npm => "npm",
      PackageManager::Bun => "bun",
      PackageManager::Pnpm => "pnpm",
      PackageManager::Yarn => "yarn",
      PackageManager::Cargo => "cargo",
    }
  }

  /// Subcommand args that precede the package specs to add production deps.
  fn add_args(&self) -> &'static [&'static str] {
    match self {
      PackageManager::Npm => &["install"],
      _ => &["add"],
    }
  }

  /// Flag that turns an add into a dev-dependency add.
  fn dev_flag(&self) -> &'static str {
    match self {
      PackageManager::Npm | PackageManager::Pnpm => "--save-dev",
      _ => "--dev",
    }
  }

  /// Manifest + lock files to snapshot so the step is reversible. Only the ones
  /// that exist are actually snapshotted.
  fn snapshot_files(&self) -> &'static [&'static str] {
    match self {
      PackageManager::Cargo => &["Cargo.toml", "Cargo.lock"],
      PackageManager::Npm => &["package.json", "package-lock.json"],
      PackageManager::Bun => &["package.json", "bun.lock", "bun.lockb"],
      PackageManager::Pnpm => &["package.json", "pnpm-lock.yaml"],
      PackageManager::Yarn => &["package.json", "yarn.lock"],
    }
  }
}

fn detect_pm(root: &Path) -> Result<PackageManager> {
  if root.join("bun.lock").exists() || root.join("bun.lockb").exists() {
    Ok(PackageManager::Bun)
  } else if root.join("pnpm-lock.yaml").exists() {
    Ok(PackageManager::Pnpm)
  } else if root.join("yarn.lock").exists() {
    Ok(PackageManager::Yarn)
  } else if root.join("package.json").exists() {
    Ok(PackageManager::Npm)
  } else if root.join("Cargo.toml").exists() {
    Ok(PackageManager::Cargo)
  } else {
    bail!("No package manager detected (no package.json or Cargo.toml in the project root)")
  }
}

pub fn execute_packages(step: &PackagesStep, project_root: &Path) -> Result<Vec<Rollback>> {
  if step.dependencies.is_empty() && step.dev_dependencies.is_empty() {
    return Ok(Vec::new());
  }
  let pm = detect_pm(project_root)?;

  let mut rollbacks = Vec::new();
  for name in pm.snapshot_files() {
    let path = project_root.join(name);
    if path.exists() {
      rollbacks.push(Rollback::RestoreFile {
        path: path.clone(),
        original: std::fs::read(&path)?,
      });
    }
  }

  let run = |extra: &[&str], specs: &[String]| -> Result<()> {
    let status = Command::new(pm.program())
      .args(pm.add_args())
      .args(extra)
      .args(specs)
      .current_dir(project_root)
      .status()
      .with_context(|| {
        format!(
          "failed to run '{}' — is it installed and on PATH?",
          pm.program()
        )
      })?;
    if !status.success() {
      bail!("'{}' exited with {}", pm.program(), status);
    }
    Ok(())
  };

  let result = (|| {
    if !step.dependencies.is_empty() {
      run(&[], &step.dependencies)?;
    }
    if !step.dev_dependencies.is_empty() {
      run(&[pm.dev_flag()], &step.dev_dependencies)?;
    }
    Ok(())
  })();

  if let Err(err) = result {
    for rb in rollbacks.iter().rev() {
      if let Rollback::RestoreFile { path, original } = rb {
        let _ = std::fs::write(path, original);
      }
    }
    return Err(err);
  }

  Ok(rollbacks)
}

/// Exposes the detected package manager's program name for integration tests,
/// without leaking the private `PackageManager` enum across the crate boundary.
pub fn detect_pm_for_tests(root: &Path) -> Result<&'static str> {
  detect_pm(root).map(|pm| pm.program())
}