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