alba_engine/watch/mod.rs
1//! Watch mode: run the target, then re-run it whenever the files its
2//! subgraph declares as `inputs` change.
3//!
4//! What lives here is the vocabulary of watching — the [`Watcher`] stream
5//! and the batches it delivers, the ways a session can end, the trouble it
6//! survives, and where a session's paths are rooted and how they are shown.
7//! The loop itself lives in [`crate::session`], of which [`watch`] is one
8//! configuration: always watching, driven by nobody.
9
10mod notify;
11pub(crate) mod set;
12
13use std::path::{Path, PathBuf};
14
15use alba_core::{Project, SourceMap};
16use tokio::sync::mpsc::UnboundedSender;
17use tokio_util::sync::CancellationToken;
18
19use crate::EngineError;
20use crate::affected::Selection;
21use crate::event::RunEvent;
22use crate::scheduler::{Executors, RunOptions};
23use crate::session::session;
24pub use notify::NotifyWatcher;
25
26/// One delivery from a file watcher.
27pub enum WatchBatch {
28 /// The paths the debouncer coalesced into this batch.
29 Paths(Vec<PathBuf>),
30 /// The watcher lost track (queue overflow): something changed, but
31 /// it cannot say what. Treated as a trigger with no named paths —
32 /// the cache absorbs the imprecision.
33 Rescan,
34}
35
36/// A stream of debounced change batches. The indirection exists for the
37/// engine's own tests, which script batches instead of touching a real
38/// file system.
39#[async_trait::async_trait]
40pub trait Watcher: Send {
41 /// The next batch; `None` when the watcher died for good.
42 ///
43 /// Must be cancel-safe: the session polls it in a `select!` against
44 /// the running build and drops the future when the run finishes
45 /// first, so a batch that was already taken from the underlying
46 /// stream would be lost with it.
47 async fn next_batch(&mut self) -> Option<WatchBatch>;
48}
49
50/// Why a session ended. Sessions have no failure exit — mid-session
51/// trouble is reported and survived — so this is the complete list.
52pub enum WatchExit {
53 /// The caller's token fired: the user is done.
54 Interrupted,
55 /// The watcher's stream ended. The session cannot honestly continue:
56 /// idling while watching nothing would look exactly like a healthy
57 /// quiet session.
58 WatcherClosed,
59}
60
61/// Mid-session trouble, handed to the caller's `on_error` for rendering.
62///
63/// Both variants carry the sources their spans index, because a session
64/// outlives the project it started on: by the time an error is reported,
65/// any number of reloads may have replaced the map the caller was holding
66/// when it started the session. Rendering against that stale map would
67/// draw the caret on text the error was never about.
68pub enum SessionError {
69 /// A Beamfile stopped loading. [`alba_core::LoadError`] already pairs
70 /// the failure with every file read before it happened.
71 Load(alba_core::LoadError),
72 /// A run could not be carried out: an unknown target, or a beam the
73 /// scheduler refuses. Reported rather than fatal — the fix is one
74 /// Beamfile save away.
75 Run {
76 error: EngineError,
77 /// The session's sources as of this failure. An
78 /// [`EngineError::Core`] carries a span and a
79 /// [`alba_core::SourceId`] that only this map resolves — the
80 /// scheduler stamps each one with the id of the file the offending
81 /// beam was declared in, precisely so the caret lands there.
82 sources: SourceMap,
83 },
84}
85
86/// Runs `selection` and keeps re-running it as long as `watcher` reports
87/// relevant changes, until `cancel` fires or the watcher dies.
88///
89/// A [`crate::session`] nobody drives: watching is on for its whole life,
90/// and no command channel can retarget or stop it. Everything this
91/// function's behaviour amounts to is documented there.
92#[allow(clippy::too_many_arguments)]
93pub async fn watch(
94 beamfile: &Path,
95 project: Project,
96 sources: SourceMap,
97 selection: Selection,
98 options: RunOptions,
99 executors: Executors,
100 events: UnboundedSender<RunEvent>,
101 cancel: CancellationToken,
102 watcher: Box<dyn Watcher>,
103 render_error: &mut (dyn FnMut(&SessionError) -> String + Send),
104) -> WatchExit {
105 session(
106 beamfile,
107 project,
108 sources,
109 selection,
110 options,
111 executors,
112 events,
113 None,
114 true,
115 cancel,
116 watcher,
117 render_error,
118 )
119 .await
120}
121
122/// The absolute directory holding `beamfile`: the project root for the
123/// root Beamfile, and its own directory for an imported one.
124///
125/// An empty parent means the current directory, and must be spelled that
126/// way rather than passed on: a bare `Beamfile` (what `alba run` resolves
127/// to without `--file`) has one, `notify` rejects an empty path outright,
128/// and `strip_prefix("")` succeeds on any path at all — so an empty root
129/// would leave every displayed path absolute with nothing to signal it.
130/// The one rule, applied both to the roots put under watch and to the root
131/// that paths are displayed against, so the two cannot drift apart.
132///
133/// Lexical: `std::path::absolute` never requires the directory to exist,
134/// and never resolves symlinks — settling those is [`relative_to`]'s job,
135/// on the paths a watcher actually reports.
136pub fn beamfile_dir(beamfile: &Path) -> PathBuf {
137 let dir = beamfile
138 .parent()
139 .filter(|parent| !parent.as_os_str().is_empty())
140 .unwrap_or_else(|| Path::new("."));
141 std::path::absolute(dir).unwrap_or_else(|_| dir.to_path_buf())
142}
143
144/// Root-relative display strings with `/` separators, deduplicated,
145/// sorted for stable output. A path outside the root (an import's input
146/// in a sibling directory) displays as-is.
147pub(crate) fn display_paths(root: &Path, paths: &[PathBuf]) -> Vec<String> {
148 let canonical_root = root.canonicalize();
149 let mut display: Vec<String> = paths
150 .iter()
151 .map(|path| {
152 relative_to(root, canonical_root.as_deref().ok(), path)
153 .to_string_lossy()
154 .replace('\\', "/")
155 })
156 .collect();
157 display.sort();
158 display.dedup();
159 display
160}
161
162/// `path` seen from the project root, or `path` itself when it lies
163/// outside it.
164///
165/// The paths are compared as they came before anything is resolved,
166/// because `canonicalize` is `realpath(3)` and fails outright on a file
167/// that no longer exists — and a deletion (an `rm`, a `git checkout`, the
168/// first half of a rename) is an ordinary watch event, whose path
169/// `WatchSet` still classifies as an input from its snapshot. Resolving
170/// only settles a disagreement about symlinks between what the watcher
171/// reports and how the root was spelled: the root's canonical form works
172/// on a deleted file too, its own does not and is the last attempt.
173fn relative_to(root: &Path, canonical_root: Option<&Path>, path: &Path) -> PathBuf {
174 if let Ok(relative) = path.strip_prefix(root) {
175 return relative.to_path_buf();
176 }
177 if let Some(base) = canonical_root
178 && let Ok(relative) = path.strip_prefix(base)
179 {
180 return relative.to_path_buf();
181 }
182 if let Some(base) = canonical_root
183 && let Ok(canonical) = path.canonicalize()
184 && let Ok(relative) = canonical.strip_prefix(base)
185 {
186 return relative.to_path_buf();
187 }
188 path.to_path_buf()
189}
190
191#[cfg(test)]
192mod tests {
193 use super::*;
194
195 /// The nominal invocation: `alba run --watch` without `--file` resolves
196 /// to a bare `Beamfile`, and the paths a watcher reports are absolute.
197 /// Rooting the session anywhere else — the empty path, most of all —
198 /// leaves those paths absolute in the status line and in the
199 /// `watch_triggered` event, which both promise root-relative ones.
200 #[test]
201 fn a_bare_beamfile_roots_the_session_at_the_current_directory() {
202 let reported = std::env::current_dir().unwrap().join("src/input.txt");
203
204 let root = beamfile_dir(Path::new("Beamfile"));
205
206 assert_eq!(display_paths(&root, &[reported]), vec!["src/input.txt"]);
207 }
208
209 /// A Beamfile named through a directory roots the session there, not in
210 /// the directory Alba happens to have been started from.
211 #[test]
212 fn a_named_beamfile_roots_the_session_at_its_own_directory() {
213 let dir = tempfile::tempdir().unwrap();
214 let reported = dir.path().join("src/input.txt");
215
216 let root = beamfile_dir(&dir.path().join("Beamfile"));
217
218 assert_eq!(display_paths(&root, &[reported]), vec!["src/input.txt"]);
219 }
220
221 /// The rule the root computation exists to satisfy: an empty root
222 /// shortens nothing, because `strip_prefix("")` succeeds and hands back
223 /// the whole path. Every branch below it is unreachable once the first
224 /// one matches, so a session rooted at the empty path displays absolute
225 /// paths with no error anywhere to say so.
226 #[test]
227 fn an_empty_root_shortens_nothing() {
228 let path = Path::new("/project/src/input.txt");
229
230 assert_eq!(relative_to(Path::new(""), None, path), path);
231 }
232
233 /// A relative root works lexically against a path spelled the same way
234 /// — no filesystem access, nothing to resolve.
235 #[test]
236 fn a_relative_root_shortens_a_path_spelled_from_it() {
237 assert_eq!(
238 relative_to(
239 Path::new("project"),
240 None,
241 Path::new("project/src/input.txt")
242 ),
243 Path::new("src/input.txt")
244 );
245 }
246
247 /// An input reached through an import in a sibling tree is not under
248 /// the root, and is shown as it came rather than as a `../..` chain.
249 #[test]
250 fn a_path_outside_the_root_is_left_as_it_came() {
251 let root = tempfile::tempdir().unwrap();
252 let elsewhere = tempfile::tempdir().unwrap();
253 let path = elsewhere.path().join("shared/input.txt");
254
255 assert_eq!(
256 relative_to(
257 root.path(),
258 root.path().canonicalize().ok().as_deref(),
259 &path
260 ),
261 path
262 );
263 }
264
265 /// A deletion is an ordinary watch event, and `canonicalize` fails on a
266 /// path that no longer exists — so shortening must not depend on it.
267 #[test]
268 fn a_path_that_no_longer_exists_is_still_shortened() {
269 let root = tempfile::tempdir().unwrap();
270 let deleted = root.path().join("src/deleted.txt");
271 assert!(!deleted.exists(), "precondition");
272
273 assert_eq!(
274 relative_to(
275 root.path(),
276 root.path().canonicalize().ok().as_deref(),
277 &deleted
278 ),
279 Path::new("src/deleted.txt")
280 );
281 }
282
283 /// The root's canonical form settles a disagreement about symlinks
284 /// between how the root was spelled and what the watcher reports
285 /// (macOS reports `/private/var/...` for `/var/...`).
286 #[test]
287 fn a_root_spelled_through_a_symlink_still_shortens_what_the_watcher_reports() {
288 let root = tempfile::tempdir().unwrap();
289 let canonical = root.path().canonicalize().unwrap();
290 let reported = canonical.join("src/input.txt");
291
292 assert_eq!(
293 relative_to(root.path(), Some(&canonical), &reported),
294 Path::new("src/input.txt")
295 );
296 }
297}