1use anyhow::{Context, Result};
30use clap::{Args, Parser, Subcommand};
31use log::{debug, info, warn};
32use std::path::PathBuf;
33use thiserror::Error;
34
35use crate::{config::Config, engine::Engine};
36
37#[derive(Error, Debug)]
39pub enum SsgError {
40 #[error("Configuration error: {0}")]
42 ConfigurationError(String),
43
44 #[error("Build error: {0}")]
46 BuildError(String),
47
48 #[error("Server error: {0}")]
50 ServerError(String),
51
52 #[error("File system error for path '{path}': {message}")]
54 FileSystemError {
55 path: PathBuf,
57 message: String,
59 },
60}
61
62#[derive(Parser, Debug)]
64#[command(author, version, about = "Static Site Generator")]
65pub struct SsgCommand {
66 #[arg(
68 short = 'd',
69 long,
70 global = true,
71 default_value = "content",
72 help = "Directory containing source content files"
73 )]
74 content_dir: PathBuf,
75
76 #[arg(
78 short = 'o',
79 long,
80 global = true,
81 default_value = "public",
82 help = "Directory where the generated site will be placed"
83 )]
84 output_dir: PathBuf,
85
86 #[arg(
88 short = 't',
89 long,
90 global = true,
91 default_value = "templates",
92 help = "Directory containing site templates"
93 )]
94 template_dir: PathBuf,
95
96 #[arg(
98 short = 'f',
99 long,
100 global = true,
101 help = "Path to custom configuration file"
102 )]
103 config: Option<PathBuf>,
104
105 #[command(subcommand)]
107 command: SsgSubCommand,
108}
109
110#[derive(Subcommand, Debug, Copy, Clone)]
112pub enum SsgSubCommand {
113 Build(BuildArgs),
115
116 Serve(ServeArgs),
118}
119
120#[derive(Args, Debug, Copy, Clone)]
122pub struct BuildArgs {
123 #[arg(
125 short,
126 long,
127 help = "Clean output directory before building"
128 )]
129 clean: bool,
130}
131
132#[derive(Args, Debug, Copy, Clone)]
134pub struct ServeArgs {
135 #[arg(
137 short,
138 long,
139 default_value = "8000",
140 help = "Port number for development server"
141 )]
142 port: u16,
143}
144
145impl SsgCommand {
146 pub async fn execute(&self) -> Result<()> {
166 info!("Starting static site generation");
167 debug!(
168 "Configuration: content_dir={:?}, output_dir={:?}, template_dir={:?}",
169 self.content_dir, self.output_dir, self.template_dir
170 );
171
172 let config = self
174 .load_config()
175 .await
176 .context("Failed to load configuration")?;
177
178 let engine = Engine::new().context(
180 "Failed to initialize the static site generator engine",
181 )?;
182
183 match &self.command {
184 SsgSubCommand::Build(args) => {
185 self.build(&engine, &config, args.clean)
186 .await
187 .context("Build process failed")?;
188 }
189 SsgSubCommand::Serve(args) => {
190 self.serve(&engine, &config, args.port)
191 .await
192 .context("Development server failed")?;
193 }
194 }
195
196 info!("Site generation completed successfully");
197 Ok(())
198 }
199
200 async fn load_config(&self) -> Result<Config> {
205 self.config.as_ref().map_or_else(
206 || {
207 Config::builder()
208 .site_name("Static Site")
209 .content_dir(&self.content_dir)
210 .output_dir(&self.output_dir)
211 .template_dir(&self.template_dir)
212 .build()
213 .context("Failed to create default configuration")
214 },
215 |config_path| {
216 Config::from_file(config_path).context(format!(
217 "Failed to load configuration from {}",
218 config_path.display()
219 ))
220 },
221 )
222 }
223
224 async fn build(
229 &self,
230 engine: &Engine,
231 config: &Config,
232 clean: bool,
233 ) -> Result<()> {
234 info!("Building static site");
235 debug!("Build configuration: {:#?}", config);
236
237 if clean {
238 self.clean_output_directory(config).await?;
239 }
240
241 tokio::fs::create_dir_all(&config.output_dir)
243 .await
244 .context(format!(
245 "Failed to create output directory: {}",
246 config.output_dir.display()
247 ))?;
248
249 engine
250 .generate(config)
251 .await
252 .context("Site generation failed")?;
253 info!("Site built successfully");
254 Ok(())
255 }
256
257 async fn serve(
262 &self,
263 engine: &Engine,
264 config: &Config,
265 port: u16,
266 ) -> Result<()> {
267 info!("Starting development server on port {}", port);
268
269 self.build(engine, config, false).await?;
271
272 warn!("Hot reloading is not yet implemented");
275 info!("Development server started");
276 Ok(())
277 }
278
279 async fn clean_output_directory(
284 &self,
285 config: &Config,
286 ) -> Result<()> {
287 if config.output_dir.exists() {
288 debug!(
289 "Cleaning output directory: {}",
290 config.output_dir.display()
291 );
292 tokio::fs::remove_dir_all(&config.output_dir)
293 .await
294 .context(format!(
295 "Failed to clean output directory: {}",
296 config.output_dir.display()
297 ))?;
298 }
299 Ok(())
300 }
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use tempfile::tempdir;
307
308 #[tokio::test]
310 async fn test_build_command() -> Result<()> {
311 let temp = tempdir()?;
313 let content_dir = temp.path().join("content");
314 let output_dir = temp.path().join("public");
315 let template_dir = temp.path().join("templates");
316
317 tokio::fs::create_dir_all(&content_dir).await?;
319 tokio::fs::create_dir_all(&output_dir).await?; tokio::fs::create_dir_all(&template_dir).await?;
321
322 let cmd = SsgCommand {
323 content_dir: content_dir.clone(),
324 output_dir: output_dir.clone(),
325 template_dir: template_dir.clone(),
326 config: None,
327 command: SsgSubCommand::Build(BuildArgs { clean: true }),
328 };
329
330 cmd.execute().await?;
331
332 assert!(output_dir.exists());
334 Ok(())
335 }
336
337 #[tokio::test]
339 async fn test_clean_build() -> Result<()> {
340 let temp = tempdir()?;
341 let output_dir = temp.path().join("public");
342
343 tokio::fs::create_dir_all(&output_dir).await?;
345 tokio::fs::write(output_dir.join("old.html"), "old content")
346 .await?;
347
348 let cmd = SsgCommand {
349 content_dir: temp.path().join("content"),
350 output_dir: output_dir.clone(),
351 template_dir: temp.path().join("templates"),
352 config: None,
353 command: SsgSubCommand::Build(BuildArgs { clean: true }),
354 };
355
356 tokio::fs::create_dir_all(&cmd.content_dir).await?;
358 tokio::fs::create_dir_all(&cmd.template_dir).await?;
359
360 cmd.execute().await?;
361
362 assert!(!output_dir.join("old.html").exists());
364 Ok(())
365 }
366
367 #[test]
369 fn test_command_parsing() {
370 let cmd = SsgCommand::try_parse_from([
371 "ssg",
372 "--content-dir",
373 "content",
374 "--output-dir",
375 "public",
376 "--template-dir",
377 "templates",
378 "build",
379 "--clean",
380 ])
381 .unwrap();
382
383 assert_eq!(cmd.content_dir, PathBuf::from("content"));
384 assert_eq!(cmd.output_dir, PathBuf::from("public"));
385 assert!(matches!(
386 cmd.command,
387 SsgSubCommand::Build(BuildArgs { clean: true })
388 ));
389 }
390
391 #[tokio::test]
393 async fn test_invalid_config() {
394 let temp = tempdir().unwrap();
395 let cmd = SsgCommand {
396 content_dir: temp.path().join("nonexistent"),
397 output_dir: temp.path().join("public"),
398 template_dir: temp.path().join("templates"),
399 config: Some(PathBuf::from("nonexistent.toml")),
400 command: SsgSubCommand::Build(BuildArgs { clean: false }),
401 };
402
403 let result = cmd.execute().await;
404 assert!(result.is_err());
405 }
406
407 #[tokio::test]
409 async fn test_serve_command() -> Result<()> {
410 let temp = tempdir()?;
412 let content_dir = temp.path().join("content");
413 let output_dir = temp.path().join("public");
414 let template_dir = temp.path().join("templates");
415
416 tokio::fs::create_dir_all(&content_dir).await?;
418 tokio::fs::create_dir_all(&output_dir).await?; tokio::fs::create_dir_all(&template_dir).await?;
420
421 let cmd = SsgCommand {
422 content_dir: content_dir.clone(),
423 output_dir: output_dir.clone(),
424 template_dir: template_dir.clone(),
425 config: None,
426 command: SsgSubCommand::Serve(ServeArgs { port: 8080 }),
427 };
428
429 cmd.execute().await?;
431
432 assert!(output_dir.exists());
434 Ok(())
435 }
436
437 #[tokio::test]
439 async fn test_load_config_valid() -> Result<()> {
440 let temp = tempdir()?;
441 let config_path = temp.path().join("config.toml");
442
443 let content_dir = temp.path().join("content");
445 let output_dir = temp.path().join("public");
446 let template_dir = temp.path().join("templates");
447 tokio::fs::create_dir_all(&content_dir).await?;
448 tokio::fs::create_dir_all(&output_dir).await?;
449 tokio::fs::create_dir_all(&template_dir).await?;
450
451 let config_contents = format!(
453 r#"
454 site_name = "Test Site"
455 content_dir = "{}"
456 output_dir = "{}"
457 template_dir = "{}"
458 "#,
459 content_dir.display(),
460 output_dir.display(),
461 template_dir.display()
462 );
463 tokio::fs::write(&config_path, config_contents).await?;
464
465 let cmd = SsgCommand {
466 content_dir: content_dir.clone(),
467 output_dir: output_dir.clone(),
468 template_dir: template_dir.clone(),
469 config: Some(config_path.clone()),
470 command: SsgSubCommand::Build(BuildArgs { clean: false }),
471 };
472
473 let config = cmd.load_config().await?;
474
475 assert_eq!(config.site_name, "Test Site");
477 assert_eq!(config.content_dir, content_dir);
478 assert_eq!(config.output_dir, output_dir);
479 assert_eq!(config.template_dir, template_dir);
480
481 Ok(())
482 }
483
484 #[tokio::test]
486 async fn test_load_config_invalid() -> Result<()> {
487 let temp = tempdir()?;
488 let config_path = temp.path().join("config.toml");
489
490 tokio::fs::write(&config_path, "invalid_toml_content").await?;
492
493 let cmd = SsgCommand {
494 content_dir: PathBuf::from("content"),
495 output_dir: PathBuf::from("public"),
496 template_dir: PathBuf::from("templates"),
497 config: Some(config_path.clone()),
498 command: SsgSubCommand::Build(BuildArgs { clean: false }),
499 };
500
501 let result = cmd.load_config().await;
502
503 assert!(result.is_err());
505
506 Ok(())
507 }
508
509 #[tokio::test]
511 async fn test_clean_output_directory_exists() -> Result<()> {
512 let temp = tempdir()?;
513 let output_dir = temp.path().join("public");
514
515 tokio::fs::create_dir_all(&output_dir).await?;
517 tokio::fs::write(output_dir.join("test.html"), "test content")
518 .await?;
519
520 let cmd = SsgCommand {
521 content_dir: temp.path().join("content"),
522 output_dir: output_dir.clone(),
523 template_dir: temp.path().join("templates"),
524 config: None,
525 command: SsgSubCommand::Build(BuildArgs { clean: true }),
526 };
527
528 tokio::fs::create_dir_all(&cmd.content_dir).await?;
530 tokio::fs::create_dir_all(&cmd.template_dir).await?; let config = Config::builder()
534 .site_name("Test Site")
535 .content_dir(&cmd.content_dir)
536 .output_dir(&cmd.output_dir)
537 .template_dir(&cmd.template_dir)
538 .build()
539 .unwrap();
540
541 cmd.clean_output_directory(&config).await?;
543
544 assert!(!output_dir.exists());
546
547 Ok(())
548 }
549
550 #[tokio::test]
552 async fn test_clean_output_directory_not_exists() -> Result<()> {
553 let temp = tempdir()?;
554 let output_dir = temp.path().join("public");
555
556 let cmd = SsgCommand {
557 content_dir: temp.path().join("content"),
558 output_dir: output_dir.clone(),
559 template_dir: temp.path().join("templates"),
560 config: None,
561 command: SsgSubCommand::Build(BuildArgs { clean: true }),
562 };
563
564 tokio::fs::create_dir_all(&cmd.content_dir).await?;
566 tokio::fs::create_dir_all(&cmd.output_dir).await?; tokio::fs::create_dir_all(&cmd.template_dir).await?; let config = Config::builder()
571 .site_name("Test Site")
572 .content_dir(&cmd.content_dir)
573 .output_dir(&cmd.output_dir)
574 .template_dir(&cmd.template_dir)
575 .build()
576 .unwrap();
577
578 cmd.clean_output_directory(&config).await?;
580
581 assert!(!output_dir.exists());
583
584 Ok(())
585 }
586
587 #[tokio::test]
589 async fn test_execute_load_config_failure() -> Result<()> {
590 let temp = tempdir()?;
591 let invalid_config_path =
592 temp.path().join("invalid_config.toml");
593
594 tokio::fs::write(&invalid_config_path, "invalid_content")
596 .await?;
597
598 let cmd = SsgCommand {
599 content_dir: PathBuf::from("content"),
600 output_dir: PathBuf::from("public"),
601 template_dir: PathBuf::from("templates"),
602 config: Some(invalid_config_path.clone()),
603 command: SsgSubCommand::Build(BuildArgs { clean: false }),
604 };
605
606 let result = cmd.execute().await;
607
608 assert!(result.is_err());
609 let err_message = result.unwrap_err().to_string();
610 assert!(
611 err_message.contains("Failed to load configuration"),
612 "Unexpected error message: {}",
613 err_message
614 );
615
616 Ok(())
617 }
618
619 #[test]
621 fn test_command_parsing_invalid() {
622 let result = SsgCommand::try_parse_from([
623 "ssg",
624 "--unknown-arg",
625 "value",
626 "build",
627 ]);
628
629 assert!(result.is_err());
630 }
631}