Skip to main content

ssg/cmd/
cli.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! CLI argument parsing and banner display.
5//!
6//! Two parsers coexist here:
7//!
8//! 1. The legacy flag-style parser exposed via [`Cli::build`] — kept for
9//!    backwards compatibility with `ssg -s public -w -t templates`
10//!    invocations. Slated for removal in 1.0.
11//! 2. The subcommand-style parser exposed via [`Cli::subcommand_app`] —
12//!    the new `ssg dev / build / check / deploy` surface introduced
13//!    by issue #527.
14//!
15//! [`parse_and_dispatch`] sniffs `argv` and routes to whichever parser
16//! matches, emitting a deprecation warning on the legacy path.
17
18use clap::{Arg, ArgAction, Command};
19use std::path::PathBuf;
20
21/// Subcommand names recognised by the unified CLI. Used to discriminate
22/// between subcommand-style invocations (`ssg dev`, `ssg build …`) and
23/// the legacy flag-only form (`ssg -s public`).
24pub const SUBCOMMANDS: &[&str] = &[
25    "build", "dev", "check", "deploy", "audit", "plugins", "help",
26];
27
28/// Deployment targets accepted by `ssg deploy --target …`.
29///
30/// `none` is the explicit "build only, no upload" target — equivalent to
31/// `ssg build` but routed through the deploy plumbing so dry-runs of CI
32/// configs are easy to validate.
33pub const DEPLOY_TARGETS: &[&str] = &[
34    "netlify",
35    "vercel",
36    "cloudflare-pages",
37    "github-pages",
38    "s3",
39    "none",
40];
41
42/// Deprecation message printed to stderr when the legacy flag-only form
43/// is used. Kept as a const so tests can assert on the exact text.
44pub 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)]
48/// A simple CLI struct for building the SSG command.
49pub struct Cli;
50
51/// Outcome of [`Cli::parse_and_dispatch`] — tells `main`/`run` which
52/// subcommand was selected, or that the legacy form was used.
53#[derive(Debug, Clone)]
54pub enum CliInvocation {
55    /// `ssg build [--…]` — produce a static site under the configured
56    /// output directory.
57    Build,
58    /// `ssg dev [--…]` — produce the site and start the dev server.
59    Dev,
60    /// `ssg check [--…]` — run validators with `dry_run: true` and exit.
61    Check,
62    /// `ssg deploy --target <target>` — build then invoke the deploy
63    /// adapter for the chosen target.
64    Deploy {
65        /// The selected deploy target (`netlify`, `vercel`, …).
66        target: String,
67    },
68    /// `ssg plugins list [--json]` — report the plugin pipeline without
69    /// building anything.
70    Plugins {
71        /// Emit JSON rather than a table.
72        json: bool,
73        /// Deploy target whose plugin should be included, mirroring
74        /// `ssg deploy --target`. Absent lists the plain build pipeline.
75        target: Option<String>,
76    },
77    /// `ssg audit [--gate <name>] [--json|--junit] [--fail-on <sev>]` —
78    /// run the 15 native CI gates against the built site (issue #549).
79    Audit,
80    /// Legacy flag-only invocation (`ssg -s public -w`). Behaves like
81    /// `Dev` if `--serve` is present, otherwise like `Build`. Emits a
82    /// deprecation warning on stderr before dispatch.
83    Legacy,
84}
85
86/// Parses a boolean-ish environment value for a `SetTrue` flag.
87///
88/// clap's default bool parser accepts only `true`/`false`, but an env var is
89/// conventionally set to `1`, `yes` or `on`. Anything unrecognised is an
90/// error rather than a silent false: a typo'd `SSG_NO_TAG_PAGES=ture` should
91/// say so, not quietly generate the pages the operator asked to skip.
92fn 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    /// Builds the legacy flag-style `clap::Command`.
104    ///
105    /// Preserved so the deprecation shim, existing examples, and the
106    /// already-shipped CI invocations keep working through the 0.0.x
107    /// line. Removed in 1.0 per issue #527 AC7.
108    ///
109    /// # Examples
110    ///
111    /// ```rust
112    /// use ssg::cmd::Cli;
113    ///
114    /// let cmd = Cli::build();
115    /// assert!(cmd.get_name().contains("ssg") || !cmd.get_name().is_empty());
116    /// ```
117    #[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)), // Change from PathBuf to String
138            )
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                    // `SetTrue` parses the env var as a *value*, and its
205                    // default parser accepts only "true"/"false" — so the
206                    // conventional `SSG_NO_TAG_PAGES=1` was rejected with
207                    // `invalid value '1'`, taking the whole build down. The
208                    // flag form was unaffected, which is how this shipped
209                    // with the release notes advertising `=1`.
210                    .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                // Resolves #422. The flag is always parsed so scripts
234                // are stable across feature-on/feature-off builds; if
235                // the binary was compiled without the `otel` feature
236                // we accept the flag and emit a warning when it's
237                // present but the runtime support isn't compiled in.
238                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    /// Builds the subcommand-style `clap::Command` (issue #527).
291    ///
292    /// Surface:
293    ///
294    /// ```text
295    /// ssg <SUBCOMMAND> [OPTIONS]
296    ///
297    /// Development:
298    ///   dev      Start the dev server with file watching
299    ///
300    /// Build:
301    ///   build    Produce a static site under the configured output dir
302    ///
303    /// Validate:
304    ///   check    Run validators (read-only) and exit
305    ///
306    /// Deploy:
307    ///   deploy   Build and ship to a pluggable target
308    /// ```
309    ///
310    /// Each subcommand carries the same `--config / --output / …`
311    /// option pile so existing scripts can be ported one-to-one.
312    ///
313    /// # Examples
314    ///
315    /// ```rust
316    /// use ssg::cmd::Cli;
317    ///
318    /// let app = Cli::subcommand_app();
319    /// let names: Vec<_> = app.get_subcommands().map(|c| c.get_name()).collect();
320    /// assert!(names.contains(&"build"));
321    /// assert!(names.contains(&"dev"));
322    /// ```
323    #[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    /// Routes `argv` to either the subcommand parser or the legacy
526    /// flag parser.
527    ///
528    /// The contract:
529    ///
530    /// * If `argv[1]` matches a known subcommand (`build`, `dev`,
531    ///   `check`, `deploy`, `help`), parses with the new surface.
532    /// * Otherwise, falls back to the legacy parser and prints
533    ///   [`LEGACY_DEPRECATION_WARNING`] to stderr (issue #527 AC5,
534    ///   except when no args were supplied at all — bare `ssg` is
535    ///   silent and behaves like the prior 0.0.42 default).
536    ///
537    /// Returns a `(CliInvocation, ArgMatches)` pair so the caller can
538    /// reuse the existing `SsgConfig::from_matches` / `RunOptions::from_matches`
539    /// helpers.
540    ///
541    /// # Examples
542    ///
543    /// ```rust
544    /// use ssg::cmd::{Cli, CliInvocation};
545    ///
546    /// let (inv, _matches) = Cli::parse_and_dispatch(vec!["ssg", "build"])
547    ///     .expect("parses");
548    /// assert!(matches!(inv, CliInvocation::Build));
549    /// ```
550    ///
551    /// # Errors
552    /// Returns the underlying `clap::Error` if parsing fails — the
553    /// caller is expected to print it and exit non-zero.
554    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        // Sniff argv[1]. If it's a known subcommand keyword, use the
565        // new parser; otherwise fall back to the legacy form.
566        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                // `--help` / `--version` short-circuit inside clap; if
599                // we somehow reach here with no subcommand, treat as
600                // legacy no-op (bare invocation).
601                _ => CliInvocation::Legacy,
602            };
603            Ok((inv, matches))
604        } else if args.len() <= 1 {
605            // Bare `ssg` with no arguments. Previously this fell through
606            // to the legacy parser and started a real build against the
607            // current working directory — so running `ssg` anywhere (a
608            // home directory, say) would try to generate a site there.
609            // A CLI with no arguments should describe itself, not act.
610            //
611            // Returning a `DisplayHelp` error lets `run_with_argv`
612            // delegate to `clap::Error::exit`, which prints help and
613            // exits 0. It also short-circuits *before* logging is
614            // initialised, so no INFO banner precedes the help text.
615            // Route through clap's own `--help` handling so the output
616            // is the canonical help on stdout, with no `error:` prefix
617            // and exit code 0 — identical to `ssg --help`.
618            Err(Self::subcommand_app()
619                .try_get_matches_from(["ssg", "--help"])
620                .err()
621                .unwrap_or_else(|| {
622                    // Unreachable in practice: `--help` always
623                    // short-circuits. Render help explicitly rather
624                    // than panicking if clap ever changes that.
625                    let mut app = Self::subcommand_app();
626                    let help = app.render_help();
627                    app.error(clap::error::ErrorKind::DisplayHelp, help)
628                }))
629        } else {
630            // Legacy path, with at least one flag present.
631            eprintln!("{LEGACY_DEPRECATION_WARNING}");
632            let matches = Self::build().try_get_matches_from(&args)?;
633            Ok((CliInvocation::Legacy, matches))
634        }
635    }
636
637    /// Displays the application banner
638    ///
639    /// # Examples
640    ///
641    /// ```rust
642    /// use ssg::cmd::Cli;
643    ///
644    /// // Prints to stdout — runnable in a doctest, no panics.
645    /// Cli::print_banner();
646    /// ```
647    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        // Regression: `SetTrue` + `.env()` parses the variable as a value,
680        // and clap's default bool parser rejects everything but true/false.
681        // `SSG_NO_TAG_PAGES=1` therefore aborted the build with
682        // `invalid value '1'` — shipped in 0.0.52 with the release notes
683        // advertising exactly that form.
684        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        // A typo must not silently produce the opposite of what was asked.
695        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        // Ensure all expected arguments are registered
722        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        // No arguments supplied — all should be absent / false
741        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    // The whole point of this test is to call the derived `Default`
788    // impl directly, since nothing else in the crate does — clippy's
789    // suggestion to construct `Cli` directly instead would defeat that.
790    #[allow(clippy::default_constructed_unit_structs)]
791    fn cli_default_is_unit_struct() {
792        let _cli = Cli;
793        // `Cli` derives `Default` and `Debug` but nothing else in the
794        // crate ever calls either derived impl — exercise both
795        // directly so they're not dead code from coverage's view.
796        let default_cli = Cli::default();
797        assert_eq!(format!("{default_cli:?}"), format!("{:?}", Cli));
798    }
799
800    // -----------------------------------------------------------------
801    // Subcommand parser — added by issue #527
802    // -----------------------------------------------------------------
803
804    #[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    /// Region-free variant of `assert!(matches!(inv, <Variant>))` —
818    /// `matches!` (or a `panic!` fallback arm) would leave a
819    /// never-taken region uncovered. `CliInvocation`'s Debug repr is
820    /// deterministic, so exact string equality is just as strict.
821    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        // Regression: bare `ssg` used to route through the legacy
893        // parser and start a real build against the current working
894        // directory. It must now describe itself and exit cleanly.
895        let err = Cli::parse_and_dispatch(["ssg"]).unwrap_err();
896        assert_eq!(err.kind(), clap::error::ErrorKind::DisplayHelp);
897        // `DisplayHelp` exits 0, so a bare invocation is not a failure.
898        assert_eq!(err.exit_code(), 0);
899        // The rendered help must actually carry the usage text rather
900        // than an empty message.
901        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        // Covers the `?` propagation from the legacy try_get_matches_from.
911        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        // Issue #527 AC4 explicitly lists netlify, vercel,
919        // cloudflare-pages, github-pages, s3, none.
920        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}