---
title: Getting Started with Glacier
description: Install Glacier, create a blog, generate posts with the scaffold, then add a validation and comments, testing as you go.
category: Start Here
order: 1
---
This guide takes you from nothing to a small working blog: posts you can
create, edit and delete, with comments underneath. Along the way it shows
how a Glacier app is laid out, what the generators write for you, and how
the tests that come with them run.
After reading this guide, you will know:
- How to install Glacier and create a new application.
- How a Glacier app is laid out, and where your code goes.
- How to generate a model, controller, views, routes and tests with the
scaffold generator, and what each generated file does.
- How to run the app and its tests.
- How to add a validation, change a view, and add a second model that
belongs to the first.
## Guide Assumptions
This guide is for people starting a first Glacier app. It doesn't assume
you know Rust well, but you will need a Rust toolchain installed, and
you'll get further if you can read a little Rust.
Glacier is young. This guide sticks to what works today, and says so where
a feature isn't built yet.
## What is Glacier?
Glacier is a batteries-included web framework for Rust, inspired by Rails,
Phoenix and Laravel. It gives you a router, controllers, compiled HTML
templates, an ORM for SQLite and Postgres, migrations, background jobs,
sessions, flash messages, CSRF protection and a test framework, with
generators that write the usual pieces for you.
Controllers, models and tests are written in `.glacier`, a small
`do … end` syntax that each app's build script compiles to ordinary
Rust, line for line. Routes and migrations are plain Rust,
and you can write Rust anywhere `.glacier` is used. [The .glacier
Language](glacier_language.html) covers the syntax; this guide only uses
what the generators write and a few small changes to it.
Because everything is compiled, mistakes that other frameworks find at
runtime are build errors in Glacier: a typo in a template expression, a
link to a route that doesn't exist, or a misspelled column in a query.
When `glacier build` (or `glacier test` or `glacier server`) fails, the
error points at the line in your `.glacier` file.
## Creating a New Glacier Project
### Installing Glacier
You need Rust 1.94 or later, with `cargo`. If you don't have it,
[rustup](https://rustup.rs) installs it.
These guides describe Glacier 0.5.0. Install the `glacier` command from
crates.io, where it's published as `glacier-web-cli`:
```sh
cargo install glacier-web-cli
```
This builds the `glacier` command and puts it on your `PATH` (in
`~/.cargo/bin`). Check that it works:
```console
$ glacier --version
glacier 0.5.0
```
The apps it creates depend on the framework from crates.io, at the same
version as the command: `glacier-web` and, for the build script,
`glacier-dialect`. Cargo downloads them the first time an app builds.
### Creating the Blog Application (updated)
`glacier new` creates an application in a new directory. Run it where you
keep your projects:
```console
$ glacier new blog
create blog/Cargo.toml
create blog/.gitignore
create blog/AGENTS.md
create blog/.mcp.json
create blog/build.rs
create blog/db/migrations/.gitkeep
create blog/src/main.rs
create blog/config/default.toml
create blog/config/development.toml
create blog/config/test.toml
create blog/config/production.toml
create blog/src/lib.rs
create blog/src/views/layouts/app.html
create blog/src/views/pages/home.html
create blog/src/views/errors/404.html
create blog/src/views/errors/500.html
create blog/assets/app.css
create blog/src/components/button.html
create blog/src/components/input.html
create blog/src/components/flash.html
create blog/src/components/header.html
create blog/src/app.glacier
create blog/src/controllers/pages_controller.glacier
create blog/tests/glacier.rs
create blog/tests/controllers/pages_controller.test.glacier
create blog/src/seeds.glacier
create blog/src/routes.glacier
Done! Next:
cd blog
glacier server
$ cd blog
```
An app name may contain letters, digits, `_` and `-`, and must start with a
letter.
This guide builds an app with HTML pages. For an app that only serves
JSON, `glacier new` takes `--api`: the app it creates has no views,
stylesheet or CSRF protection, answers errors in JSON, and its generators
write JSON controllers. [JSON APIs](json_apis.html) covers it.
### The Application's Layout (updated)
A new app is an ordinary Cargo package. Here is what each part is for:
| `Cargo.toml` | The package: depends on `glacier-web` (used in code as `glacier`) and, for the build script, `glacier-dialect` |
| `build.rs` | Compiles every `.glacier` file, and builds the stylesheet, before each build |
| `.gitignore` | Leaves out `target/`, the SQLite databases in `db/`, the compiled `.glacier/` folder and the built stylesheet |
| `config/` | Settings: `default.toml` for every environment, then `development.toml`, `test.toml` and `production.toml` |
| `public/` | Static files served as they are, and the built stylesheet, `public/assets/app.css` |
| `src/app.glacier` | The app: its own settings, such as middleware (the rest is added for you) |
| `src/main.rs`, `src/lib.rs` | One line each, never edited: `glacier::serve!()` starts the server, `glacier::app!()` builds the app from everything under `src/` |
| `src/routes.glacier` | The route table |
| `src/controllers/` | Controllers, one `<name>_controller.glacier` file each |
| `src/models/` | Models, one file each (none yet) |
| `db/migrations/` | Database migrations, one file each, beside `db/schema.sql` (none yet) |
| `src/views/` | HTML templates: `layouts/app.html` wraps every page, each controller has a folder, and `errors/` holds the pages for `404` and `500` |
| `src/components/` | Components used across pages, as `<.button>` and `<.input>`: `button`, `input`, `flash` and `header` to start with |
| `assets/app.css` | The stylesheet: [Tailwind CSS](views.html#styling-with-tailwind), the app's colours, light and dark, and the look of plain HTML |
| `tests/` | Tests in `.glacier`, all found by `tests/glacier.rs` |
Two more folders appear once you build and run the app: `.glacier/`, where
the build script writes the Rust compiled from your `.glacier` files, and
`db/`, which holds the SQLite database. You never edit either.
`src/app.glacier` is the app itself. Its configuration, database,
migrations, seeds, static files and routes are added for you, so it
holds only the settings of its own, here two middleware:
```glacier
# The app. Its config, database, migrations, seeds, jobs, static files and
# routes are added for you; these are the settings of its own.
app do
alias glacier.middleware.MethodOverride
alias glacier.middleware.Logger
# Before the logger, so logs show the overridden method.
middleware MethodOverride
middleware Logger
end
```
`src/lib.rs` and `src/main.rs` are one line each, and you never edit
them: `glacier::app!()` finds every file under `src/` and builds the app,
and `glacier::serve!()` reads the configuration, connects to the database
and serves it:
```rust
// The app: every module under src/, its migrations in db/, and the
// settings in src/app.glacier.
glacier::app!();
```
```rust
// Serves the app (or runs a command: `blog migrate`, `blog seed`).
glacier::serve!();
```
The configuration is plain TOML. `config/default.toml` sets the address the
server listens on, and its comment shows how environment variables
override any setting:
```toml
# Settings shared by every environment. `config/<env>.toml` overrides these,
# and environment variables override both: GLACIER__SERVER__PORT=8080.
[server]
host = "127.0.0.1"
port = 4000
```
In development the database is a SQLite file. `config/development.toml`
also holds a `secret_key_base`, which signs and encrypts the session
cookie; `glacier new` writes a random one. In production you set both
through the environment, as `config/production.toml` explains:
```toml
# Set secrets through the environment, never in this file:
#
# GLACIER__SECRET_KEY_BASE=$(glacier secret)
# GLACIER__DATABASE__URL=postgres://user:password@host/blog
[server]
host = "0.0.0.0"
```
The app runs in development unless the `GLACIER_ENV` environment variable
says `test` or `production`.
## Hello, Glacier! (updated)
### Starting the Server
From inside the `blog` directory, start the server:
```sh
glacier server
```
The first build compiles Glacier and its dependencies, so it takes a
while; later builds are quick. When the app is up, its log says it is
listening on `http://127.0.0.1:4000 (development)`. Open
<http://127.0.0.1:4000> in a browser to see the home page.
`glacier server` watches `src/`, `config/`, `public/` and `Cargo.toml`.
When you save a change it rebuilds and restarts the app. If the rebuild
fails, it shows the errors and keeps the previous version running until
you fix them. Files in `public/` are served from disk, and a template
whose code is unchanged (only its markup, classes or wording) is read
again from its file, so neither needs a rebuild. Either way the page in
your browser reloads itself ([Views](views.html#changing-templates-under-glacier-server)
has the details). Press Ctrl-C to stop the server.
### The Home Page
The home page comes from three pieces. The route table in `src/routes.glacier`
sends `GET /` to the `home` action of the `Pages` controller (you'll see
the whole file once the scaffold has added to it). The controller is in
`src/controllers/pages_controller.glacier`:
```glacier
module PagesController do
implements Controller
# Renders src/views/pages/home.html inside src/views/layouts/app.html.
def home do
render title: "Home"
end
end
```
`render` picks the template by convention, from the controller and action
names: `src/views/pages/home.html`. That template is plain HTML:
```html
<h1>blog is running on Glacier</h1>
<p>This page is <code>src/views/pages/home.html</code>, rendered by <code>Pages.home</code> in <code>src/controllers/</code>.</p>
<p>Add routes in <code>src/routes.rs</code>, then <code>glacier generate migration create_posts</code> to start on a model.</p>
```
and `render` places it inside the layout, `src/views/layouts/app.html`,
passing it the `title`:
```html
{% props title: String, content: Rendered %}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ title }} · blog</title>
<link rel="stylesheet" href="/assets/app.css">
</head>
<body>
<.flash kind="notice" />
<.flash kind="alert" />
{{ content }}
</body>
</html>
```
A template that takes values starts with `{% props … %}`, which declares them, with
Rust types. `{{ … }}` outputs a value, escaped for HTML, and `{% if %}`,
`{% for %}` and `{% end %}` control what is shown. Templates are compiled,
so a mistake in one is a build error. [Layouts and
Rendering](views.html) has the details.
`glacier new` also wrote a test for this page:
```glacier
test "shows the home page" do
res = get "/"
assert res.status == :ok
assert res.body =~ "is running on Glacier"
end
```
## Generating a Scaffold
A blog needs posts. The scaffold generator writes everything for a
resource at once: a migration, a model, a controller with all seven
actions, their views and routes, and tests. Run it in the `blog` directory:
```console
$ glacier generate scaffold Post title:string body:text:optional published:boolean
create db/migrations/m20261004100354_create_posts.glacier
create src/models/post.glacier
create tests/factories/post.glacier
create tests/models/post.test.glacier
update tests/glacier.rs
create src/controllers/posts_controller.glacier
create src/views/posts/index.html
create src/views/posts/show.html
create src/views/posts/new.html
create src/views/posts/edit.html
create src/views/posts/_form.html
create tests/controllers/posts_controller.test.glacier
update tests/glacier.rs
update src/routes.glacier
Next: glacier db migrate, then visit /posts
```
Each field is `name:type`. The types are `string`, `text`, `integer`,
`bigint`, `float`, `boolean` and `references`, and `:optional` makes a
`string` or `text` field nullable, and `:unique` makes a field unique,
validated and indexed (`email:string:unique`). Every model also gets an `id` and the
`inserted_at` and `updated_at` timestamps, so you don't list those.
`g` is short for `generate`. There are smaller generators too: `glacier
generate model` writes only the migration, model, their tests and a factory, and
`glacier generate controller Reports summary` writes a controller with the
actions you name, their views, routes and tests.
### Running the Migration
The migration creates the `posts` table. Apply it:
```console
$ glacier db migrate
migrated m20261004100354_create_posts
```
`glacier db status` lists the migrations and whether each has run, and
`glacier db rollback` reverts the last one. If you start the server in
development with migrations still pending, it logs a warning that tells
you to run `glacier db migrate`. [Migrations](migrations.html) covers them
in depth.
Now start the server again (or let the running one restart) and visit
<http://127.0.0.1:4000/posts>. You can list, create, show, edit and delete
posts.
## What the Scaffold Generated
None of the generated code is hidden in the framework: it's ordinary code
in your app, for you to read and change.
### The Migration
```glacier
migration do
create_table :posts do
string :title, null: false
text :body
boolean :published, null: false, default: false
timestamps
end
end
```
A migration says what to change, and `glacier db migrate` runs the ones
that haven't run, oldest first by the version in their names. They live
in `db/migrations/`, beside the schema in `db/schema.sql`; there's no list
to keep.
### The Model
```glacier
module Post do
implements Model
field title: String, required
field body: Option<String>
field published: bool
end
```
A model declares its fields with their Rust types. `String` is a required
column; `Option<String>` may be empty (`NULL`). `required` is a validation:
saving a post with a blank title fails, and the post carries the error
message. There's no list to add it to: every model in `src/models/` is
found when the app builds.
Models load and save themselves: `Post.find(id)!` loads one,
`Post.order(inserted_at: :desc)!` loads them all in order, and
`post.save()!` writes one. You'll see these in the controller next.
[Models](models.html) and [Querying](querying.html) cover
models fully.
### The Controller
```glacier
module PostsController do
implements Controller
def index do
posts = Post.order(inserted_at: :desc)!
render posts, title: "Posts"
end
def show do
post = Post.find(params[:id])!
render post, title: "Post"
end
def new do
render post: Post.new(), title: "New post"
end
def create do
post = Post.build(params)
if post.save()! do
redirect post, notice: "Post created."
else
render :new, post, title: "New post", status: 422
end
end
def edit do
post = Post.find(params[:id])!
render post, title: "Edit post"
end
def update do
post = Post.find(params[:id])!
if post.update(params)! do
redirect post, notice: "Post updated."
else
render :edit, post, title: "Edit post", status: 422
end
end
def destroy do
Post.find(params[:id])!.delete()!
redirect Post.collection_path(), notice: "Post deleted."
end
end
```
Each `def` is an action. `params` holds the request's parameters: the
submitted form, and the parts of the URL, so `params[:id]` is the `:id`
from `/posts/:id`.
A `!` after a call waits for it and passes any error up, so
`Post.find(params[:id])!` either gives you the post or ends the action
with an error; for a post that doesn't exist (or an id that isn't a
number), that error is a `404 Not Found`.
`render` renders the action's template, here `src/views/posts/index.html`
and so on, passing what you give it as the template's props.
`render :new, post, …` renders a different template of the same
controller, which is how `create` shows the form again with its errors and
a `422` status. `redirect` sends the browser elsewhere: given a post, it
redirects to that post's page, and `notice:` sets a flash message that the
layout shows on the next page.
`Post.build(params)` makes a new post from the form, converting each
field to its type, and `post.save()!` validates and inserts it, giving
`true` or `false`. `post.update(params)!` does the same for an
existing post.
### The Routes
The generator added one line to `src/routes.glacier`:
```glacier
# The route table. It generates `draw()` for the app and a `*_path()`
# helper for every named route, so links can't point at missing routes.
routes do
# HTML pages: security headers + CSRF protection.
pipeline browser = Pipeline.browser()
scope "/", through: :browser do
get "/", PagesController.home, as: :home
resources "/posts", PostsController
end
end
```
`resources "/posts", PostsController` declares the seven routes of a resource,
and `glacier routes` lists them:
```console
$ glacier routes
GET / Pages#home
GET /posts Posts#index
GET /posts/:id Posts#show
GET /posts/new Posts#new
POST /posts Posts#create
GET /posts/:id/edit Posts#edit
PATCH /posts/:id Posts#update
PUT /posts/:id Posts#update
DELETE /posts/:id Posts#destroy
```
The route table also generates a path helper for each named route:
`posts_path()`, `new_post_path()`, `post_path(id)` and
`edit_post_path(id)`, along with `home_path()` from `as home`. Templates
use these helpers to build links, so a link can't point at a route that
doesn't exist: that would be a build error. The routes sit inside a
`scope "/", through: :browser`, so they pass through the `browser` pipeline,
which adds security headers and checks CSRF tokens on form submissions.
[Routing](routing.html) explains the rest.
### The Views (updated)
The index lists the posts:
```html
{% props posts: Array<Post> %}
<h1>Posts</h1>
{% if posts.is_empty() -%}
<p>No posts yet.</p>
{%- else -%}
<table>
<thead>
<tr><th>Title</th><th>Body</th><th>Published</th><th></th></tr>
</thead>
<tbody>
{%- for post in posts %}
<tr>
<td>{{ post.title }}</td>
<td>{{ post.body }}</td>
<td>{{ post.published }}</td>
<td>
{{ link_to("Show", post) }}
{{ link_to("Edit", edit_post_path(post.id)) }}
</td>
</tr>
{%- end %}
</tbody>
</table>
{%- end %}
<p><a href="{{ new_post_path() }}">New post</a></p>
```
`{% props posts: Array<Post> %}` says this template takes a list of posts,
which is what `render posts` in the `index` action passes.
`link_to("Show", post)` links to a post's own page.
The new and edit pages share a form, `_form.html`. A file whose name
starts with `_` is a partial, rendered from another template:
```html
{% props post: Post %}
{{ form_for(&post) }}
<.input field={post.field("title")} />
<.input field={post.field("body")} type="textarea" />
<.input field={post.field("published")} type="checkbox" />
<.button>Save</.button>
</form>
```
`form_for(&post)` opens the right form for the post: `POST /posts` for a
new one, and `PATCH /posts/:id` for one that's already saved, with the
CSRF token included. Each field is an `<.input>`, one of the core
components `glacier new` writes into `src/components/` (with `<.button>`,
`<.flash>` and `<.header>`): `post.field("title")` gives it the field's
name, the value to show and its validation errors, and it draws the
label, the input and the errors. They're the app's own files, to restyle
or change; [Views](views.html#components) covers components.
`new.html` renders the form with `{{ Form { post }.render() }}`:
```html
{% props post: Post %}
<h1>New post</h1>
{{ Form { post }.render() }}
<p><a href="{{ posts_path() }}">Back</a></p>
```
### The Tests and the Factory
The scaffold wrote tests for the model and the controller. The model
tests check that a valid post saves and that the title is required:
```glacier
# Tests for the Post model, generated by `glacier generate`.
test "saves a valid post" do
post = insert(Post)
assert Post.count()! == 1
assert post.title == "Example title"
end
test "requires title" do
saved = Post.create(params_for(Post, title: ""))!
assert errors_on(saved) =~ "title can't be blank"
end
```
The controller tests make requests to the app, as a browser would, and
check the responses:
```glacier
# Tests for /posts, generated by `glacier generate scaffold`.
# Load a form first, as a browser would, for its CSRF token.
setup do
get "/posts/new"
end
test "lists posts" do
insert(Post)
res = get "/posts"
assert res.status == :ok
assert res.body =~ "Example title"
end
test "creates a post" do
res = post "/posts", params_for(Post)
assert res.status == :see_other
assert get(res.location).body =~ "Post created."
end
test "rejects a post without title" do
res = post "/posts", params_for(Post), title: ""
assert res.status == :unprocessable_entity
assert res.body =~ "can't be blank"
end
test "updates a post" do
post = insert(Post)
res = patch post, params_for(Post), title: "Updated"
assert res.status == :see_other
assert get(post).body =~ "Updated"
end
test "deletes a post" do
post = insert(Post)
res = delete post
assert res.status == :see_other
assert get(post).status == :not_found
end
```
Both use the factory in `tests/factories/post.glacier`, which gives valid
fields for a post. `insert(Post)` saves a post with them, and
`params_for(Post)` gives them as form fields; either takes overrides, such
as `title: ""`:
```glacier
# Valid fields for a Post, used by `insert(Post)` and
# `params_for(Post)` in tests. Override any of them per call:
# `insert(Post, title: …)`.
factory Post do
title: "Example title"
body: "Example body"
published: true
end
```
Every test gets a fresh database with the migrations applied, so tests
don't affect each other. The generator listed the new files in
`tests/glacier.rs`, which compiles all the tests into one test binary.
## Running the Tests
Run the tests with `glacier test`:
```console
$ glacier test
running 8 tests
test models::post::requires_title ... ok
test controllers::pages_controller::shows_the_home_page ... ok
test controllers::posts_controller::rejects_a_post_without_title ... ok
test models::post::saves_a_valid_post ... ok
test controllers::posts_controller::lists_posts ... ok
test controllers::posts_controller::creates_a_post ... ok
test controllers::posts_controller::updates_a_post ... ok
test controllers::posts_controller::deletes_a_post ... ok
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
```
(Cargo's build lines and its summaries of the empty unit-test targets are
cut from this output.)
Each test is named after its folder, file and description:
`controllers::posts_controller::lists_posts`. Anything after `glacier test`
is passed on to `cargo test`, so `glacier test posts_controller` runs only
the tests whose names contain `posts_controller`. `cargo test` runs the
same tests; `glacier test` also points failures at your `.glacier` lines.
[Testing](testing.html) covers writing tests.
## Adding a Validation
Short titles make a poor blog, so require at least five characters. A
validation goes on the field's line in `src/models/post.glacier`; change
the title line to:
```glacier
field title: String, required, length(min: 5)
```
`length(min: 5)` also takes `max:`, or both. Saving a post with a shorter
title now fails, and the form shows "Title is too short (minimum is 5
characters)" beneath the field. A blank title still reports only "can't be
blank".
Add tests for both to `tests/models/post.test.glacier`:
```glacier
test "requires a title of at least five characters" do
saved = Post.create(params_for(Post, title: "Hi"))!
assert errors_on(saved) =~ "title is too short (minimum is 5 characters)"
end
test "reports only that a blank title is blank" do
saved = Post.create(params_for(Post, title: ""))!
errors = errors_on(saved)
assert errors =~ "title can't be blank"
refute errors =~ "too short"
end
```
`Post.create(…)!` validates and inserts a post in one step, and
`errors_on(saved)` lists its errors with their fields, so the tests can
check the messages. `refute` is the opposite of `assert`.
## Adding Comments
Next, let readers comment on posts. A comment belongs to a post, so it
needs its own model.
### Generating the Comment Model
Only the model is needed this time, since comments will be shown and
added on the post's page:
```console
$ glacier generate model Comment commenter:string body:text post:references
create db/migrations/m20261004100649_create_comments.glacier
create src/models/comment.glacier
create tests/factories/comment.glacier
create tests/models/comment.test.glacier
update tests/glacier.rs
Next: glacier db migrate
```
`post:references` adds a `post_id` column, which points at the `posts`
table, and a `belongs_to :post` line in the model:
```glacier
module Comment do
implements Model
field commenter: String, required
field body: String, required
belongs_to :post
end
```
Before migrating, make one change to the migration. A comment's `post_id`
is a foreign key, so the database won't delete a post that still has
comments. `on_delete_cascade()` tells it to delete the comments along with
the post:
```glacier
migration do
create_table :comments do
string :commenter, null: false
text :body, null: false
references :post, null: false, on_delete: :cascade
timestamps
end
end
```
NOTE: This guide deletes a post's comments in the database, with
`on_delete_cascade()`. An association can do it instead, with `has_many
:comments, dependent: :delete_all` (or `:destroy`, which runs each
comment's callbacks); see [Associations](associations.html).
Now run the migration:
```console
$ glacier db migrate
migrated m20261004100649_create_comments
```
The comment factory makes a post for each comment it inserts, unless the
test gives a `post_id`:
```glacier
# Valid fields for a Comment, used by `insert(Comment)` and
# `params_for(Comment)` in tests. Override any of them per call:
# `insert(Comment, commenter: …)`.
factory Comment do
commenter: "Example commenter"
body: "Example body"
post_id: insert(Post).id
end
```
### Associating the Models
Tell the post about its comments by adding `has_many :comments` to
`src/models/post.glacier`, which now reads:
```glacier
module Post do
implements Model
field title: String, required, length(min: 5)
field body: Option<String>
field published: bool
has_many :comments
end
```
Associations in Glacier are never loaded behind your back. To use a post's
comments, ask for them in the query with `preload`; reading
`post.comments` without it panics with a message telling you to add one.
That way an accidental N+1 query shows up the first time the code runs,
rather than as a slow page later.
NOTE: Glacier doesn't load an association the first time you use it,
and association methods such as `post.comments.create(…)`
aren't available yet. Preload an association to read it, and set
`post_id` to link a new comment to its post.
### A Route for Comments
Comments are created from the post's page, at a URL under the post. Add
this route to `src/routes.glacier`, after the posts:
```glacier
post "/posts/:post_id/comments", CommentsController.create, as: :post_comments
```
`:post_id` is captured from the URL, and `as post_comments` names the route,
which generates a `post_comments_path(post_id)` helper.
NOTE: The route is written out in full here. The route table can also nest
resources, which generates the same kind of path and helper; see
[Routing](routing.html#nested-resources).
### The Comments Controller
Create `src/controllers/comments_controller.glacier`:
```glacier
module CommentsController do
implements Controller
def create do
post = Post.find(params[:post_id])!
comment = Comment.build(params)
if comment.save()! do
redirect post, notice: "Comment added."
else
redirect post, notice: "A comment needs your name and some text."
end
end
end
```
There's no list to add it to: every controller in `src/controllers/` is
found when the app builds.
`params[:post_id]` reads `:post_id` from the URL. Finding the post first
means a comment on a post that doesn't exist is a `404 Not Found`. `params` includes the parameters from the
URL as well as the form, so `Comment.build(params)` gets the
comment's `post_id` as well as its commenter and body. Either way the
action returns to the post's page, with a flash message saying what
happened.
### Showing Comments on the Post
The `show` action needs to load the post's comments. In
`src/controllers/posts_controller.glacier`, change it to:
```glacier
def show(ctx) do
id = ctx.id()?
post = Post.where(id: id).preload(:comments).one()!
title = post.title.clone()
render post, title: title
end
```
`Post.where(id: id).preload(:comments).one()!` loads the post and its
comments, in two queries. `one()` returns the record, or a `404 Not Found`
if there's none, just as `find` does. The page title is now the post's
title.
The id is read into a variable first because keyword arguments such as
`id: id` compile into a closure, and `?` can't be used inside one. The
title is copied before `render`, because `render post` hands the post
over to the template, after which it can't be read here (Rust's
ownership rules). Get either wrong and the build fails with an error at
the line in this file; [The .glacier Language](glacier_language.html)
shows one.
Finally, change `src/views/posts/show.html` to use the title as its
heading, list the comments, and add a form for a new one:
```html
{% props post: Post %}
<h1>{{ post.title }}</h1>
<dl>
<dt>Body</dt>
<dd>{{ post.body }}</dd>
<dt>Published</dt>
<dd>{{ post.published }}</dd>
</dl>
<p>
<a href="{{ edit_post_path(post.id) }}">Edit</a>
<a href="{{ posts_path() }}">Back</a>
</p>
{{ form_tag(post.to_path(), "delete") }}<button>Delete</button></form>
<h2>Comments</h2>
{% for comment in &post.comments %}
<p><strong>{{ comment.commenter }}</strong>: {{ comment.body }}</p>
{% end %}
{{ form_tag(post_comments_path(post.id), "post") }}
<p><label>Your name<br><input name="commenter"></label></p>
<p><label>Comment<br><textarea name="body"></textarea></label></p>
<button>Add comment</button>
</form>
```
`form_tag(path, "post")` opens a form that submits to `path`, with the CSRF
token included; `post_comments_path(post.id)` is the helper from the new
route. With the server running, open a post and add a comment.
### Testing Comments
Add a controller test for comments in
`tests/controllers/comments_controller.test.glacier`:
```glacier
# Tests for comments on a post.
alias Routes.post_comments_path
alias Routes.post_path
# Load a form first, as a browser would, for its CSRF token.
setup do
get "/posts/new"
end
test "adds a comment to a post" do
post = insert(Post)
res = post post_comments_path(post.id), [commenter: "Ada", body: "Lovely post."]
assert res.status == :see_other
page = get(post).body
assert page =~ "Comment added."
assert page =~ "Lovely post."
end
test "rejects a comment without a body" do
post = insert(Post)
res = post post_comments_path(post.id), [commenter: "Ada", body: ""]
assert res.status == :see_other
assert get(post).body =~ "A comment needs your name and some text."
assert Comment.count()! == 0
end
test "deleting a post deletes its comments" do
comment = insert(Comment)
res = delete post_path(comment.post_id)
assert res.status == :see_other
assert Comment.count()! == 0
end
```
Route helpers aren't visible in tests until you name them with `alias`,
as the first lines do. One `alias Routes.…` line is in fact enough, because
it also brings in the rest of the routes module. A keyword list such as `[commenter: "Ada", body:
"Lovely post."]` is sent as form fields. The last test checks that
deleting a post deletes its comments, which is the
`on_delete_cascade()` from the migration at work.
List the new test file in `tests/glacier.rs`, next to the generated ones:
```rust
//! The app's `.glacier` tests and factories (`tests/**/*.glacier`),
//! compiled into one test binary; every `.glacier` file under tests/ is found.
//! Run them with `glacier test` or `cargo test`.
glacier::tests!();
```
and run the tests:
```console
$ glacier test
running 15 tests
test models::post::requires_a_title_of_at_least_five_characters ... ok
test models::post::requires_title ... ok
test models::post::reports_only_that_a_blank_title_is_blank ... ok
test models::comment::requires_commenter ... ok
test controllers::pages_controller::shows_the_home_page ... ok
test controllers::posts_controller::rejects_a_post_without_title ... ok
test models::comment::saves_a_valid_comment ... ok
test controllers::posts_controller::lists_posts ... ok
test controllers::posts_controller::creates_a_post ... ok
test controllers::posts_controller::deletes_a_post ... ok
test controllers::posts_controller::updates_a_post ... ok
test controllers::comments_controller::rejects_a_comment_without_a_body ... ok
test controllers::comments_controller::deleting_a_post_deletes_its_comments ... ok
test models::post::saves_a_valid_post ... ok
test controllers::comments_controller::adds_a_comment_to_a_post ... ok
test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.05s
```
(Cargo's build lines and the empty unit-test targets are cut, as before.)
## What's Next
You now have a blog with posts and comments, and tests for both. From
here:
- [The .glacier Language](glacier_language.html) explains the syntax
you've been writing and how it compiles to Rust.
- [Models](models.html), [Associations](associations.html),
[Querying](querying.html) and [Migrations](migrations.html) cover
models and the database.
- [Routing](routing.html), [Controllers](controllers.html) and
[Layouts and Rendering](views.html) cover the request side.
- [Testing](testing.html) covers tests and factories.
- [Authentication](authentication.html) adds sign up and sign in, and
[JSON APIs](json_apis.html) covers APIs and serializers.
- [Background Jobs](background_jobs.html) covers work that runs outside
a request, queued in the database.
- [Working with AI Agents](ai_agents.html) connects a coding agent to
your app through `glacier mcp`.