noxid-cli 0.2.1

The Noxid compiler command line: check, build, test, adapt, and the agent surface
use crate::project::{self, ProjectBuildOptions};
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};

pub fn run(mut arguments: impl Iterator<Item = String>) -> Result<(), String> {
    let operation = arguments.next().ok_or("noxid task requires `run <name>`")?;
    if operation != "run" {
        return Err(format!(
            "unknown noxid task operation `{operation}`; expected `run <name>`"
        ));
    }
    let name = arguments
        .next()
        .ok_or("noxid task run requires a task name")?;
    if arguments.next().is_some() {
        return Err("noxid task run accepts exactly one task name".into());
    }
    let project = std::env::current_dir()
        .map_err(|error| format!("cannot resolve the current project directory: {error}"))?;
    run_in(&project, &name)
}

pub(crate) fn run_in(project_root: &Path, name: &str) -> Result<(), String> {
    if !project::task_schedules(project_root)?
        .iter()
        .any(|(task, _)| task == name)
    {
        return Err(format!(
            "error[TASK_NOT_FOUND]: no scheduled task `{name}` is declared under server/tasks"
        ));
    }
    let nonce = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .map_err(|error| error.to_string())?
        .as_nanos();
    let output =
        std::env::temp_dir().join(format!("noxid-task-run-{}-{nonce}", std::process::id()));
    let _cleanup = TemporaryBuild(output.clone());
    project::build_project(
        project_root,
        &ProjectBuildOptions {
            out_dir: output.clone(),
            title: None,
            development: true,
            strict_npm: false,
        },
    )?;
    let handler = output.join("server/handler.js");
    if !handler.is_file() {
        return Err(
            "error[TASK_HANDLER_MISSING]: task build did not emit server/handler.js".into(),
        );
    }
    let base = project::base_path(project_root)?;
    let prefix = if base == "/" {
        String::new()
    } else {
        base.trim_end_matches('/').to_string()
    };
    let url = format!(
        "http://noxid.local{prefix}/_noxid/tasks/{}",
        percent_encode_path_segment(name)
    );
    let script = r#"const [handlerPath, taskUrl] = process.argv.slice(1);
const { pathToFileURL } = await import("node:url");
const { fetch: handle } = await import(pathToFileURL(handlerPath).href);
const response = await handle(new Request(taskUrl, { method: "POST" }), process.env, Object.create(null));
const body = await response.text();
if (!response.ok) { process.stderr.write(body + "\n"); process.exitCode = 1; }
else process.stdout.write(body + "\n");
"#;
    let result = Command::new("node")
        .args(["--input-type=module", "-e", script])
        .arg(&handler)
        .arg(&url)
        .current_dir(project_root)
        .output()
        .map_err(|error| format!("cannot start Node.js task runner: {error}"))?;
    if !result.status.success() {
        return Err(format!(
            "task `{name}` was refused:\n{}",
            String::from_utf8_lossy(&result.stderr).trim()
        ));
    }
    print!("{}", String::from_utf8_lossy(&result.stdout));
    Ok(())
}

fn percent_encode_path_segment(value: &str) -> String {
    let mut encoded = String::new();
    for byte in value.bytes() {
        if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
            encoded.push(char::from(byte));
        } else {
            encoded.push_str(&format!("%{byte:02X}"));
        }
    }
    encoded
}

struct TemporaryBuild(PathBuf);

impl Drop for TemporaryBuild {
    fn drop(&mut self) {
        if self.0.starts_with(std::env::temp_dir()) {
            let _ = fs::remove_dir_all(&self.0);
        }
    }
}