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