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
6use clap::{Arg, ArgAction, Command};
7use std::path::PathBuf;
8
9#[derive(Clone, Copy, Debug, Default)]
10/// A simple CLI struct for building the SSG command.
11pub struct Cli;
12
13impl Cli {
14    /// Creates the command-line interface.
15    #[must_use]
16    pub fn build() -> Command {
17        Command::new(env!("CARGO_PKG_NAME"))
18            .author(env!("CARGO_PKG_AUTHORS"))
19            .about(env!("CARGO_PKG_DESCRIPTION"))
20            .version(env!("CARGO_PKG_VERSION"))
21            .arg(
22                Arg::new("config")
23                    .help("Configuration file path")
24                    .long("config")
25                    .short('f')
26                    .value_name("FILE")
27                    .value_parser(clap::value_parser!(PathBuf)),
28            )
29            .arg(
30                Arg::new("new")
31                    .help("Create new project")
32                    .long("new")
33                    .short('n')
34                    .value_name("NAME")
35                    .value_parser(clap::value_parser!(String)), // Change from PathBuf to String
36            )
37            .arg(
38                Arg::new("content")
39                    .help("Content directory")
40                    .long("content")
41                    .short('c')
42                    .value_name("DIR")
43                    .value_parser(clap::value_parser!(PathBuf)),
44            )
45            .arg(
46                Arg::new("output")
47                    .help("Output directory")
48                    .long("output")
49                    .short('o')
50                    .value_name("DIR")
51                    .value_parser(clap::value_parser!(PathBuf)),
52            )
53            .arg(
54                Arg::new("template")
55                    .help("Template directory")
56                    .long("template")
57                    .short('t')
58                    .value_name("DIR")
59                    .value_parser(clap::value_parser!(PathBuf)),
60            )
61            .arg(
62                Arg::new("serve")
63                    .help("Development server directory")
64                    .long("serve")
65                    .short('s')
66                    .value_name("DIR")
67                    .value_parser(clap::value_parser!(PathBuf)),
68            )
69            .arg(
70                Arg::new("watch")
71                    .help("Watch for changes")
72                    .long("watch")
73                    .short('w')
74                    .action(ArgAction::SetTrue),
75            )
76            .arg(
77                Arg::new("drafts")
78                    .help("Include draft pages in the build")
79                    .long("drafts")
80                    .action(ArgAction::SetTrue),
81            )
82            .arg(
83                Arg::new("deploy")
84                    .help("Generate deployment config (netlify, vercel, cloudflare, github)")
85                    .long("deploy")
86                    .value_name("TARGET")
87                    .value_parser(clap::value_parser!(String)),
88            )
89            .arg(
90                Arg::new("validate")
91                    .help("Validate content schemas without building")
92                    .long("validate")
93                    .action(ArgAction::SetTrue),
94            )
95            .arg(
96                Arg::new("quiet")
97                    .help("Suppress non-error output")
98                    .long("quiet")
99                    .short('q')
100                    .action(ArgAction::SetTrue),
101            )
102            .arg(
103                Arg::new("verbose")
104                    .help("Show detailed build information")
105                    .long("verbose")
106                    .action(ArgAction::SetTrue),
107            )
108            .arg(
109                // Resolves #422. The flag is always parsed so scripts
110                // are stable across feature-on/feature-off builds; if
111                // the binary was compiled without the `otel` feature
112                // we accept the flag and emit a warning when it's
113                // present but the runtime support isn't compiled in.
114                Arg::new("trace")
115                    .help("Enable OpenTelemetry build tracing (requires `otel` feature)")
116                    .long("trace")
117                    .action(ArgAction::SetTrue),
118            )
119            .arg(
120                Arg::new("jobs")
121                    .help("Number of parallel threads (default: num CPUs)")
122                    .long("jobs")
123                    .short('j')
124                    .value_name("N")
125                    .value_parser(clap::value_parser!(usize)),
126            )
127            .arg(
128                Arg::new("max-memory")
129                    .help("Peak memory budget in MB for streaming compilation (default: 512)")
130                    .long("max-memory")
131                    .value_name("MB")
132                    .value_parser(clap::value_parser!(usize)),
133            )
134            .arg(
135                Arg::new("ai-fix")
136                    .help("Run agentic AI pipeline to audit and fix content readability")
137                    .long("ai-fix")
138                    .action(ArgAction::SetTrue),
139            )
140            .arg(
141                Arg::new("ai-fix-dry-run")
142                    .help("Preview AI fixes without writing changes")
143                    .long("ai-fix-dry-run")
144                    .action(ArgAction::SetTrue),
145            )
146    }
147
148    /// Displays the application banner
149    pub fn print_banner() {
150        let version = env!("CARGO_PKG_VERSION");
151        let mut title = String::with_capacity(16 + version.len());
152        title.push_str("SSG \u{1f980} v");
153        title.push_str(version);
154
155        let description =
156            "A Fast and Flexible Static Site Generator written in Rust";
157        let width = title.len().max(description.len()) + 4;
158        let line = "\u{2500}".repeat(width - 2);
159
160        println!("\n\u{250c}{line}\u{2510}");
161        println!(
162            "\u{2502}{:^width$}\u{2502}",
163            format!("\x1b[1;32m{title}\x1b[0m"),
164            width = width - 3
165        );
166        println!("\u{251c}{line}\u{2524}");
167        println!(
168            "\u{2502}{:^width$}\u{2502}",
169            format!("\x1b[1;34m{description}\x1b[0m"),
170            width = width - 2
171        );
172        println!("\u{2514}{line}\u{2518}\n");
173    }
174}
175
176#[cfg(test)]
177#[allow(clippy::unwrap_used, clippy::expect_used)]
178mod tests {
179
180    use super::*;
181
182    #[test]
183    fn test_banner_display() {
184        let version = env!("CARGO_PKG_VERSION");
185        let title = format!("SSG \u{1f980} v{version}");
186        let description =
187            "A Fast and Flexible Static Site Generator written in Rust";
188        let width = title.len().max(description.len()) + 4;
189        let line = "\u{2500}".repeat(width - 2);
190
191        Cli::print_banner();
192
193        assert!(!line.is_empty());
194        assert!(title.contains("SSG"));
195        assert!(title.contains(version));
196    }
197
198    #[test]
199    fn build_returns_valid_command() {
200        let cmd = Cli::build();
201        assert_eq!(cmd.get_name(), env!("CARGO_PKG_NAME"));
202        // Ensure all expected arguments are registered
203        let arg_names: Vec<&str> =
204            cmd.get_arguments().map(|a| a.get_id().as_str()).collect();
205        for expected in [
206            "config", "new", "content", "output", "template", "serve", "watch",
207            "drafts", "deploy", "validate", "quiet", "verbose", "jobs",
208        ] {
209            assert!(
210                arg_names.contains(&expected),
211                "missing expected arg: {expected}"
212            );
213        }
214    }
215
216    #[test]
217    fn parse_minimal_args() {
218        let cmd = Cli::build();
219        let matches = cmd.try_get_matches_from(["ssg"]).unwrap();
220        // No arguments supplied — all should be absent / false
221        assert!(matches.get_one::<PathBuf>("config").is_none());
222        assert!(matches.get_one::<PathBuf>("output").is_none());
223        assert!(!matches.get_flag("watch"));
224        assert!(!matches.get_flag("drafts"));
225    }
226
227    #[test]
228    fn parse_quiet_flag() {
229        let cmd = Cli::build();
230        let matches = cmd.try_get_matches_from(["ssg", "--quiet"]).unwrap();
231        assert!(matches.get_flag("quiet"));
232    }
233
234    #[test]
235    fn parse_verbose_flag() {
236        let cmd = Cli::build();
237        let matches = cmd.try_get_matches_from(["ssg", "--verbose"]).unwrap();
238        assert!(matches.get_flag("verbose"));
239    }
240
241    #[test]
242    fn parse_drafts_flag() {
243        let cmd = Cli::build();
244        let matches = cmd.try_get_matches_from(["ssg", "--drafts"]).unwrap();
245        assert!(matches.get_flag("drafts"));
246    }
247
248    #[test]
249    fn parse_combined_flags_and_values() {
250        let cmd = Cli::build();
251        let matches = cmd
252            .try_get_matches_from([
253                "ssg", "--quiet", "--drafts", "--output", "/tmp/out", "--jobs",
254                "4",
255            ])
256            .unwrap();
257        assert!(matches.get_flag("quiet"));
258        assert!(matches.get_flag("drafts"));
259        assert_eq!(
260            matches.get_one::<PathBuf>("output").unwrap(),
261            &PathBuf::from("/tmp/out")
262        );
263        assert_eq!(*matches.get_one::<usize>("jobs").unwrap(), 4);
264    }
265
266    #[test]
267    fn cli_default_is_unit_struct() {
268        let _cli = Cli;
269        // Cli is a ZST — just ensure Default works.
270    }
271}