waterui_cli/debug/
runner.rs

1//! Hot reload runner that orchestrates file watching, building, and broadcasting.
2
3use std::path::PathBuf;
4
5use futures::FutureExt;
6use smol::Task;
7use smol::channel::{self, Receiver, Sender};
8use target_lexicon::Triple;
9
10use super::file_watcher::FileWatcher;
11use super::hot_reload::{BroadcastMessage, BuildManager, DEFAULT_PORT, HotReloadServer};
12use crate::build::RustBuild;
13use crate::project::Project;
14
15/// Events emitted by the hot reload runner.
16#[derive(Debug, Clone)]
17pub enum HotReloadEvent {
18    /// Server started and listening.
19    ServerStarted {
20        /// Host address the server is bound to.
21        host: String,
22        /// Port the server is listening on.
23        port: u16,
24    },
25    /// File change detected, waiting for debounce.
26    FileChanged,
27    /// Starting a rebuild.
28    Rebuilding,
29    /// Build completed successfully, broadcasting to clients.
30    Built {
31        /// Path to the built dylib.
32        path: PathBuf,
33    },
34    /// Build failed with an error message.
35    BuildFailed {
36        /// Error message.
37        error: String,
38    },
39    /// Library broadcast to connected clients.
40    Broadcast,
41}
42
43/// Orchestrates hot reload: file watching, building, and broadcasting.
44pub struct HotReloadRunner {
45    server: HotReloadServer,
46    event_rx: Receiver<HotReloadEvent>,
47    _runner_task: Task<()>,
48}
49
50impl std::fmt::Debug for HotReloadRunner {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.debug_struct("HotReloadRunner")
53            .field("server", &self.server)
54            .finish_non_exhaustive()
55    }
56}
57
58impl HotReloadRunner {
59    /// Create a new hot reload runner for the given project.
60    ///
61    /// # Arguments
62    /// * `project` - The project to watch and rebuild
63    /// * `triple` - The target triple to build for
64    ///
65    /// # Errors
66    /// Returns an error if the server or file watcher cannot be started.
67    pub async fn new(project: &Project, triple: Triple) -> color_eyre::Result<Self> {
68        let server = HotReloadServer::launch(DEFAULT_PORT).await?;
69        let watcher = FileWatcher::new(project.root())?;
70
71        let (event_tx, event_rx) = channel::unbounded();
72
73        // Send initial server started event
74        let _ = event_tx
75            .send(HotReloadEvent::ServerStarted {
76                host: server.host(),
77                port: server.port(),
78            })
79            .await;
80
81        let rust_build = RustBuild::new(project.root(), triple, true);
82        let file_rx = watcher.receiver().clone();
83        let broadcast_tx = server.broadcast_sender();
84        let crate_name = project.crate_name().replace('-', "_");
85
86        // Spawn the runner task
87        let runner_task = smol::spawn(run_loop(
88            rust_build,
89            file_rx,
90            broadcast_tx,
91            event_tx,
92            watcher,
93            crate_name,
94        ));
95
96        Ok(Self {
97            server,
98            event_rx,
99            _runner_task: runner_task,
100        })
101    }
102
103    /// Get the host address for the hot reload server.
104    #[must_use]
105    pub fn host(&self) -> String {
106        self.server.host()
107    }
108
109    /// Get the port the hot reload server is listening on.
110    #[must_use]
111    pub const fn port(&self) -> u16 {
112        self.server.port()
113    }
114
115    /// Get the event receiver for hot reload events.
116    #[must_use]
117    pub const fn events(&self) -> &Receiver<HotReloadEvent> {
118        &self.event_rx
119    }
120
121    /// Consume the runner and return the underlying server to keep it alive.
122    #[must_use]
123    pub fn into_server(self) -> HotReloadServer {
124        self.server
125    }
126}
127
128/// Main loop that handles file changes, debouncing, building, and broadcasting.
129async fn run_loop(
130    rust_build: RustBuild,
131    file_rx: Receiver<()>,
132    broadcast_tx: Sender<BroadcastMessage>,
133    event_tx: Sender<HotReloadEvent>,
134    _watcher: FileWatcher, // Keep watcher alive
135    crate_name: String,
136) {
137    let mut build_manager = BuildManager::new();
138    let mut reported_change = false;
139
140    loop {
141        futures::select! {
142            // File change detected
143            _ = file_rx.recv().fuse() => {
144                while file_rx.try_recv().is_ok() {}
145
146                if !reported_change {
147                    let _ = event_tx.send(HotReloadEvent::FileChanged).await;
148                    reported_change = true;
149                }
150                build_manager.request_rebuild();
151            }
152
153            // Check debounce timer
154            _ = FutureExt::fuse(smol::Timer::after(std::time::Duration::from_millis(50))) => {
155                if let Some(result) = build_manager.poll_build().await {
156                    match result {
157                        Ok(lib_dir) => {
158                            let lib_name = format!(
159                                "{}{}{}",
160                                std::env::consts::DLL_PREFIX,
161                                crate_name,
162                                std::env::consts::DLL_SUFFIX
163                            );
164                            let dylib_path = lib_dir.join(&lib_name);
165
166                            if !dylib_path.exists() {
167                                let _ = event_tx.send(HotReloadEvent::BuildFailed {
168                                    error: format!("Library not found: {}", dylib_path.display()),
169                                }).await;
170                                reported_change = false;
171                                continue;
172                            }
173
174                            let _ = event_tx.send(HotReloadEvent::Built {
175                                path: dylib_path.clone(),
176                            }).await;
177
178                            // Read and broadcast the library
179                            match smol::fs::read(&dylib_path).await {
180                                Ok(data) => {
181                                    let _ = broadcast_tx.send(BroadcastMessage::Binary(data)).await;
182                                    let _ = event_tx.send(HotReloadEvent::Broadcast).await;
183                                    reported_change = false;
184                                }
185                                Err(e) => {
186                                    let _ = event_tx.send(HotReloadEvent::BuildFailed {
187                                        error: format!("Failed to read library: {e}"),
188                                    }).await;
189                                    reported_change = false;
190                                }
191                            }
192                        }
193                        Err(e) => {
194                            let _ = event_tx.send(HotReloadEvent::BuildFailed {
195                                error: e.to_string(),
196                            }).await;
197                            reported_change = false;
198                        }
199                    }
200                }
201
202                // Check if debounce completed and we should start building
203                if build_manager.should_start_build() {
204                    // Notify clients that building is starting (instant feedback)
205                    let _ = broadcast_tx.send(BroadcastMessage::Text("building".to_string())).await;
206                    let _ = event_tx.send(HotReloadEvent::Rebuilding).await;
207                    build_manager.start_build(rust_build.clone());
208                }
209            }
210        }
211    }
212}