mod auth;
mod diagnostics;
mod dialect;
mod generate;
mod lsp;
mod scaffold;
mod server;
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,
},
#[command(alias = "s")]
Server {
#[arg(long)]
no_watch: bool,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
Routes,
#[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,
Fmt {
#[arg(long)]
check: bool,
paths: Vec<PathBuf>,
},
#[command(subcommand)]
Db(DbCommand),
#[command(subcommand, alias = "g")]
Generate(GenerateCommand),
Secret,
}
#[derive(Subcommand)]
enum DbCommand {
Migrate,
Rollback {
#[arg(long, default_value_t = 1)]
steps: usize,
},
Status,
}
#[derive(Subcommand)]
enum GenerateCommand {
Migration {
name: 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>,
},
Authentication {
#[arg(long)]
api: bool,
},
Scaffold {
name: String,
#[arg(required = true)]
fields: Vec<String>,
#[arg(long, value_enum, default_value_t = Dialect::Glacier)]
dialect: Dialect,
},
}
#[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")),
("build.rs", include_str!("../templates/build.rs.tmpl")),
("src/main.rs", include_str!("../templates/main.rs.tmpl")),
(
"src/migrations/mod.rs",
include_str!("../templates/migrations_mod.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/routes.rs", include_str!("../templates/routes.rs.tmpl")),
("src/views.rs", include_str!("../templates/views.rs.tmpl")),
(
"src/views/layouts/app.html",
include_str!("../templates/layout.html.tmpl"),
),
(
"src/views/pages/home.html",
include_str!("../templates/home.html.tmpl"),
),
("public/app.css", include_str!("../templates/app.css.tmpl")),
];
const API_TEMPLATE: &[(&str, &str)] = &[
("src/lib.rs", include_str!("../templates/api/lib.rs.tmpl")),
(
"src/routes.rs",
include_str!("../templates/api/routes.rs.tmpl"),
),
];
fn main() -> ExitCode {
let result = match Cli::parse().command {
Command::New {
name,
glacier_path,
dialect,
api,
} => new_app(&name, glacier_path, dialect, api),
Command::Server { no_watch, args } => server::run(&args, !no_watch),
Command::Routes => task("routes", &[]),
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::Fmt { check, paths } => dialect::format(&paths, check),
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::Generate(GenerateCommand::Migration { name }) => generate::migration(&name),
Command::Generate(GenerateCommand::Job { name, fields }) => scaffold::job(&name, &fields),
Command::Generate(GenerateCommand::Policy { name }) => scaffold::policy(&name),
Command::Generate(GenerateCommand::Authentication { api }) => auth::authentication(api),
Command::Generate(GenerateCommand::Scaffold {
name,
fields,
dialect,
}) => scaffold::scaffold(&name, &fields, dialect),
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)> {
match (dialect, api) {
(Dialect::Glacier, true) => vec![
(
"src/models/mod.rs",
include_str!("../templates/models_mod_glacier.rs.tmpl"),
),
(
"src/controllers/mod.rs",
include_str!("../templates/api/controllers_mod_glacier.rs.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"),
),
],
(Dialect::Rust, true) => vec![
(
"src/models/mod.rs",
include_str!("../templates/models_mod.rs.tmpl"),
),
(
"src/controllers/mod.rs",
include_str!("../templates/api/controllers_mod.rs.tmpl"),
),
(
"src/controllers/home_controller.rs",
include_str!("../templates/api/home_controller.rs.tmpl"),
),
],
(Dialect::Glacier, false) => vec![
(
"src/models/mod.rs",
include_str!("../templates/models_mod_glacier.rs.tmpl"),
),
(
"src/controllers/mod.rs",
include_str!("../templates/controllers_mod_glacier.rs.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"),
),
],
(Dialect::Rust, false) => vec![
(
"src/models/mod.rs",
include_str!("../templates/models_mod.rs.tmpl"),
),
(
"src/controllers/mod.rs",
include_str!("../templates/controllers_mod.rs.tmpl"),
),
(
"src/controllers/pages_controller.rs",
include_str!("../templates/pages_controller.rs.tmpl"),
),
],
}
}
fn new_app(
name: &str,
glacier_path: Option<PathBuf>,
dialect: Dialect,
api: bool,
) -> Result<(), String> {
validate_name(name)?;
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 controllers = controller_files(dialect, api);
for (path, template) in APP_TEMPLATE.iter().chain(flavour).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("{{glacier_dependency}}", &glacier_dependency)
.replace("{{dialect_dependency}}", &dialect_dependency)
.replace("{{secret}}", &secret());
fs::write(&target, contents).map_err(|e| format!("writing {}: {e}", target.display()))?;
println!(" create {name}/{path}");
}
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 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}"))
}
}
#[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());
}
}