1use clap::{Arg, ArgAction, Command};
7use std::path::PathBuf;
8
9#[derive(Clone, Copy, Debug, Default)]
10pub struct Cli;
12
13impl Cli {
14 #[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)), )
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 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 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 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 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 }
271}