Skip to main content

hblank_cli/
generate.rs

1use std::{
2    fmt::Write as _,
3    fs,
4    path::{Path, PathBuf},
5};
6
7use thiserror::Error;
8
9use crate::{Config, DiscoveredFixtureFile, DiscoveryError, discover_fixture_files};
10
11pub const GENERATED_FIXTURES_PATH: &str = ".hblank/generated/fixtures.rs";
12
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct GenerationResult {
15    pub fixture_files: Vec<DiscoveredFixtureFile>,
16    pub changed: bool,
17}
18
19/// Discovers fixture files and atomically refreshes the generated Rust imports when needed.
20///
21/// # Errors
22/// Returns an error when discovery or generated-file IO fails.
23pub fn refresh_generated_fixtures(
24    project_root: &Path,
25    config: &Config,
26) -> Result<GenerationResult, GenerationError> {
27    let fixture_files = discover_fixture_files(project_root, config)?;
28    let source = generated_source(&fixture_files);
29    let path = project_root.join(GENERATED_FIXTURES_PATH);
30    let changed = fs::read(&path).map_or(true, |current| current != source.as_bytes());
31    if changed {
32        write_atomically(&path, source.as_bytes())?;
33    }
34    Ok(GenerationResult {
35        fixture_files,
36        changed,
37    })
38}
39
40#[must_use]
41pub fn generated_source(fixture_files: &[DiscoveredFixtureFile]) -> String {
42    let mut source = String::from(
43        "// Generated by hblank dev. Do not edit.
44",
45    );
46    for fixture_file in fixture_files {
47        let path = rust_string(&fixture_file.absolute_path);
48        source.push_str(
49            "#[allow(dead_code, unused_imports)]
50",
51        );
52        write!(
53            &mut source,
54            "#[path = {path}]\nmod {};\n",
55            fixture_file.module_name
56        )
57        .expect("writing generated imports to a String cannot fail");
58    }
59    source
60}
61
62fn rust_string(path: &Path) -> String {
63    format!("{:?}", path.to_string_lossy())
64}
65
66fn write_atomically(path: &Path, contents: &[u8]) -> Result<(), GenerationError> {
67    let parent = path
68        .parent()
69        .ok_or_else(|| GenerationError::MissingParent(path.to_path_buf()))?;
70    fs::create_dir_all(parent).map_err(|source| GenerationError::CreateDirectory {
71        path: parent.to_path_buf(),
72        source,
73    })?;
74    let temporary = parent.join(format!(".fixtures.rs.{}.tmp", std::process::id()));
75    fs::write(&temporary, contents).map_err(|source| GenerationError::Write {
76        path: temporary.clone(),
77        source,
78    })?;
79    if let Err(source) = fs::rename(&temporary, path) {
80        let _ = fs::remove_file(&temporary);
81        return Err(GenerationError::Write {
82            path: path.to_path_buf(),
83            source,
84        });
85    }
86    Ok(())
87}
88
89#[derive(Debug, Error)]
90pub enum GenerationError {
91    #[error(transparent)]
92    Discovery(#[from] DiscoveryError),
93    #[error("generated fixture path has no parent: {0}")]
94    MissingParent(PathBuf),
95    #[error("could not create generated source directory {path}: {source}")]
96    CreateDirectory {
97        path: PathBuf,
98        source: std::io::Error,
99    },
100    #[error("could not write generated fixture source {path}: {source}")]
101    Write {
102        path: PathBuf,
103        source: std::io::Error,
104    },
105}