1use std::{
2 ffi::OsStr,
3 fs,
4 hash::{DefaultHasher, Hash, Hasher},
5 path::{Path, PathBuf},
6 process::{Child, Command, ExitStatus, Stdio},
7 sync::mpsc,
8 time::Duration,
9};
10
11use notify::{RecursiveMode, Watcher};
12use thiserror::Error;
13
14use crate::{
15 Config, ConfigError, DiscoveredFixtureFile, GenerationError, refresh_generated_fixtures,
16};
17
18const DEBOUNCE: Duration = Duration::from_millis(160);
19const IDLE_POLL: Duration = Duration::from_millis(250);
20
21#[derive(Clone, Debug)]
22pub struct DevOptions {
23 pub project_root: PathBuf,
24 pub fixture: Option<PathBuf>,
25}
26
27impl DevOptions {
28 #[must_use]
29 pub fn new(project_root: impl Into<PathBuf>) -> Self {
30 Self {
31 project_root: project_root.into(),
32 fixture: None,
33 }
34 }
35
36 #[must_use]
38 pub fn with_fixture(mut self, fixture: impl Into<PathBuf>) -> Self {
39 self.fixture = Some(fixture.into());
40 self
41 }
42}
43
44pub fn run_dev(options: &DevOptions) -> Result<(), DevError> {
49 let project_root =
50 options
51 .project_root
52 .canonicalize()
53 .map_err(|source| DevError::ProjectRoot {
54 path: options.project_root.clone(),
55 source,
56 })?;
57 let mut config = Config::load(&project_root)?;
58 let initial = refresh_generated_fixtures(&project_root, &config)?;
59 let fixture = options
60 .fixture
61 .as_deref()
62 .map(|path| resolve_initial_fixture(&project_root, path, &initial.fixture_files))
63 .transpose()?;
64 let mut fingerprint = source_fingerprint(&project_root)?;
65 println!(
66 "Discovered {} Hblank fixture files",
67 initial.fixture_files.len()
68 );
69 if let Some(fixture) = &fixture {
70 println!("Opening fixture {}", fixture.display());
71 }
72
73 let mut preview = PreviewProcess::build_and_start(&project_root, &config, fixture.as_deref())?;
74 let (sender, receiver) = mpsc::channel();
75 let mut watcher = notify::recommended_watcher(sender).map_err(DevError::Watcher)?;
76 watcher
77 .watch(&project_root, RecursiveMode::Recursive)
78 .map_err(DevError::Watcher)?;
79
80 loop {
81 match receiver.recv_timeout(IDLE_POLL) {
82 Ok(event) => {
83 let mut relevant = event.map_err(DevError::WatchEvent)?.paths;
84 while let Ok(event) = receiver.recv_timeout(DEBOUNCE) {
85 relevant.extend(event.map_err(DevError::WatchEvent)?.paths);
86 }
87 relevant.retain(|path| is_relevant_change(&project_root, path));
88 relevant.sort();
89 relevant.dedup();
90 if relevant.is_empty() {
91 continue;
92 }
93 let next_fingerprint = source_fingerprint(&project_root)?;
94 if next_fingerprint == fingerprint {
95 continue;
96 }
97 fingerprint = next_fingerprint;
98 if relevant
99 .iter()
100 .any(|path| path == &project_root.join(crate::CONFIG_PATH))
101 {
102 match Config::load(&project_root) {
103 Ok(next) => config = next,
104 Err(error) => {
105 eprintln!("Hblank config error; keeping the current preview: {error}");
106 continue;
107 }
108 }
109 }
110 match rebuild(&project_root, &config, &mut preview) {
111 Ok(count) => println!("Reloaded {count} Hblank fixture files"),
112 Err(error) => {
113 eprintln!("Hblank rebuild failed; keeping the current preview: {error}");
114 }
115 }
116 }
117 Err(mpsc::RecvTimeoutError::Timeout) => {
118 if preview.has_exited()? {
119 println!("Hblank preview closed");
120 return Ok(());
121 }
122 }
123 Err(mpsc::RecvTimeoutError::Disconnected) => return Err(DevError::WatcherDisconnected),
124 }
125 }
126}
127
128fn resolve_initial_fixture(
129 project_root: &Path,
130 requested: &Path,
131 fixture_files: &[DiscoveredFixtureFile],
132) -> Result<PathBuf, DevError> {
133 let path = if requested.is_absolute() {
134 requested.to_path_buf()
135 } else {
136 project_root.join(requested)
137 };
138 let canonical = path
139 .canonicalize()
140 .map_err(|source| DevError::FixturePath {
141 path: path.clone(),
142 source,
143 })?;
144 if fixture_files
145 .iter()
146 .any(|fixture_file| fixture_file.absolute_path == canonical)
147 {
148 Ok(canonical)
149 } else {
150 Err(DevError::FixtureNotDiscovered(canonical))
151 }
152}
153
154fn rebuild(
155 project_root: &Path,
156 config: &Config,
157 preview: &mut PreviewProcess,
158) -> Result<usize, DevError> {
159 let generated = refresh_generated_fixtures(project_root, config)?;
160 build_preview(project_root)?;
161 let replacement = spawn_preview(project_root, config, None)?;
162 preview.replace(replacement)?;
163 Ok(generated.fixture_files.len())
164}
165
166fn source_fingerprint(project_root: &Path) -> Result<u64, DevError> {
167 let mut hasher = DefaultHasher::new();
168 for entry in walkdir::WalkDir::new(project_root).sort_by_file_name() {
169 let entry = entry.map_err(DevError::FingerprintWalk)?;
170 let path = entry.path();
171 if !entry.file_type().is_file() || !is_relevant_change(project_root, path) {
172 continue;
173 }
174 path.strip_prefix(project_root)
175 .expect("walked source must remain inside the project")
176 .hash(&mut hasher);
177 fs::read(path)
178 .map_err(|source| DevError::FingerprintRead {
179 path: path.to_path_buf(),
180 source,
181 })?
182 .hash(&mut hasher);
183 }
184 Ok(hasher.finish())
185}
186
187fn is_relevant_change(project_root: &Path, path: &Path) -> bool {
188 let Ok(relative) = path.strip_prefix(project_root) else {
189 return false;
190 };
191 if relative.starts_with("target")
192 || relative.starts_with(".git")
193 || (relative.starts_with(".hblank") && relative != Path::new(crate::CONFIG_PATH))
194 {
195 return false;
196 }
197 relative == Path::new(crate::CONFIG_PATH)
198 || relative.file_name() == Some(OsStr::new("Cargo.toml"))
199 || relative.file_name() == Some(OsStr::new("Cargo.lock"))
200 || relative.extension() == Some(OsStr::new("rs"))
201}
202
203struct PreviewProcess {
204 child: Child,
205}
206
207impl PreviewProcess {
208 fn build_and_start(
209 project_root: &Path,
210 config: &Config,
211 fixture: Option<&Path>,
212 ) -> Result<Self, DevError> {
213 build_preview(project_root)?;
214 let child = spawn_preview(project_root, config, fixture)?;
215 Ok(Self { child })
216 }
217
218 fn replace(&mut self, replacement: Child) -> Result<(), DevError> {
219 self.stop()?;
220 self.child = replacement;
221 Ok(())
222 }
223
224 fn has_exited(&mut self) -> Result<bool, DevError> {
225 self.child
226 .try_wait()
227 .map(|status| status.is_some())
228 .map_err(DevError::Process)
229 }
230
231 fn stop(&mut self) -> Result<(), DevError> {
232 if self.child.try_wait().map_err(DevError::Process)?.is_none() {
233 self.child.kill().map_err(DevError::Process)?;
234 self.child.wait().map_err(DevError::Process)?;
235 }
236 Ok(())
237 }
238}
239
240impl Drop for PreviewProcess {
241 fn drop(&mut self) {
242 let _ = self.stop();
243 }
244}
245
246fn build_preview(project_root: &Path) -> Result<(), DevError> {
247 let manifest = project_root.join(".hblank/Cargo.toml");
248 let target = project_root.join(".hblank/target");
249 let status = Command::new("cargo")
250 .arg("build")
251 .arg("--manifest-path")
252 .arg(&manifest)
253 .arg("--target-dir")
254 .arg(&target)
255 .stdin(Stdio::null())
256 .status()
257 .map_err(DevError::Process)?;
258 if status.success() {
259 Ok(())
260 } else {
261 Err(DevError::BuildFailed(status))
262 }
263}
264
265fn spawn_preview(
266 project_root: &Path,
267 config: &Config,
268 fixture: Option<&Path>,
269) -> Result<Child, DevError> {
270 let binary_name = preview_package_name(project_root)?;
271 let mut binary = project_root.join(".hblank/target/debug").join(binary_name);
272 if cfg!(windows) {
273 binary.set_extension("exe");
274 }
275 let mut command = Command::new(&binary);
276 command
277 .env("HBLANK_PROJECT_ROOT", project_root)
278 .env("HBLANK_WINDOW_TITLE", &config.window.title)
279 .env("HBLANK_WINDOW_WIDTH", config.window.width.to_string())
280 .env("HBLANK_WINDOW_HEIGHT", config.window.height.to_string())
281 .stdin(Stdio::null());
282 if let Some(fixture) = fixture {
283 command.env("HBLANK_INITIAL_FIXTURE", fixture);
284 }
285 command
286 .spawn()
287 .map_err(|source| DevError::Spawn { binary, source })
288}
289
290fn preview_package_name(project_root: &Path) -> Result<String, DevError> {
291 let path = project_root.join(".hblank/Cargo.toml");
292 let source = std::fs::read_to_string(&path).map_err(|source| DevError::ReadManifest {
293 path: path.clone(),
294 source,
295 })?;
296 let manifest =
297 toml::from_str::<toml::Value>(&source).map_err(|source| DevError::ParseManifest {
298 path: path.clone(),
299 source,
300 })?;
301 manifest
302 .get("package")
303 .and_then(|package| package.get("name"))
304 .and_then(toml::Value::as_str)
305 .map(str::to_owned)
306 .ok_or(DevError::MissingPreviewPackage(path))
307}
308
309#[derive(Debug, Error)]
310pub enum DevError {
311 #[error("could not resolve project root {path}: {source}")]
312 ProjectRoot {
313 path: PathBuf,
314 source: std::io::Error,
315 },
316 #[error(transparent)]
317 Config(#[from] ConfigError),
318 #[error(transparent)]
319 Generation(#[from] GenerationError),
320 #[error("could not resolve requested fixture path {path}: {source}")]
321 FixturePath {
322 path: PathBuf,
323 source: std::io::Error,
324 },
325 #[error("requested fixture {0} is not matched by the configured fixture file patterns")]
326 FixtureNotDiscovered(PathBuf),
327 #[error("could not scan watched project sources: {0}")]
328 FingerprintWalk(walkdir::Error),
329 #[error("could not read watched project source {path}: {source}")]
330 FingerprintRead {
331 path: PathBuf,
332 source: std::io::Error,
333 },
334 #[error("could not start filesystem watcher: {0}")]
335 Watcher(notify::Error),
336 #[error("filesystem watcher failed: {0}")]
337 WatchEvent(notify::Error),
338 #[error("filesystem watcher disconnected")]
339 WatcherDisconnected,
340 #[error("preview build exited unsuccessfully: {0}")]
341 BuildFailed(ExitStatus),
342 #[error("could not manage preview process: {0}")]
343 Process(std::io::Error),
344 #[error("could not launch preview binary {binary}: {source}")]
345 Spawn {
346 binary: PathBuf,
347 source: std::io::Error,
348 },
349 #[error("could not read preview manifest at {path}: {source}")]
350 ReadManifest {
351 path: PathBuf,
352 source: std::io::Error,
353 },
354 #[error("could not parse preview manifest at {path}: {source}")]
355 ParseManifest {
356 path: PathBuf,
357 source: toml::de::Error,
358 },
359 #[error("preview manifest at {0} has no package name")]
360 MissingPreviewPackage(PathBuf),
361}
362
363#[cfg(test)]
364mod tests {
365 use super::{DevError, is_relevant_change, resolve_initial_fixture, source_fingerprint};
366 use crate::DiscoveredFixtureFile;
367 use std::{fs, path::Path};
368
369 #[test]
370 fn filters_generated_and_build_events() {
371 let root = Path::new("/project");
372 assert!(is_relevant_change(
373 root,
374 Path::new("/project/src/button.rs")
375 ));
376 assert!(is_relevant_change(
377 root,
378 Path::new("/project/.hblank/config.toml")
379 ));
380 assert!(is_relevant_change(root, Path::new("/project/Cargo.toml")));
381 assert!(!is_relevant_change(
382 root,
383 Path::new("/project/.hblank/Cargo.lock")
384 ));
385 assert!(!is_relevant_change(
386 root,
387 Path::new("/project/target/debug/app")
388 ));
389 assert!(!is_relevant_change(
390 root,
391 Path::new("/project/.hblank/generated/fixtures.rs")
392 ));
393 assert!(!is_relevant_change(
394 root,
395 Path::new("/project/assets/icon.png")
396 ));
397 }
398
399 #[test]
400 fn fingerprint_changes_only_for_watched_inputs() {
401 let project = tempfile::tempdir().expect("temporary project should be created");
402 let source = project.path().join("src/button.rs");
403 fs::create_dir_all(source.parent().expect("source has a parent"))
404 .expect("source directory should be created");
405 fs::write(&source, "pub const LABEL: &str = \"Before\";\n")
406 .expect("source should be written");
407 let before = source_fingerprint(project.path()).expect("source should fingerprint");
408
409 let generated = project.path().join(".hblank/target/debug/preview");
410 fs::create_dir_all(generated.parent().expect("generated path has a parent"))
411 .expect("generated directory should be created");
412 fs::write(&generated, "build output").expect("generated output should be written");
413 let after_generated =
414 source_fingerprint(project.path()).expect("generated output should fingerprint");
415
416 fs::write(&source, "pub const LABEL: &str = \"After\";\n")
417 .expect("source should be updated");
418 let after_source = source_fingerprint(project.path()).expect("source should fingerprint");
419
420 assert_eq!(before, after_generated);
421 assert_ne!(before, after_source);
422 }
423
424 #[test]
425 fn resolves_relative_and_absolute_discovered_fixture_file_paths() {
426 let project = tempfile::tempdir().expect("temporary project should be created");
427 let project_root = project
428 .path()
429 .canonicalize()
430 .expect("project should resolve");
431 let fixture = project_root.join("src/card.hblank.rs");
432 fs::create_dir_all(fixture.parent().expect("fixture has a parent"))
433 .expect("fixture directory should be created");
434 fs::write(&fixture, "// fixture\n").expect("fixture should be written");
435 let fixture_files = vec![DiscoveredFixtureFile {
436 relative_path: Path::new("src/card.hblank.rs").to_path_buf(),
437 absolute_path: fixture.clone(),
438 module_name: "__hblank_card".to_owned(),
439 }];
440
441 let relative = resolve_initial_fixture(
442 &project_root,
443 Path::new("src/card.hblank.rs"),
444 &fixture_files,
445 )
446 .expect("relative fixture should resolve");
447 let absolute = resolve_initial_fixture(&project_root, &fixture, &fixture_files)
448 .expect("absolute fixture should resolve");
449
450 assert_eq!(relative, fixture);
451 assert_eq!(absolute, fixture);
452 }
453
454 #[test]
455 fn rejects_missing_and_undiscovered_fixture_paths() {
456 let project = tempfile::tempdir().expect("temporary project should be created");
457 let project_root = project
458 .path()
459 .canonicalize()
460 .expect("project should resolve");
461 let unmatched = project_root.join("src/unmatched.rs");
462 fs::create_dir_all(unmatched.parent().expect("fixture has a parent"))
463 .expect("fixture directory should be created");
464 fs::write(&unmatched, "// not discovered\n").expect("fixture should be written");
465
466 let unmatched_error = resolve_initial_fixture(&project_root, &unmatched, &[])
467 .expect_err("unmatched fixture should fail");
468 let missing_error =
469 resolve_initial_fixture(&project_root, Path::new("src/missing.hblank.rs"), &[])
470 .expect_err("missing fixture should fail");
471
472 assert!(matches!(
473 unmatched_error,
474 DevError::FixtureNotDiscovered(path) if path == unmatched
475 ));
476 assert!(matches!(
477 missing_error,
478 DevError::FixturePath { path, .. }
479 if path == project_root.join("src/missing.hblank.rs")
480 ));
481 }
482}