hblank-cli 0.4.2

Command-line harness for Hblank
Documentation
use std::{
    fs::{self, OpenOptions},
    io::Write,
    path::{Path, PathBuf},
};

use thiserror::Error;

use crate::config::Config;

const GENERATED_PATH: &str = ".hblank/generated/fixtures.rs";
const PREVIEW_MANIFEST_PATH: &str = ".hblank/Cargo.toml";
const PREVIEW_MAIN_PATH: &str = ".hblank/src/main.rs";
const HBLANK_IGNORE_PATH: &str = ".hblank/.gitignore";

#[derive(Clone, Debug)]
pub struct InitOptions {
    pub project_root: PathBuf,
    pub runtime_path: Option<PathBuf>,
}

impl InitOptions {
    #[must_use]
    pub fn new(project_root: impl Into<PathBuf>) -> Self {
        Self {
            project_root: project_root.into(),
            runtime_path: None,
        }
    }
}

#[derive(Debug, PartialEq, Eq)]
pub struct InitReport {
    pub project_root: PathBuf,
    pub created: Vec<PathBuf>,
}

/// Initializes a Rust package with Hblank's config and private preview crate.
///
/// # Errors
/// Returns an error for an invalid package, existing generated files, or filesystem failures.
pub fn initialize(options: &InitOptions) -> Result<InitReport, InitError> {
    let project_root = normalize_project_root(&options.project_root)?;
    let package_name = read_package_name(&project_root)?;
    let runtime_path = options
        .runtime_path
        .as_deref()
        .map(|path| normalize_runtime_path(&project_root, path))
        .transpose()?
        .or_else(local_source_runtime_path);
    let runtime_dependency = runtime_dependency(runtime_path.as_deref());
    let files = vec![
        (
            PathBuf::from(crate::config::CONFIG_PATH),
            Config::for_project(&package_name).to_toml()?.into_bytes(),
        ),
        (
            PathBuf::from(PREVIEW_MANIFEST_PATH),
            preview_manifest(&package_name, &runtime_dependency).into_bytes(),
        ),
        (
            PathBuf::from(PREVIEW_MAIN_PATH),
            preview_main().as_bytes().to_vec(),
        ),
        (
            PathBuf::from(GENERATED_PATH),
            b"// Generated by hblank dev.\n".to_vec(),
        ),
        (
            PathBuf::from(HBLANK_IGNORE_PATH),
            b"target/\ngenerated/\nstate.toml\n".to_vec(),
        ),
    ];

    let existing = files
        .iter()
        .map(|(path, _)| project_root.join(path))
        .filter(|path| path.exists())
        .collect::<Vec<_>>();
    if !existing.is_empty() {
        return Err(InitError::ExistingFiles(existing));
    }

    let mut created = Vec::with_capacity(files.len());
    for (relative, contents) in files {
        let path = project_root.join(relative);
        if let Some(parent) = path.parent() {
            fs::create_dir_all(parent).map_err(|source| InitError::CreateDirectory {
                path: parent.to_path_buf(),
                source,
            })?;
        }
        if let Err(source) = write_new(&path, &contents) {
            for created_path in &created {
                let _ = fs::remove_file(created_path);
            }
            return Err(InitError::Write { path, source });
        }
        created.push(path);
    }

    Ok(InitReport {
        project_root,
        created,
    })
}

fn normalize_project_root(path: &Path) -> Result<PathBuf, InitError> {
    path.canonicalize()
        .map_err(|source| InitError::ProjectRoot {
            path: path.to_path_buf(),
            source,
        })
}

fn normalize_runtime_path(project_root: &Path, path: &Path) -> Result<PathBuf, InitError> {
    let path = if path.is_absolute() {
        path.to_path_buf()
    } else {
        project_root.join(path)
    };
    path.canonicalize()
        .map_err(|source| InitError::RuntimePath { path, source })
}

fn read_package_name(project_root: &Path) -> Result<String, InitError> {
    let path = project_root.join("Cargo.toml");
    let source = fs::read_to_string(&path).map_err(|source| InitError::ReadManifest {
        path: path.clone(),
        source,
    })?;
    let manifest =
        toml::from_str::<toml::Value>(&source).map_err(|source| InitError::ParseManifest {
            path: path.clone(),
            source,
        })?;
    let name = manifest
        .get("package")
        .and_then(|package| package.get("name"))
        .and_then(toml::Value::as_str)
        .ok_or_else(|| InitError::MissingPackageName(path.clone()))?;
    if !name
        .chars()
        .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
    {
        return Err(InitError::InvalidPackageName(name.to_owned()));
    }
    Ok(name.to_owned())
}

fn local_source_runtime_path() -> Option<PathBuf> {
    let source_runtime = Path::new(env!("CARGO_MANIFEST_DIR")).join("../hblank");
    source_runtime
        .join("Cargo.toml")
        .is_file()
        .then(|| source_runtime.canonicalize().ok())
        .flatten()
}

fn runtime_dependency(path: Option<&Path>) -> String {
    path.map_or_else(
        || {
            let version = toml::Value::String(format!("={}", env!("CARGO_PKG_VERSION")));
            format!("{{ version = {version}, features = [\"test-support\"] }}")
        },
        |path| {
            let path = toml::Value::String(path.to_string_lossy().into_owned());
            format!("{{ path = {path}, features = [\"test-support\"] }}")
        },
    )
}

fn preview_manifest(package_name: &str, runtime_dependency: &str) -> String {
    format!(
        r#"[package]
name = "{package_name}-hblank-preview"
version = "0.0.0"
edition = "2024"
publish = false

[workspace]

[dependencies]
gpui = {{ version = "0.2.2", features = ["test-support"] }}
hblank = {runtime_dependency}
hblank_project = {{ package = "{package_name}", path = ".." }}
"#
    )
}

const fn preview_main() -> &'static str {
    r#"pub use hblank::gpui;

mod fixtures {
    include!(concat!(env!("CARGO_MANIFEST_DIR"), "/generated/fixtures.rs"));
}

fn main() {
    hblank::run_harness();
}
"#
}

fn write_new(path: &Path, contents: &[u8]) -> std::io::Result<()> {
    let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
    if let Err(error) = file.write_all(contents) {
        let _ = fs::remove_file(path);
        return Err(error);
    }
    file.sync_all()
}

#[derive(Debug, Error)]
pub enum InitError {
    #[error("could not resolve project root {path}: {source}")]
    ProjectRoot {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error("could not resolve local Hblank runtime at {path}: {source}")]
    RuntimePath {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error("could not read Rust manifest at {path}: {source}")]
    ReadManifest {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error("could not parse Rust manifest at {path}: {source}")]
    ParseManifest {
        path: PathBuf,
        source: toml::de::Error,
    },
    #[error("Rust manifest at {0} does not define [package].name")]
    MissingPackageName(PathBuf),
    #[error("Rust package name '{0}' contains unsupported characters")]
    InvalidPackageName(String),
    #[error("Hblank is already initialized; refusing to overwrite: {0:?}")]
    ExistingFiles(Vec<PathBuf>),
    #[error("could not create Hblank directory {path}: {source}")]
    CreateDirectory {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error("could not write Hblank file {path}: {source}")]
    Write {
        path: PathBuf,
        source: std::io::Error,
    },
    #[error(transparent)]
    Config(#[from] crate::config::ConfigError),
}