use clap::{Arg, ArgAction, Command};
use std::path::PathBuf;
pub const SUBCOMMANDS: &[&str] = &["build", "dev", "check", "deploy", "help"];
pub const DEPLOY_TARGETS: &[&str] = &[
"netlify",
"vercel",
"cloudflare-pages",
"github-pages",
"s3",
"none",
];
pub const LEGACY_DEPRECATION_WARNING: &str =
"warning: legacy CLI form deprecated; use 'ssg dev' (will be removed in 1.0)";
#[derive(Clone, Copy, Debug, Default)]
pub struct Cli;
#[derive(Debug, Clone)]
pub enum CliInvocation {
Build,
Dev,
Check,
Deploy {
target: String,
},
Legacy,
}
impl Cli {
#[must_use]
pub fn build() -> Command {
Command::new(env!("CARGO_PKG_NAME"))
.author(env!("CARGO_PKG_AUTHORS"))
.about(env!("CARGO_PKG_DESCRIPTION"))
.version(env!("CARGO_PKG_VERSION"))
.arg(
Arg::new("config")
.help("Configuration file path")
.long("config")
.short('f')
.value_name("FILE")
.value_parser(clap::value_parser!(PathBuf)),
)
.arg(
Arg::new("new")
.help("Create new project")
.long("new")
.short('n')
.value_name("NAME")
.value_parser(clap::value_parser!(String)), )
.arg(
Arg::new("content")
.help("Content directory")
.long("content")
.short('c')
.value_name("DIR")
.value_parser(clap::value_parser!(PathBuf)),
)
.arg(
Arg::new("output")
.help("Output directory")
.long("output")
.short('o')
.value_name("DIR")
.value_parser(clap::value_parser!(PathBuf)),
)
.arg(
Arg::new("template")
.help("Template directory")
.long("template")
.short('t')
.value_name("DIR")
.value_parser(clap::value_parser!(PathBuf)),
)
.arg(
Arg::new("serve")
.help("Development server directory")
.long("serve")
.short('s')
.value_name("DIR")
.value_parser(clap::value_parser!(PathBuf)),
)
.arg(
Arg::new("watch")
.help("Watch for changes")
.long("watch")
.short('w')
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("drafts")
.help("Include draft pages in the build")
.long("drafts")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("deploy")
.help("Generate deployment config (netlify, vercel, cloudflare, github)")
.long("deploy")
.value_name("TARGET")
.value_parser(clap::value_parser!(String)),
)
.arg(
Arg::new("validate")
.help("Validate content schemas without building")
.long("validate")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("quiet")
.help("Suppress non-error output")
.long("quiet")
.short('q')
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("verbose")
.help("Show detailed build information")
.long("verbose")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("trace")
.help("Enable OpenTelemetry build tracing (requires `otel` feature)")
.long("trace")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("jobs")
.help("Number of parallel threads (default: num CPUs)")
.long("jobs")
.short('j')
.value_name("N")
.value_parser(clap::value_parser!(usize)),
)
.arg(
Arg::new("max-memory")
.help("Peak memory budget in MB for streaming compilation (default: 512)")
.long("max-memory")
.value_name("MB")
.value_parser(clap::value_parser!(usize)),
)
.arg(
Arg::new("ai-fix")
.help("Run agentic AI pipeline to audit and fix content readability")
.long("ai-fix")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("ai-fix-dry-run")
.help("Preview AI fixes without writing changes")
.long("ai-fix-dry-run")
.action(ArgAction::SetTrue),
)
}
#[must_use]
pub fn subcommand_app() -> Command {
let shared = || -> Vec<Arg> {
vec![
Arg::new("config")
.help("Configuration file path")
.long("config")
.short('f')
.value_name("FILE")
.value_parser(clap::value_parser!(PathBuf)),
Arg::new("content")
.help("Content directory")
.long("content")
.short('c')
.value_name("DIR")
.value_parser(clap::value_parser!(PathBuf)),
Arg::new("output")
.help("Output directory")
.long("output")
.short('o')
.value_name("DIR")
.value_parser(clap::value_parser!(PathBuf)),
Arg::new("template")
.help("Template directory")
.long("template")
.short('t')
.value_name("DIR")
.value_parser(clap::value_parser!(PathBuf)),
Arg::new("quiet")
.help("Suppress non-error output")
.long("quiet")
.short('q')
.action(ArgAction::SetTrue),
Arg::new("verbose")
.help("Show detailed build information")
.long("verbose")
.action(ArgAction::SetTrue),
Arg::new("jobs")
.help("Number of parallel threads (default: num CPUs)")
.long("jobs")
.short('j')
.value_name("N")
.value_parser(clap::value_parser!(usize)),
]
};
Command::new(env!("CARGO_PKG_NAME"))
.author(env!("CARGO_PKG_AUTHORS"))
.about(env!("CARGO_PKG_DESCRIPTION"))
.version(env!("CARGO_PKG_VERSION"))
.subcommand_required(false)
.arg_required_else_help(false)
.after_help(
"Development:\n dev Start the dev server with watch + HMR\n\n\
Build:\n build Produce a static site under public/\n\n\
Validate:\n check Run validators (no output written)\n\n\
Deploy:\n deploy Build then ship to a pluggable target\n\n\
Run `ssg <SUBCOMMAND> --help` for subcommand-specific options."
)
.subcommand(
Command::new("build")
.about("Produce a static site under the configured output directory")
.long_about(
"Run the full build pipeline and exit. Equivalent to the legacy \
`ssg -s <dir>` invocation without `--watch`."
)
.args(shared())
.arg(
Arg::new("drafts")
.help("Include draft pages in the build")
.long("drafts")
.action(ArgAction::SetTrue),
)
.arg(
Arg::new("max-memory")
.help("Peak memory budget in MB for streaming compilation")
.long("max-memory")
.value_name("MB")
.value_parser(clap::value_parser!(usize)),
),
)
.subcommand(
Command::new("dev")
.about("Start the dev server with file watching and HMR")
.long_about(
"Build the site, then serve it on http://127.0.0.1:8000 (or \
$SSG_HOST:$SSG_PORT). File changes trigger a rebuild and an \
HMR push to the browser."
)
.args(shared())
.arg(
Arg::new("serve")
.help("Override the directory served (defaults to output dir)")
.long("serve")
.short('s')
.value_name("DIR")
.value_parser(clap::value_parser!(PathBuf)),
)
.arg(
Arg::new("drafts")
.help("Include draft pages in the dev build")
.long("drafts")
.action(ArgAction::SetTrue),
),
)
.subcommand(
Command::new("check")
.about("Run all build-time validators without writing output")
.long_about(
"Executes the content validation, accessibility, SEO, JSON-LD \
and CSP plugins with `dry_run: true`. Exits 0 iff every page \
would have passed; otherwise prints the violating pages and \
reasons (issue #527 AC3)."
)
.args(shared()),
)
.subcommand(
Command::new("deploy")
.about("Build the site and ship to a pluggable target")
.long_about(
"Runs the build pipeline, then invokes the deploy adapter for \
the chosen target. Tokens come from per-target env vars \
(e.g. SSG_NETLIFY_TOKEN). The `none` target performs the \
build but skips the upload — handy for CI dry-runs."
)
.args(shared())
.arg(
Arg::new("target")
.help("Deploy target")
.long("target")
.value_name("TARGET")
.required(true)
.value_parser(
clap::builder::PossibleValuesParser::new(
DEPLOY_TARGETS,
),
),
)
.arg(
Arg::new("drafts")
.help("Include draft pages in the deploy build")
.long("drafts")
.action(ArgAction::SetTrue),
),
)
}
pub fn parse_and_dispatch<I, T>(
argv: I,
) -> Result<(CliInvocation, clap::ArgMatches), clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let args: Vec<std::ffi::OsString> =
argv.into_iter().map(Into::into).collect();
let uses_subcommand =
args.get(1).and_then(|a| a.to_str()).is_some_and(|s| {
SUBCOMMANDS.contains(&s)
|| s == "--help"
|| s == "-h"
|| s == "--version"
|| s == "-V"
});
if uses_subcommand {
let matches = Self::subcommand_app().try_get_matches_from(&args)?;
let inv = match matches.subcommand() {
Some(("build", _)) => CliInvocation::Build,
Some(("dev", _)) => CliInvocation::Dev,
Some(("check", _)) => CliInvocation::Check,
Some(("deploy", sub_m)) => {
let target = sub_m
.get_one::<String>("target")
.cloned()
.unwrap_or_else(|| "none".to_string());
CliInvocation::Deploy { target }
}
_ => CliInvocation::Legacy,
};
Ok((inv, matches))
} else {
if args.len() > 1 {
eprintln!("{LEGACY_DEPRECATION_WARNING}");
}
let matches = Self::build().try_get_matches_from(&args)?;
Ok((CliInvocation::Legacy, matches))
}
}
pub fn print_banner() {
let version = env!("CARGO_PKG_VERSION");
let mut title = String::with_capacity(16 + version.len());
title.push_str("SSG \u{1f980} v");
title.push_str(version);
let description =
"A Fast and Flexible Static Site Generator written in Rust";
let width = title.len().max(description.len()) + 4;
let line = "\u{2500}".repeat(width - 2);
println!("\n\u{250c}{line}\u{2510}");
println!(
"\u{2502}{:^width$}\u{2502}",
format!("\x1b[1;32m{title}\x1b[0m"),
width = width - 3
);
println!("\u{251c}{line}\u{2524}");
println!(
"\u{2502}{:^width$}\u{2502}",
format!("\x1b[1;34m{description}\x1b[0m"),
width = width - 2
);
println!("\u{2514}{line}\u{2518}\n");
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn test_banner_display() {
let version = env!("CARGO_PKG_VERSION");
let title = format!("SSG \u{1f980} v{version}");
let description =
"A Fast and Flexible Static Site Generator written in Rust";
let width = title.len().max(description.len()) + 4;
let line = "\u{2500}".repeat(width - 2);
Cli::print_banner();
assert!(!line.is_empty());
assert!(title.contains("SSG"));
assert!(title.contains(version));
}
#[test]
fn build_returns_valid_command() {
let cmd = Cli::build();
assert_eq!(cmd.get_name(), env!("CARGO_PKG_NAME"));
let arg_names: Vec<&str> =
cmd.get_arguments().map(|a| a.get_id().as_str()).collect();
for expected in [
"config", "new", "content", "output", "template", "serve", "watch",
"drafts", "deploy", "validate", "quiet", "verbose", "jobs",
] {
assert!(
arg_names.contains(&expected),
"missing expected arg: {expected}"
);
}
}
#[test]
fn parse_minimal_args() {
let cmd = Cli::build();
let matches = cmd.try_get_matches_from(["ssg"]).unwrap();
assert!(matches.get_one::<PathBuf>("config").is_none());
assert!(matches.get_one::<PathBuf>("output").is_none());
assert!(!matches.get_flag("watch"));
assert!(!matches.get_flag("drafts"));
}
#[test]
fn parse_quiet_flag() {
let cmd = Cli::build();
let matches = cmd.try_get_matches_from(["ssg", "--quiet"]).unwrap();
assert!(matches.get_flag("quiet"));
}
#[test]
fn parse_verbose_flag() {
let cmd = Cli::build();
let matches = cmd.try_get_matches_from(["ssg", "--verbose"]).unwrap();
assert!(matches.get_flag("verbose"));
}
#[test]
fn parse_drafts_flag() {
let cmd = Cli::build();
let matches = cmd.try_get_matches_from(["ssg", "--drafts"]).unwrap();
assert!(matches.get_flag("drafts"));
}
#[test]
fn parse_combined_flags_and_values() {
let cmd = Cli::build();
let matches = cmd
.try_get_matches_from([
"ssg", "--quiet", "--drafts", "--output", "/tmp/out", "--jobs",
"4",
])
.unwrap();
assert!(matches.get_flag("quiet"));
assert!(matches.get_flag("drafts"));
assert_eq!(
matches.get_one::<PathBuf>("output").unwrap(),
&PathBuf::from("/tmp/out")
);
assert_eq!(*matches.get_one::<usize>("jobs").unwrap(), 4);
}
#[test]
fn cli_default_is_unit_struct() {
let _cli = Cli;
}
#[test]
fn subcommand_app_has_all_four_subcommands() {
let app = Cli::subcommand_app();
let names: Vec<&str> =
app.get_subcommands().map(Command::get_name).collect();
for expected in ["build", "dev", "check", "deploy"] {
assert!(
names.contains(&expected),
"subcommand `{expected}` missing"
);
}
}
#[test]
fn parse_build_subcommand() {
let (inv, _m) = Cli::parse_and_dispatch(["ssg", "build"]).unwrap();
assert!(matches!(inv, CliInvocation::Build));
}
#[test]
fn parse_dev_subcommand() {
let (inv, _m) = Cli::parse_and_dispatch(["ssg", "dev"]).unwrap();
assert!(matches!(inv, CliInvocation::Dev));
}
#[test]
fn parse_check_subcommand() {
let (inv, _m) = Cli::parse_and_dispatch(["ssg", "check"]).unwrap();
assert!(matches!(inv, CliInvocation::Check));
}
#[test]
fn parse_deploy_subcommand_with_target() {
let (inv, _m) =
Cli::parse_and_dispatch(["ssg", "deploy", "--target", "netlify"])
.unwrap();
match inv {
CliInvocation::Deploy { target } => assert_eq!(target, "netlify"),
other => panic!("expected Deploy, got {other:?}"),
}
}
#[test]
fn deploy_rejects_unknown_target() {
let err = Cli::parse_and_dispatch([
"ssg",
"deploy",
"--target",
"moon-base-alpha",
])
.unwrap_err();
assert_eq!(
err.kind(),
clap::error::ErrorKind::InvalidValue,
"unknown deploy target must be rejected by clap"
);
}
#[test]
fn deploy_requires_target() {
let err = Cli::parse_and_dispatch(["ssg", "deploy"]).unwrap_err();
assert_eq!(
err.kind(),
clap::error::ErrorKind::MissingRequiredArgument,
"--target must be required"
);
}
#[test]
fn legacy_invocation_with_flags_is_detected() {
let (inv, _m) =
Cli::parse_and_dispatch(["ssg", "-s", "public"]).unwrap();
assert!(matches!(inv, CliInvocation::Legacy));
}
#[test]
fn bare_invocation_routes_through_legacy_parser() {
let (inv, _m) = Cli::parse_and_dispatch(["ssg"]).unwrap();
assert!(matches!(inv, CliInvocation::Legacy));
}
#[test]
fn deploy_targets_const_has_six_entries() {
assert_eq!(DEPLOY_TARGETS.len(), 6);
for t in [
"netlify",
"vercel",
"cloudflare-pages",
"github-pages",
"s3",
"none",
] {
assert!(DEPLOY_TARGETS.contains(&t), "deploy target `{t}` missing");
}
}
}