frontmatter_gen/
ssg.rs

1// Copyright © 2024 Shokunin Static Site Generator. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! # Static Site Generator Module
5//!
6//! This module provides comprehensive functionality for generating static websites from markdown content with frontmatter. It handles the entire build process including template rendering, asset copying, and site structure generation.
7//!
8//! ## Features
9//!
10//! * Asynchronous file processing for improved performance
11//! * Structured logging with detailed build progress
12//! * Comprehensive error handling with context
13//! * Safe and secure file system operations
14//! * Development server with hot reloading
15//!
16//! ## Example
17//!
18//! ```rust,no_run
19//! use frontmatter_gen::ssg::SsgCommand;
20//! use clap::Parser;
21//!
22//! #[tokio::main]
23//! async fn main() -> anyhow::Result<()> {
24//!     let cmd = SsgCommand::parse();
25//!     cmd.execute().await
26//! }
27//! ```
28
29use 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/// Errors specific to the Static Site Generator functionality
38#[derive(Error, Debug)]
39pub enum SsgError {
40    /// Configuration error with context
41    #[error("Configuration error: {0}")]
42    ConfigurationError(String),
43
44    /// Build process error with context
45    #[error("Build error: {0}")]
46    BuildError(String),
47
48    /// Server error with context
49    #[error("Server error: {0}")]
50    ServerError(String),
51
52    /// File system error with path context
53    #[error("File system error for path '{path}': {message}")]
54    FileSystemError {
55        /// Path associated with the error
56        path: PathBuf,
57        /// Message associated with the error
58        message: String,
59    },
60}
61
62/// Command-line interface for the Static Site Generator
63#[derive(Parser, Debug)]
64#[command(author, version, about = "Static Site Generator")]
65pub struct SsgCommand {
66    /// Input content directory containing markdown files and assets
67    #[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    /// Output directory for the generated static site
77    #[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    /// Template directory containing site templates
87    #[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    /// Optional configuration file path
97    #[arg(
98        short = 'f',
99        long,
100        global = true,
101        help = "Path to custom configuration file"
102    )]
103    config: Option<PathBuf>,
104
105    /// Subcommands for static site generation
106    #[command(subcommand)]
107    command: SsgSubCommand,
108}
109
110/// Available subcommands for the Static Site Generator
111#[derive(Subcommand, Debug, Copy, Clone)]
112pub enum SsgSubCommand {
113    /// Build the static site
114    Build(BuildArgs),
115
116    /// Serve the static site locally with hot reloading
117    Serve(ServeArgs),
118}
119
120/// Arguments for the build subcommand
121#[derive(Args, Debug, Copy, Clone)]
122pub struct BuildArgs {
123    /// Clean the output directory before building
124    #[arg(
125        short,
126        long,
127        help = "Clean output directory before building"
128    )]
129    clean: bool,
130}
131
132/// Arguments for the serve subcommand
133#[derive(Args, Debug, Copy, Clone)]
134pub struct ServeArgs {
135    /// Port number for the development server
136    #[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    /// Executes the static site generation command
147    ///
148    /// This function orchestrates the entire site generation process, including:
149    /// - Loading configuration
150    /// - Initialising the engine
151    /// - Processing content
152    /// - Generating the static site
153    ///
154    /// # Returns
155    ///
156    /// Returns `Ok(())` on successful execution, or an error if site generation fails.
157    ///
158    /// # Errors
159    ///
160    /// This function will return an error if:
161    /// - Configuration loading fails
162    /// - Engine initialisation fails
163    /// - Site generation process encounters an error
164    /// - Development server fails to start (when using serve command)
165    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        // Load or create configuration with detailed error context
173        let config = self
174            .load_config()
175            .await
176            .context("Failed to load configuration")?;
177
178        // Initialize the engine with error handling
179        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    /// Loads or creates the site configuration
201    ///
202    /// Attempts to load configuration from a file if specified, otherwise creates
203    /// a default configuration using command line arguments.
204    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    /// Builds the static site
225    ///
226    /// Handles the complete build process including cleaning the output directory
227    /// if requested and generating all static content.
228    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        // Ensure output directory exists
242        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    /// Serves the static site locally
258    ///
259    /// Starts a development server with hot reloading capabilities for
260    /// local testing and development.
261    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        // Build the site first
270        self.build(engine, config, false).await?;
271
272        // Configure and start the development server
273        // TODO: Implement hot reloading and live server
274        warn!("Hot reloading is not yet implemented");
275        info!("Development server started");
276        Ok(())
277    }
278
279    /// Cleans the output directory
280    ///
281    /// Removes all contents from the output directory while maintaining
282    /// its existence.
283    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    /// Tests the build command functionality
309    #[tokio::test]
310    async fn test_build_command() -> Result<()> {
311        // Create temporary directories for testing
312        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        // Create required directories
318        tokio::fs::create_dir_all(&content_dir).await?;
319        tokio::fs::create_dir_all(&output_dir).await?; // Add this line
320        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        // Verify output directory exists
333        assert!(output_dir.exists());
334        Ok(())
335    }
336
337    /// Tests clean build functionality
338    #[tokio::test]
339    async fn test_clean_build() -> Result<()> {
340        let temp = tempdir()?;
341        let output_dir = temp.path().join("public");
342
343        // Create output directory with a test file
344        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        // Create required directories
357        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        // Verify old file was removed
363        assert!(!output_dir.join("old.html").exists());
364        Ok(())
365    }
366
367    /// Tests command line argument parsing
368    #[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    /// Tests error handling for invalid configuration
392    #[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    /// Tests the serve command functionality
408    #[tokio::test]
409    async fn test_serve_command() -> Result<()> {
410        // Create temporary directories for testing
411        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        // Create required directories
417        tokio::fs::create_dir_all(&content_dir).await?;
418        tokio::fs::create_dir_all(&output_dir).await?; // Add this line
419        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        // Execute the serve command
430        cmd.execute().await?;
431
432        // Verify output directory exists
433        assert!(output_dir.exists());
434        Ok(())
435    }
436
437    /// Tests loading configuration from a valid config file
438    #[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        // Create required directories
444        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        // Write a valid config file with absolute paths
452        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        // Verify that the config was loaded correctly
476        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    /// Tests loading configuration from an invalid config file
485    #[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        // Write an invalid config file
491        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        // Verify that an error is returned
504        assert!(result.is_err());
505
506        Ok(())
507    }
508
509    /// Tests cleaning the output directory when it exists
510    #[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        // Create output directory with a test file
516        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        // Create the necessary directories before building the config
529        tokio::fs::create_dir_all(&cmd.content_dir).await?;
530        tokio::fs::create_dir_all(&cmd.template_dir).await?; // Add this line
531
532        // Create a config object
533        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        // Call clean_output_directory
542        cmd.clean_output_directory(&config).await?;
543
544        // Verify that the output directory does not exist
545        assert!(!output_dir.exists());
546
547        Ok(())
548    }
549
550    /// Tests cleaning the output directory when it does not exist
551    #[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        // Create the necessary directories before building the config
565        tokio::fs::create_dir_all(&cmd.content_dir).await?;
566        tokio::fs::create_dir_all(&cmd.output_dir).await?; // Add this line
567        tokio::fs::create_dir_all(&cmd.template_dir).await?; // Add this line
568
569        // Create a config object
570        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        // Call clean_output_directory
579        cmd.clean_output_directory(&config).await?;
580
581        // Verify that the output directory still does not exist
582        assert!(!output_dir.exists());
583
584        Ok(())
585    }
586
587    /// Tests error handling in the execute method when load_config fails
588    #[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        // Write an invalid configuration file
595        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    /// Tests command line argument parsing with invalid inputs
620    #[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}