Skip to main content

hblank_cli/
dev.rs

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    /// Requests one discovered fixture source file for the initial preview selection.
37    #[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
44/// Builds, launches, watches, and automatically reloads an initialized Hblank project.
45///
46/// # Errors
47/// Returns an error when configuration, discovery, watching, building, or process control fails.
48pub 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}