hblank-cli 0.4.2

Command-line harness for Hblank
Documentation
use std::{
    fmt::Write as _,
    fs,
    path::{Path, PathBuf},
};

use thiserror::Error;

use crate::{Config, DiscoveredFixtureFile, DiscoveryError, discover_fixture_files};

pub const GENERATED_FIXTURES_PATH: &str = ".hblank/generated/fixtures.rs";

#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GenerationResult {
    pub fixture_files: Vec<DiscoveredFixtureFile>,
    pub changed: bool,
}

/// Discovers fixture files and atomically refreshes the generated Rust imports when needed.
///
/// # Errors
/// Returns an error when discovery or generated-file IO fails.
pub fn refresh_generated_fixtures(
    project_root: &Path,
    config: &Config,
) -> Result<GenerationResult, GenerationError> {
    let fixture_files = discover_fixture_files(project_root, config)?;
    let source = generated_source(&fixture_files);
    let path = project_root.join(GENERATED_FIXTURES_PATH);
    let changed = fs::read(&path).map_or(true, |current| current != source.as_bytes());
    if changed {
        write_atomically(&path, source.as_bytes())?;
    }
    Ok(GenerationResult {
        fixture_files,
        changed,
    })
}

#[must_use]
pub fn generated_source(fixture_files: &[DiscoveredFixtureFile]) -> String {
    let mut source = String::from(
        "// Generated by hblank dev. Do not edit.
",
    );
    for fixture_file in fixture_files {
        let path = rust_string(&fixture_file.absolute_path);
        source.push_str(
            "#[allow(dead_code, unused_imports)]
",
        );
        write!(
            &mut source,
            "#[path = {path}]\nmod {};\n",
            fixture_file.module_name
        )
        .expect("writing generated imports to a String cannot fail");
    }
    source
}

fn rust_string(path: &Path) -> String {
    format!("{:?}", path.to_string_lossy())
}

fn write_atomically(path: &Path, contents: &[u8]) -> Result<(), GenerationError> {
    let parent = path
        .parent()
        .ok_or_else(|| GenerationError::MissingParent(path.to_path_buf()))?;
    fs::create_dir_all(parent).map_err(|source| GenerationError::CreateDirectory {
        path: parent.to_path_buf(),
        source,
    })?;
    let temporary = parent.join(format!(".fixtures.rs.{}.tmp", std::process::id()));
    fs::write(&temporary, contents).map_err(|source| GenerationError::Write {
        path: temporary.clone(),
        source,
    })?;
    if let Err(source) = fs::rename(&temporary, path) {
        let _ = fs::remove_file(&temporary);
        return Err(GenerationError::Write {
            path: path.to_path_buf(),
            source,
        });
    }
    Ok(())
}

#[derive(Debug, Error)]
pub enum GenerationError {
    #[error(transparent)]
    Discovery(#[from] DiscoveryError),
    #[error("generated fixture path has no parent: {0}")]
    MissingParent(PathBuf),
    #[error("could not create generated source directory {path}: {source}")]
    CreateDirectory {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error("could not write generated fixture source {path}: {source}")]
    Write {
        path: PathBuf,
        source: std::io::Error,
    },
}