nestrs-cli-rs 0.1.0

Rust port of the Nest CLI for the nestrs organization.
Documentation
//! Executable start behavior for Rust projects generated by this CLI.

use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::thread;

use crate::Result;
use crate::build_executor::{
    BuildExecutionPlan, BuildWatchState, BuildWatchTickResult, create_build_execution_plan,
    create_build_watch_state, execute_build_plan, execute_build_watch_tick,
};
use crate::error::CliError;
use crate::runners::RunnerCommand;
use crate::start_action::StartActionPlan;

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StartExecutionPlan {
    pub command: RunnerCommand,
    pub warnings: Vec<String>,
    pub watch: Option<StartWatchExecutionPlan>,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StartWatchExecutionPlan {
    pub build_plan: BuildExecutionPlan,
    pub kill_previous_process_on_success: bool,
}

pub trait StartChildProcess {
    fn kill(&mut self) -> Result<()>;
}

pub trait StartProcessSpawner {
    type Child: StartChildProcess;

    fn spawn(&mut self, command: &RunnerCommand) -> Result<Self::Child>;
}

#[derive(Debug, Default)]
pub struct OsStartProcessSpawner;

#[derive(Debug)]
pub struct OsStartChildProcess {
    child: Child,
}

pub fn create_start_execution_plan(plan: &StartActionPlan) -> Result<StartExecutionPlan> {
    let mut warnings = Vec::new();
    if plan.process_plan.debug_flag.is_some() {
        warnings.push("`--debug` is ignored for Rust cargo runs".to_string());
    }
    if plan.process_plan.requested_exec.is_some() {
        warnings.push("`--exec` is ignored for Rust cargo runs".to_string());
    }
    if !plan.process_plan.shell {
        warnings.push("`--no-shell` is ignored for Rust cargo runs".to_string());
    }

    let watch = if plan.build_plan.watch_mode {
        let build_plan = create_build_execution_plan(&plan.build_plan)?;
        warnings.extend(build_plan.warnings.clone());
        Some(StartWatchExecutionPlan {
            build_plan,
            kill_previous_process_on_success: plan
                .process_plan
                .restart
                .kill_previous_process_on_success,
        })
    } else {
        None
    };

    let mut command = plan.process_plan.source_root_command.clone();
    command.env = load_env_files(command.cwd.as_deref(), &plan.process_plan.env_file)?;

    Ok(StartExecutionPlan {
        command,
        warnings,
        watch,
    })
}

pub fn execute_start_plan(plan: &StartExecutionPlan) -> Result<()> {
    if plan.watch.is_some() {
        let mut spawner = OsStartProcessSpawner;
        return execute_start_watch_plan_with(plan, &mut spawner);
    }

    plan.command.execute().map(|_| ())
}

pub fn execute_start_watch_plan_with<S>(plan: &StartExecutionPlan, spawner: &mut S) -> Result<()>
where
    S: StartProcessSpawner,
{
    let watch = plan.watch.as_ref().ok_or_else(|| {
        CliError::UnsupportedCommand(
            "`nest start --watch` requires a watch execution plan".to_string(),
        )
    })?;
    let build_watch = watch.build_plan.watch.as_ref().ok_or_else(|| {
        CliError::UnsupportedCommand(
            "`nest start --watch` requires a build watch execution plan".to_string(),
        )
    })?;

    execute_build_plan(&watch.build_plan)?;
    let mut child = spawner.spawn(&plan.command)?;
    let mut state = create_build_watch_state(build_watch)?;

    loop {
        thread::sleep(build_watch.poll_interval);
        start_watch_tick(plan, &mut state, &mut child, spawner)?;
    }
}

pub fn start_watch_tick<S>(
    plan: &StartExecutionPlan,
    state: &mut BuildWatchState,
    child: &mut S::Child,
    spawner: &mut S,
) -> Result<Vec<BuildWatchTickResult>>
where
    S: StartProcessSpawner,
{
    let watch = plan.watch.as_ref().ok_or_else(|| {
        CliError::UnsupportedCommand(
            "`nest start --watch` requires a watch execution plan".to_string(),
        )
    })?;

    let results = execute_build_watch_tick(&watch.build_plan, state)?;
    if results.iter().any(|result| result.changed) {
        let mut next_child = spawner.spawn(&plan.command)?;
        if watch.kill_previous_process_on_success {
            child.kill()?;
        }
        std::mem::swap(child, &mut next_child);
    }

    Ok(results)
}

impl StartProcessSpawner for OsStartProcessSpawner {
    type Child = OsStartChildProcess;

    fn spawn(&mut self, command: &RunnerCommand) -> Result<Self::Child> {
        let mut process = command_for_spawn(command);
        if let Some(cwd) = &command.cwd {
            process.current_dir(cwd);
        }
        process.envs(command.env.iter().map(|(key, value)| (key, value)));
        process
            .stdin(Stdio::inherit())
            .stdout(Stdio::inherit())
            .stderr(Stdio::inherit());

        let child = process.spawn().map_err(|error| CliError::RunnerFailed {
            command: command.raw_full_command(),
            reason: format!("failed to spawn process: {error}"),
        })?;

        Ok(OsStartChildProcess { child })
    }
}

impl StartChildProcess for OsStartChildProcess {
    fn kill(&mut self) -> Result<()> {
        self.child.kill()?;
        self.child.wait()?;
        Ok(())
    }
}

fn command_for_spawn(command: &RunnerCommand) -> Command {
    if command.shell {
        return shell_command(&command_line_for_execution(command));
    }

    let mut process = Command::new(&command.binary);
    process.args(&command.prefix_args).arg(&command.command);
    process.envs(command.env.iter().map(|(key, value)| (key, value)));
    process
}

pub fn load_env_files(cwd: Option<&Path>, env_files: &[String]) -> Result<Vec<(String, String)>> {
    let cwd = cwd.unwrap_or_else(|| Path::new("."));
    let mut values = Vec::new();

    for env_file in env_files {
        let path = resolve_env_file(cwd, env_file);
        let content = fs::read_to_string(&path).map_err(|error| {
            CliError::InvalidConfiguration(format!(
                "Failed to read env file `{}`: {error}",
                path.display()
            ))
        })?;
        values.extend(parse_env_file(&content)?);
    }

    Ok(values)
}

fn resolve_env_file(cwd: &Path, env_file: &str) -> PathBuf {
    let path = Path::new(env_file);
    if path.is_absolute() {
        path.to_path_buf()
    } else {
        cwd.join(path)
    }
}

pub fn parse_env_file(content: &str) -> Result<Vec<(String, String)>> {
    let mut values = Vec::new();

    for (line_number, raw_line) in content.lines().enumerate() {
        let line = raw_line.trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let line = line.strip_prefix("export ").unwrap_or(line);
        let Some((key, value)) = line.split_once('=') else {
            return Err(CliError::InvalidConfiguration(format!(
                "Invalid env file line {}: missing `=`",
                line_number + 1
            )));
        };
        let key = key.trim();
        if key.is_empty() || key.contains(char::is_whitespace) {
            return Err(CliError::InvalidConfiguration(format!(
                "Invalid env file line {}: invalid key `{key}`",
                line_number + 1
            )));
        }
        values.push((key.to_string(), parse_env_value(value.trim())));
    }

    Ok(values)
}

fn parse_env_value(value: &str) -> String {
    let value = strip_inline_comment(value).trim();
    if (value.starts_with('"') && value.ends_with('"'))
        || (value.starts_with('\'') && value.ends_with('\''))
    {
        value[1..value.len() - 1].to_string()
    } else {
        value.to_string()
    }
}

fn strip_inline_comment(value: &str) -> &str {
    let mut in_single = false;
    let mut in_double = false;
    let mut previous = None;

    for (index, character) in value.char_indices() {
        match character {
            '\'' if !in_double => in_single = !in_single,
            '"' if !in_single => in_double = !in_double,
            '#' if !in_single
                && !in_double
                && previous.is_none_or(|previous: char| previous.is_whitespace()) =>
            {
                return &value[..index];
            }
            _ => {}
        }
        previous = Some(character);
    }
    value
}

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

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('\'', "'\\''"))
    }
}