Skip to main content

hblank_cli/
init.rs

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
38/// Initializes a Rust package with Hblank's config and private preview crate.
39///
40/// # Errors
41/// Returns an error for an invalid package, existing generated files, or filesystem failures.
42pub 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}