main-spex 0.23.0

AI-first development framework that turns specifications into deterministic, verifiable code
use std::fs;
use std::io::Write;
use std::path::Path;
use std::process::{Command, Stdio};

pub fn report_text(stdout: &[u8]) -> String {
    String::from_utf8_lossy(stdout).replace('\u{8}', "")
}

pub fn with_version_header(body: &str) -> String {
    format!("spex {}\n\n{body}", env!("CARGO_PKG_VERSION"))
}

pub fn run_spex(dir: &Path, args: &[&str], stdin: &str) -> std::process::Output {
    run_spex_with_env(dir, args, stdin, &[])
}

pub fn run_spex_with_env(
    dir: &Path,
    args: &[&str],
    stdin: &str,
    envs: &[(&str, &str)],
) -> std::process::Output {
    let bin = Path::new(env!("CARGO_BIN_EXE_spex"));
    let bin_dir = bin.parent().expect("binary directory");
    let path = match std::env::var_os("PATH") {
        Some(existing) => {
            let mut dirs = vec![bin_dir.to_path_buf()];
            dirs.extend(std::env::split_paths(&existing));
            std::env::join_paths(dirs).expect("join PATH")
        }
        None => bin_dir.as_os_str().to_owned(),
    };

    let mut child = Command::new(bin)
        .args(args)
        .current_dir(dir)
        .env("PATH", path)
        .env_remove("RUST_BACKTRACE")
        .env_remove("RUST_LIB_BACKTRACE")
        .envs(envs.iter().copied())
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped())
        .spawn()
        .expect("spawn spex");

    child
        .stdin
        .take()
        .expect("open spex stdin")
        .write_all(stdin.as_bytes())
        .expect("write spex stdin");

    child.wait_with_output().expect("wait for spex")
}

pub fn dummy_project_fixture() -> std::path::PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/dummy-project")
}

pub fn runners_project_fixture() -> std::path::PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/runners-project")
}

pub fn script_env_project_fixture() -> std::path::PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/script-env-project")
}

pub fn temp_project(fixture: &Path) -> (tempfile::TempDir, std::path::PathBuf) {
    let temp_dir = tempfile::tempdir().expect("create temp dir");
    let project = temp_dir.path().join("proj");
    copy_project(fixture, &project);
    (temp_dir, project)
}

pub fn copy_project(fixture: &Path, project: &Path) {
    copy_dir(fixture, project);
    let bundled_schemas = Path::new(env!("CARGO_MANIFEST_DIR")).join("templates/schemas");
    for entry in fs::read_dir(project).expect("read project dir") {
        let path = entry.expect("read dir entry").path();
        let is_spex_dir = path.is_dir()
            && path
                .file_name()
                .and_then(|name| name.to_str())
                .is_some_and(|name| name == "spex" || name.ends_with("-spex"));
        if is_spex_dir {
            copy_dir(&bundled_schemas, &path.join("schemas"));
        }
    }
}

pub fn canonical_display(path: &Path) -> String {
    path.canonicalize()
        .expect("canonical path")
        .display()
        .to_string()
}

pub fn listed_ids(stdout: &[u8]) -> Vec<String> {
    let mut ids: Vec<String> = String::from_utf8_lossy(stdout)
        .lines()
        .filter_map(|line| line.strip_prefix("- id: "))
        .map(str::to_string)
        .collect();
    ids.sort();
    ids
}

pub fn assert_fails_mentioning(output: &std::process::Output, needle: &str) {
    let stderr = String::from_utf8_lossy(&output.stderr);
    assert!(
        !output.status.success(),
        "expected a non-zero exit\nstdout:\n{}",
        String::from_utf8_lossy(&output.stdout),
    );
    assert!(
        stderr.contains(needle),
        "stderr did not mention `{needle}`\nstderr:\n{stderr}",
    );
}

pub fn copy_dir(src: &Path, dst: &Path) {
    fs::create_dir_all(dst).expect("create destination dir");
    for entry in fs::read_dir(src).expect("read source dir") {
        let entry = entry.expect("read dir entry");
        let from = entry.path();
        let to = dst.join(entry.file_name());
        let file_type = entry.file_type().expect("read file type");
        if file_type.is_symlink() {
            let target = fs::read_link(&from).expect("read symlink target");
            std::os::unix::fs::symlink(target, &to).expect("create symlink");
        } else if file_type.is_dir() {
            let name = entry.file_name();
            if matches!(
                name.to_str(),
                Some("node_modules") | Some("target") | Some("dist")
            ) {
                continue;
            }
            copy_dir(&from, &to);
        } else {
            fs::copy(&from, &to).expect("copy file");
        }
    }
}

pub fn copy_no_spex(project: &Path) {
    let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/no-spex-project");
    copy_dir(&fixture, project);

    let project_name = project
        .file_name()
        .and_then(|name| name.to_str())
        .expect("project folder name")
        .to_string();
    for short in ["api", "frontend"] {
        fs::rename(
            project.join(short),
            project.join(format!("{project_name}-{short}")),
        )
        .expect("rename package to follow naming convention");
    }
}

pub fn add_packages(project: &Path) {
    let config_path = project.join("proj-spex/spex.yaml");
    let mut config = fs::read_to_string(&config_path).expect("read spex.yaml");
    config.push_str(
        "  - package_name: proj-api\n    package_short_name: api\n    path: ../proj-api\n    rules:\n      - secret-handling\n  - package_name: proj-frontend\n    package_short_name: frontend\n    path: ../proj-frontend\n    rules:\n      - writing-style\n",
    );
    fs::write(&config_path, config).expect("write spex.yaml");
}