Skip to main content

ssg/core/
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//!
15//! Most binaries should call [`crate::run`] rather than this module
16//! directly; the helpers here are exposed for tests and embedders that
17//! need a smaller building block than the full pipeline.
18//!
19//! # Source files are immutable
20//!
21//! As of issue #543, this module never writes back to any file under
22//! `content/`. An earlier `preprocess_content` helper used to rewrite
23//! markdown sources in place with a `<!--frontmatter-processed-->`
24//! sentinel; that path was destructive (it dirtied users' git working
25//! trees on every build and left source files partially transformed if
26//! the build crashed mid-pass), was not load-bearing for any active
27//! plugin, and has been removed. Front-matter parsing now happens in
28//! memory inside [`staticdatagen::compiler::service::compile`].
29
30use clap::ArgMatches;
31use std::{fs, path::Path};
32/// Represents errors that may occur during argument processing.
33///
34/// Marked `#[non_exhaustive]` so new error cases can be added in minor
35/// versions. Consumers should always include a wildcard arm.
36#[derive(Debug)]
37#[non_exhaustive]
38pub enum ProcessError {
39    /// Occurs when a directory cannot be created.
40    ///
41    /// # Fields
42    /// - `dir_type`: The type of directory (e.g., "content", "output").
43    /// - `path`: The file path where the directory creation failed.
44    DirectoryCreation {
45        /// Type of the directory, such as "content" or "output".
46        dir_type: String,
47        /// Path where the directory creation failed.
48        path: String,
49        /// The underlying IO error that occurred.
50        source: std::io::Error,
51    },
52
53    /// Triggered when a required command-line argument is missing.
54    ///
55    /// # Fields
56    /// - The name of the missing argument.
57    MissingArgument(String),
58
59    /// Represents a failure during the compilation process.
60    ///
61    /// # Fields
62    /// - Compilation error message.
63    CompilationError(String),
64
65    /// Wraps underlying I/O errors.
66    IoError(std::io::Error),
67
68    /// Represents a failure during the frontmatter processing.
69    FrontmatterError(String),
70}
71
72impl std::fmt::Display for ProcessError {
73    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74        match self {
75            Self::DirectoryCreation {
76                dir_type,
77                path,
78                source,
79            } => write!(
80                f,
81                "Failed to create {dir_type} directory at '{path}': {source}"
82            ),
83            Self::MissingArgument(arg) => {
84                write!(f, "Required argument missing: {arg}")
85            }
86            Self::CompilationError(msg) => {
87                write!(f, "Compilation error: {msg}")
88            }
89            Self::IoError(e) => write!(f, "{e}"),
90            Self::FrontmatterError(msg) => {
91                write!(f, "Frontmatter processing error: {msg}")
92            }
93        }
94    }
95}
96
97impl std::error::Error for ProcessError {
98    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
99        match self {
100            Self::DirectoryCreation { source, .. } => Some(source),
101            Self::IoError(e) => Some(e),
102            _ => None,
103        }
104    }
105}
106
107impl From<std::io::Error> for ProcessError {
108    fn from(e: std::io::Error) -> Self {
109        Self::IoError(e)
110    }
111}
112
113/// Retrieves the value of a specified command-line argument.
114///
115/// # Arguments
116///
117/// * `matches` - Clap argument matches object containing parsed arguments.
118/// * `name` - The name of the argument to retrieve.
119///
120/// # Returns
121///
122/// * `Result<String, ProcessError>` - Returns the argument value on success or an error if the argument is missing.
123///
124/// # Errors
125///
126/// - Returns `ProcessError::MissingArgument` if the specified argument is not provided.
127///
128/// # Examples
129///
130/// ```rust
131/// use clap::{Arg, Command};
132/// use ssg::process::get_argument;
133///
134/// let matches = Command::new("t")
135///     .arg(Arg::new("name").long("name"))
136///     .get_matches_from(vec!["t", "--name", "value"]);
137/// assert_eq!(get_argument(&matches, "name").unwrap(), "value");
138/// ```
139pub fn get_argument(
140    matches: &ArgMatches,
141    name: &str,
142) -> Result<String, ProcessError> {
143    matches
144        .get_one::<String>(name)
145        .ok_or_else(|| ProcessError::MissingArgument(name.to_string()))
146        .map(String::from)
147}
148
149/// Ensures the specified directory exists, creating it if necessary.
150///
151/// # Arguments
152///
153/// * `path` - The path of the directory to check.
154/// * `dir_type` - A label describing the directory type (e.g., "content", "output").
155///
156/// # Returns
157///
158/// * `Result<(), ProcessError>` - Returns `Ok` if the directory exists or is successfully created.
159///
160/// # Errors
161///
162/// - Returns `ProcessError::DirectoryCreation` if the directory cannot be created due to permissions or other issues.
163///
164/// # Examples
165///
166/// ```rust
167/// use ssg::process::ensure_directory;
168/// use tempfile::tempdir;
169///
170/// let dir = tempdir().unwrap();
171/// let new = dir.path().join("created");
172/// ensure_directory(&new, "output").unwrap();
173/// assert!(new.is_dir());
174/// ```
175pub fn ensure_directory(
176    path: &Path,
177    dir_type: &str,
178) -> Result<(), ProcessError> {
179    if path.exists() {
180        // Check if the existing path is a directory
181        if !path.is_dir() {
182            return Err(ProcessError::DirectoryCreation {
183                dir_type: dir_type.to_string(),
184                path: path.display().to_string(),
185                source: std::io::Error::new(
186                    std::io::ErrorKind::AlreadyExists,
187                    "Path exists but is not a directory",
188                ),
189            });
190        }
191    } else {
192        fs::create_dir_all(path).map_err(|e| {
193            ProcessError::DirectoryCreation {
194                dir_type: dir_type.to_string(),
195                path: path.display().to_string(),
196                source: e,
197            }
198        })?;
199    }
200    Ok(())
201}
202
203/// Compiles the static site by generating the necessary files from the provided paths.
204///
205/// # Parameters
206///
207/// * `build_path`: The path where the compiled site will be built.
208/// * `content_path`: The path to the directory containing the content files.
209/// * `site_path`: The path to the directory where the site project will be created.
210/// * `template_path`: The path to the directory containing the template files.
211///
212/// # Return
213///
214/// * `Result<(), String>`: Returns `Ok(())` if the compilation is successful, or an error message as a string if an error occurs.
215///
216/// # Errors
217///
218/// * If any error occurs during the compilation process, an error message will be returned as a string.
219fn internal_compile(
220    build_path: &Path,
221    content_path: &Path,
222    site_path: &Path,
223    template_path: &Path,
224) -> Result<(), String> {
225    staticdatagen::compiler::service::compile(
226        build_path,
227        content_path,
228        site_path,
229        template_path,
230    )
231    .map_err(|e| e.to_string())
232}
233
234/// Processes CLI arguments and executes the corresponding site compilation workflow.
235///
236/// This function performs the following steps:
237/// 1. Retrieves required directory paths from command-line arguments.
238/// 2. Ensures each directory exists, creating it if necessary.
239/// 3. Calls the compilation service to generate the static site.
240///
241/// # Arguments
242///
243/// * `matches` - Parsed command-line arguments from `clap`.
244///
245/// # Returns
246///
247/// * `Result<(), ProcessError>` - Returns `Ok` on successful completion, or an error if a problem occurs.
248///
249/// # Errors
250///
251/// - Returns `ProcessError::MissingArgument` if a required argument is not provided.
252/// - Returns `ProcessError::DirectoryCreation` if a directory cannot be created.
253/// - Returns `ProcessError::CompilationError` if the site fails to compile.
254///
255/// # Examples
256///
257/// ```rust
258/// use clap::{Arg, Command};
259/// use ssg::process::args;
260///
261/// // Missing required arguments ⇒ `MissingArgument` error.
262/// let matches = Command::new("t")
263///     .arg(Arg::new("content").long("content"))
264///     .get_matches_from(vec!["t"]);
265/// assert!(args(&matches).is_err());
266/// ```
267pub fn args(matches: &ArgMatches) -> Result<(), ProcessError> {
268    // Get required paths
269    let content_dir = get_argument(matches, "content")?;
270    let output_dir = get_argument(matches, "output")?;
271    let site_dir = get_argument(matches, "new")?;
272    let template_dir = get_argument(matches, "template")?;
273
274    // Create Path objects
275    let content_path = Path::new(&content_dir);
276    let build_path = Path::new(&output_dir);
277    let site_path = Path::new(&site_dir);
278    let template_path = Path::new(&template_dir);
279
280    // Ensure directories exist
281    ensure_directory(content_path, "content")?;
282    ensure_directory(build_path, "output")?;
283    ensure_directory(site_path, "project")?;
284    ensure_directory(template_path, "template")?;
285
286    // Compile the site. Note: front-matter is parsed in memory by
287    // `staticdatagen::compiler::service::compile`; we deliberately do
288    // NOT pre-process / rewrite source `.md` files here (see issue
289    // #543 — the previous in-place writer dirtied users' git trees).
290    internal_compile(build_path, content_path, site_path, template_path)
291        .map_err(ProcessError::CompilationError)?;
292
293    Ok(())
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use anyhow::Result;
300    use clap::{arg, Command};
301    use std::fs::{self, File};
302    use tempfile::tempdir;
303
304    /// Variant predicates used instead of inline `matches!` /
305    /// `match … => panic!` so both the matching and non-matching arms
306    /// are exercised (see `variant_helpers_reject_non_matching_values`).
307    fn is_missing_argument(
308        r: &Result<String, ProcessError>,
309        name: &str,
310    ) -> bool {
311        matches!(r, Err(ProcessError::MissingArgument(arg)) if arg == name)
312    }
313
314    fn is_missing_argument_unit(
315        r: &Result<(), ProcessError>,
316        name: &str,
317    ) -> bool {
318        matches!(r, Err(ProcessError::MissingArgument(arg)) if arg == name)
319    }
320
321    fn is_directory_creation(r: &Result<(), ProcessError>) -> bool {
322        matches!(r, Err(ProcessError::DirectoryCreation { .. }))
323    }
324
325    fn is_io_error(e: &ProcessError) -> bool {
326        matches!(e, ProcessError::IoError(_))
327    }
328
329    fn is_input_error(r: &Result<(), ProcessError>) -> bool {
330        matches!(
331            r,
332            Err(ProcessError::CompilationError(_)
333                | ProcessError::DirectoryCreation { .. })
334        )
335    }
336
337    fn directory_creation_source_kind(
338        e: ProcessError,
339    ) -> Option<std::io::ErrorKind> {
340        match e {
341            ProcessError::DirectoryCreation { source, .. } => {
342                Some(source.kind())
343            }
344            _ => None,
345        }
346    }
347
348    #[test]
349    fn variant_helpers_reject_non_matching_values() {
350        assert!(!is_missing_argument(&Ok("v".to_string()), "content"));
351        assert!(!is_missing_argument(
352            &Err(ProcessError::MissingArgument("a".to_string())),
353            "b"
354        ));
355        assert!(!is_missing_argument_unit(&Ok(()), "content"));
356        assert!(!is_missing_argument_unit(
357            &Err(ProcessError::MissingArgument("a".to_string())),
358            "b"
359        ));
360        assert!(!is_directory_creation(&Ok(())));
361        assert!(is_input_error(&Err(ProcessError::CompilationError(
362            "x".to_string()
363        ))));
364        assert!(!is_input_error(&Ok(())));
365        assert!(!is_io_error(&ProcessError::FrontmatterError(
366            "f".to_string()
367        )));
368        assert!(
369            directory_creation_source_kind(ProcessError::MissingArgument(
370                "m".to_string()
371            ))
372            .is_none()
373        );
374    }
375
376    /// Helper function to create a test `ArgMatches` with all required arguments.
377    fn create_test_command() -> ArgMatches {
378        Command::new("test")
379            .arg(arg!(--"content" <CONTENT> "Content directory"))
380            .arg(arg!(--"output" <OUTPUT> "Output directory"))
381            .arg(arg!(--"new" <NEW> "New site directory"))
382            .arg(arg!(--"template" <TEMPLATE> "Template directory"))
383            .get_matches_from(vec![
384                "test",
385                "--content",
386                "content",
387                "--output",
388                "output",
389                "--new",
390                "new_site",
391                "--template",
392                "template",
393            ])
394    }
395
396    #[test]
397    fn test_get_argument_present() {
398        let matches = create_test_command();
399        let content = get_argument(&matches, "content").unwrap();
400        assert_eq!(content, "content");
401    }
402
403    #[test]
404    fn test_get_argument_missing() {
405        let matches = Command::new("test")
406            .arg(arg!(--"config" <CONFIG> "Config file"))
407            .get_matches_from(vec!["test"]);
408        let result = get_argument(&matches, "config");
409        assert!(is_missing_argument(&result, "config"));
410    }
411
412    #[test]
413    fn test_ensure_directory_exists() {
414        let temp_dir = tempdir().unwrap();
415        let result = ensure_directory(temp_dir.path(), "temp");
416        assert!(result.is_ok());
417    }
418
419    #[test]
420    fn test_args_missing_content_argument() {
421        // Mirrors `test_args_missing_template_argument` but for the
422        // first `?` in `args()` — exercises the early-return path for
423        // a missing `content` argument specifically through `args()`
424        // (not just through `get_argument` in isolation).
425        let matches = Command::new("test")
426            .arg(arg!(--"content" <CONTENT> "Content directory"))
427            .arg(arg!(--"output" <OUTPUT> "Output directory"))
428            .arg(arg!(--"new" <NEW> "New site directory"))
429            .arg(arg!(--"template" <TEMPLATE> "Template directory"))
430            .get_matches_from(vec![
431                "test",
432                "--output",
433                "output",
434                "--new",
435                "new_site",
436                "--template",
437                "template",
438            ]);
439        let result = args(&matches);
440        assert!(is_missing_argument_unit(&result, "content"));
441    }
442
443    #[test]
444    fn test_args_missing_output_argument() {
445        let matches = Command::new("test")
446            .arg(arg!(--"content" <CONTENT> "Content directory"))
447            .arg(arg!(--"output" <OUTPUT> "Output directory"))
448            .arg(arg!(--"new" <NEW> "New site directory"))
449            .arg(arg!(--"template" <TEMPLATE> "Template directory"))
450            .get_matches_from(vec![
451                "test",
452                "--content",
453                "content",
454                "--new",
455                "new_site",
456                "--template",
457                "template",
458            ]);
459        let result = args(&matches);
460        assert!(is_missing_argument_unit(&result, "output"));
461    }
462
463    #[test]
464    fn test_args_missing_new_argument() {
465        let matches = Command::new("test")
466            .arg(arg!(--"content" <CONTENT> "Content directory"))
467            .arg(arg!(--"output" <OUTPUT> "Output directory"))
468            .arg(arg!(--"new" <NEW> "New site directory"))
469            .arg(arg!(--"template" <TEMPLATE> "Template directory"))
470            .get_matches_from(vec![
471                "test",
472                "--content",
473                "content",
474                "--output",
475                "output",
476                "--template",
477                "template",
478            ]);
479        let result = args(&matches);
480        assert!(is_missing_argument_unit(&result, "new"));
481    }
482
483    #[test]
484    fn test_args_missing_template_argument() {
485        let matches = Command::new("test")
486            .arg(arg!(--"content" <CONTENT> "Content directory"))
487            .arg(arg!(--"output" <OUTPUT> "Output directory"))
488            .arg(arg!(--"new" <NEW> "New site directory"))
489            .arg(arg!(--"template" <TEMPLATE> "Template directory"))
490            .get_matches_from(vec![
491                "test",
492                "--content",
493                "content",
494                "--output",
495                "output",
496                "--new",
497                "new_site",
498            ]);
499        let result = args(&matches);
500        assert!(is_missing_argument_unit(&result, "template"));
501    }
502
503    #[test]
504    fn test_ensure_directory_already_exists() {
505        let temp_dir = tempdir().unwrap();
506        ensure_directory(temp_dir.path(), "existing").unwrap();
507        assert!(temp_dir.path().exists());
508    }
509
510    #[cfg(not(target_os = "windows"))] // Unix-specific: path behaviour / error messages differ on Windows
511    #[test]
512    fn test_process_error_display() {
513        let error = ProcessError::MissingArgument("content".to_string());
514        assert_eq!(error.to_string(), "Required argument missing: content");
515
516        let error = ProcessError::DirectoryCreation {
517            dir_type: "content".to_string(),
518            path: "/invalid/path".to_string(),
519            source: std::io::Error::from_raw_os_error(13),
520        };
521        assert_eq!(
522            error.to_string(),
523            "Failed to create content directory at '/invalid/path': Permission denied (os error 13)"
524        );
525
526        let error =
527            ProcessError::CompilationError("Failed to compile".to_string());
528        assert_eq!(error.to_string(), "Compilation error: Failed to compile");
529    }
530
531    #[test]
532    fn test_process_error_io_error() {
533        let io_error = std::io::Error::other("an I/O error occurred");
534        let error: ProcessError = io_error.into();
535        assert!(is_io_error(&error));
536        assert_eq!(error.to_string(), "an I/O error occurred");
537    }
538
539    #[test]
540    fn test_process_error_io_error_format() {
541        let io_error =
542            std::io::Error::new(std::io::ErrorKind::NotFound, "File not found");
543        let error: ProcessError = io_error.into();
544        assert!(is_io_error(&error));
545        assert_eq!(error.to_string(), "File not found");
546    }
547
548    #[cfg(unix)]
549    #[test]
550    fn test_ensure_directory_permission_denied() {
551        use std::fs::Permissions;
552        use std::os::unix::fs::PermissionsExt;
553
554        let temp_dir = tempdir().unwrap();
555        let protected_path = temp_dir.path().join("protected_dir");
556
557        // Create the directory and make it read-only
558        fs::create_dir(&protected_path).unwrap();
559        fs::set_permissions(&protected_path, Permissions::from_mode(0o400))
560            .unwrap();
561
562        // Attempt to create a subdirectory inside the protected directory to trigger a permission error
563        let sub_dir = protected_path.join("sub_dir");
564        let result = ensure_directory(&sub_dir, "sub_directory");
565
566        // Check that the permission-denied error was triggered
567        assert!(is_directory_creation(&result));
568
569        // Reset permissions for cleanup
570        fs::set_permissions(&protected_path, Permissions::from_mode(0o700))
571            .unwrap();
572    }
573
574    #[test]
575    fn test_args_all_required_arguments() {
576        // v0.0.46: staticdatagen 0.0.10's recursive `add()` returns
577        // an empty file list (not an error) for nonexistent paths, so
578        // we have to pass a real *file* where the content directory
579        // is expected — `read_dir` fails on a non-directory and that
580        // bubbles up as a `CompilationError`.
581        let temp_dir = tempdir().unwrap();
582        let content_file = temp_dir.path().join("content_file");
583        fs::write(&content_file, "not a directory").unwrap();
584        let output_dir = temp_dir.path().join("output");
585        let site_dir = temp_dir.path().join("new_site");
586        let template_dir = temp_dir.path().join("template");
587
588        let matches = Command::new("test")
589            .arg(arg!(--"content" <CONTENT> "Content directory"))
590            .arg(arg!(--"output" <OUTPUT> "Output directory"))
591            .arg(arg!(--"new" <NEW> "New site directory"))
592            .arg(arg!(--"template" <TEMPLATE> "Template directory"))
593            .get_matches_from(vec![
594                "test",
595                "--content",
596                content_file.to_str().unwrap(),
597                "--output",
598                output_dir.to_str().unwrap(),
599                "--new",
600                site_dir.to_str().unwrap(),
601                "--template",
602                template_dir.to_str().unwrap(),
603            ]);
604
605        let result = args(&matches);
606        // v0.0.46: `args()` runs `ensure_directory` against each path
607        // before reaching the compile pipeline, so the invalid
608        // `content_file` (a regular file, not a dir) now surfaces as
609        // `ProcessError::DirectoryCreation` rather than wrapping into
610        // `ProcessError::CompilationError`. Either variant indicates
611        // a correctly-propagated input error.
612        assert!(
613            is_input_error(&result),
614            "Expected DirectoryCreation or CompilationError from args, got: {result:?}"
615        );
616    }
617
618    /// Builds `ArgMatches` pointing at the four given directory paths.
619    fn matches_for_paths(
620        content: &Path,
621        output: &Path,
622        site: &Path,
623        template: &Path,
624    ) -> ArgMatches {
625        Command::new("test")
626            .arg(arg!(--"content" <CONTENT> "Content directory"))
627            .arg(arg!(--"output" <OUTPUT> "Output directory"))
628            .arg(arg!(--"new" <NEW> "New site directory"))
629            .arg(arg!(--"template" <TEMPLATE> "Template directory"))
630            .get_matches_from(vec![
631                "test",
632                "--content",
633                content.to_str().unwrap(),
634                "--output",
635                output.to_str().unwrap(),
636                "--new",
637                site.to_str().unwrap(),
638                "--template",
639                template.to_str().unwrap(),
640            ])
641    }
642
643    #[test]
644    fn test_args_succeeds_with_empty_content_and_templates() {
645        // staticdatagen treats empty content + empty templates as
646        // "no work to do", so `args` runs the full pipeline — all
647        // four ensure_directory calls plus a successful compile.
648        let temp_dir = tempdir().unwrap();
649        let content = temp_dir.path().join("content");
650        let output = temp_dir.path().join("output");
651        let site = temp_dir.path().join("new_site");
652        let template = temp_dir.path().join("template");
653
654        let matches = matches_for_paths(&content, &output, &site, &template);
655        let result = args(&matches);
656        assert!(result.is_ok(), "expected success, got: {result:?}");
657        assert!(content.is_dir(), "content dir should have been created");
658        assert!(template.is_dir(), "template dir should have been created");
659    }
660
661    #[test]
662    fn test_args_output_directory_creation_failure() {
663        // Content is fine, but the output path nests under a file so
664        // the second ensure_directory call fails.
665        let temp_dir = tempdir().unwrap();
666        let content = temp_dir.path().join("content");
667        fs::create_dir_all(&content).unwrap();
668        let blocker = temp_dir.path().join("blocker");
669        fs::write(&blocker, "file").unwrap();
670
671        let matches = matches_for_paths(
672            &content,
673            &blocker.join("output"),
674            &temp_dir.path().join("site"),
675            &temp_dir.path().join("template"),
676        );
677        assert!(is_directory_creation(&args(&matches)));
678    }
679
680    #[test]
681    fn test_args_site_directory_creation_failure() {
682        let temp_dir = tempdir().unwrap();
683        let content = temp_dir.path().join("content");
684        fs::create_dir_all(&content).unwrap();
685        let blocker = temp_dir.path().join("blocker");
686        fs::write(&blocker, "file").unwrap();
687
688        let matches = matches_for_paths(
689            &content,
690            &temp_dir.path().join("output"),
691            &blocker.join("site"),
692            &temp_dir.path().join("template"),
693        );
694        assert!(is_directory_creation(&args(&matches)));
695    }
696
697    #[test]
698    fn test_args_template_directory_creation_failure() {
699        let temp_dir = tempdir().unwrap();
700        let content = temp_dir.path().join("content");
701        fs::create_dir_all(&content).unwrap();
702        let blocker = temp_dir.path().join("blocker");
703        fs::write(&blocker, "file").unwrap();
704
705        let matches = matches_for_paths(
706            &content,
707            &temp_dir.path().join("output"),
708            &temp_dir.path().join("site"),
709            &blocker.join("template"),
710        );
711        assert!(is_directory_creation(&args(&matches)));
712    }
713
714    #[cfg(unix)]
715    #[test]
716    fn test_args_compilation_error_from_unreadable_content() {
717        use std::os::unix::fs::PermissionsExt;
718
719        // All four directories pass ensure_directory, but content is
720        // unreadable so staticdatagen's read_dir fails and args maps
721        // it into ProcessError::CompilationError.
722        let temp_dir = tempdir().unwrap();
723        let content = temp_dir.path().join("content");
724        fs::create_dir_all(&content).unwrap();
725        fs::set_permissions(&content, fs::Permissions::from_mode(0o000))
726            .unwrap();
727
728        let matches = matches_for_paths(
729            &content,
730            &temp_dir.path().join("output"),
731            &temp_dir.path().join("site"),
732            &temp_dir.path().join("template"),
733        );
734        let result = args(&matches);
735
736        // Restore permissions so tempdir cleanup succeeds.
737        fs::set_permissions(&content, fs::Permissions::from_mode(0o755))
738            .unwrap();
739
740        assert!(
741            result.is_err(),
742            "expected CompilationError, got: {result:?}"
743        );
744        let msg = result.unwrap_err().to_string();
745        assert!(msg.contains("Compilation error"), "got: {msg}");
746    }
747    // NOTE: Tests for the old `preprocess_content` / `process_frontmatter`
748    // helpers were removed in issue #543 along with the destructive in-place
749    // writer those helpers backed. Source files in `content/` are no longer
750    // rewritten during a build; see the new integration test at
751    // `tests/build_does_not_mutate_sources.rs` for the regression guard.
752
753    #[test]
754    fn test_internal_compile_error_handling() {
755        let temp_dir = tempdir().unwrap();
756        let result = internal_compile(
757            &temp_dir.path().join("build"),
758            &temp_dir.path().join("content"),
759            &temp_dir.path().join("site"),
760            &temp_dir.path().join("template"),
761        );
762        assert!(result.is_err());
763    }
764
765    #[test]
766    fn test_get_argument_with_empty_value() {
767        let matches = Command::new("test")
768            .arg(arg!(--"empty" <EMPTY> "Empty value"))
769            .get_matches_from(vec!["test", "--empty", ""]);
770
771        let result = get_argument(&matches, "empty");
772        assert!(result.is_ok());
773        assert_eq!(result.unwrap(), "");
774    }
775
776    #[test]
777    fn test_ensure_directory_with_existing_file() {
778        let temp_dir = tempdir().unwrap();
779        let file_path = temp_dir.path().join("existing_file");
780
781        // Create a file instead of a directory
782        let _file = File::create(&file_path).unwrap();
783
784        // Attempt to ensure directory at the same path
785        let result = ensure_directory(&file_path, "test");
786
787        // Verify that the operation failed because path exists but is not a directory
788        let err = result.unwrap_err();
789        let kind = directory_creation_source_kind(err)
790            .expect("expected DirectoryCreation error");
791        assert_eq!(kind, std::io::ErrorKind::AlreadyExists);
792    }
793
794    #[test]
795    fn test_ensure_directory_with_existing_directory() {
796        let temp_dir = tempdir().unwrap();
797        let dir_path = temp_dir.path().join("existing_dir");
798
799        // First create the directory
800        fs::create_dir(&dir_path).unwrap();
801
802        // Attempt to ensure directory at the same path
803        let result = ensure_directory(&dir_path, "test");
804
805        // Should succeed because path exists and is a directory
806        assert!(result.is_ok());
807    }
808
809    #[test]
810    fn test_ensure_directory_with_symlink() {
811        let temp_dir = tempdir().unwrap();
812        let real_dir = temp_dir.path().join("real_dir");
813        let symlink = temp_dir.path().join("symlink_dir");
814
815        fs::create_dir(&real_dir).unwrap();
816
817        #[cfg(unix)]
818        std::os::unix::fs::symlink(&real_dir, &symlink).unwrap();
819        #[cfg(windows)]
820        std::os::windows::fs::symlink_dir(&real_dir, &symlink).unwrap();
821
822        // Should succeed as symlink points to a valid directory
823        let result = ensure_directory(&symlink, "symlink");
824        assert!(result.is_ok());
825    }
826
827    #[test]
828    fn test_process_error_frontmatter_display() {
829        let error = ProcessError::FrontmatterError("bad yaml".to_string());
830        assert_eq!(error.to_string(), "Frontmatter processing error: bad yaml");
831    }
832
833    #[test]
834    fn test_process_error_source_for_directory_creation() {
835        use std::error::Error;
836        let error = ProcessError::DirectoryCreation {
837            dir_type: "output".to_string(),
838            path: "/bad".to_string(),
839            source: std::io::Error::new(
840                std::io::ErrorKind::PermissionDenied,
841                "denied",
842            ),
843        };
844        assert!(error.source().is_some());
845    }
846
847    #[test]
848    fn test_process_error_source_for_io_error() {
849        use std::error::Error;
850        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "gone");
851        let error = ProcessError::IoError(io_err);
852        assert!(error.source().is_some());
853    }
854
855    #[test]
856    fn test_process_error_source_for_missing_argument() {
857        use std::error::Error;
858        let error = ProcessError::MissingArgument("foo".to_string());
859        assert!(error.source().is_none());
860    }
861
862    #[test]
863    fn test_process_error_source_for_compilation_error() {
864        use std::error::Error;
865        let error = ProcessError::CompilationError("oops".to_string());
866        assert!(error.source().is_none());
867    }
868
869    #[test]
870    fn test_process_error_source_for_frontmatter_error() {
871        use std::error::Error;
872        let error = ProcessError::FrontmatterError("bad".to_string());
873        assert!(error.source().is_none());
874    }
875
876    #[test]
877    fn test_process_error_debug() {
878        let error = ProcessError::MissingArgument("arg".to_string());
879        let debug = format!("{error:?}");
880        assert!(debug.contains("MissingArgument"));
881    }
882
883    #[test]
884    fn test_internal_compile_with_empty_directories() {
885        // v0.0.46: staticdatagen 0.0.10 treats empty content + empty
886        // templates as "no work to do", so this test now asserts
887        // error PROPAGATION (not raw "empty inputs fail"). Pass a
888        // real file where `content_dir` is expected — the underlying
889        // `read_dir` fails on a non-directory.
890        let temp_dir = tempdir().unwrap();
891
892        let build_dir = temp_dir.path().join("build");
893        let content_file = temp_dir.path().join("content_file");
894        let site_dir = temp_dir.path().join("site");
895        let template_dir = temp_dir.path().join("template");
896
897        fs::create_dir_all(&build_dir).unwrap();
898        fs::write(&content_file, "not a directory").unwrap();
899        fs::create_dir_all(&site_dir).unwrap();
900        fs::create_dir_all(&template_dir).unwrap();
901
902        let result = internal_compile(
903            &build_dir,
904            &content_file,
905            &site_dir,
906            &template_dir,
907        );
908
909        assert!(
910            result.is_err(),
911            "internal_compile should propagate the io error when \
912             content_dir is a file, got: {result:?}"
913        );
914    }
915}