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>,
}
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),
}