Skip to main content

ssg/
process.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Argument-driven site processing.
5//!
6//! Bridges the parsed [`clap::ArgMatches`] from `cmd::Cli` to the build
7//! pipeline orchestrated in [`crate::run`]. Responsibilities:
8//!
9//! - Resolve content / output / template directories from CLI flags or
10//!   configuration files, applying sensible defaults when callers omit
11//!   them.
12//! - Create build and site directories on disk, ensuring distinct paths
13//!   so `staticdatagen::compile` can finalise output by renaming.
14//! - Pre-process front-matter for every page in the content tree before
15//!   the compiler is invoked.
16//!
17//! Most binaries should call [`crate::run`] rather than this module
18//! directly; the helpers here are exposed for tests and embedders that
19//! need a smaller building block than the full pipeline.
20
21use anyhow::Result;
22use clap::ArgMatches;
23use std::{fs, path::Path};
24/// Represents errors that may occur during argument processing.
25///
26/// Marked `#[non_exhaustive]` so new error cases can be added in minor
27/// versions. Consumers should always include a wildcard arm.
28#[derive(Debug)]
29#[non_exhaustive]
30pub enum ProcessError {
31    /// Occurs when a directory cannot be created.
32    ///
33    /// # Fields
34    /// - `dir_type`: The type of directory (e.g., "content", "output").
35    /// - `path`: The file path where the directory creation failed.
36    DirectoryCreation {
37        /// Type of the directory, such as "content" or "output".
38        dir_type: String,
39        /// Path where the directory creation failed.
40        path: String,
41        /// The underlying IO error that occurred.
42        source: std::io::Error,
43    },
44
45    /// Triggered when a required command-line argument is missing.
46    ///
47    /// # Fields
48    /// - The name of the missing argument.
49    MissingArgument(String),
50
51    /// Represents a failure during the compilation process.
52    ///
53    /// # Fields
54    /// - Compilation error message.
55    CompilationError(String),
56
57    /// Wraps underlying I/O errors.
58    IoError(std::io::Error),
59
60    /// Represents a failure during the frontmatter processing.
61    FrontmatterError(String),
62}
63
64impl std::fmt::Display for ProcessError {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        match self {
67            Self::DirectoryCreation {
68                dir_type,
69                path,
70                source,
71            } => write!(
72                f,
73                "Failed to create {dir_type} directory at '{path}': {source}"
74            ),
75            Self::MissingArgument(arg) => {
76                write!(f, "Required argument missing: {arg}")
77            }
78            Self::CompilationError(msg) => {
79                write!(f, "Compilation error: {msg}")
80            }
81            Self::IoError(e) => write!(f, "{e}"),
82            Self::FrontmatterError(msg) => {
83                write!(f, "Frontmatter processing error: {msg}")
84            }
85        }
86    }
87}
88
89impl std::error::Error for ProcessError {
90    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
91        match self {
92            Self::DirectoryCreation { source, .. } => Some(source),
93            Self::IoError(e) => Some(e),
94            _ => None,
95        }
96    }
97}
98
99impl From<std::io::Error> for ProcessError {
100    fn from(e: std::io::Error) -> Self {
101        Self::IoError(e)
102    }
103}
104
105/// Retrieves the value of a specified command-line argument.
106///
107/// # Arguments
108///
109/// * `matches` - Clap argument matches object containing parsed arguments.
110/// * `name` - The name of the argument to retrieve.
111///
112/// # Returns
113///
114/// * `Result<String, ProcessError>` - Returns the argument value on success or an error if the argument is missing.
115///
116/// # Errors
117///
118/// - Returns `ProcessError::MissingArgument` if the specified argument is not provided.
119///
120/// # Example
121///
122/// ```rust,no_run
123/// # use clap::{ArgMatches, Command};
124/// # use ssg::process::get_argument;
125/// let matches = Command::new("test")
126///     .arg(clap::arg!(--"config" <CONFIG> "Specifies the configuration file"))
127///     .get_matches_from(vec!["test", "--config", "path/to/config.toml"]);
128/// let config_path = get_argument(&matches, "config").expect("Argument not found");
129/// println!("Config path: {}", config_path);
130/// ```
131pub fn get_argument(
132    matches: &ArgMatches,
133    name: &str,
134) -> Result<String, ProcessError> {
135    matches
136        .get_one::<String>(name)
137        .ok_or_else(|| ProcessError::MissingArgument(name.to_string()))
138        .map(String::from)
139}
140
141/// Ensures the specified directory exists, creating it if necessary.
142///
143/// # Arguments
144///
145/// * `path` - The path of the directory to check.
146/// * `dir_type` - A label describing the directory type (e.g., "content", "output").
147///
148/// # Returns
149///
150/// * `Result<(), ProcessError>` - Returns `Ok` if the directory exists or is successfully created.
151///
152/// # Errors
153///
154/// - Returns `ProcessError::DirectoryCreation` if the directory cannot be created due to permissions or other issues.
155///
156/// # Example
157///
158/// ```rust,no_run
159/// # use std::path::Path;
160/// # use ssg::process::ensure_directory;
161/// let path = Path::new("path/to/output");
162/// ensure_directory(path, "output").expect("Failed to ensure directory exists");
163/// ```
164pub fn ensure_directory(
165    path: &Path,
166    dir_type: &str,
167) -> Result<(), ProcessError> {
168    if path.exists() {
169        // Check if the existing path is a directory
170        if !path.is_dir() {
171            return Err(ProcessError::DirectoryCreation {
172                dir_type: dir_type.to_string(),
173                path: path.display().to_string(),
174                source: std::io::Error::new(
175                    std::io::ErrorKind::AlreadyExists,
176                    "Path exists but is not a directory",
177                ),
178            });
179        }
180    } else {
181        fs::create_dir_all(path).map_err(|e| {
182            ProcessError::DirectoryCreation {
183                dir_type: dir_type.to_string(),
184                path: path.display().to_string(),
185                source: e,
186            }
187        })?;
188    }
189    Ok(())
190}
191
192/// Compiles the static site by generating the necessary files from the provided paths.
193///
194/// # Parameters
195///
196/// * `build_path`: The path where the compiled site will be built.
197/// * `content_path`: The path to the directory containing the content files.
198/// * `site_path`: The path to the directory where the site project will be created.
199/// * `template_path`: The path to the directory containing the template files.
200///
201/// # Return
202///
203/// * `Result<(), String>`: Returns `Ok(())` if the compilation is successful, or an error message as a string if an error occurs.
204///
205/// # Errors
206///
207/// * If any error occurs during the compilation process, an error message will be returned as a string.
208fn internal_compile(
209    build_path: &Path,
210    content_path: &Path,
211    site_path: &Path,
212    template_path: &Path,
213) -> Result<(), String> {
214    staticdatagen::compiler::service::compile(
215        build_path,
216        content_path,
217        site_path,
218        template_path,
219    )
220    .map_err(|e| e.to_string())
221}
222
223/// Preprocesses markdown files to properly handle frontmatter
224fn preprocess_content(content_path: &Path) -> Result<(), ProcessError> {
225    if !content_path.exists() {
226        return Ok(());
227    }
228
229    // Process all .md files in the content directory
230    for entry in fs::read_dir(content_path)? {
231        let entry = entry?;
232        let path = entry.path();
233
234        if path.is_file() && path.extension().is_some_and(|ext| ext == "md") {
235            let content = fs::read_to_string(&path)?;
236            let processed_content = process_frontmatter(&content)?;
237            fs::write(&path, processed_content)?;
238        }
239    }
240    Ok(())
241}
242
243/// Processes frontmatter in markdown content to ensure proper rendering
244fn process_frontmatter(content: &str) -> Result<String, ProcessError> {
245    const DELIMITER: &str = "---";
246
247    let parts: Vec<&str> = content.splitn(3, DELIMITER).collect();
248    match parts.len() {
249        3 => {
250            // Format: ---\nfrontmatter\n---\ncontent
251            let frontmatter = parts[1].trim();
252            let main_content = parts[2].trim();
253
254            // Add an HTML comment to preserve frontmatter for metadata processing
255            // while preventing it from appearing in the rendered output
256            Ok(format!(
257                "---\n{frontmatter}\n---\n<!--frontmatter-processed-->\n{main_content}"
258            ))
259        }
260        _ => Ok(content.to_string()), // Return unchanged if no frontmatter found
261    }
262}
263
264/// Processes CLI arguments and executes the corresponding site compilation workflow.
265///
266/// This function performs the following steps:
267/// 1. Retrieves required directory paths from command-line arguments.
268/// 2. Ensures each directory exists, creating it if necessary.
269/// 3. Calls the compilation service to generate the static site.
270///
271/// # Arguments
272///
273/// * `matches` - Parsed command-line arguments from `clap`.
274///
275/// # Returns
276///
277/// * `Result<(), ProcessError>` - Returns `Ok` on successful completion, or an error if a problem occurs.
278///
279/// # Errors
280///
281/// - Returns `ProcessError::MissingArgument` if a required argument is not provided.
282/// - Returns `ProcessError::DirectoryCreation` if a directory cannot be created.
283/// - Returns `ProcessError::CompilationError` if the site fails to compile.
284///
285pub fn args(matches: &ArgMatches) -> Result<(), ProcessError> {
286    // Get required paths
287    let content_dir = get_argument(matches, "content")?;
288    let output_dir = get_argument(matches, "output")?;
289    let site_dir = get_argument(matches, "new")?;
290    let template_dir = get_argument(matches, "template")?;
291
292    // Create Path objects
293    let content_path = Path::new(&content_dir);
294    let build_path = Path::new(&output_dir);
295    let site_path = Path::new(&site_dir);
296    let template_path = Path::new(&template_dir);
297
298    // Ensure directories exist
299    ensure_directory(content_path, "content")?;
300    ensure_directory(build_path, "output")?;
301    ensure_directory(site_path, "project")?;
302    ensure_directory(template_path, "template")?;
303
304    // Preprocess content files to handle frontmatter
305    preprocess_content(content_path)?;
306
307    // Compile the site
308    internal_compile(build_path, content_path, site_path, template_path)
309        .map_err(ProcessError::CompilationError)?;
310
311    Ok(())
312}
313
314#[cfg(test)]
315#[allow(clippy::unwrap_used, clippy::expect_used)]
316mod tests {
317    use super::*;
318    use clap::{arg, Command};
319    use std::fs::{self, File};
320    use tempfile::tempdir;
321
322    /// Helper function to create a test `ArgMatches` with all required arguments.
323    fn create_test_command() -> ArgMatches {
324        Command::new("test")
325            .arg(arg!(--"content" <CONTENT> "Content directory"))
326            .arg(arg!(--"output" <OUTPUT> "Output directory"))
327            .arg(arg!(--"new" <NEW> "New site directory"))
328            .arg(arg!(--"template" <TEMPLATE> "Template directory"))
329            .get_matches_from(vec![
330                "test",
331                "--content",
332                "content",
333                "--output",
334                "output",
335                "--new",
336                "new_site",
337                "--template",
338                "template",
339            ])
340    }
341
342    #[test]
343    fn test_get_argument_present() {
344        let matches = create_test_command();
345        let content = get_argument(&matches, "content").unwrap();
346        assert_eq!(content, "content");
347    }
348
349    #[test]
350    fn test_get_argument_missing() {
351        let matches = Command::new("test")
352            .arg(arg!(--"config" <CONFIG> "Config file"))
353            .get_matches_from(vec!["test"]);
354        let result = get_argument(&matches, "config");
355        assert!(matches!(result, Err(ProcessError::MissingArgument(_))));
356    }
357
358    #[test]
359    fn test_ensure_directory_exists() {
360        let temp_dir = tempdir().unwrap();
361        let result = ensure_directory(temp_dir.path(), "temp");
362        assert!(result.is_ok());
363    }
364
365    #[test]
366    fn test_args_missing_template_argument() {
367        let matches = Command::new("test")
368            .arg(arg!(--"content" <CONTENT> "Content directory"))
369            .arg(arg!(--"output" <OUTPUT> "Output directory"))
370            .arg(arg!(--"new" <NEW> "New site directory"))
371            .arg(arg!(--"template" <TEMPLATE> "Template directory"))
372            .get_matches_from(vec![
373                "test",
374                "--content",
375                "content",
376                "--output",
377                "output",
378                "--new",
379                "new_site",
380            ]);
381        let result = args(&matches);
382        assert!(matches!(
383            result,
384            Err(ProcessError::MissingArgument(ref arg)) if arg == "template"
385        ));
386    }
387
388    #[test]
389    fn test_ensure_directory_already_exists() -> Result<()> {
390        let temp_dir = tempdir()?;
391        ensure_directory(temp_dir.path(), "existing")?;
392        assert!(temp_dir.path().exists());
393        Ok(())
394    }
395
396    #[cfg(not(target_os = "windows"))] // Unix-specific: path behaviour / error messages differ on Windows
397    #[test]
398    fn test_process_error_display() {
399        let error = ProcessError::MissingArgument("content".to_string());
400        assert_eq!(error.to_string(), "Required argument missing: content");
401
402        let error = ProcessError::DirectoryCreation {
403            dir_type: "content".to_string(),
404            path: "/invalid/path".to_string(),
405            source: std::io::Error::from_raw_os_error(13),
406        };
407        assert_eq!(
408            error.to_string(),
409            "Failed to create content directory at '/invalid/path': Permission denied (os error 13)"
410        );
411
412        let error =
413            ProcessError::CompilationError("Failed to compile".to_string());
414        assert_eq!(error.to_string(), "Compilation error: Failed to compile");
415    }
416
417    #[test]
418    fn test_process_error_io_error() {
419        let io_error = std::io::Error::other("an I/O error occurred");
420        let error: ProcessError = io_error.into();
421        assert!(matches!(error, ProcessError::IoError(_)));
422        assert_eq!(error.to_string(), "an I/O error occurred");
423    }
424
425    #[test]
426    fn test_process_error_io_error_format() {
427        let io_error =
428            std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
429        let error: ProcessError = io_error.into();
430        assert!(matches!(error, ProcessError::IoError(_)));
431        assert_eq!(error.to_string(), "File not found");
432    }
433
434    #[cfg(unix)]
435    #[test]
436    fn test_ensure_directory_permission_denied() {
437        use std::fs::Permissions;
438        use std::os::unix::fs::PermissionsExt;
439
440        let temp_dir = tempdir().unwrap();
441        let protected_path = temp_dir.path().join("protected_dir");
442
443        // Create the directory and make it read-only
444        fs::create_dir(&protected_path).unwrap();
445        fs::set_permissions(&protected_path, Permissions::from_mode(0o400))
446            .unwrap();
447
448        // Attempt to create a subdirectory inside the protected directory to trigger a permission error
449        let sub_dir = protected_path.join("sub_dir");
450        let result = ensure_directory(&sub_dir, "sub_directory");
451
452        // Check that the permission-denied error was triggered
453        assert!(matches!(
454            result,
455            Err(ProcessError::DirectoryCreation { .. })
456        ));
457
458        // Reset permissions for cleanup
459        fs::set_permissions(&protected_path, Permissions::from_mode(0o700))
460            .unwrap();
461    }
462
463    #[test]
464    fn test_args_all_required_arguments(
465    ) -> Result<(), Box<dyn std::error::Error>> {
466        let temp_dir = tempdir()?;
467        let content_dir = temp_dir.path().join("content");
468        let output_dir = temp_dir.path().join("output");
469        let site_dir = temp_dir.path().join("new_site");
470        let template_dir = temp_dir.path().join("template");
471
472        let matches = Command::new("test")
473            .arg(arg!(--"content" <CONTENT> "Content directory"))
474            .arg(arg!(--"output" <OUTPUT> "Output directory"))
475            .arg(arg!(--"new" <NEW> "New site directory"))
476            .arg(arg!(--"template" <TEMPLATE> "Template directory"))
477            .get_matches_from(vec![
478                "test",
479                "--content",
480                content_dir.to_str().unwrap(),
481                "--output",
482                output_dir.to_str().unwrap(),
483                "--new",
484                site_dir.to_str().unwrap(),
485                "--template",
486                template_dir.to_str().unwrap(),
487            ]);
488
489        // Since `compile` is shadowed, it will use the mock compile function
490        let result = args(&matches);
491        assert!(
492            matches!(result, Err(ProcessError::CompilationError(_))),
493            "Expected CompilationError from args"
494        );
495
496        Ok(())
497    }
498    #[test]
499    fn test_process_frontmatter_with_valid_frontmatter(
500    ) -> Result<(), ProcessError> {
501        let content = "\
502---
503title: Test Post
504date: 2024-01-01
505---
506# Main Content
507This is the main content.";
508
509        let processed = process_frontmatter(content)?;
510        assert!(processed.contains("<!--frontmatter-processed-->"));
511        assert!(processed.contains("title: Test Post"));
512        assert!(processed.contains("# Main Content"));
513        Ok(())
514    }
515
516    #[test]
517    fn test_process_frontmatter_without_frontmatter() -> Result<(), ProcessError>
518    {
519        let content = "# Just Content\nNo frontmatter here.";
520        let processed = process_frontmatter(content)?;
521        assert_eq!(processed, content);
522        Ok(())
523    }
524
525    #[test]
526    fn test_process_frontmatter_with_empty_frontmatter(
527    ) -> Result<(), ProcessError> {
528        let content = "---\n---\nContent after empty frontmatter";
529        let processed = process_frontmatter(content)?;
530        assert!(processed.contains("<!--frontmatter-processed-->"));
531        Ok(())
532    }
533
534    #[test]
535    fn test_preprocess_content_with_multiple_files() -> Result<(), ProcessError>
536    {
537        let temp_dir = tempdir()?;
538
539        // Create multiple markdown files
540        let file1_path = temp_dir.path().join("post1.md");
541        let file2_path = temp_dir.path().join("post2.md");
542        let non_md_path = temp_dir.path().join("other.txt");
543
544        fs::write(&file1_path, "---\ntitle: Post 1\n---\nContent 1")?;
545        fs::write(&file2_path, "---\ntitle: Post 2\n---\nContent 2")?;
546        fs::write(&non_md_path, "Not a markdown file")?;
547
548        preprocess_content(temp_dir.path())?;
549
550        // Verify markdown files were processed
551        let content1 = fs::read_to_string(&file1_path)?;
552        let content2 = fs::read_to_string(&file2_path)?;
553        let other = fs::read_to_string(&non_md_path)?;
554
555        assert!(content1.contains("<!--frontmatter-processed-->"));
556        assert!(content2.contains("<!--frontmatter-processed-->"));
557        assert_eq!(other, "Not a markdown file");
558
559        Ok(())
560    }
561
562    #[test]
563    fn test_preprocess_content_with_non_existent_directory(
564    ) -> Result<(), ProcessError> {
565        let non_existent = Path::new("non_existent_directory");
566        let result = preprocess_content(non_existent);
567        assert!(result.is_ok());
568        Ok(())
569    }
570
571    #[cfg(unix)]
572    #[test]
573    fn test_preprocess_content_with_invalid_permissions() {
574        use std::fs::Permissions;
575        use std::os::unix::fs::PermissionsExt;
576
577        let temp_dir = tempdir().unwrap();
578        let file_path = temp_dir.path().join("readonly.md");
579
580        // Create file with frontmatter
581        fs::write(&file_path, "---\ntitle: Test\n---\nContent").unwrap();
582
583        // Make file read-only
584        fs::set_permissions(&file_path, Permissions::from_mode(0o444)).unwrap();
585
586        let result = preprocess_content(temp_dir.path());
587        assert!(result.is_err());
588
589        // Reset permissions for cleanup
590        fs::set_permissions(&file_path, Permissions::from_mode(0o666)).unwrap();
591    }
592
593    #[test]
594    fn test_internal_compile_error_handling() {
595        let temp_dir = tempdir().unwrap();
596        let result = internal_compile(
597            &temp_dir.path().join("build"),
598            &temp_dir.path().join("content"),
599            &temp_dir.path().join("site"),
600            &temp_dir.path().join("template"),
601        );
602        assert!(result.is_err());
603    }
604
605    #[test]
606    fn test_get_argument_with_empty_value() {
607        let matches = Command::new("test")
608            .arg(arg!(--"empty" <EMPTY> "Empty value"))
609            .get_matches_from(vec!["test", "--empty", ""]);
610
611        let result = get_argument(&matches, "empty");
612        assert!(result.is_ok());
613        assert_eq!(result.unwrap(), "");
614    }
615
616    #[test]
617    fn test_ensure_directory_with_existing_file(
618    ) -> Result<(), Box<dyn std::error::Error>> {
619        let temp_dir = tempdir()?;
620        let file_path = temp_dir.path().join("existing_file");
621
622        // Create a file instead of a directory
623        let _file = File::create(&file_path)?;
624
625        // Attempt to ensure directory at the same path
626        let result = ensure_directory(&file_path, "test");
627
628        // Verify that the operation failed because path exists but is not a directory
629        let err = result.unwrap_err();
630        match err {
631            ProcessError::DirectoryCreation { source, .. } => {
632                assert_eq!(source.kind(), std::io::ErrorKind::AlreadyExists);
633            }
634            other => panic!("Expected DirectoryCreation, got: {other}"),
635        }
636
637        Ok(())
638    }
639
640    #[test]
641    fn test_ensure_directory_with_existing_directory(
642    ) -> Result<(), Box<dyn std::error::Error>> {
643        let temp_dir = tempdir()?;
644        let dir_path = temp_dir.path().join("existing_dir");
645
646        // First create the directory
647        fs::create_dir(&dir_path)?;
648
649        // Attempt to ensure directory at the same path
650        let result = ensure_directory(&dir_path, "test");
651
652        // Should succeed because path exists and is a directory
653        assert!(result.is_ok());
654
655        Ok(())
656    }
657
658    #[test]
659    fn test_preprocess_content_with_invalid_utf8() -> Result<()> {
660        let temp_dir = tempdir()?;
661        let file_path = temp_dir.path().join("invalid.md");
662
663        // Write invalid UTF-8 bytes
664        let invalid_bytes = vec![0xFF, 0xFF];
665        fs::write(&file_path, invalid_bytes)?;
666
667        let result = preprocess_content(temp_dir.path());
668        assert!(result.is_err());
669        Ok(())
670    }
671
672    #[test]
673    fn test_process_frontmatter_with_multiple_delimiters() -> Result<()> {
674        let content = "\
675---
676title: First
677---
678---
679title: Second
680---
681Content";
682
683        let processed = process_frontmatter(content)?;
684        // Should only process the first frontmatter section
685        assert!(processed.contains("title: First"));
686        assert!(processed.contains("---\ntitle: Second"));
687        Ok(())
688    }
689
690    #[test]
691    fn test_process_frontmatter_with_malformed_delimiters(
692    ) -> Result<(), ProcessError> {
693        // Test case where there's only one delimiter
694        let content = "---\ntitle: Test\nContent";
695        let processed = process_frontmatter(content)?;
696        assert_eq!(processed, content); // Should remain unchanged with single delimiter
697
698        // Test case with extra spaces in delimiters (this should still be valid frontmatter)
699        let content = "---\ntitle: Test\n---\nContent";
700        let processed = process_frontmatter(content)?;
701        assert!(processed.contains("<!--frontmatter-processed-->"));
702        assert!(processed.contains("title: Test"));
703        assert!(processed.contains("Content"));
704
705        Ok(())
706    }
707
708    #[test]
709    fn test_process_frontmatter_with_whitespace() -> Result<(), ProcessError> {
710        // Test with whitespace before first delimiter
711        let content = "\n\n---\ntitle: Test\n---\nContent";
712        let processed = process_frontmatter(content)?;
713        // Should still process valid frontmatter even with leading whitespace
714        assert!(processed.contains("<!--frontmatter-processed-->"));
715        assert!(processed.contains("title: Test"));
716        assert!(processed.contains("Content"));
717
718        // Test with mixed whitespace in frontmatter
719        let content = "---\n  title: Test  \n  author: Someone  \n---\nContent";
720        let processed = process_frontmatter(content)?;
721        assert!(processed.contains("<!--frontmatter-processed-->"));
722        assert!(processed.contains("title: Test"));
723        assert!(processed.contains("author: Someone"));
724        assert!(processed.contains("Content"));
725
726        Ok(())
727    }
728
729    #[test]
730    fn test_process_frontmatter_with_invalid_format() -> Result<(), ProcessError>
731    {
732        // Missing second delimiter completely
733        let content = "---\ntitle: Test\nContent";
734        let processed = process_frontmatter(content)?;
735        assert_eq!(processed, content);
736
737        // Wrong delimiter character
738        let content = "===\ntitle: Test\n===\nContent";
739        let processed = process_frontmatter(content)?;
740        assert_eq!(processed, content);
741
742        // Empty content between delimiters
743        let content = "---\n\n---\nContent";
744        let processed = process_frontmatter(content)?;
745        assert!(processed.contains("<!--frontmatter-processed-->"));
746
747        Ok(())
748    }
749
750    #[test]
751    fn test_preprocess_content_with_nested_directories(
752    ) -> Result<(), ProcessError> {
753        let temp_dir = tempdir()?;
754        let nested_dir = temp_dir.path().join("nested");
755        fs::create_dir(&nested_dir)?;
756
757        // Create files in both root and nested directory
758        let root_file = temp_dir.path().join("root.md");
759        let nested_file = nested_dir.join("nested.md");
760
761        fs::write(&root_file, "---\ntitle: Root\n---\nRoot content")?;
762        fs::write(&nested_file, "---\ntitle: Nested\n---\nNested content")?;
763
764        preprocess_content(temp_dir.path())?;
765
766        // Verify only root file was processed (since we don't recurse into subdirectories)
767        let root_content = fs::read_to_string(&root_file)?;
768        assert!(root_content.contains("<!--frontmatter-processed-->"));
769
770        let nested_content = fs::read_to_string(&nested_file)?;
771        assert!(!nested_content.contains("<!--frontmatter-processed-->"));
772
773        Ok(())
774    }
775
776    #[test]
777    fn test_preprocess_content_with_empty_files() -> Result<(), ProcessError> {
778        let temp_dir = tempdir()?;
779        let empty_file = temp_dir.path().join("empty.md");
780
781        // Create empty markdown file
782        fs::write(&empty_file, "")?;
783
784        preprocess_content(temp_dir.path())?;
785
786        // Verify empty file remains unchanged
787        let content = fs::read_to_string(&empty_file)?;
788        assert!(content.is_empty());
789
790        Ok(())
791    }
792
793    #[test]
794    fn test_ensure_directory_with_symlink() -> Result<(), ProcessError> {
795        let temp_dir = tempdir()?;
796        let real_dir = temp_dir.path().join("real_dir");
797        let symlink = temp_dir.path().join("symlink_dir");
798
799        fs::create_dir(&real_dir)?;
800
801        #[cfg(unix)]
802        std::os::unix::fs::symlink(&real_dir, &symlink)?;
803        #[cfg(windows)]
804        std::os::windows::fs::symlink_dir(&real_dir, &symlink)?;
805
806        // Should succeed as symlink points to a valid directory
807        let result = ensure_directory(&symlink, "symlink");
808        assert!(result.is_ok());
809
810        Ok(())
811    }
812
813    #[test]
814    fn test_process_error_frontmatter_display() {
815        let error = ProcessError::FrontmatterError("bad yaml".to_string());
816        assert_eq!(error.to_string(), "Frontmatter processing error: bad yaml");
817    }
818
819    #[test]
820    fn test_process_error_source_for_directory_creation() {
821        use std::error::Error;
822        let error = ProcessError::DirectoryCreation {
823            dir_type: "output".to_string(),
824            path: "/bad".to_string(),
825            source: std::io::Error::new(
826                std::io::ErrorKind::PermissionDenied,
827                "denied",
828            ),
829        };
830        assert!(error.source().is_some());
831    }
832
833    #[test]
834    fn test_process_error_source_for_io_error() {
835        use std::error::Error;
836        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
837        let error = ProcessError::IoError(io_err);
838        assert!(error.source().is_some());
839    }
840
841    #[test]
842    fn test_process_error_source_for_missing_argument() {
843        use std::error::Error;
844        let error = ProcessError::MissingArgument("foo".to_string());
845        assert!(error.source().is_none());
846    }
847
848    #[test]
849    fn test_process_error_source_for_compilation_error() {
850        use std::error::Error;
851        let error = ProcessError::CompilationError("oops".to_string());
852        assert!(error.source().is_none());
853    }
854
855    #[test]
856    fn test_process_error_source_for_frontmatter_error() {
857        use std::error::Error;
858        let error = ProcessError::FrontmatterError("bad".to_string());
859        assert!(error.source().is_none());
860    }
861
862    #[test]
863    fn test_process_error_debug() {
864        let error = ProcessError::MissingArgument("arg".to_string());
865        let debug = format!("{error:?}");
866        assert!(debug.contains("MissingArgument"));
867    }
868
869    #[test]
870    fn test_preprocess_content_empty_directory() -> Result<(), ProcessError> {
871        let temp_dir = tempdir()?;
872        // Empty directory should succeed without error
873        preprocess_content(temp_dir.path())?;
874        Ok(())
875    }
876
877    #[test]
878    fn test_process_frontmatter_only_delimiters() -> Result<(), ProcessError> {
879        let content = "---\n---\n";
880        let processed = process_frontmatter(content)?;
881        assert!(processed.contains("<!--frontmatter-processed-->"));
882        Ok(())
883    }
884
885    #[test]
886    fn test_internal_compile_with_empty_directories() {
887        let temp_dir = tempdir().unwrap();
888
889        // Create empty required directories
890        let build_dir = temp_dir.path().join("build");
891        let content_dir = temp_dir.path().join("content");
892        let site_dir = temp_dir.path().join("site");
893        let template_dir = temp_dir.path().join("template");
894
895        fs::create_dir_all(&build_dir).unwrap();
896        fs::create_dir_all(&content_dir).unwrap();
897        fs::create_dir_all(&site_dir).unwrap();
898        fs::create_dir_all(&template_dir).unwrap();
899
900        let result = internal_compile(
901            &build_dir,
902            &content_dir,
903            &site_dir,
904            &template_dir,
905        );
906
907        assert!(result.is_err());
908    }
909}