1use std::{
2 fs::{self, OpenOptions},
3 io::Write,
4 path::{Path, PathBuf},
5};
6
7use thiserror::Error;
8
9use crate::config::Config;
10
11const GENERATED_PATH: &str = ".hblank/generated/fixtures.rs";
12const PREVIEW_MANIFEST_PATH: &str = ".hblank/Cargo.toml";
13const PREVIEW_MAIN_PATH: &str = ".hblank/src/main.rs";
14const HBLANK_IGNORE_PATH: &str = ".hblank/.gitignore";
15
16#[derive(Clone, Debug)]
17pub struct InitOptions {
18 pub project_root: PathBuf,
19 pub runtime_path: Option<PathBuf>,
20}
21
22impl InitOptions {
23 #[must_use]
24 pub fn new(project_root: impl Into<PathBuf>) -> Self {
25 Self {
26 project_root: project_root.into(),
27 runtime_path: None,
28 }
29 }
30}
31
32#[derive(Debug, PartialEq, Eq)]
33pub struct InitReport {
34 pub project_root: PathBuf,
35 pub created: Vec<PathBuf>,
36}
37
38pub fn initialize(options: &InitOptions) -> Result<InitReport, InitError> {
43 let project_root = normalize_project_root(&options.project_root)?;
44 let package_name = read_package_name(&project_root)?;
45 let runtime_path = options
46 .runtime_path
47 .as_deref()
48 .map(|path| normalize_runtime_path(&project_root, path))
49 .transpose()?
50 .or_else(local_source_runtime_path);
51 let runtime_dependency = runtime_dependency(runtime_path.as_deref());
52 let files = vec![
53 (
54 PathBuf::from(crate::config::CONFIG_PATH),
55 Config::for_project(&package_name).to_toml()?.into_bytes(),
56 ),
57 (
58 PathBuf::from(PREVIEW_MANIFEST_PATH),
59 preview_manifest(&package_name, &runtime_dependency).into_bytes(),
60 ),
61 (
62 PathBuf::from(PREVIEW_MAIN_PATH),
63 preview_main().as_bytes().to_vec(),
64 ),
65 (
66 PathBuf::from(GENERATED_PATH),
67 b"// Generated by hblank dev.\n".to_vec(),
68 ),
69 (
70 PathBuf::from(HBLANK_IGNORE_PATH),
71 b"target/\ngenerated/\nstate.toml\n".to_vec(),
72 ),
73 ];
74
75 let existing = files
76 .iter()
77 .map(|(path, _)| project_root.join(path))
78 .filter(|path| path.exists())
79 .collect::<Vec<_>>();
80 if !existing.is_empty() {
81 return Err(InitError::ExistingFiles(existing));
82 }
83
84 let mut created = Vec::with_capacity(files.len());
85 for (relative, contents) in files {
86 let path = project_root.join(relative);
87 if let Some(parent) = path.parent() {
88 fs::create_dir_all(parent).map_err(|source| InitError::CreateDirectory {
89 path: parent.to_path_buf(),
90 source,
91 })?;
92 }
93 if let Err(source) = write_new(&path, &contents) {
94 for created_path in &created {
95 let _ = fs::remove_file(created_path);
96 }
97 return Err(InitError::Write { path, source });
98 }
99 created.push(path);
100 }
101
102 Ok(InitReport {
103 project_root,
104 created,
105 })
106}
107
108fn normalize_project_root(path: &Path) -> Result<PathBuf, InitError> {
109 path.canonicalize()
110 .map_err(|source| InitError::ProjectRoot {
111 path: path.to_path_buf(),
112 source,
113 })
114}
115
116fn normalize_runtime_path(project_root: &Path, path: &Path) -> Result<PathBuf, InitError> {
117 let path = if path.is_absolute() {
118 path.to_path_buf()
119 } else {
120 project_root.join(path)
121 };
122 path.canonicalize()
123 .map_err(|source| InitError::RuntimePath { path, source })
124}
125
126fn read_package_name(project_root: &Path) -> Result<String, InitError> {
127 let path = project_root.join("Cargo.toml");
128 let source = fs::read_to_string(&path).map_err(|source| InitError::ReadManifest {
129 path: path.clone(),
130 source,
131 })?;
132 let manifest =
133 toml::from_str::<toml::Value>(&source).map_err(|source| InitError::ParseManifest {
134 path: path.clone(),
135 source,
136 })?;
137 let name = manifest
138 .get("package")
139 .and_then(|package| package.get("name"))
140 .and_then(toml::Value::as_str)
141 .ok_or_else(|| InitError::MissingPackageName(path.clone()))?;
142 if !name
143 .chars()
144 .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_'))
145 {
146 return Err(InitError::InvalidPackageName(name.to_owned()));
147 }
148 Ok(name.to_owned())
149}
150
151fn local_source_runtime_path() -> Option<PathBuf> {
152 let source_runtime = Path::new(env!("CARGO_MANIFEST_DIR")).join("../hblank");
153 source_runtime
154 .join("Cargo.toml")
155 .is_file()
156 .then(|| source_runtime.canonicalize().ok())
157 .flatten()
158}
159
160fn runtime_dependency(path: Option<&Path>) -> String {
161 path.map_or_else(
162 || {
163 let version = toml::Value::String(format!("={}", env!("CARGO_PKG_VERSION")));
164 format!("{{ version = {version}, features = [\"test-support\"] }}")
165 },
166 |path| {
167 let path = toml::Value::String(path.to_string_lossy().into_owned());
168 format!("{{ path = {path}, features = [\"test-support\"] }}")
169 },
170 )
171}
172
173fn preview_manifest(package_name: &str, runtime_dependency: &str) -> String {
174 format!(
175 r#"[package]
176name = "{package_name}-hblank-preview"
177version = "0.0.0"
178edition = "2024"
179publish = false
180
181[workspace]
182
183[dependencies]
184gpui = {{ version = "0.2.2", features = ["test-support"] }}
185hblank = {runtime_dependency}
186hblank_project = {{ package = "{package_name}", path = ".." }}
187"#
188 )
189}
190
191const fn preview_main() -> &'static str {
192 r#"pub use hblank::gpui;
193
194mod fixtures {
195 include!(concat!(env!("CARGO_MANIFEST_DIR"), "/generated/fixtures.rs"));
196}
197
198fn main() {
199 hblank::run_harness();
200}
201"#
202}
203
204fn write_new(path: &Path, contents: &[u8]) -> std::io::Result<()> {
205 let mut file = OpenOptions::new().write(true).create_new(true).open(path)?;
206 if let Err(error) = file.write_all(contents) {
207 let _ = fs::remove_file(path);
208 return Err(error);
209 }
210 file.sync_all()
211}
212
213#[derive(Debug, Error)]
214pub enum InitError {
215 #[error("could not resolve project root {path}: {source}")]
216 ProjectRoot {
217 path: PathBuf,
218 source: std::io::Error,
219 },
220 #[error("could not resolve local Hblank runtime at {path}: {source}")]
221 RuntimePath {
222 path: PathBuf,
223 source: std::io::Error,
224 },
225 #[error("could not read Rust manifest at {path}: {source}")]
226 ReadManifest {
227 path: PathBuf,
228 source: std::io::Error,
229 },
230 #[error("could not parse Rust manifest at {path}: {source}")]
231 ParseManifest {
232 path: PathBuf,
233 source: toml::de::Error,
234 },
235 #[error("Rust manifest at {0} does not define [package].name")]
236 MissingPackageName(PathBuf),
237 #[error("Rust package name '{0}' contains unsupported characters")]
238 InvalidPackageName(String),
239 #[error("Hblank is already initialized; refusing to overwrite: {0:?}")]
240 ExistingFiles(Vec<PathBuf>),
241 #[error("could not create Hblank directory {path}: {source}")]
242 CreateDirectory {
243 path: PathBuf,
244 source: std::io::Error,
245 },
246 #[error("could not write Hblank file {path}: {source}")]
247 Write {
248 path: PathBuf,
249 source: std::io::Error,
250 },
251 #[error(transparent)]
252 Config(#[from] crate::config::ConfigError),
253}