1use clap::{Arg, ArgAction, Command};
19use std::path::PathBuf;
20
21pub const SUBCOMMANDS: &[&str] = &[
25 "build", "dev", "check", "deploy", "audit", "plugins", "help",
26];
27
28pub const DEPLOY_TARGETS: &[&str] = &[
34 "netlify",
35 "vercel",
36 "cloudflare-pages",
37 "github-pages",
38 "s3",
39 "none",
40];
41
42pub const LEGACY_DEPRECATION_WARNING: &str =
45 "warning: legacy CLI form deprecated; use 'ssg dev' (will be removed in 1.0)";
46
47#[derive(Clone, Copy, Debug, Default)]
48pub struct Cli;
50
51#[derive(Debug, Clone)]
54pub enum CliInvocation {
55 Build,
58 Dev,
60 Check,
62 Deploy {
65 target: String,
67 },
68 Plugins {
71 json: bool,
73 target: Option<String>,
76 },
77 Audit,
80 Legacy,
84}
85
86fn parse_env_bool(s: &str) -> Result<bool, String> {
93 match s.trim().to_ascii_lowercase().as_str() {
94 "1" | "true" | "yes" | "on" => Ok(true),
95 "0" | "false" | "no" | "off" | "" => Ok(false),
96 other => Err(format!(
97 "expected a boolean (1/true/yes/on or 0/false/no/off), got {other:?}"
98 )),
99 }
100}
101
102impl Cli {
103 #[must_use]
118 pub fn build() -> Command {
119 Command::new(env!("CARGO_PKG_NAME"))
120 .author(env!("CARGO_PKG_AUTHORS"))
121 .about(env!("CARGO_PKG_DESCRIPTION"))
122 .version(env!("CARGO_PKG_VERSION"))
123 .arg(
124 Arg::new("config")
125 .help("Configuration file path")
126 .long("config")
127 .short('f')
128 .value_name("FILE")
129 .value_parser(clap::value_parser!(PathBuf)),
130 )
131 .arg(
132 Arg::new("new")
133 .help("Create new project")
134 .long("new")
135 .short('n')
136 .value_name("NAME")
137 .value_parser(clap::value_parser!(String)), )
139 .arg(
140 Arg::new("content")
141 .help("Content directory")
142 .long("content")
143 .short('c')
144 .value_name("DIR")
145 .value_parser(clap::value_parser!(PathBuf)),
146 )
147 .arg(
148 Arg::new("output")
149 .help("Output directory")
150 .long("output")
151 .short('o')
152 .value_name("DIR")
153 .value_parser(clap::value_parser!(PathBuf)),
154 )
155 .arg(
156 Arg::new("template")
157 .help("Template directory")
158 .long("template")
159 .short('t')
160 .value_name("DIR")
161 .value_parser(clap::value_parser!(PathBuf)),
162 )
163 .arg(
164 Arg::new("theme")
165 .help("Theme name; resolves the template directory")
166 .long("theme")
167 .value_name("NAME"),
168 )
169 .arg(
170 Arg::new("serve")
171 .help("Development server directory")
172 .long("serve")
173 .short('s')
174 .value_name("DIR")
175 .value_parser(clap::value_parser!(PathBuf)),
176 )
177 .arg(
178 Arg::new("watch")
179 .help("Watch for changes")
180 .long("watch")
181 .short('w')
182 .action(ArgAction::SetTrue),
183 )
184 .arg(
185 Arg::new("drafts")
186 .help("Include draft pages in the build")
187 .long("drafts")
188 .action(ArgAction::SetTrue),
189 )
190 .arg(
191 Arg::new("deploy")
192 .help("Generate deployment config (netlify, vercel, cloudflare, github)")
193 .long("deploy")
194 .value_name("TARGET")
195 .value_parser(clap::value_parser!(String)),
196 )
197 .arg(
198 Arg::new("no_tag_pages")
199 .help(
200 "Skip taxonomy (tag/category/topic) page generation",
201 )
202 .long("no-tag-pages")
203 .env("SSG_NO_TAG_PAGES")
204 .value_parser(parse_env_bool)
211 .action(ArgAction::SetTrue),
212 )
213 .arg(
214 Arg::new("validate")
215 .help("Validate content schemas without building")
216 .long("validate")
217 .action(ArgAction::SetTrue),
218 )
219 .arg(
220 Arg::new("quiet")
221 .help("Suppress non-error output")
222 .long("quiet")
223 .short('q')
224 .action(ArgAction::SetTrue),
225 )
226 .arg(
227 Arg::new("verbose")
228 .help("Show detailed build information")
229 .long("verbose")
230 .action(ArgAction::SetTrue),
231 )
232 .arg(
233 Arg::new("trace")
239 .help("Enable OpenTelemetry build tracing (requires `otel` feature)")
240 .long("trace")
241 .action(ArgAction::SetTrue),
242 )
243 .arg(
244 Arg::new("jobs")
245 .help("Number of parallel threads (default: num CPUs)")
246 .long("jobs")
247 .short('j')
248 .value_name("N")
249 .value_parser(clap::value_parser!(usize)),
250 )
251 .arg(
252 Arg::new("max-memory")
253 .help("Peak memory budget in MB for streaming compilation (default: 512)")
254 .long("max-memory")
255 .value_name("MB")
256 .value_parser(clap::value_parser!(usize)),
257 )
258 .arg(
259 Arg::new("ai-fix")
260 .help("Run agentic AI pipeline to audit and fix content readability")
261 .long("ai-fix")
262 .action(ArgAction::SetTrue),
263 )
264 .arg(
265 Arg::new("ai-fix-dry-run")
266 .help("Preview AI fixes without writing changes")
267 .long("ai-fix-dry-run")
268 .action(ArgAction::SetTrue),
269 )
270 .arg(
271 Arg::new("incremental")
272 .help("Rebuild only the pages affected by source changes (issue #524)")
273 .long("incremental")
274 .action(ArgAction::SetTrue),
275 )
276 .arg(
277 Arg::new("no-llm-cache")
278 .help("Disable the deterministic LLM inference cache")
279 .long("no-llm-cache")
280 .action(ArgAction::SetTrue),
281 )
282 .arg(
283 Arg::new("isr")
284 .help("Emit ISR manifest + raw KV payloads under dist/.ssg/ (opt-in, issue #546)")
285 .long("isr")
286 .action(ArgAction::SetTrue),
287 )
288 }
289
290 #[must_use]
324 pub fn subcommand_app() -> Command {
325 let shared = || -> Vec<Arg> {
326 vec![
327 Arg::new("config")
328 .help("Configuration file path")
329 .long("config")
330 .short('f')
331 .value_name("FILE")
332 .value_parser(clap::value_parser!(PathBuf)),
333 Arg::new("content")
334 .help("Content directory")
335 .long("content")
336 .short('c')
337 .value_name("DIR")
338 .value_parser(clap::value_parser!(PathBuf)),
339 Arg::new("output")
340 .help("Output directory")
341 .long("output")
342 .short('o')
343 .value_name("DIR")
344 .value_parser(clap::value_parser!(PathBuf)),
345 Arg::new("template")
346 .help("Template directory")
347 .long("template")
348 .short('t')
349 .value_name("DIR")
350 .value_parser(clap::value_parser!(PathBuf)),
351 Arg::new("theme")
352 .help("Theme name; resolves the template directory")
353 .long("theme")
354 .value_name("NAME"),
355 Arg::new("quiet")
356 .help("Suppress non-error output")
357 .long("quiet")
358 .short('q')
359 .action(ArgAction::SetTrue),
360 Arg::new("verbose")
361 .help("Show detailed build information")
362 .long("verbose")
363 .action(ArgAction::SetTrue),
364 Arg::new("jobs")
365 .help("Number of parallel threads (default: num CPUs)")
366 .long("jobs")
367 .short('j')
368 .value_name("N")
369 .value_parser(clap::value_parser!(usize)),
370 Arg::new("no-llm-cache")
371 .help("Disable the deterministic LLM inference cache")
372 .long("no-llm-cache")
373 .action(ArgAction::SetTrue),
374 ]
375 };
376
377 Command::new(env!("CARGO_PKG_NAME"))
378 .author(env!("CARGO_PKG_AUTHORS"))
379 .about(env!("CARGO_PKG_DESCRIPTION"))
380 .version(env!("CARGO_PKG_VERSION"))
381 .subcommand_required(false)
382 .arg_required_else_help(false)
383 .after_help(
384 "Development:\n dev Start the dev server with watch + HMR\n\n\
385 Build:\n build Produce a static site under public/\n\n\
386 Validate:\n check Run validators (no output written)\n\n\
387 Deploy:\n deploy Build then ship to a pluggable target\n\n\
388 Run `ssg <SUBCOMMAND> --help` for subcommand-specific options."
389 )
390 .subcommand(
391 Command::new("build")
392 .about("Produce a static site under the configured output directory")
393 .long_about(
394 "Run the full build pipeline and exit. Equivalent to the legacy \
395 `ssg -s <dir>` invocation without `--watch`."
396 )
397 .args(shared())
398 .arg(
399 Arg::new("drafts")
400 .help("Include draft pages in the build")
401 .long("drafts")
402 .action(ArgAction::SetTrue),
403 )
404 .arg(
405 Arg::new("max-memory")
406 .help("Peak memory budget in MB for streaming compilation")
407 .long("max-memory")
408 .value_name("MB")
409 .value_parser(clap::value_parser!(usize)),
410 )
411 .arg(
412 Arg::new("incremental")
413 .help("Rebuild only the pages affected by source changes (issue #524)")
414 .long("incremental")
415 .action(ArgAction::SetTrue),
416 )
417 .arg(
418 Arg::new("isr")
419 .help("Emit ISR manifest + raw KV payloads under dist/.ssg/ (opt-in, issue #546)")
420 .long("isr")
421 .action(ArgAction::SetTrue),
422 ),
423 )
424 .subcommand(
425 Command::new("dev")
426 .about("Start the dev server with file watching and HMR")
427 .long_about(
428 "Build the site, then serve it on http://127.0.0.1:8000 (or \
429 $SSG_HOST:$SSG_PORT). File changes trigger a rebuild and an \
430 HMR push to the browser."
431 )
432 .args(shared())
433 .arg(
434 Arg::new("serve")
435 .help("Override the directory served (defaults to output dir)")
436 .long("serve")
437 .short('s')
438 .value_name("DIR")
439 .value_parser(clap::value_parser!(PathBuf)),
440 )
441 .arg(
442 Arg::new("drafts")
443 .help("Include draft pages in the dev build")
444 .long("drafts")
445 .action(ArgAction::SetTrue),
446 ),
447 )
448 .subcommand(
449 Command::new("check")
450 .about("Run all build-time validators without writing output")
451 .long_about(
452 "Executes the content validation, accessibility, SEO, JSON-LD \
453 and CSP plugins with `dry_run: true`. Exits 0 iff every page \
454 would have passed; otherwise prints the violating pages and \
455 reasons (issue #527 AC3)."
456 )
457 .args(shared()),
458 )
459 .subcommand(
460 Command::new("plugins")
461 .about("Inspect the plugin pipeline")
462 .long_about(
463 "Reports the plugins the build would run, in execution \
464 order, with the optional hooks each opts into. This is \
465 the source of truth for the plugin count quoted in the \
466 README, so documentation cannot drift from the code."
467 )
468 .subcommand(
469 Command::new("list")
470 .about("List registered plugins in execution order")
471 .arg(
472 Arg::new("json")
473 .help("Emit machine-readable JSON")
474 .long("json")
475 .action(ArgAction::SetTrue),
476 )
477 .arg(
478 Arg::new("target")
479 .help(
480 "Include the deploy plugin for this \
481 target, as `ssg deploy` would register it",
482 )
483 .long("target")
484 .value_name("TARGET")
485 .value_parser(
486 clap::builder::PossibleValuesParser::new(
487 DEPLOY_TARGETS,
488 ),
489 ),
490 ),
491 ),
492 )
493 .subcommand(super::audit::build_subcommand())
494 .subcommand(
495 Command::new("deploy")
496 .about("Build the site and ship to a pluggable target")
497 .long_about(
498 "Runs the build pipeline, then invokes the deploy adapter for \
499 the chosen target. Tokens come from per-target env vars \
500 (e.g. SSG_NETLIFY_TOKEN). The `none` target performs the \
501 build but skips the upload — handy for CI dry-runs."
502 )
503 .args(shared())
504 .arg(
505 Arg::new("target")
506 .help("Deploy target")
507 .long("target")
508 .value_name("TARGET")
509 .required(true)
510 .value_parser(
511 clap::builder::PossibleValuesParser::new(
512 DEPLOY_TARGETS,
513 ),
514 ),
515 )
516 .arg(
517 Arg::new("drafts")
518 .help("Include draft pages in the deploy build")
519 .long("drafts")
520 .action(ArgAction::SetTrue),
521 ),
522 )
523 }
524
525 pub fn parse_and_dispatch<I, T>(
555 argv: I,
556 ) -> Result<(CliInvocation, clap::ArgMatches), clap::Error>
557 where
558 I: IntoIterator<Item = T>,
559 T: Into<std::ffi::OsString> + Clone,
560 {
561 let args: Vec<std::ffi::OsString> =
562 argv.into_iter().map(Into::into).collect();
563
564 let uses_subcommand =
567 args.get(1).and_then(|a| a.to_str()).is_some_and(|s| {
568 SUBCOMMANDS.contains(&s)
569 || s == "--help"
570 || s == "-h"
571 || s == "--version"
572 || s == "-V"
573 });
574
575 if uses_subcommand {
576 let matches = Self::subcommand_app().try_get_matches_from(&args)?;
577 let inv = match matches.subcommand() {
578 Some(("build", _)) => CliInvocation::Build,
579 Some(("dev", _)) => CliInvocation::Dev,
580 Some(("check", _)) => CliInvocation::Check,
581 Some(("audit", _)) => CliInvocation::Audit,
582 Some(("plugins", sub_m)) => {
583 let list = sub_m.subcommand_matches("list");
584 CliInvocation::Plugins {
585 json: list.is_some_and(|m| m.get_flag("json")),
586 target: list
587 .and_then(|m| m.get_one::<String>("target"))
588 .cloned(),
589 }
590 }
591 Some(("deploy", sub_m)) => {
592 let target = sub_m
593 .get_one::<String>("target")
594 .cloned()
595 .unwrap_or_else(|| "none".to_string());
596 CliInvocation::Deploy { target }
597 }
598 _ => CliInvocation::Legacy,
602 };
603 Ok((inv, matches))
604 } else if args.len() <= 1 {
605 Err(Self::subcommand_app()
619 .try_get_matches_from(["ssg", "--help"])
620 .err()
621 .unwrap_or_else(|| {
622 let mut app = Self::subcommand_app();
626 let help = app.render_help();
627 app.error(clap::error::ErrorKind::DisplayHelp, help)
628 }))
629 } else {
630 eprintln!("{LEGACY_DEPRECATION_WARNING}");
632 let matches = Self::build().try_get_matches_from(&args)?;
633 Ok((CliInvocation::Legacy, matches))
634 }
635 }
636
637 pub fn print_banner() {
648 let version = env!("CARGO_PKG_VERSION");
649 let mut title = String::with_capacity(16 + version.len());
650 title.push_str("SSG \u{1f980} v");
651 title.push_str(version);
652
653 let description =
654 "A Fast and Flexible Static Site Generator written in Rust";
655 let width = title.len().max(description.len()) + 4;
656 let line = "\u{2500}".repeat(width - 2);
657
658 println!("\n\u{250c}{line}\u{2510}");
659 println!(
660 "\u{2502}{:^width$}\u{2502}",
661 format!("\x1b[1;32m{title}\x1b[0m"),
662 width = width - 3
663 );
664 println!("\u{251c}{line}\u{2524}");
665 println!(
666 "\u{2502}{:^width$}\u{2502}",
667 format!("\x1b[1;34m{description}\x1b[0m"),
668 width = width - 2
669 );
670 println!("\u{2514}{line}\u{2518}\n");
671 }
672}
673
674#[cfg(test)]
675mod tests {
676
677 #[test]
678 fn env_bool_accepts_conventional_truthy_values() {
679 for v in ["1", "true", "TRUE", "yes", "on", " on "] {
685 assert_eq!(parse_env_bool(v), Ok(true), "{v:?}");
686 }
687 for v in ["0", "false", "no", "off", ""] {
688 assert_eq!(parse_env_bool(v), Ok(false), "{v:?}");
689 }
690 }
691
692 #[test]
693 fn env_bool_rejects_garbage_rather_than_defaulting_false() {
694 assert!(parse_env_bool("ture").is_err());
696 assert!(parse_env_bool("maybe").is_err());
697 }
698
699 use super::*;
700
701 #[test]
702 fn test_banner_display() {
703 let version = env!("CARGO_PKG_VERSION");
704 let title = format!("SSG \u{1f980} v{version}");
705 let description =
706 "A Fast and Flexible Static Site Generator written in Rust";
707 let width = title.len().max(description.len()) + 4;
708 let line = "\u{2500}".repeat(width - 2);
709
710 Cli::print_banner();
711
712 assert!(!line.is_empty());
713 assert!(title.contains("SSG"));
714 assert!(title.contains(version));
715 }
716
717 #[test]
718 fn build_returns_valid_command() {
719 let cmd = Cli::build();
720 assert_eq!(cmd.get_name(), env!("CARGO_PKG_NAME"));
721 let arg_names: Vec<&str> =
723 cmd.get_arguments().map(|a| a.get_id().as_str()).collect();
724 for expected in [
725 "config", "new", "content", "output", "template", "theme", "serve",
726 "watch", "drafts", "deploy", "validate", "quiet", "verbose",
727 "jobs",
728 ] {
729 assert!(
730 arg_names.contains(&expected),
731 "missing expected arg: {expected}"
732 );
733 }
734 }
735
736 #[test]
737 fn parse_minimal_args() {
738 let cmd = Cli::build();
739 let matches = cmd.try_get_matches_from(["ssg"]).unwrap();
740 assert!(matches.get_one::<PathBuf>("config").is_none());
742 assert!(matches.get_one::<PathBuf>("output").is_none());
743 assert!(!matches.get_flag("watch"));
744 assert!(!matches.get_flag("drafts"));
745 }
746
747 #[test]
748 fn parse_quiet_flag() {
749 let cmd = Cli::build();
750 let matches = cmd.try_get_matches_from(["ssg", "--quiet"]).unwrap();
751 assert!(matches.get_flag("quiet"));
752 }
753
754 #[test]
755 fn parse_verbose_flag() {
756 let cmd = Cli::build();
757 let matches = cmd.try_get_matches_from(["ssg", "--verbose"]).unwrap();
758 assert!(matches.get_flag("verbose"));
759 }
760
761 #[test]
762 fn parse_drafts_flag() {
763 let cmd = Cli::build();
764 let matches = cmd.try_get_matches_from(["ssg", "--drafts"]).unwrap();
765 assert!(matches.get_flag("drafts"));
766 }
767
768 #[test]
769 fn parse_combined_flags_and_values() {
770 let cmd = Cli::build();
771 let matches = cmd
772 .try_get_matches_from([
773 "ssg", "--quiet", "--drafts", "--output", "/tmp/out", "--jobs",
774 "4",
775 ])
776 .unwrap();
777 assert!(matches.get_flag("quiet"));
778 assert!(matches.get_flag("drafts"));
779 assert_eq!(
780 matches.get_one::<PathBuf>("output").unwrap(),
781 &PathBuf::from("/tmp/out")
782 );
783 assert_eq!(*matches.get_one::<usize>("jobs").unwrap(), 4);
784 }
785
786 #[test]
787 #[allow(clippy::default_constructed_unit_structs)]
791 fn cli_default_is_unit_struct() {
792 let _cli = Cli;
793 let default_cli = Cli::default();
797 assert_eq!(format!("{default_cli:?}"), format!("{:?}", Cli));
798 }
799
800 #[test]
805 fn subcommand_app_has_all_four_subcommands() {
806 let app = Cli::subcommand_app();
807 let names: Vec<&str> =
808 app.get_subcommands().map(Command::get_name).collect();
809 for expected in ["build", "dev", "check", "deploy"] {
810 assert!(
811 names.contains(&expected),
812 "subcommand `{expected}` missing"
813 );
814 }
815 }
816
817 fn assert_invocation(inv: &CliInvocation, expected: &str) {
822 assert_eq!(format!("{inv:?}"), expected);
823 }
824
825 #[test]
826 fn parse_build_subcommand() {
827 let (inv, _m) = Cli::parse_and_dispatch(["ssg", "build"]).unwrap();
828 assert_invocation(&inv, "Build");
829 }
830
831 #[test]
832 fn parse_dev_subcommand() {
833 let (inv, _m) = Cli::parse_and_dispatch(["ssg", "dev"]).unwrap();
834 assert_invocation(&inv, "Dev");
835 }
836
837 #[test]
838 fn parse_check_subcommand() {
839 let (inv, _m) = Cli::parse_and_dispatch(["ssg", "check"]).unwrap();
840 assert_invocation(&inv, "Check");
841 }
842
843 #[test]
844 fn parse_audit_subcommand() {
845 let (inv, _m) = Cli::parse_and_dispatch(["ssg", "audit"]).unwrap();
846 assert_invocation(&inv, "Audit");
847 }
848
849 #[test]
850 fn parse_deploy_subcommand_with_target() {
851 let (inv, _m) =
852 Cli::parse_and_dispatch(["ssg", "deploy", "--target", "netlify"])
853 .unwrap();
854 assert_invocation(&inv, "Deploy { target: \"netlify\" }");
855 }
856
857 #[test]
858 fn deploy_rejects_unknown_target() {
859 let err = Cli::parse_and_dispatch([
860 "ssg",
861 "deploy",
862 "--target",
863 "moon-base-alpha",
864 ])
865 .unwrap_err();
866 assert_eq!(
867 err.kind(),
868 clap::error::ErrorKind::InvalidValue,
869 "unknown deploy target must be rejected by clap"
870 );
871 }
872
873 #[test]
874 fn deploy_requires_target() {
875 let err = Cli::parse_and_dispatch(["ssg", "deploy"]).unwrap_err();
876 assert_eq!(
877 err.kind(),
878 clap::error::ErrorKind::MissingRequiredArgument,
879 "--target must be required"
880 );
881 }
882
883 #[test]
884 fn legacy_invocation_with_flags_is_detected() {
885 let (inv, _m) =
886 Cli::parse_and_dispatch(["ssg", "-s", "public"]).unwrap();
887 assert_invocation(&inv, "Legacy");
888 }
889
890 #[test]
891 fn bare_invocation_displays_help_instead_of_building() {
892 let err = Cli::parse_and_dispatch(["ssg"]).unwrap_err();
896 assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
897 assert_eq!(err.exit_code(), 0);
899 let rendered = err.to_string();
902 assert!(
903 rendered.contains("Usage"),
904 "help output missing usage line: {rendered}"
905 );
906 }
907
908 #[test]
909 fn legacy_parser_rejects_unknown_flag() {
910 let err = Cli::parse_and_dispatch(["ssg", "--definitely-not-a-flag"])
912 .unwrap_err();
913 assert_eq!(err.kind(), clap::error::ErrorKind::UnknownArgument);
914 }
915
916 #[test]
917 fn deploy_targets_const_has_six_entries() {
918 assert_eq!(DEPLOY_TARGETS.len(), 6);
921 for t in [
922 "netlify",
923 "vercel",
924 "cloudflare-pages",
925 "github-pages",
926 "s3",
927 "none",
928 ] {
929 assert!(DEPLOY_TARGETS.contains(&t), "deploy target `{t}` missing");
930 }
931 }
932}