nestrs-cli-rs 0.1.0

Rust port of the Nest CLI for the nestrs organization.
Documentation
use std::cell::RefCell;
use std::fs;
use std::path::PathBuf;
use std::rc::Rc;
use std::time::{SystemTime, UNIX_EPOCH};

use cli::build_executor::{BuildExecutionPlan, BuildWatchExecutionPlan, create_build_watch_state};
use cli::commands::{Input, InputValue};
use cli::configuration::Configuration;
use cli::error::CliError;
use cli::start_action::{StartActionPlanRequest, create_start_action_plan};
use cli::start_executor::{
    StartChildProcess, StartExecutionPlan, StartProcessSpawner, StartWatchExecutionPlan,
    create_start_execution_plan, execute_start_plan, execute_start_watch_plan_with, parse_env_file,
    start_watch_tick,
};
use schematics::factories::ApplicationOptions;
use schematics::generators::write_application;

#[test]
fn start_executor_runs_generated_rust_project_with_cargo_run() {
    let root = unique_temp_dir("nestrs-start-generated");
    write_application(
        ApplicationOptions {
            name: "Cats API".to_string(),
            author: None,
            description: None,
            directory: None,
            strict: None,
            version: None,
            language: Some("rs".to_string()),
            package_manager: None,
            dependencies: None,
            dev_dependencies: None,
            spec: None,
            spec_file_suffix: None,
        },
        &root,
    )
    .expect("application should be generated");

    let project_root = root.join("cats-api");
    let plan = create_start_action_plan(StartActionPlanRequest {
        cwd: project_root,
        configuration: Configuration::default(),
        command_inputs: vec![],
        command_options: vec![],
        extra_flags: vec![],
        ts_build_info_file: None,
    })
    .expect("start action plan should be created");
    let mut execution_plan =
        create_start_execution_plan(&plan).expect("start execution plan should be created");

    assert_eq!(execution_plan.command.binary, "cargo");
    assert_eq!(execution_plan.command.command, "run");
    execution_plan.command.collect = true;
    execute_start_plan(&execution_plan).expect("generated Rust project should cargo run");
}

#[test]
fn start_executor_loads_env_files_into_cargo_run_command() {
    let root = unique_temp_dir("nestrs-start-env-file");
    write_file(
        &root.join(".env"),
        "PORT=3000\nexport APP_NAME=\"Cats API\"\n# comment\nEMPTY=\n",
    );

    let plan = create_start_action_plan(StartActionPlanRequest {
        cwd: root,
        configuration: Configuration::default(),
        command_inputs: vec![],
        command_options: vec![Input::new(
            "envFile",
            Some(InputValue::StringList(vec![".env".to_string()])),
        )],
        extra_flags: vec![],
        ts_build_info_file: None,
    })
    .expect("start action plan should be created");
    let execution_plan =
        create_start_execution_plan(&plan).expect("start execution plan should be created");

    assert!(execution_plan.warnings.is_empty());
    assert_eq!(
        execution_plan.command.env,
        vec![
            ("PORT".to_string(), "3000".to_string()),
            ("APP_NAME".to_string(), "Cats API".to_string()),
            ("EMPTY".to_string(), String::new()),
        ]
    );
}

#[test]
fn env_parser_ignores_comments_and_strips_quotes() {
    let values = parse_env_file(
        r#"
        # ignored
        FOO=bar # inline
        QUOTED="hello # still value"
        SINGLE='value'
        "#,
    )
    .expect("env file should parse");

    assert_eq!(
        values,
        vec![
            ("FOO".to_string(), "bar".to_string()),
            ("QUOTED".to_string(), "hello # still value".to_string()),
            ("SINGLE".to_string(), "value".to_string()),
        ]
    );
}

#[test]
fn start_executor_watch_rebuilds_and_restarts_running_process() {
    let root = unique_temp_dir("nestrs-start-watch-restart");
    write_file(
        &root.join("Cargo.toml"),
        r#"[package]
name = "watch-restart-fixture"
version = "0.1.0"
edition = "2024"
"#,
    );
    let source_file = root.join("src").join("main.rs");
    write_file(&source_file, "fn main() {}\n");

    let plan = create_start_action_plan(StartActionPlanRequest {
        cwd: root,
        configuration: Configuration::default(),
        command_inputs: vec![],
        command_options: vec![Input::new("watch", Some(InputValue::Bool(true)))],
        extra_flags: vec![],
        ts_build_info_file: None,
    })
    .expect("start action plan should be created");
    let execution_plan =
        create_start_execution_plan(&plan).expect("start execution plan should be created");
    let watch = execution_plan.watch.as_ref().expect("watch plan");
    let build_watch = watch.build_plan.watch.as_ref().expect("build watch plan");
    let mut state = create_build_watch_state(build_watch).expect("watch state");
    let kills = Rc::new(RefCell::new(Vec::new()));
    let mut spawner = MockSpawner::new(kills.clone());
    let mut child = spawner
        .spawn(&execution_plan.command)
        .expect("initial child should spawn");

    write_file(&source_file, "fn main() { println!(\"changed\"); }\n");

    let results = start_watch_tick(&execution_plan, &mut state, &mut child, &mut spawner)
        .expect("watch tick should rebuild and restart");

    assert_eq!(results.len(), 1);
    assert!(results[0].changed);
    assert_eq!(spawner.spawns.len(), 2);
    assert_eq!(kills.borrow().as_slice(), &[1]);
    assert_eq!(child.id, 2);
}

#[test]
fn start_executor_watch_spawn_failure_is_reported() {
    let execution_plan = StartExecutionPlan {
        command: cli::runners::RunnerCommand {
            binary: "missing-cargo".to_string(),
            prefix_args: Vec::new(),
            command: "run".to_string(),
            collect: false,
            cwd: None,
            shell: true,
            env: Vec::new(),
        },
        warnings: Vec::new(),
        watch: Some(StartWatchExecutionPlan {
            build_plan: BuildExecutionPlan {
                commands: Vec::new(),
                warnings: Vec::new(),
                projects: Vec::new(),
                watch: Some(BuildWatchExecutionPlan {
                    poll_interval: std::time::Duration::from_millis(1),
                    projects: Vec::new(),
                }),
            },
            kill_previous_process_on_success: true,
        }),
    };
    let mut spawner = FailingSpawner;

    let error = execute_start_watch_plan_with(&execution_plan, &mut spawner)
        .unwrap_err()
        .to_string();

    assert!(error.contains("Failed to execute command"));
    assert!(error.contains("failed to spawn process"));
}

#[test]
fn start_executor_watch_does_not_restart_when_rebuild_fails() {
    let root = unique_temp_dir("nestrs-start-watch-build-fails");
    write_file(
        &root.join("Cargo.toml"),
        r#"[package]
name = "watch-build-fails-fixture"
version = "0.1.0"
edition = "2024"
"#,
    );
    let source_file = root.join("src").join("main.rs");
    write_file(&source_file, "fn main() {}\n");

    let plan = create_start_action_plan(StartActionPlanRequest {
        cwd: root,
        configuration: Configuration::default(),
        command_inputs: vec![],
        command_options: vec![Input::new("watch", Some(InputValue::Bool(true)))],
        extra_flags: vec![],
        ts_build_info_file: None,
    })
    .expect("start action plan should be created");
    let execution_plan =
        create_start_execution_plan(&plan).expect("start execution plan should be created");
    let watch = execution_plan.watch.as_ref().expect("watch plan");
    let build_watch = watch.build_plan.watch.as_ref().expect("build watch plan");
    let mut state = create_build_watch_state(build_watch).expect("watch state");
    let kills = Rc::new(RefCell::new(Vec::new()));
    let mut spawner = MockSpawner::new(kills.clone());
    let mut child = spawner
        .spawn(&execution_plan.command)
        .expect("initial child should spawn");

    write_file(&source_file, "fn main( {\n");

    let error = start_watch_tick(&execution_plan, &mut state, &mut child, &mut spawner)
        .expect_err("watch tick should report the failed rebuild")
        .to_string();

    assert!(error.contains("Failed to execute command"));
    assert_eq!(spawner.spawns.len(), 1);
    assert!(kills.borrow().is_empty());
    assert_eq!(child.id, 1);
}

fn unique_temp_dir(name: &str) -> PathBuf {
    let timestamp = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .expect("system time should be after epoch")
        .as_nanos();
    let root = std::env::temp_dir().join(format!("{name}-{}-{timestamp}", std::process::id()));
    if root.exists() {
        fs::remove_dir_all(&root).expect("stale temp directory should be removable");
    }
    fs::create_dir_all(&root).expect("temp directory should be created");
    root
}

fn write_file(path: &std::path::Path, content: &str) {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent).expect("parent directory should be created");
    }
    fs::write(path, content).expect("file should be written");
}

#[derive(Debug)]
struct MockChild {
    id: usize,
    kills: Rc<RefCell<Vec<usize>>>,
}

impl StartChildProcess for MockChild {
    fn kill(&mut self) -> cli::Result<()> {
        self.kills.borrow_mut().push(self.id);
        Ok(())
    }
}

#[derive(Debug)]
struct MockSpawner {
    next_id: usize,
    spawns: Vec<String>,
    kills: Rc<RefCell<Vec<usize>>>,
}

impl MockSpawner {
    fn new(kills: Rc<RefCell<Vec<usize>>>) -> Self {
        Self {
            next_id: 1,
            spawns: Vec::new(),
            kills,
        }
    }
}

impl StartProcessSpawner for MockSpawner {
    type Child = MockChild;

    fn spawn(&mut self, command: &cli::runners::RunnerCommand) -> cli::Result<Self::Child> {
        self.spawns.push(command.raw_full_command());
        let child = MockChild {
            id: self.next_id,
            kills: self.kills.clone(),
        };
        self.next_id += 1;
        Ok(child)
    }
}

#[derive(Debug)]
struct FailingSpawner;

impl StartProcessSpawner for FailingSpawner {
    type Child = MockChild;

    fn spawn(&mut self, command: &cli::runners::RunnerCommand) -> cli::Result<Self::Child> {
        Err(CliError::RunnerFailed {
            command: command.raw_full_command(),
            reason: "failed to spawn process: test failure".to_string(),
        })
    }
}