mod access;
mod analysis;
mod audit;
mod auth;
mod diagnostics;
mod dialect;
mod docs;
mod generate;
mod lsp;
mod mcp;
mod scaffold;
mod server;
mod symbols;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command as Process, ExitCode};
use clap::{Parser, Subcommand, ValueEnum};
#[derive(Parser)]
#[command(name = "glacier", version, about = "The Glacier web framework")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
New {
name: String,
#[arg(long)]
glacier_path: Option<PathBuf>,
#[arg(long, value_enum, default_value_t = Dialect::Glacier)]
dialect: Dialect,
#[arg(long)]
api: bool,
#[arg(long)]
no_tailwind: bool,
#[arg(long)]
vite: bool,
},
#[command(alias = "s")]
Server {
#[arg(long)]
no_watch: bool,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
Openapi {
#[arg(long)]
api_version: Option<String>,
},
Types {
#[arg(long)]
api_version: Option<String>,
},
Routes,
Run {
task: String,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
Tasks,
Cache {
#[command(subcommand)]
action: CacheCommand,
},
Webhooks {
#[command(subcommand)]
action: Option<WebhooksCommand>,
},
Jobs {
#[command(subcommand)]
action: Option<JobsCommand>,
},
#[command(alias = "b")]
Build {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
#[command(alias = "t")]
Test {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
Compile,
Lsp,
Mcp,
Check {
#[arg(long)]
json: bool,
#[arg(long)]
glacier_only: bool,
},
Fmt {
#[arg(long)]
check: bool,
paths: Vec<PathBuf>,
},
#[command(subcommand)]
Db(DbCommand),
#[command(subcommand, alias = "g")]
Generate(GenerateCommand),
Secret,
Audit,
Precommit,
}
#[derive(Subcommand)]
enum WebhooksCommand {
Replay {
id: i64,
},
}
#[derive(Subcommand)]
enum JobsCommand {
Schedules,
Retry {
id: i64,
},
Work {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
}
#[derive(Subcommand)]
enum CacheCommand {
Clear,
}
#[derive(Subcommand)]
enum DbCommand {
Migrate,
Rollback {
#[arg(long, default_value_t = 1)]
steps: usize,
},
Status,
Seed,
Setup,
Dump,
}
#[derive(Subcommand)]
enum GenerateCommand {
Migration {
name: String,
#[arg(long)]
database: Option<String>,
},
Model {
name: String,
#[arg(required = true)]
fields: Vec<String>,
#[arg(long, value_enum, default_value_t = Dialect::Glacier)]
dialect: Dialect,
},
Controller {
name: String,
#[arg(required = true)]
actions: Vec<String>,
#[arg(long, value_enum, default_value_t = Dialect::Glacier)]
dialect: Dialect,
},
Policy {
name: String,
},
Job {
name: String,
fields: Vec<String>,
},
Webhook {
provider: String,
},
Client {
name: String,
base_url: String,
},
Middleware {
name: String,
},
Mailer {
name: String,
actions: Vec<String>,
},
Authentication {
#[arg(long)]
api: bool,
#[arg(long)]
impersonation: bool,
#[arg(long)]
api_tokens: bool,
},
Access {
kind: String,
#[arg(long)]
urls: Option<String>,
},
Scaffold {
name: String,
#[arg(required = true)]
fields: Vec<String>,
#[arg(long, value_enum, default_value_t = Dialect::Glacier)]
dialect: Dialect,
#[arg(long)]
access: Option<String>,
#[arg(long, conflicts_with = "access")]
no_access: bool,
},
}
#[derive(Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum Dialect {
Glacier,
Rust,
}
const APP_TEMPLATE: &[(&str, &str)] = &[
("Cargo.toml", include_str!("../templates/Cargo.toml.tmpl")),
(".gitignore", include_str!("../templates/gitignore.tmpl")),
("AGENTS.md", include_str!("../templates/AGENTS.md.tmpl")),
(".mcp.json", include_str!("../templates/mcp.json.tmpl")),
("build.rs", include_str!("../templates/build.rs.tmpl")),
("db/migrations/.gitkeep", ""),
("src/main.rs", include_str!("../templates/main.rs.tmpl")),
(
"config/default.toml",
include_str!("../templates/config_default.toml.tmpl"),
),
(
"config/development.toml",
include_str!("../templates/config_development.toml.tmpl"),
),
(
"config/test.toml",
include_str!("../templates/config_test.toml.tmpl"),
),
(
"config/production.toml",
include_str!("../templates/config_production.toml.tmpl"),
),
];
const BROWSER_TEMPLATE: &[(&str, &str)] = &[
("src/lib.rs", include_str!("../templates/lib.rs.tmpl")),
(
"src/views/layouts/app.html",
include_str!("../templates/layout.html.tmpl"),
),
(
"src/views/pages/home.html",
include_str!("../templates/home.html.tmpl"),
),
(
"src/views/errors/404.html",
include_str!("../templates/errors/404.html.tmpl"),
),
(
"src/views/errors/500.html",
include_str!("../templates/errors/500.html.tmpl"),
),
];
const TAILWIND_TEMPLATE: &[(&str, &str)] = &[(
"assets/app.css",
include_str!("../templates/tailwind/app.css.tmpl"),
)];
const PLAIN_CSS_TEMPLATE: &[(&str, &str)] = &[(
"public/app.css",
include_str!("../templates/plain/app.css.tmpl"),
)];
mod ui_files {
include!(concat!(env!("OUT_DIR"), "/ui_files.rs"));
}
const VITE_TEMPLATE: &[(&str, &str)] = &[
(
"package.json",
include_str!("../templates/vite/package.json.tmpl"),
),
(
"vite.config.js",
include_str!("../templates/vite/vite.config.js.tmpl"),
),
(
"assets/js/app.js",
include_str!("../templates/vite/app.js.tmpl"),
),
];
const API_TEMPLATE: &[(&str, &str)] =
&[("src/lib.rs", include_str!("../templates/api/lib.rs.tmpl"))];
fn main() -> ExitCode {
let result = match Cli::parse().command {
Command::New {
name,
glacier_path,
dialect,
api,
no_tailwind,
vite,
} => new_app(&name, glacier_path, dialect, api, !no_tailwind, vite),
Command::Server { no_watch, args } => server::run(&args, !no_watch),
Command::Types { api_version } => task(
"typescript",
&[("GLACIER_API_VERSION", api_version.as_deref().unwrap_or(""))],
),
Command::Routes => task("routes", &[]),
Command::Run { task, args } => {
let mut cargo = vec!["-q".to_owned(), "--".to_owned(), task];
cargo.extend(args);
cargo_run(&cargo, &[])
}
Command::Tasks => task("tasks", &[]),
Command::Cache {
action: CacheCommand::Clear,
} => task("cache:clear", &[]),
Command::Webhooks { action: None } => task("webhooks", &[]),
Command::Webhooks {
action: Some(WebhooksCommand::Replay { id }),
} => task("webhooks:replay", &[("GLACIER_EVENT_ID", &id.to_string())]),
Command::Jobs { action: None } => task("jobs", &[]),
Command::Jobs {
action: Some(JobsCommand::Schedules),
} => task("jobs:schedules", &[]),
Command::Jobs {
action: Some(JobsCommand::Retry { id }),
} => task("jobs:retry", &[("GLACIER_JOB_ID", &id.to_string())]),
Command::Jobs {
action: Some(JobsCommand::Work { args }),
} => {
let mut args = args;
args.extend(["--".to_owned(), "work".to_owned()]);
cargo_run(&args, &[])
}
Command::Openapi { api_version } => task(
"openapi",
&[("GLACIER_API_VERSION", api_version.as_deref().unwrap_or(""))],
),
Command::Build { args } => {
if dialect::cargo("build", &args) {
Ok(())
} else {
Err("build failed".into())
}
}
Command::Test { args } => {
if dialect::cargo("test", &args) {
Ok(())
} else {
Err("tests failed".into())
}
}
Command::Lsp => lsp::run(),
Command::Mcp => mcp::run(),
Command::Check { json, glacier_only } => check(json, glacier_only),
Command::Fmt { check, paths } => dialect::format(&paths, check),
Command::Audit => audit::run(Path::new(".")),
Command::Precommit => precommit(),
Command::Compile => dialect::compile_all().map(|written| {
let total = dialect::sources().len();
println!("compiled {total} .glacier file(s), {written} changed");
}),
Command::Db(DbCommand::Migrate) => task("db:migrate", &[]),
Command::Db(DbCommand::Rollback { steps }) => {
task("db:rollback", &[("GLACIER_STEPS", &steps.to_string())])
}
Command::Db(DbCommand::Status) => task("db:status", &[]),
Command::Db(DbCommand::Seed) => task("db:seed", &[]),
Command::Db(DbCommand::Dump) => task("db:dump", &[]),
Command::Db(DbCommand::Setup) => {
task("db:migrate", &[]).and_then(|()| task("db:seed", &[]))
}
Command::Generate(GenerateCommand::Migration { name, database }) => match database {
Some(database) => generate::named_migration(&name, &database),
None if name == "create_glacier_jobs" => {
generate::migration_with("create_glacier_jobs", scaffold::JOBS_MIGRATION)
}
None => generate::migration(&name),
},
Command::Generate(GenerateCommand::Job { name, fields }) => scaffold::job(&name, &fields),
Command::Generate(GenerateCommand::Webhook { provider }) => scaffold::webhook(&provider),
Command::Generate(GenerateCommand::Client { name, base_url }) => {
scaffold::client(&name, &base_url)
}
Command::Generate(GenerateCommand::Mailer { name, actions }) => {
scaffold::mailer(&name, &actions)
}
Command::Generate(GenerateCommand::Middleware { name }) => scaffold::middleware(&name),
Command::Generate(GenerateCommand::Policy { name }) => scaffold::policy(&name),
Command::Generate(GenerateCommand::Authentication {
api,
impersonation,
api_tokens,
}) => auth::authentication(
api,
auth::Extras {
impersonation,
api_tokens,
},
),
Command::Generate(GenerateCommand::Access { kind, urls }) => {
access::access(&kind, urls.as_deref())
}
Command::Generate(GenerateCommand::Scaffold {
name,
fields,
dialect,
access,
no_access,
}) => scaffold::scaffold(&name, &fields, dialect, access.as_deref(), no_access),
Command::Generate(GenerateCommand::Model {
name,
fields,
dialect,
}) => scaffold::model_only(&name, &fields, dialect),
Command::Generate(GenerateCommand::Controller {
name,
actions,
dialect,
}) => scaffold::controller_only(&name, &actions, dialect),
Command::Secret => {
println!("{}", secret());
Ok(())
}
};
match result {
Ok(()) => ExitCode::SUCCESS,
Err(message) => {
eprintln!("error: {message}");
ExitCode::FAILURE
}
}
}
fn controller_files(dialect: Dialect, api: bool) -> Vec<(&'static str, &'static str)> {
let routes = match (dialect, api) {
(Dialect::Glacier, false) => (
"src/routes.glacier",
include_str!("../templates/routes.glacier.tmpl"),
),
(Dialect::Glacier, true) => (
"src/routes.glacier",
include_str!("../templates/api/routes.glacier.tmpl"),
),
(Dialect::Rust, false) => ("src/routes.rs", include_str!("../templates/routes.rs.tmpl")),
(Dialect::Rust, true) => (
"src/routes.rs",
include_str!("../templates/api/routes.rs.tmpl"),
),
};
let mut files = controller_and_model_files(dialect, api);
files.push(routes);
files
}
fn controller_and_model_files(dialect: Dialect, api: bool) -> Vec<(&'static str, &'static str)> {
match (dialect, api) {
(Dialect::Glacier, true) => vec![
(
"src/app.glacier",
include_str!("../templates/api/app.glacier.tmpl"),
),
(
"src/controllers/home_controller.glacier",
include_str!("../templates/api/home_controller.glacier.tmpl"),
),
(
"tests/glacier.rs",
include_str!("../templates/api/tests_glacier.rs.tmpl"),
),
(
"tests/controllers/home_controller.test.glacier",
include_str!("../templates/api/home_controller.test.glacier.tmpl"),
),
(
"src/seeds.glacier",
include_str!("../templates/seeds.glacier.tmpl"),
),
],
(Dialect::Rust, true) => vec![
("src/app.rs", include_str!("../templates/api/app.rs.tmpl")),
(
"src/controllers/home_controller.rs",
include_str!("../templates/api/home_controller.rs.tmpl"),
),
("src/seeds.rs", include_str!("../templates/seeds.rs.tmpl")),
],
(Dialect::Glacier, false) => vec![
(
"src/app.glacier",
include_str!("../templates/app.glacier.tmpl"),
),
(
"src/controllers/pages_controller.glacier",
include_str!("../templates/pages_controller.glacier.tmpl"),
),
("tests/glacier.rs", scaffold::TESTS_RS),
(
"tests/controllers/pages_controller.test.glacier",
include_str!("../templates/pages_controller.test.glacier.tmpl"),
),
(
"src/seeds.glacier",
include_str!("../templates/seeds.glacier.tmpl"),
),
],
(Dialect::Rust, false) => vec![
("src/app.rs", include_str!("../templates/app.rs.tmpl")),
(
"src/controllers/pages_controller.rs",
include_str!("../templates/pages_controller.rs.tmpl"),
),
("src/seeds.rs", include_str!("../templates/seeds.rs.tmpl")),
],
}
}
fn new_app(
name: &str,
glacier_path: Option<PathBuf>,
dialect: Dialect,
api: bool,
tailwind: bool,
vite: bool,
) -> Result<(), String> {
validate_name(name)?;
if vite && api {
return Err("`--vite` is for apps with pages; an `--api` app has no assets".into());
}
let root = Path::new(name);
if root.exists() {
return Err(format!("`{name}` already exists"));
}
let (glacier_dependency, dialect_dependency) = dependencies(glacier_path)?;
let flavour = if api { API_TEMPLATE } else { BROWSER_TEMPLATE };
let styles: &[(&str, &str)] = match (api, tailwind) {
(true, _) => &[],
(false, true) => TAILWIND_TEMPLATE,
(false, false) => PLAIN_CSS_TEMPLATE,
};
let controllers = controller_files(dialect, api);
let scripts: &[(&str, &str)] = if vite { VITE_TEMPLATE } else { &[] };
for (path, template) in APP_TEMPLATE
.iter()
.chain(flavour)
.chain(styles)
.chain(scripts)
.chain(&controllers)
{
let target = root.join(path);
if let Some(dir) = target.parent() {
fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
}
let contents = template
.replace("{{name}}", name)
.replace("{{crate_name}}", &name.replace('-', "_"))
.replace(
"{{display_name}}",
&auth::display_name(&name.replace('-', "_")),
)
.replace("{{glacier_dependency}}", &glacier_dependency)
.replace("{{dialect_dependency}}", &dialect_dependency)
.replace("{{secret}}", &secret());
let contents = match *path {
"src/views/layouts/app.html" if !tailwind => {
contents.replace("/assets/app.css", "/app.css")
}
".gitignore" if tailwind && !api => {
contents + "# Built from assets/app.css by every build\n/public/assets/\n"
}
_ => contents,
};
let contents = match *path {
"src/views/layouts/app.html" if vite => contents.replace(
" </head>",
" {{ vite(\"assets/js/app.js\") }}\n </head>",
),
".gitignore" if vite => {
contents
+ "# Vite: packages, and what a release build writes\n/node_modules/\n/public/build/\n"
}
_ => contents,
};
fs::write(&target, contents).map_err(|e| format!("writing {}: {e}", target.display()))?;
println!(" create {name}/{path}");
}
if !api {
for (path, bytes) in ui_files::UI_FILES {
let target = root.join(path);
if let Some(dir) = target.parent() {
fs::create_dir_all(dir).map_err(|e| format!("creating {}: {e}", dir.display()))?;
}
fs::write(&target, bytes).map_err(|e| format!("writing {}: {e}", target.display()))?;
}
println!(
" create {name}/src/components/ ({} components), public/ui/, public/fonts/",
ui_files::UI_FILES
.iter()
.filter(|(path, _)| path.ends_with(".html") && !path.ends_with(".preview.html"))
.count()
);
}
if vite {
println!("\nDone! Next:\n\n cd {name}\n npm install\n glacier server\n");
} else {
println!("\nDone! Next:\n\n cd {name}\n glacier server\n");
}
Ok(())
}
fn dependencies(glacier_path: Option<PathBuf>) -> Result<(String, String), String> {
let checkout = Path::new(env!("CARGO_MANIFEST_DIR")).join("../glacier");
let path = match glacier_path {
Some(path) => Some(
path.canonicalize()
.map_err(|_| format!("can't find the glacier crate at {}", path.display()))?,
),
None => is_glacier_crate(&checkout)
.then(|| checkout.canonicalize().ok())
.flatten(),
};
Ok(match path {
Some(path) => {
let glacier = path.display().to_string().replace('\\', "\\\\");
let dialect = path
.join("../glacier-dialect")
.display()
.to_string()
.replace('\\', "\\\\");
(
format!("glacier-web = {{ path = \"{glacier}\" }}"),
format!("glacier-dialect = {{ path = \"{dialect}\" }}"),
)
}
None => {
let version = env!("CARGO_PKG_VERSION");
(
format!("glacier-web = \"{version}\""),
format!("glacier-dialect = \"{version}\""),
)
}
})
}
fn is_glacier_crate(dir: &Path) -> bool {
fs::read_to_string(dir.join("Cargo.toml"))
.is_ok_and(|manifest| manifest.contains("name = \"glacier-web\""))
}
fn validate_name(name: &str) -> Result<(), String> {
let starts_with_letter = name.chars().next().is_some_and(|c| c.is_ascii_alphabetic());
let valid_chars = name
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if starts_with_letter && valid_chars {
Ok(())
} else {
Err(format!(
"invalid app name `{name}`: use letters, digits, `_` or `-`, starting with a letter"
))
}
}
fn secret() -> String {
let mut bytes = [0u8; 64];
getrandom::fill(&mut bytes).expect("the OS random number generator failed");
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn check(as_json: bool, glacier_only: bool) -> Result<(), String> {
let app = Path::new(".");
let diagnostics = if glacier_only {
analysis::glacier_errors(app)
} else {
analysis::build_errors(app)?
};
let errors = diagnostics
.iter()
.filter(|d| d["severity"] == "error")
.count();
for d in &diagnostics {
if as_json {
println!("{d}");
} else {
let place = format!(
"{}:{}:{}",
d["file"].as_str().unwrap_or("?"),
d["line"],
d["column"]
);
println!(
"{}: {}: {}",
place,
d["severity"].as_str().unwrap_or("error"),
d["message"].as_str().unwrap_or_default()
);
if let Some(expected) = d["expected"].as_str() {
println!(" expected: {expected}");
}
if let Some(fix) = d["fix"]["replacement"].as_str() {
println!(" fix: {fix}");
}
}
}
if errors == 0 {
if !as_json {
println!("No errors.");
}
Ok(())
} else {
Err(format!("{errors} error(s)"))
}
}
fn task(name: &str, envs: &[(&str, &str)]) -> Result<(), String> {
let mut envs = envs.to_vec();
envs.push(("GLACIER_TASK", name));
cargo_run(&["-q".into()], &envs)
}
pub(crate) fn cargo_run(args: &[String], envs: &[(&str, &str)]) -> Result<(), String> {
if !Path::new("Cargo.toml").exists() {
return Err("no Cargo.toml here; run this from your app's directory".into());
}
let status = Process::new("cargo")
.arg("run")
.args(args)
.envs(envs.iter().copied())
.status()
.map_err(|e| format!("failed to run cargo: {e}"))?;
if status.success() {
Ok(())
} else {
Err(format!("cargo run exited with {status}"))
}
}
fn precommit() -> Result<(), String> {
println!("== glacier fmt --check");
dialect::format(&[], true)?;
println!("== cargo fmt --check");
let rustfmt = std::process::Command::new("cargo")
.args(["fmt", "--check"])
.status()
.map_err(|e| format!("running cargo fmt: {e}"))?;
if !rustfmt.success() {
return Err("Rust files need formatting: run `cargo fmt`".into());
}
println!("== clippy");
let clippy_args: Vec<String> = ["--all-targets", "--", "-D", "warnings"]
.iter()
.map(|a| (*a).to_owned())
.collect();
if !dialect::cargo("clippy", &clippy_args) {
return Err("clippy found problems".into());
}
println!("== tests");
if !dialect::cargo("test", &[]) {
return Err("tests failed".into());
}
println!("== audit");
if let Err(summary) = audit::run(Path::new(".")) {
println!("{summary} (not a failure: each is a place to look)");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validates_names() {
assert!(validate_name("blog").is_ok());
assert!(validate_name("my-app_2").is_ok());
assert!(validate_name("2fast").is_err());
assert!(validate_name("").is_err());
assert!(validate_name("../etc").is_err());
}
}