#[cfg(feature = "with-db")]
use {crate::boot::run_db, crate::db, sea_orm_migration::MigratorTrait};
mod tree;
pub use tree::format_templates_as_tree;
use tree::show_list_endpoints;
use clap::{ArgAction, ArgGroup, Parser, Subcommand, ValueHint};
use colored::Colorize;
use duct::cmd;
use std::fmt::Write;
use std::path::PathBuf;
use std::process::exit;
#[cfg(feature = "worker")]
use crate::bgworker::JobStatus;
#[cfg(debug_assertions)]
use crate::controller;
use crate::{
app::{AppContext, Hooks},
boot::{
create_app, create_context, list_middlewares, run_scheduler, run_task, start, RunDbCommand,
ServeParams, StartMode,
},
config::Config,
doctor,
environment::{resolve_from_env, Environment, DEFAULT_ENVIRONMENT},
logger, task, Error,
};
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
struct Playground {
#[arg(short, long, global = true, help = &format!("Specify the environment [default: {}]", DEFAULT_ENVIRONMENT))]
environment: Option<String>,
}
#[derive(Parser)]
#[command(author, version, about, long_about = None)]
#[command(propagate_version = true)]
struct Cli {
#[command(subcommand)]
command: Commands,
#[arg(short, long, global = true, help = &format!("Specify the environment [default: {}]", DEFAULT_ENVIRONMENT))]
environment: Option<String>,
}
#[derive(Subcommand)]
enum Commands {
#[command(group(ArgGroup::new("start_mode").args(&["worker", "server_and_worker", "all"])))]
#[clap(alias("s"))]
Start {
#[arg(short, long, action, value_delimiter = ',', num_args = 0.., conflicts_with_all = &["server_and_worker", "all"])]
worker: Option<Vec<String>>,
#[arg(short, long, action, conflicts_with_all = &["worker", "all"])]
server_and_worker: bool,
#[arg(short, long, action, conflicts_with_all = &["worker", "server_and_worker"])]
all: bool,
#[arg(long, action, conflicts_with = "all")]
scheduler: bool,
#[arg(short, long, action)]
binding: Option<String>,
#[arg(short, long, action)]
port: Option<i32>,
#[arg(short, long, action = ArgAction::SetTrue)]
no_banner: bool,
},
#[cfg(feature = "with-db")]
Db {
#[command(subcommand)]
command: DbCommands,
},
Routes {},
Middleware {
#[arg(short = 'c', long = "config", action)]
show_config: bool,
},
#[clap(alias("t"))]
Task {
name: Option<String>,
#[clap(value_parser = parse_key_val::<String,String>)]
params: Vec<(String, String)>,
},
#[cfg(feature = "worker")]
Jobs {
#[command(subcommand)]
command: JobsCommands,
},
Scheduler {
#[arg(short, long, action)]
name: Option<String>,
#[arg(short, long, action)]
tag: Option<String>,
#[clap(value_parser)]
#[arg(short = 'c', long = "config", action, value_hint = ValueHint::FilePath)]
config_path: Option<PathBuf>,
#[arg(short, long, action)]
list: bool,
},
#[cfg(debug_assertions)]
#[clap(alias("g"))]
Generate {
#[command(subcommand)]
component: ComponentArg,
},
Doctor {
#[arg(short, long, action)]
config: bool,
#[arg(short, long, action)]
production: bool,
},
Version {},
#[clap(alias("w"))]
Watch {
#[arg(short, long, action, value_delimiter = ',', num_args = 0..)]
worker: Option<Vec<String>>,
#[arg(short, long, action)]
server_and_worker: bool,
#[arg(long, action)]
scheduler: bool,
},
}
#[cfg(debug_assertions)]
#[derive(Subcommand)]
enum ComponentArg {
#[cfg(feature = "with-db")]
#[command(after_help = format!(
"{}
- Generate empty model:
$ cargo loco g model posts
- Generate model with fields:
$ cargo loco g model posts title:string! content:text
- Generate model with references:
$ cargo loco g model movies long_title:string director:references award:references:prize_id
# 'director:references' references the 'directors' table with 'director_id' on 'movies'
# 'award:references:prize_id' references the 'awards' table with 'prize_id' on 'movies'
- Generate model without timestamps:
$ cargo loco g model posts title:string content:text --without-tz
",
"Examples:".bold().underline()
))]
Model {
name: String,
#[arg(long, action)]
without_tz: bool,
#[clap(value_parser = parse_key_val::<String,String>)]
fields: Vec<(String, String)>,
},
#[cfg(feature = "with-db")]
#[command(after_help = format!("{}
- Create a new table:
$ cargo loco g migration CreatePosts title:string
# Creates a migration to add a 'posts' table with a 'title' column of type string.
- Add columns to an existing table:
$ cargo loco g migration AddNameAndAgeToUsers name:string age:int
# Adds 'name' (string) and 'age' (integer) columns to the 'users' table.
- Remove columns from a table:
$ cargo loco g migration RemoveNameAndAgeFromUsers name:string age:int
# Removes 'name' and 'age' columns from the 'users' table.
- Add a foreign key reference:
$ cargo loco g migration AddUserRefToPosts user:references
# Adds a reference to the 'users' table in the 'posts' table.
- Create a join table:
$ cargo loco g migration CreateJoinTableUsersAndGroups count:int
# Creates a join table 'users_groups' with an additional 'count' column.
- Create an empty migration:
$ cargo loco g migration FixUsersTable
# Creates a blank migration file for custom edits to the 'users' table.
- Create migration without timestamps:
$ cargo loco g migration CreatePosts title:string --without-tz
# Creates a migration without timestamp columns
- Create join table without timestamps:
$ cargo loco g migration CreateJoinTableUsersAndGroups count:int --without-tz
# Creates a join table without timestamp columns
After running the migration, follow these steps to complete the process:
- Apply the migration:
$ cargo loco db migrate
- Generate the model entities:
$ cargo loco db entities
", "Examples:".bold().underline()))]
Migration {
name: String,
#[arg(long, action)]
without_tz: bool,
#[clap(value_parser = parse_key_val::<String,String>, )]
fields: Vec<(String, String)>,
},
#[cfg(feature = "with-db")]
#[command(after_help = format!("{}
$ cargo loco g model posts title:string! user:references
$ cargo loco g scaffold posts title:string! user:references --without-tz
$ cargo loco g scaffold posts title:string! --no-auth", "Examples:".bold().underline()))]
Scaffold {
name: String,
#[arg(long, action)]
without_tz: bool,
#[arg(long, action)]
no_auth: bool,
#[arg(long, action, hide = true)]
api: bool,
#[arg(long, action, hide = true)]
html: bool,
#[arg(long, action, hide = true)]
htmx: bool,
#[clap(value_parser = parse_key_val::<String,String>)]
fields: Vec<(String, String)>,
},
#[command(after_help = format!(
"{}
- Generate an empty controller:
$ cargo loco generate controller posts
- Generate a controller with actions:
$ cargo loco generate controller posts list remove update
- Generate a controller whose routes require a JWT:
$ cargo loco generate controller posts --auth
",
"Examples:".bold().underline()
))]
Controller {
name: String,
#[arg(long, action)]
auth: bool,
#[arg(long, action, hide = true)]
api: bool,
#[arg(long, action, hide = true)]
html: bool,
#[arg(long, action, hide = true)]
htmx: bool,
actions: Vec<String>,
},
Task {
name: String,
},
Scheduler {},
Worker {
name: String,
},
Mailer {
name: String,
},
Data {
name: String,
},
Deployment {
#[clap(value_enum)]
kind: DeploymentKind,
},
#[command(after_help = format!("{}
- Override a Specific File:
* cargo loco generate override scaffold/api/controller.t
* cargo loco generate override migration/add_columns.t
- Override All Files in a Folder:
* cargo loco generate override scaffold/api
* cargo loco generate override task
- Override All templates:
* cargo loco generate override .
", "Examples:".bold().underline()))]
Override {
template_path: Option<String>,
#[arg(long, action)]
info: bool,
},
}
#[cfg(debug_assertions)]
#[allow(clippy::similar_names)]
fn warn_legacy_scaffold_kind(api: bool, html: bool, htmx: bool) -> crate::Result<()> {
if html || htmx {
return Err(crate::Error::string(
"`--html`/`--htmx` view scaffolds were replaced by the React SPA frontend in 1.0. \
Generators are adaptive now: scaffold emits the React frontend automatically when \
the app has a `frontend/`, and only the typed backend otherwise — no kind flag \
needed. See https://loco.rs/docs/how-to/use-generators/",
));
}
if api {
eprintln!(
"note: `--api` is no longer needed — generators are adaptive in 1.0 (headless by \
default, React frontend when the app has one)."
);
}
Ok(())
}
#[cfg(debug_assertions)]
impl ComponentArg {
fn into_gen_component(self, config: &Config) -> crate::Result<loco_gen::Component> {
match self {
#[cfg(feature = "with-db")]
Self::Model {
name,
without_tz,
fields,
} => Ok(loco_gen::Component::Model {
name,
with_tz: !without_tz,
fields,
}),
#[cfg(feature = "with-db")]
Self::Migration {
name,
without_tz,
fields,
} => Ok(loco_gen::Component::Migration {
name,
with_tz: !without_tz,
fields,
}),
#[cfg(feature = "with-db")]
Self::Scaffold {
name,
without_tz,
no_auth,
api,
html,
htmx,
fields,
} => {
warn_legacy_scaffold_kind(api, html, htmx)?;
Ok(loco_gen::Component::Scaffold {
name,
with_tz: !without_tz,
fields,
frontend: std::path::Path::new("frontend/src/routes.tsx").exists(),
auth: !no_auth,
})
}
Self::Controller {
name,
auth,
api,
html,
htmx,
actions,
} => {
warn_legacy_scaffold_kind(api, html, htmx)?;
Ok(loco_gen::Component::Controller {
name,
actions,
auth,
})
}
Self::Task { name } => Ok(loco_gen::Component::Task { name }),
Self::Scheduler {} => Ok(loco_gen::Component::Scheduler {}),
Self::Worker { name } => Ok(loco_gen::Component::Worker { name }),
Self::Mailer { name } => Ok(loco_gen::Component::Mailer { name }),
Self::Data { name } => Ok(loco_gen::Component::Data { name }),
Self::Deployment { kind } => Ok(kind.to_generator_component(config)),
Self::Override {
template_path: _,
info: _,
} => Err(crate::Error::string(
"Error: Override could not be generated.",
)),
}
}
}
#[derive(Subcommand)]
enum DbCommands {
Create,
Migrate,
Down {
#[arg(default_value_t = 1)]
steps: u32,
},
Reset,
Status,
#[cfg(debug_assertions)]
Entities,
Truncate,
Seed {
#[arg(short, long)]
reset: bool,
#[arg(short, long)]
dump: bool,
#[arg(long, value_delimiter = ',')]
dump_tables: Option<Vec<String>>,
#[arg(long, default_value = "src/fixtures")]
from: PathBuf,
},
Schema,
}
impl From<DbCommands> for RunDbCommand {
fn from(value: DbCommands) -> Self {
match value {
DbCommands::Migrate => Self::Migrate,
DbCommands::Down { steps } => Self::Down(steps),
DbCommands::Reset => Self::Reset,
DbCommands::Status => Self::Status,
#[cfg(debug_assertions)]
DbCommands::Entities => Self::Entities,
DbCommands::Truncate => Self::Truncate,
DbCommands::Seed {
reset,
from,
dump,
dump_tables,
} => Self::Seed {
reset,
from,
dump,
dump_tables,
},
DbCommands::Create => {
unreachable!("Create db should't handled in the global db commands")
}
DbCommands::Schema => Self::Schema,
}
}
}
#[derive(clap::ValueEnum, Clone)]
pub enum DeploymentKind {
Docker,
Nginx,
Lambda,
}
impl DeploymentKind {
#[cfg(debug_assertions)]
fn to_generator_component(&self, config: &Config) -> loco_gen::Component {
let kind = match self {
Self::Docker => {
let is_client_side_rendering =
PathBuf::from("frontend").join("package.json").exists();
loco_gen::DeploymentKind::Docker {
copy_paths: Self::runtime_asset_paths(config),
is_client_side_rendering,
}
}
Self::Nginx => loco_gen::DeploymentKind::Nginx {
host: config.server.host.clone(),
port: config.server.port,
},
Self::Lambda => loco_gen::DeploymentKind::Lambda {
db: cfg!(feature = "with-db"),
include_paths: Self::runtime_asset_paths(config),
},
};
loco_gen::Component::Deployment { kind }
}
#[cfg(debug_assertions)]
fn runtime_asset_paths(config: &Config) -> Vec<PathBuf> {
let mut paths = vec![];
if let Some(static_assets) = &config.server.middlewares.static_assets {
let asset_folder = PathBuf::from(controller::views::engines::DEFAULT_ASSET_FOLDER);
if asset_folder.exists() {
paths.push(asset_folder.clone());
}
if !static_assets.folder.path.starts_with(&asset_folder) {
paths.push(PathBuf::from(&static_assets.folder.path));
}
if !static_assets.fallback.starts_with(asset_folder) {
paths.push(PathBuf::from(&static_assets.fallback));
}
}
paths
}
}
#[cfg(feature = "worker")]
#[derive(Subcommand)]
enum JobsCommands {
Cancel {
#[arg(long)]
name: String,
},
Tidy {},
Purge {
#[arg(long, default_value_t = 90)]
max_age: i64,
#[arg(long, use_value_delimiter = true)]
status: Option<Vec<JobStatus>>,
#[arg(long)]
dump: Option<PathBuf>,
},
Dump {
#[arg(long, use_value_delimiter = true)]
status: Option<Vec<JobStatus>>,
#[arg(short, long, default_value = ".")]
folder: PathBuf,
},
Import {
#[arg(short, long)]
file: PathBuf,
},
Retry {
#[arg(long)]
id: Option<String>,
},
Requeue {
#[arg(long, default_value_t = 0)]
from_age: i64,
},
}
fn parse_key_val<T, U>(
s: &str,
) -> std::result::Result<(T, U), Box<dyn std::error::Error + Send + Sync>>
where
T: std::str::FromStr,
T::Err: std::error::Error + Send + Sync + 'static,
U: std::str::FromStr,
U::Err: std::error::Error + Send + Sync + 'static,
{
let pos = s
.find(':')
.ok_or_else(|| format!("expected `key:value`, found no `:` in `{s}`"))?;
Ok((s[..pos].parse()?, s[pos + 1..].parse()?))
}
#[cfg(feature = "with-db")]
pub async fn playground<H: Hooks>() -> crate::Result<AppContext> {
let cli = Playground::parse();
let environment: Environment = cli.environment.unwrap_or_else(resolve_from_env).into();
let config = H::load_config(&environment).await?;
let app_context = create_context::<H>(&environment, config).await?;
if !H::init_logger(&app_context)? {
logger::init::<H>(&app_context.config.logger)?;
}
Ok(app_context)
}
#[cfg(feature = "with-db")]
pub async fn main<H: Hooks, M: MigratorTrait>() -> crate::Result<()> {
let cli: Cli = Cli::parse();
let environment: Environment = cli.environment.unwrap_or_else(resolve_from_env).into();
let environment = match cli.command {
Commands::Doctor {
production: true, ..
} if environment != Environment::Production => {
eprintln!(
"`doctor --production` is deprecated; use `--environment production` (or set \
LOCO_ENV). Checking the production environment."
);
Environment::Production
}
_ => environment,
};
let config = H::load_config(&environment).await?;
let app_context = create_context::<H>(&environment, config).await?;
if !H::init_logger(&app_context)? {
logger::init::<H>(&app_context.config.logger)?;
}
let task_span = create_root_span(&environment);
let _guard = task_span.enter();
match cli.command {
Commands::Start {
worker,
server_and_worker,
all,
scheduler,
binding,
port,
no_banner,
} => {
let start_mode = start_mode_from_flags(all, server_and_worker, worker, scheduler);
let boot_result =
create_app::<H, M>(start_mode, &environment, app_context.config).await?;
let serve_params = ServeParams {
port: port.map_or(boot_result.app_context.config.server.port, |p| p),
binding: binding
.unwrap_or_else(|| boot_result.app_context.config.server.binding.clone()),
};
start::<H>(boot_result, serve_params, no_banner).await?;
}
#[cfg(feature = "with-db")]
Commands::Db { command } => {
if matches!(command, DbCommands::Create) {
db::create(&app_context.config.database.uri).await?;
} else {
run_db::<H, M>(&app_context, command.into()).await?;
}
}
command => dispatch_common::<H>(command, &environment, app_context).await?,
}
Ok(())
}
#[allow(clippy::too_many_lines)]
#[allow(clippy::cognitive_complexity)]
async fn dispatch_common<H: Hooks>(
command: Commands,
environment: &Environment,
app_context: AppContext,
) -> crate::Result<()> {
match command {
Commands::Routes {} => show_list_endpoints::<H>(&app_context),
Commands::Middleware { show_config } => {
let middlewares = list_middlewares::<H>(&app_context);
for middleware in middlewares.iter().filter(|m| m.enabled) {
println!(
"{:<22} {}",
middleware.id.bold(),
if show_config {
middleware.detail.as_str()
} else {
""
}
);
}
println!("\n");
for middleware in middlewares.iter().filter(|m| !m.enabled) {
println!("{:<22} (disabled)", middleware.id.bold().dimmed());
}
}
Commands::Task { name, params } => {
let vars = task::Vars::from_cli_args(params);
run_task::<H>(&app_context, name.as_ref(), &vars).await?;
}
#[cfg(feature = "worker")]
Commands::Jobs { command } => {
handle_job_command(command, &app_context).await?;
}
Commands::Scheduler {
name,
config_path,
tag,
list,
} => {
run_scheduler::<H>(&app_context, config_path.as_ref(), name, tag, list).await?;
}
#[cfg(debug_assertions)]
Commands::Generate { component } => {
handle_generate_command::<H>(component, &app_context.config)?;
}
Commands::Doctor {
config: config_arg,
production: _,
} => {
if config_arg {
println!("{}", app_context.config);
println!("Environment: {environment}");
} else {
let production = environment == &Environment::Production;
let mut should_exit = false;
for (_, check) in doctor::run_all::<H>(&app_context, production).await? {
if !should_exit && !check.valid() {
should_exit = true;
}
println!("{check}");
}
if should_exit {
exit(1);
}
}
}
Commands::Version {} => {
println!("{}", H::app_version());
}
Commands::Watch {
worker,
server_and_worker,
scheduler,
} => {
let mut cmd_str = String::from("cargo loco start");
if let Some(worker_tags) = worker {
if worker_tags.is_empty() {
cmd_str.push_str(" --worker");
} else {
write!(cmd_str, " --worker={}", worker_tags.join(","))
.expect("Failed to write to string");
}
} else if server_and_worker {
cmd_str.push_str(" --server-and-worker");
}
if scheduler {
cmd_str.push_str(" --scheduler");
}
cmd("cargo-watch", &["-s", &cmd_str]).run().map_err(|err| {
Error::Message(format!(
"failed to start with `cargo-watch`. Did you `cargo install \
cargo-watch`?. error details: `{err}`",
))
})?;
}
Commands::Start { .. } => {
return Err(Error::string(
"internal error: `Start` command must be handled by the caller, not \
`dispatch_common`",
));
}
#[cfg(feature = "with-db")]
Commands::Db { .. } => {
return Err(Error::string(
"internal error: `Db` command must be handled by the caller, not \
`dispatch_common`",
));
}
}
Ok(())
}
#[cfg(not(feature = "with-db"))]
pub async fn main<H: Hooks>() -> crate::Result<()> {
let cli = Cli::parse();
let environment: Environment = cli.environment.unwrap_or_else(resolve_from_env).into();
let config = H::load_config(&environment).await?;
let app_context = create_context::<H>(&environment, config).await?;
if !H::init_logger(&app_context)? {
logger::init::<H>(&app_context.config.logger)?;
}
let task_span = create_root_span(&environment);
let _guard = task_span.enter();
match cli.command {
Commands::Start {
worker,
server_and_worker,
all,
scheduler,
binding,
port,
no_banner,
} => {
let start_mode = start_mode_from_flags(all, server_and_worker, worker, scheduler);
let boot_result = create_app::<H>(start_mode, &environment, app_context.config).await?;
let serve_params = ServeParams {
port: port.map_or(boot_result.app_context.config.server.port, |p| p),
binding: binding
.unwrap_or_else(|| boot_result.app_context.config.server.binding.clone()),
};
start::<H>(boot_result, serve_params, no_banner).await?;
}
command => dispatch_common::<H>(command, &environment, app_context).await?,
}
Ok(())
}
fn create_root_span(environment: &Environment) -> tracing::Span {
tracing::span!(tracing::Level::DEBUG, "app", environment = %environment)
}
fn start_mode_from_flags(
all: bool,
server_and_worker: bool,
worker: Option<Vec<String>>,
scheduler: bool,
) -> StartMode {
if all || (server_and_worker && scheduler) {
StartMode::All
} else if server_and_worker {
StartMode::ServerAndWorker
} else if let Some(tags) = worker {
if scheduler {
StartMode::WorkerAndScheduler { tags }
} else {
StartMode::WorkerOnly { tags }
}
} else if scheduler {
StartMode::ServerAndScheduler
} else {
StartMode::ServerOnly
}
}
#[cfg(feature = "worker")]
async fn handle_job_command(command: JobsCommands, app_context: &AppContext) -> crate::Result<()> {
let queue = app_context.queue_provider.clone().unwrap_or_else(|| {
println!("queue not configured");
exit(1);
});
match &command {
JobsCommands::Cancel { name } => queue.cancel_jobs(name).await,
JobsCommands::Tidy {} => {
queue
.clear_by_status(vec![JobStatus::Completed, JobStatus::Cancelled])
.await
}
JobsCommands::Purge {
max_age,
status,
dump,
} => {
let status = status.as_ref().map_or_else(
|| {
vec![
JobStatus::Failed,
JobStatus::Cancelled,
JobStatus::Queued,
JobStatus::Completed,
]
},
std::clone::Clone::clone,
);
if let Some(path) = dump {
let dump_path = queue
.dump(path.as_path(), Some(&status), Some(*max_age))
.await?;
println!("Jobs successfully dumped to: {}", dump_path.display());
}
queue.clear_jobs_older_than(*max_age, &status).await
}
JobsCommands::Dump { status, folder } => {
let dump_path = queue.dump(folder.as_path(), status.as_ref(), None).await?;
println!("Jobs successfully dumped to: {}", dump_path.display());
Ok(())
}
JobsCommands::Import { file } => queue.import(file.as_path()).await,
JobsCommands::Retry { id } => {
let retried = queue.retry_failed(id.as_deref()).await?;
println!("{retried} job(s) moved back to the queue");
if retried > 0 {
println!(
"note: on the Redis provider these are queued to `default` — the queue a job \
was submitted to is not recorded once it fails"
);
}
Ok(())
}
JobsCommands::Requeue { from_age } => queue.requeue(from_age).await,
}
}
#[cfg(debug_assertions)]
fn handle_generate_command<H: Hooks>(
component: ComponentArg,
config: &Config,
) -> crate::Result<()> {
use std::path::Path;
if let ComponentArg::Override {
template_path,
info,
} = component
{
match (template_path, info) {
(None, true | false) => {
let templates = loco_gen::template::collect();
println!("{}", format_templates_as_tree(templates));
}
(Some(path), true) => {
let templates = loco_gen::template::collect_files_path(Path::new(&path)).unwrap();
println!("{}", format_templates_as_tree(templates));
}
(Some(path), false) => {
let copied_files = loco_gen::copy_template(
Path::new(&path),
Path::new(loco_gen::template::DEFAULT_LOCAL_TEMPLATE),
)?;
if copied_files.is_empty() {
println!("{}", "No templates were found to copy.".red());
} else {
println!(
"{}",
"The following templates were successfully copied:".green()
);
for f in copied_files {
println!(" * {}", f.display());
}
}
}
}
} else {
let get_result = loco_gen::generate(
&loco_gen::new_generator(),
component.into_gen_component(config)?,
&loco_gen::AppInfo {
app_name: H::app_name().to_string(),
working_dir: ".".into(),
},
)?;
let messages = loco_gen::collect_messages(&get_result);
println!("{messages}");
format_generated_code();
}
Ok(())
}
#[cfg(debug_assertions)]
fn format_generated_code() {
let _ = std::process::Command::new("cargo")
.arg("fmt")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status();
}
#[cfg(all(test, debug_assertions))]
mod tests {
use super::*;
use clap::{CommandFactory, Parser};
use rstest::rstest;
#[test]
fn command_tree_is_well_formed() {
Cli::command().debug_assert();
Playground::command().debug_assert();
}
#[rstest]
#[case::default(false, false, None, false, StartMode::ServerOnly)]
#[case::all(true, false, None, false, StartMode::All)]
#[case::server_and_worker(false, true, None, false, StartMode::ServerAndWorker)]
#[case::server_worker_scheduler(false, true, None, true, StartMode::All)]
#[case::scheduler(false, false, None, true, StartMode::ServerAndScheduler)]
#[case::worker(false, false, Some(vec![]), false, StartMode::WorkerOnly { tags: vec![] })]
#[case::worker_and_scheduler(
false, false, Some(vec!["mail".to_string()]), true,
StartMode::WorkerAndScheduler { tags: vec!["mail".to_string()] }
)]
#[case::all_wins(true, true, Some(vec![]), true, StartMode::All)]
fn start_flags_pick_a_mode(
#[case] all: bool,
#[case] server_and_worker: bool,
#[case] worker: Option<Vec<String>>,
#[case] scheduler: bool,
#[case] expected: StartMode,
) {
assert_eq!(
start_mode_from_flags(all, server_and_worker, worker, scheduler),
expected
);
}
#[test]
fn key_value_pairs_split_on_the_first_colon() {
assert_eq!(
parse_key_val::<String, String>("url:http://example.com").unwrap(),
("url".to_string(), "http://example.com".to_string())
);
assert!(parse_key_val::<String, String>("no-separator")
.unwrap_err()
.to_string()
.contains("key:value"));
}
#[test]
fn legacy_api_flag_is_a_noop() {
assert!(warn_legacy_scaffold_kind(true, false, false).is_ok());
assert!(warn_legacy_scaffold_kind(false, false, false).is_ok());
}
#[test]
fn legacy_html_htmx_flags_error_with_guidance() {
let html = warn_legacy_scaffold_kind(false, true, false)
.unwrap_err()
.to_string();
assert!(html.contains("React SPA"), "got: {html}");
let htmx = warn_legacy_scaffold_kind(false, false, true)
.unwrap_err()
.to_string();
assert!(htmx.contains("React SPA"), "got: {htmx}");
}
#[test]
fn generate_controller_still_accepts_legacy_kind_flags() {
for flag in ["--api", "--html", "--htmx"] {
let parsed =
Cli::try_parse_from(["loco", "generate", "controller", "notes", "list", flag]);
assert!(
parsed.is_ok(),
"controller {flag} should parse, got: {:?}",
parsed.err()
);
}
}
#[cfg(feature = "with-db")]
#[test]
fn generate_scaffold_still_accepts_legacy_kind_flags() {
for flag in ["--api", "--html", "--htmx"] {
let parsed = Cli::try_parse_from([
"loco",
"generate",
"scaffold",
"posts",
"title:string",
flag,
]);
assert!(
parsed.is_ok(),
"scaffold {flag} should parse, got: {:?}",
parsed.err()
);
}
}
}