// Copyright (C) 2026 Doan Luu Duc Tai // // This program is free software: you can redistribute it and/or modify // it under the terms of the GNU Affero General Public License as // published by the Free Software Foundation, either version 3 of the // License, or (at your option) any later version. // // This program is distributed in the hope that it will be useful, // but WITHOUT ANY WARRANTY; without even the implied warranty of // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the // GNU Affero General Public License for more details. // // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . #![doc = "Rust commands for personal web infrastructure."] pub(crate) mod archive; pub(crate) mod files; pub(crate) mod images; pub(crate) mod process; pub(crate) mod project; pub(crate) mod release; use std::path::{Path, PathBuf}; use std::process::ExitCode; use anyhow::Result; use clap::{Parser, Subcommand}; use crate::project::{Mode, Project}; #[derive(Debug, Parser)] #[command( name = "stack", version, about = "Develop, deploy, and back up the personal web stack" )] struct Cli { #[arg( long, global = true, help = "Application root; otherwise discovered from the working directory or binary" )] root: Option, #[command(subcommand)] command: Action, } #[derive(Debug, Subcommand)] enum Action { #[command(about = "Start the local development services")] Dev { #[arg(long, default_value_t = 8080)] port: u16, }, #[command(about = "Start or update services in the selected mode")] Up { #[arg(long, conflicts_with = "local")] production: bool, #[arg(long)] local: bool, #[arg(long)] port: Option, #[arg(long)] online: bool, }, #[command(about = "Show service status")] Status, #[command(about = "Stop services while retaining data")] Down, #[command(about = "Create a GPG-encrypted application export")] Backup { destination: Option, #[arg(long)] include_images: bool, #[arg(long, default_value = archive::RECIPIENT)] recipient: String, #[arg(long)] key: Option, }, #[command( about = "Decrypt, validate, and restore into a new directory without starting services" )] Restore { archive: PathBuf, destination: PathBuf, }, #[command(about = "Run formatting, strict lints, tests, documentation, and dependency checks")] Check, #[command(about = "Check and build a portable production release locally")] Release { destination: Option }, #[command(about = "Install a prebuilt release without compiling on the server")] Activate { archive: PathBuf }, #[command(about = "Generate the third-party license page with GNU license names")] Licenses, } fn main() -> ExitCode { let arguments = match Cli::try_parse() { Ok(arguments) => arguments, Err(error) => { let status = if error.use_stderr() { ExitCode::FAILURE } else { ExitCode::SUCCESS }; drop(error.print()); return status; } }; match execute(arguments) { Ok(()) => ExitCode::SUCCESS, Err(error) => { drop(process::error(format_args!("Error: {error:#}"))); ExitCode::FAILURE } } } fn execute(arguments: Cli) -> Result<()> { if let Action::Restore { archive, destination, } = &arguments.command { return archive::restore(&absolute(archive)?, &absolute(destination)?); } let project = Project::discover(arguments.root.as_deref())?; let _operation = project.lock()?; match arguments.command { Action::Dev { port } => { let mut settings = project.settings()?; settings.mode = Mode::Local; settings.port = port; images::prepare(&project, &mut settings)?; project.up(&settings) } Action::Up { production, local, port, online, } => { let mut settings = project.settings()?; if production { settings.mode = Mode::Production; } if local { settings.mode = Mode::Local; } if let Some(selected_port) = port { settings.port = selected_port; } if online { settings.online = true; } images::prepare(&project, &mut settings)?; project.up(&settings) } Action::Status => project.status(&project.settings()?), Action::Down => project.down(&project.settings()?), Action::Backup { destination, include_images, recipient, key, } => { let output = destination.map_or_else( || Ok(project.root.join("var/exports")), |path| absolute(&path), )?; archive::backup( &project, &output, include_images, &recipient, key.as_deref(), ) } Action::Release { destination } => { let output = destination .map_or_else(|| Ok(project.root.join("dist")), |path| absolute(&path))?; release::create(&project, &output) } Action::Activate { archive } => release::activate(&absolute(&archive)?, &project), Action::Check => check(&project), Action::Licenses => licenses(&project), Action::Restore { .. } => anyhow::bail!("Restore action was not handled"), } } fn absolute(path: &Path) -> Result { Ok(if path.is_absolute() { path.to_path_buf() } else { std::env::current_dir()?.join(path) }) } fn licenses(project: &Project) -> Result<()> { process::run(project.command("cargo")?.args([ "about", "generate", "about.hbs", "--output-file", "web/THIRD-PARTY-LICENSES.html", ])) } fn check(project: &Project) -> Result<()> { process::run( project .command("cargo")? .args(["fmt", "--all", "--", "--check"]), )?; process::run(project.command("cargo")?.args([ "clippy", "--locked", "--workspace", "--all-targets", "--all-features", "--", "-D", "warnings", ]))?; process::run(project.command("cargo")?.args([ "test", "--locked", "--workspace", "--all-targets", "--all-features", ]))?; process::run( project .command("cargo")? .env("RUSTDOCFLAGS", "-D warnings") .args([ "doc", "--locked", "--workspace", "--no-deps", "--document-private-items", ]), )?; process::run( project .command("cargo")? .args(["deny", "--all-features", "check"]), )?; licenses(project) }