config-in-a-can 0.0.7

Config-in-a-Can
use camino::Utf8PathBuf;
use std::{env, ffi::OsStr, fs, process::ExitCode};

#[derive(Debug, clap::Parser)]
#[clap(version)]
pub struct Args {
    #[clap(subcommand)]
    pub subcommand: Subcommand,
}

#[derive(Debug, clap::Subcommand)]
pub enum Subcommand {
    Dev(SubcmdDev),
    Build(SubcmdBuild),
    Check(SubcmdCheck),
}

impl Args {
    pub fn parse() -> Args {
        <Args as clap::Parser>::parse()
    }
}

pub fn main() -> ExitCode {
    if is_global_installation() {
        // if so, then re-execute using the project specific can installation

        // TODO handle case where can isn't installed in the project (ie. run pnpm install)

        cd_to_project_root();

        // TODO this code is gross
        let mut args = env::args();
        args.next();
        let args: Vec<_> = args.map(|a| a.to_string()).collect();
        let args_ref: Vec<_> = args.iter().map(|a| a.as_ref()).collect();
        return result_to_code(execute("./node_modules/config-in-a-can/can", &args_ref));
    }

    let args = Args::parse();
    result_to_code(match args.subcommand {
        Subcommand::Dev(args) => subcmd_dev(args),
        Subcommand::Build(args) => subcmd_build(args),
        Subcommand::Check(args) => subcmd_check(args),
    })
}

fn result_to_code(result: Result<(), ExitCode>) -> ExitCode {
    match result {
        Ok(_) => ExitCode::from(0),
        Err(code) => code,
    }
}

fn is_global_installation() -> bool {
    // is this the can version installed globally (eg: with cargo)

    // get the path to the binary
    let mut exe = env::current_exe().unwrap();

    // and check if it's inside a node_modules folder
    while exe.pop() {
        if exe.file_name() == Some(OsStr::new("node_modules")) {
            // if so is isn't the global installation
            return false;
        }
    }
    true
}

fn cd_to_project_root() {
    // this assumes the first package.json we find is the
    // later this should use the presence of the can.config.ts file as the root

    let cwd = env::current_dir().unwrap();
    let mut dir = Some(cwd.as_path());
    while let Some(current) = dir {
        let candidate = current.join("package.json");
        if candidate.exists() {
            env::set_current_dir(current).unwrap();
            return;
        }
        dir = current.parent();
    }
    panic!("could not find package.json, unable to determine project root directory")
}

#[derive(Debug, clap::Args)]
pub struct SubcmdDev {
    //
}

fn subcmd_dev(_args: SubcmdDev) -> Result<(), ExitCode> {
    ensure_config();
    execute_dependency_bin("vite", &[])
}

#[derive(Debug, clap::Args)]
pub struct SubcmdBuild {
    //
}

fn subcmd_build(_args: SubcmdBuild) -> Result<(), ExitCode> {
    ensure_config();
    execute_dependency_bin("vite", &["build"])
}

#[derive(Debug, clap::Args)]
pub struct SubcmdCheck {
    //
}

fn subcmd_check(_args: SubcmdCheck) -> Result<(), ExitCode> {
    ensure_config();
    let oxlint = execute_dependency_bin("oxlint", &[]);
    let biome = execute_dependency_bin("biome", &["format"]);
    let svelte_check = execute_dependency_bin("svelte-check", &["--tsconfig", "./tsconfig.json"]);
    svelte_check.or(oxlint).or(biome)
}

// execute a dependency binary
fn execute_dependency_bin(bin: &str, args: &[&str]) -> Result<(), ExitCode> {
    let mut path = Utf8PathBuf::from("./node_modules");
    // because of potential bugs caused by symlinks in pnpm/node (not sure which)
    // resolve the initial symlink as a workaround
    let can_path = fs::read_link("./node_modules/config-in-a-can").unwrap();
    let can_path: Utf8PathBuf = can_path.try_into().unwrap();
    path.push(can_path);
    path.push("node_modules/.bin");
    path.push(bin);
    execute(path.as_str(), args)
}

fn execute(bin: &str, args: &[&str]) -> Result<(), ExitCode> {
    use std::process::Command;

    print!("$ {bin}");
    for arg in args {
        print!(" {arg}");
    }
    println!();

    // node seems to have some behaviour that causes chained ../ that go above the initial ./ to
    // fail
    let mut fullpath = env::current_dir().unwrap();
    fullpath.push(bin);

    let status = Command::new(fullpath).args(args).status();
    match status.map(|status| status.code()) {
        Ok(Some(0)) => Ok(()),
        Ok(Some(code)) => Err(ExitCode::from(code as u8)),
        _ => Err(ExitCode::FAILURE),
    }
}

#[derive(rust_embed::Embed)]
#[folder = "config/"]
struct Config;

fn ensure_config() {
    println!("Generating config files:");
    for path in Config::iter() {
        let file = Config::get(&path).unwrap();
        let path = path.strip_suffix(".template").unwrap_or(&path);
        println!(" {path}");
        fs::write(path, file.data).unwrap();
    }
}