px-cli 0.1.0

Phoenix-rs application CLI (px new / make / migrate / dev)
Documentation
use std::{
    env,
    ffi::OsString,
    path::PathBuf,
    process::{Command, ExitCode},
};

use phoenix_cli::{
    ControllerOptions, DependencySource, DevConfig, DevSupervisor, GenerateOptions, ModelOptions,
    NewProjectOptions, ProjectGenerator, create_project, release_build, release_install,
    release_rollback, release_status,
};

const HELP: &str = r"Phoenix-rs application CLI (px)

Install: cargo install px
         cargo install --git https://github.com/MageGojo/Phoenix-rs px
         cargo install --path crates/phoenix-cli

Usage:
  px new <project> [--framework-path <path>] [--no-install] [--no-git]
  px dev
  px migrate
  px status
  px rollback [--step <count>]
  px fresh [--seed]
  px seed
  px make:controller <name> [--resource] [--route] [--force]
  px make:model <name> [--all] [--migration] [--controller] [--resource]
                            [--request] [--api-resource] [--page] [--force]
  px make:migration <name> [--force]
  px make:request <name> [--force]
  px make:resource <name> [--force]
  px make:middleware <name> [--force]
  px make:page <path> [--force]
  px make:island <name> [--force]
  px make:command <name> [--force]
  px list
  px release [--version <v>] [--output <dir>] [--tarball] [--bin <name>]
               [--skip-npm] [--skip-types] [--target <triple>]
  px release:install --tarball <path> | --path <dir> [--deploy-root <dir>]
                     [--version <v>] [--skip-migrate] [--no-switch]
                     [--restart-cmd <shell>] [--dry-run]
  px release:rollback [--deploy-root <dir>] [--to <version>] [--steps <n>]
                      [--restart-cmd <shell>] [--skip-restart] [--dry-run]
  px release:status [--deploy-root <dir>] [--json]

Examples:
  px new my-app
  px migrate
  px rollback --step 2
  px fresh --seed
  px make:model Post --all
  px make:controller Admin/ReportController --resource
  px make:page posts/index
  px make:command Update
  px release --version 0.1.0 --tarball
  px release:install --tarball ./app-0.1.0.tar.gz --version 0.1.0
  px release:status
";

#[tokio::main]
async fn main() -> ExitCode {
    match run(env::args_os().skip(1).collect()).await {
        Ok(()) => ExitCode::SUCCESS,
        Err(error) => {
            eprintln!("px failed: {error}");
            ExitCode::FAILURE
        }
    }
}

async fn run(raw: Vec<OsString>) -> Result<(), String> {
    let mut arguments = raw
        .into_iter()
        .map(|argument| {
            argument
                .into_string()
                .map_err(|_| "arguments must be valid UTF-8".to_owned())
        })
        .collect::<Result<Vec<_>, _>>()?;
    if arguments.is_empty() || matches!(arguments[0].as_str(), "help" | "--help" | "-h") {
        print!("{HELP}");
        return Ok(());
    }
    let command = arguments.remove(0);
    match command.as_str() {
        "dev" => dev(arguments).await,
        "new" => new_project(arguments),
        "migrate" => database_command("migrate", &no_options(&arguments)?),
        "status" => database_command("status", &no_options(&arguments)?),
        "rollback" => database_command("rollback", &rollback_options(&arguments)?),
        "fresh" => database_command("fresh", &fresh_options(&arguments)?),
        "seed" => database_command("seed", &no_options(&arguments)?),
        "make:controller" => make_controller(arguments),
        "make:model" => make_model(arguments),
        "make:migration" => make_simple(arguments, |generator, name, options| {
            generator.migration(name, options)
        }),
        "make:request" => make_simple(arguments, |generator, name, options| {
            generator.request(name, options)
        }),
        "make:resource" => make_simple(arguments, |generator, name, options| {
            generator.resource(name, options)
        }),
        "make:middleware" => make_simple(arguments, |generator, name, options| {
            generator.middleware(name, options)
        }),
        "make:page" => make_simple(arguments, |generator, name, options| {
            generator.page(name, options)
        }),
        "make:island" => make_simple(arguments, |generator, name, options| {
            generator.island(name, options)
        }),
        "make:command" => make_simple(arguments, |generator, name, options| {
            generator.command(name, options)
        }),
        "list" => {
            require_empty(&arguments)?;
            print!("{HELP}");
            Ok(())
        }
        "release" | "release:build" => release_build(arguments),
        "release:install" => release_install(arguments),
        "release:rollback" => release_rollback(arguments),
        "release:status" => release_status(arguments),
        _ => Err(format!("unknown command `{command}`\n\n{HELP}")),
    }
}

async fn dev(arguments: Vec<String>) -> Result<(), String> {
    require_empty(&arguments)?;
    let generator = current_generator()?;
    if !generator.root().join("node_modules").is_dir() {
        return Err("JavaScript dependencies are missing; run `npm install` first".to_owned());
    }

    println!("Phoenix development environment");
    println!("  application: {}", generator.root().display());
    println!("  backend:     cargo run -- serve");
    println!("  frontend:    npm run dev -- --strictPort");
    println!("Press Ctrl-C to stop both processes.\n");

    DevSupervisor::new(DevConfig::default().working_directory(generator.root()))
        .run()
        .await
        .map_err(|error| error.to_string())
}

fn database_command(command: &str, arguments: &[String]) -> Result<(), String> {
    let generator = current_generator()?;
    let manager = generator.root().join("src/bin/phoenix-manage.rs");
    if !manager.is_file() {
        return Err(format!(
            "{} is missing; add the Phoenix management binary before running database commands",
            manager.display()
        ));
    }

    let cargo = env::var_os("CARGO").unwrap_or_else(|| OsString::from("cargo"));
    let status = Command::new(cargo)
        .args(["run", "--quiet", "--bin", "phoenix-manage", "--", command])
        .args(arguments)
        .current_dir(generator.root())
        .status()
        .map_err(|error| format!("failed to start the project management binary: {error}"))?;
    if status.success() {
        Ok(())
    } else {
        Err(format!(
            "project management command `{command}` exited with {status}"
        ))
    }
}

fn no_options(arguments: &[String]) -> Result<Vec<String>, String> {
    require_empty(arguments)?;
    Ok(Vec::new())
}

fn rollback_options(arguments: &[String]) -> Result<Vec<String>, String> {
    let mut steps = 1_usize;
    let mut index = 0;
    while index < arguments.len() {
        let argument = &arguments[index];
        if argument == "--step" {
            index += 1;
            let value = arguments.get(index).ok_or("--step requires a count")?;
            steps = parse_steps(value)?;
        } else if let Some(value) = argument.strip_prefix("--step=") {
            steps = parse_steps(value)?;
        } else {
            return Err(format!("unknown rollback option `{argument}`"));
        }
        index += 1;
    }
    Ok(vec![steps.to_string()])
}

fn parse_steps(value: &str) -> Result<usize, String> {
    value
        .parse::<usize>()
        .ok()
        .filter(|steps| *steps > 0)
        .ok_or_else(|| "rollback step count must be a positive integer".to_owned())
}

fn fresh_options(arguments: &[String]) -> Result<Vec<String>, String> {
    match arguments {
        [] => Ok(Vec::new()),
        [option] if option == "--seed" => Ok(vec![option.clone()]),
        [option] => Err(format!("unknown fresh option `{option}`")),
        _ => Err(format!("unexpected arguments: {}", arguments.join(" "))),
    }
}

fn new_project(arguments: Vec<String>) -> Result<(), String> {
    let (target, flags) = required_name(arguments)?;
    let mut options = NewProjectOptions::new(&target);
    let mut index = 0;
    while index < flags.len() {
        match flags[index].as_str() {
            "--no-install" => options.install_dependencies = false,
            "--no-git" => options.initialize_git = false,
            "--framework-path" => {
                index += 1;
                let path = flags.get(index).ok_or("--framework-path requires a path")?;
                options.dependencies = DependencySource::Local(PathBuf::from(path));
            }
            flag => return Err(format!("unknown new-project option `{flag}`")),
        }
        index += 1;
    }
    create_project(&options).map_err(|error| error.to_string())?;
    println!(
        "Created Phoenix application at {}",
        options.target.display()
    );
    println!("Next: cd {} && px dev", options.target.display());
    Ok(())
}

fn make_controller(arguments: Vec<String>) -> Result<(), String> {
    let (name, flags) = required_name(arguments)?;
    let mut options = ControllerOptions::default();
    for flag in flags {
        match flag.as_str() {
            "--resource" | "-r" => {
                options.resource = true;
                options.route = true;
            }
            "--route" => options.route = true,
            "--force" | "-f" => options.force = true,
            _ => return Err(format!("unknown controller option `{flag}`")),
        }
    }
    let generator = current_generator()?;
    let written = generator
        .controller(&name, options)
        .map_err(|error| error.to_string())?;
    finish_generation(&generator, &written)
}

fn make_model(arguments: Vec<String>) -> Result<(), String> {
    let (name, flags) = required_name(arguments)?;
    let mut options = ModelOptions::default();
    for flag in flags {
        match flag.as_str() {
            "--all" | "-a" => options.all = true,
            "--migration" | "-m" => options.migration = true,
            "--controller" | "-c" => options.controller = true,
            "--resource" | "-r" => {
                options.controller = true;
                options.resource_controller = true;
            }
            "--request" => options.request = true,
            "--api-resource" => options.api_resource = true,
            "--page" => options.page = true,
            "--force" | "-f" => options.force = true,
            _ => return Err(format!("unknown model option `{flag}`")),
        }
    }
    let generator = current_generator()?;
    let written = generator
        .model(&name, options)
        .map_err(|error| error.to_string())?;
    finish_generation(&generator, &written)
}

fn make_simple<F>(arguments: Vec<String>, generate: F) -> Result<(), String>
where
    F: FnOnce(
        &ProjectGenerator,
        &str,
        GenerateOptions,
    ) -> Result<Vec<PathBuf>, phoenix_cli::ScaffoldError>,
{
    let (name, flags) = required_name(arguments)?;
    let mut options = GenerateOptions::default();
    for flag in flags {
        match flag.as_str() {
            "--force" | "-f" => options.force = true,
            _ => return Err(format!("unknown generator option `{flag}`")),
        }
    }
    let generator = current_generator()?;
    let written = generate(&generator, &name, options).map_err(|error| error.to_string())?;
    finish_generation(&generator, &written)
}

fn required_name(mut arguments: Vec<String>) -> Result<(String, Vec<String>), String> {
    if arguments.is_empty() {
        return Err("this command requires a name".to_owned());
    }
    let name = arguments.remove(0);
    if name.starts_with('-') {
        return Err("the generated artifact name must come before options".to_owned());
    }
    Ok((name, arguments))
}

fn current_generator() -> Result<ProjectGenerator, String> {
    let current = env::current_dir().map_err(|error| error.to_string())?;
    ProjectGenerator::discover(current).map_err(|error| error.to_string())
}

fn print_written(generator: &ProjectGenerator, paths: &[PathBuf]) {
    for path in paths {
        println!(
            "WROTE {}",
            path.strip_prefix(generator.root())
                .unwrap_or(path)
                .display()
        );
    }
}

fn finish_generation(generator: &ProjectGenerator, paths: &[PathBuf]) -> Result<(), String> {
    print_written(generator, paths);
    if generator
        .refresh_types()
        .map_err(|error| error.to_string())?
    {
        println!("REFRESHED views/generated contracts and routes");
    } else {
        println!("Type files will refresh automatically after npm install or px dev");
    }
    Ok(())
}

fn require_empty(arguments: &[String]) -> Result<(), String> {
    if arguments.is_empty() {
        Ok(())
    } else {
        Err(format!("unexpected arguments: {}", arguments.join(" ")))
    }
}