nestrs-cli-rs 0.1.0

Rust port of the Nest CLI for the nestrs organization.
Documentation
//! Process runners for npm, yarn, pnpm, schematics, and git.
//!
//! Upstream runners spawn commands through a shell. This module keeps command
//! descriptions as data and only executes them when `run` or `execute` is
//! explicitly called.

use std::fmt;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus, Stdio};

use crate::Result;
use crate::error::CliError;

pub mod abstract_runner;
pub mod git_runner;
pub mod npm_runner;
pub mod pnpm_runner;
pub mod runner;
pub mod runner_factory;
pub mod schematic_runner;
pub mod yarn_runner;

#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)]
pub enum RunnerKind {
    Schematic,
    Npm,
    Yarn,
    Pnpm,
    Git,
    Cargo,
}

impl RunnerKind {
    pub const fn binary(self) -> &'static str {
        match self {
            Self::Schematic => "node",
            Self::Npm => "npm",
            Self::Yarn => "yarn",
            Self::Pnpm => "pnpm",
            Self::Git => "git",
            Self::Cargo => "cargo",
        }
    }
}

impl fmt::Display for RunnerKind {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::Schematic => "schematic",
            Self::Npm => "npm",
            Self::Yarn => "yarn",
            Self::Pnpm => "pnpm",
            Self::Git => "git",
            Self::Cargo => "cargo",
        })
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct RunnerCommand {
    pub binary: String,
    pub prefix_args: Vec<String>,
    pub command: String,
    pub collect: bool,
    pub cwd: Option<PathBuf>,
    pub shell: bool,
    pub env: Vec<(String, String)>,
}

impl RunnerCommand {
    pub fn raw_full_command(&self) -> String {
        let mut parts = Vec::with_capacity(2 + self.prefix_args.len());
        parts.push(self.binary.as_str());
        parts.extend(self.prefix_args.iter().map(String::as_str));
        parts.push(self.command.as_str());
        parts.join(" ")
    }

    /// Execute the described command and return process details.
    ///
    /// This mirrors upstream `AbstractRunner.run`, including shell execution,
    /// inherited stdio by default, and piped stdout when `collect` is true.
    pub fn execute(&self) -> Result<RunnerExecution> {
        let command_line = self.command_line_for_execution();
        let mut command = if self.shell {
            shell_command(&command_line)
        } else {
            let mut command = Command::new(&self.binary);
            command.args(&self.prefix_args).arg(&self.command);
            command
        };

        if let Some(cwd) = &self.cwd {
            command.current_dir(cwd);
        }
        command.envs(self.env.iter().map(|(key, value)| (key, value)));

        if self.collect {
            command.stdout(Stdio::piped()).stderr(Stdio::piped());
            let output = command.output()?;
            let stdout =
                strip_one_line_ending(String::from_utf8_lossy(&output.stdout).into_owned());
            let stderr = String::from_utf8_lossy(&output.stderr).into_owned();

            if output.status.success() {
                Ok(RunnerExecution {
                    status: output.status,
                    stdout: Some(stdout),
                    stderr,
                })
            } else {
                Err(CliError::RunnerFailed {
                    command: self.raw_full_command(),
                    reason: failure_reason(output.status, stderr),
                })
            }
        } else {
            command
                .stdin(Stdio::inherit())
                .stdout(Stdio::inherit())
                .stderr(Stdio::inherit());
            let status = command.status()?;

            if status.success() {
                Ok(RunnerExecution {
                    status,
                    stdout: None,
                    stderr: String::new(),
                })
            } else {
                Err(CliError::RunnerFailed {
                    command: self.raw_full_command(),
                    reason: failure_reason(status, String::new()),
                })
            }
        }
    }

    /// Execute the described command and return collected stdout when requested.
    pub fn run(&self) -> Result<Option<String>> {
        Ok(self.execute()?.stdout)
    }

    fn command_line_for_execution(&self) -> String {
        let mut parts = Vec::with_capacity(2 + self.prefix_args.len());
        parts.push(quote_shell_part(&self.binary));
        parts.extend(self.prefix_args.iter().map(|arg| quote_shell_part(arg)));
        parts.push(self.command.clone());
        parts.join(" ")
    }
}

#[derive(Debug)]
pub struct RunnerExecution {
    pub status: ExitStatus,
    pub stdout: Option<String>,
    pub stderr: String,
}

impl RunnerExecution {
    pub fn success(&self) -> bool {
        self.status.success()
    }
}

pub trait Runner {
    fn kind(&self) -> RunnerKind;
    fn binary(&self) -> &str;
    fn prefix_args(&self) -> &[String];

    fn describe(
        &self,
        command: impl Into<String>,
        collect: bool,
        cwd: Option<PathBuf>,
    ) -> RunnerCommand {
        RunnerCommand {
            binary: self.binary().to_owned(),
            prefix_args: self.prefix_args().to_vec(),
            command: command.into(),
            collect,
            cwd,
            shell: true,
            env: Vec::new(),
        }
    }

    fn raw_full_command(&self, command: impl AsRef<str>) -> String {
        self.describe(command.as_ref(), false, None)
            .raw_full_command()
    }

    fn execute(
        &self,
        command: impl Into<String>,
        collect: bool,
        cwd: Option<PathBuf>,
    ) -> Result<RunnerExecution> {
        self.describe(command, collect, cwd).execute()
    }

    fn run(
        &self,
        command: impl Into<String>,
        collect: bool,
        cwd: Option<PathBuf>,
    ) -> Result<Option<String>> {
        self.describe(command, collect, cwd).run()
    }
}

#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProcessRunner {
    kind: RunnerKind,
    binary: String,
    prefix_args: Vec<String>,
}

impl ProcessRunner {
    pub fn new(kind: RunnerKind) -> Self {
        Self {
            kind,
            binary: kind.binary().to_owned(),
            prefix_args: Vec::new(),
        }
    }

    pub fn with_binary(kind: RunnerKind, binary: impl Into<String>) -> Self {
        Self {
            kind,
            binary: binary.into(),
            prefix_args: Vec::new(),
        }
    }

    pub fn schematic(schematics_binary: impl AsRef<Path>) -> Self {
        Self {
            kind: RunnerKind::Schematic,
            binary: RunnerKind::Schematic.binary().to_owned(),
            prefix_args: vec![quote_path(schematics_binary.as_ref())],
        }
    }
}

impl Runner for ProcessRunner {
    fn kind(&self) -> RunnerKind {
        self.kind
    }

    fn binary(&self) -> &str {
        &self.binary
    }

    fn prefix_args(&self) -> &[String] {
        &self.prefix_args
    }
}

#[derive(Clone, Copy, Debug, Default)]
pub struct RunnerFactory;

impl RunnerFactory {
    pub fn create(kind: RunnerKind) -> ProcessRunner {
        ProcessRunner::new(kind)
    }

    pub fn create_schematic(schematics_binary: impl AsRef<Path>) -> ProcessRunner {
        ProcessRunner::schematic(schematics_binary)
    }
}

fn quote_path(path: &Path) -> String {
    format!("\"{}\"", path.display())
}

fn shell_command(command_line: &str) -> Command {
    #[cfg(windows)]
    {
        let mut command =
            Command::new(std::env::var_os("COMSPEC").unwrap_or_else(|| "cmd.exe".into()));
        command.arg("/C").arg(command_line);
        command
    }

    #[cfg(not(windows))]
    {
        let mut command = Command::new("sh");
        command.arg("-c").arg(command_line);
        command
    }
}

fn quote_shell_part(part: &str) -> String {
    if part.is_empty()
        || part.starts_with('"')
        || part.starts_with('\'')
        || !part.chars().any(char::is_whitespace)
    {
        return part.to_owned();
    }

    #[cfg(windows)]
    {
        format!("\"{}\"", part.replace('"', "\\\""))
    }

    #[cfg(not(windows))]
    {
        format!("'{}'", part.replace('\'', "'\\''"))
    }
}

fn strip_one_line_ending(mut value: String) -> String {
    if value.ends_with("\r\n") {
        value.truncate(value.len() - 2);
    } else if value.ends_with('\n') {
        value.truncate(value.len() - 1);
    }
    value
}

fn failure_reason(status: ExitStatus, stderr: String) -> String {
    let status = match status.code() {
        Some(code) => format!("process exited with status code {code}"),
        None => "process terminated by signal".to_owned(),
    };

    let stderr = stderr.trim();
    if stderr.is_empty() {
        status
    } else {
        format!("{status}: {stderr}")
    }
}