Console

Console

On this page

The console is the command-line half of the framework. Your main.rs is the entry point, and it boots the same application an HTTP request would.

// src/main.rs
#[tokio::main]
async fn main() {
    let app = match bootstrap::boot(bootstrap::Mode::Running).await {
        Ok(app) => app,
        Err(e) => {
            eprintln!("the application failed to boot: {}", e.message());
            std::process::exit(1);
        }
    };

    let code = routes::console::commands().run_from_env(&app).await;
    std::process::exit(code);
}
cargo run -- list
cargo run -- serve --port=3000
cargo run -- route:list --json
cargo run -- app:seed --fresh

The built-in commands

CommandDoes
listevery registered command
route:list [--json]the route table, with resolved middleware
serve [--host] [--port]start the HTTP server
migrate [--pretend]run pending migrations
migrate:rollback [--batches] [--pretend]undo the last batch
queue:work [--queue] [--once] [--max-jobs] [--sleep]process jobs
schedule:runrun the tasks due this minute
schedule:work [--once]the same, in a loop, for a container
schedule:listwhat is scheduled and when it next runs

<command> --help prints its usage.

use rainier_framework::console;

// A console with all four registered.
let console = console("rainier");

The argument is the program name the console prints in its usage — not a crate name.

route:list

METHOD    URI                        NAME               MIDDLEWARE
GET|HEAD  /                          home
POST      /login                     login              AddHeaders, ThrottleRequests
GET|HEAD  /api/posts                 api.posts.index    ThrottleRequests

The middleware column is what is actually compiled into each route's pipeline, every group flattened — because the command reads the same CompiledRouter the kernel serves. Global middleware is not in it: HandleCors, TrimStrings and the rest wrap the router rather than living inside it, so they run for every row and for requests that match no row at all. See Routing.

serve

Host and port default to server.host and server.port in config, so the flags are an override rather than the usual path.

migrate --pretend

Lists what would run without running it. Worth making a habit before a deploy.

migrate:rollback

Undoes the last batch — everything one migrate applied — in reverse order. --batches=N takes back more than one. Refuses the whole range up front if any step declared itself irreversible, and exits non-zero when it does, so a deploy script stops rather than carrying on. See Migrations.

queue:work

See Queues. Exits non-zero if any job failed, so a supervisor notices.

Writing a command

use rainier_framework::console_kernel::{Arguments, Command};
use rainier_framework::prelude::*;

pub struct SeedCommand;

#[async_trait]
impl Command for SeedCommand {
    fn name(&self) -> &str {
        "app:seed"
    }

    fn description(&self) -> &str {
        "Populate the database with demo data"
    }

    fn help(&self) -> Option<&str> {
        Some("Usage:\n  app:seed [--fresh]\n\nOptions:\n  --fresh  Delete existing rows first")
    }

    async fn handle(&self, args: &Arguments, app: &Application) -> Result<i32> {
        crate::database::seeders::run(app, args.flag("fresh")).await?;
        println!("Seeded.");
        Ok(0)
    }
}

Register it alongside the built-ins:

// src/routes/console.rs — routes/console.php
pub fn commands() -> Console {
    rainier_framework::console("app").register(SeedCommand)
}

name() is what the user types. The verb:noun convention (app:seed, route:list) groups related commands in the listing.

Arguments

args.command();                       // "app:seed"
args.positional();                    // &[String]
args.argument(0);                     // Option<&str>

args.option("queue");                 // --queue=high
args.option_or("queue", "default");
args.parsed_or("port", 8000u16);      // parsed, with a fallback
args.flag("fresh");                   // --fresh
args.wants_help();                    // --help or -h

args.options();
args.flags();

The parser handles --key=value, --flag, and positionals. There is no clap dependency: the surface a framework console needs is small, and a command with a genuinely complex interface can parse args.positional() with whatever it likes.

Talking to whoever is running it

rainier_console::io — a table, a question, a secret, a confirmation.

use rainier_framework::console_kernel::io;

io::table(
    &["ID", "Queue", "Failed at"],
    &failures.iter().map(|f| vec![f.id.clone(), f.queue.clone(), f.at.clone()]).collect::<Vec<_>>(),
);

let name = io::ask("What should the user be called?")?;
let queue = io::ask_with_default("Which queue?", "default")?;
let password = io::secret("Password:")?;

if io::confirm("Send the invitation now?", true)? {
    // …
}
+--------+---------+---------------------+
| ID     | Queue   | Failed at           |
+--------+---------+---------------------+
| 01HXQ… | mail    | 2026-07-25 09:14:00 |
+--------+---------+---------------------+

Every non-trivial command ends up re-implementing these, and the hand-rolled versions get the same three things wrong:

  • Column widths counted in bytes. "café" is five bytes and four characters, so a byte-padded table goes crooked the first time a name has an accent in it — and stays crooked for the one row you were reading. io::table counts characters.
  • A prompt with nowhere to read from. Under cron, in CI, behind a pipe, stdin is closed: read_line returns Ok(0) forever and a loop that re-asks spins until something kills it. Everything here returns an error at end of input.
  • A password echoed to the terminal, into the scrollback and often into the CI log. io::secret turns echo off through the terminal's own API, and refuses rather than falling back to a visible read.

io::table_to_string returns the same thing as a String, for a test or for writing it somewhere else. io::is_interactive() answers whether anyone is actually watching — false under cron, in CI, and behind a pipe.

Confirming something expensive

if !io::confirm_by_typing("This will drop every table.", "production")? {
    return Ok(exit::FAILURE);
}

GitHub's "type the repository name to delete it", for the operations where a stray y is expensive. One attempt and no retry loop — someone who typed the wrong thing gets to think about it.

It refuses outright when stdin is not a terminal. A destructive confirmation that an empty pipe can satisfy is not a confirmation, so a command that must also run unattended should take --force and check it before getting here:

if !args.flag("force") && !io::confirm_by_typing(warning, "production")? {
    return Ok(exit::FAILURE);
}

Exit codes

use rainier_framework::console_kernel::exit;

exit::SUCCESS   // 0
exit::FAILURE   // 1
exit::USAGE     // 2 — unknown command, or bad arguments

Returning Err from handle prints the message and exits 1. Return Ok(code) to choose.

The Err is also reported, as a CommandFailed naming the command — through the application's reporter, which every booted one has, or the process-wide one otherwise. So is a panic, which is then resumed: the command still ends the way a panic always ended it. A non-zero Ok(code) is the command's own answer and is not reported.

An option given as a bare flag — --port 8000 where --port=8000 was meant — is ignored with a warning when a command asks for it, and reported as a ConfigurationFailed whose setting is --port.

Getting these right matters more than it looks: they are what a CI step, a supervisor, or a && in a deploy script reads.

Running it

console.run_from_env(&app).await;                    // std::env::args()
console.run_argv(&app, ["app:seed", "--fresh"]).await;
console.run(&app, Arguments::parse(argv)).await;

The last two are what a test uses.

Console commands run in a booted application

flowchart LR
    A[argv] --> B["bootstrap::boot()"]
    B --> C[providers registered<br/>and booted]
    C --> D[Arguments::parse]
    D --> E{known?}
    E -->|no| F[usage, exit 2]
    E -->|yes| G["Command::handle(args, app)"]
    G --> H[exit code]

Same container, same providers, same config as a request — only there is no request. Which means:

  • app.resolve::<T>() works for anything a provider bound
  • facades work
  • anything reaching for HTTP state does not, because there is none

That last point is the one to watch. A job or mailable that reads the current request will work in a controller test and fail in queue:work — pass what it needs into it instead.

Testing a command

#[tokio::test]
async fn seeding_creates_the_demo_user() {
    let app = boot(Mode::Testing).await.unwrap();
    let console = Console::new("app").register(SeedCommand);

    let code = console.run_argv(&app, ["app:seed"]).await;

    assert_eq!(code, 0);
    let users = app.resolve::<UserRepository>().unwrap();
    assert!(users.by_email("[email protected]").await.unwrap().is_some());
}

Assert on the exit code and the effect, not on stdout. The code is the contract a script depends on; the wording of a println! is not.

This page is the framework’s docs/console.md at 7b8cb15. Found a mistake? Edit it on GitHub.

Type to search 2.0.0 docs.