Skip to main content

haz_cli/
context.rs

1//! Per-invocation context for `haz` subcommands.
2//!
3//! Every `commands/*/execute` function opens with the same
4//! sequence: load the discovered workspace + validated graph,
5//! locate the bearing project from cwd, and (for cache-touching
6//! commands) construct a cache handle keyed by the workspace's
7//! configured hash function (`CFG-027`).
8//!
9//! [`Context`] centralises that sequence. A single [`Context::open`]
10//! call replaces the per-command preamble; per-command code then
11//! reaches the workspace, graph, bearing project, filesystem, and
12//! cache handles through stable accessors. Adding a new subcommand
13//! no longer requires reading an existing command to crib the
14//! preamble shape.
15//!
16//! The cache hash algorithm flows from `workspace.settings.cache.hash`
17//! (`CFG-027`), not a per-command constant. A workspace whose
18//! `cache.hash` is `sha256` produces a [`Context`] whose
19//! [`Context::hash_algo`] returns `HashAlgo::Sha256`, and every
20//! command transitively consumes that value. The previous per-
21//! command `const HASH_ALGO: HashAlgo = HashAlgo::Blake3;` constants
22//! are gone.
23
24use std::path::{Path, PathBuf};
25
26use haz_cache::{CacheReader, CacheWriter};
27use haz_dag::graph::TaskGraph;
28use haz_discovery::bearing::bearing_project;
29use haz_domain::name::ProjectName;
30use haz_domain::settings::cache::HashAlgo;
31use haz_domain::workspace::Workspace;
32use haz_vfs::StdFilesystem;
33use miette::Result;
34
35use crate::workspace_load::{load_workspace, load_workspace_accumulating};
36
37/// Per-invocation handle binding the cwd, the discovered workspace,
38/// the validated task graph, and the host filesystem.
39///
40/// Constructed once per `haz` invocation by [`Context::open`] in
41/// [`crate::dispatch`] and moved into the matched subcommand by
42/// value; per-command `execute` functions take [`Context`] directly
43/// and no longer carry a `cwd` parameter (it lives on the Context).
44/// A future change to the loading pipeline (e.g. the `AUX-010`
45/// error-accumulating shape) lands in one place.
46#[derive(Debug, Clone)]
47pub struct Context {
48    cwd: PathBuf,
49    workspace: Workspace,
50    graph: TaskGraph,
51    fs: StdFilesystem,
52}
53
54impl Context {
55    /// Open the workspace rooted at or above `cwd` and build the
56    /// per-invocation [`Context`].
57    ///
58    /// This runs the full discovery + DAG-construction pipeline
59    /// (`DISC-001..DISC-009`, `DAG-001..DAG-018`) via
60    /// `load_workspace` and pairs the result with the cwd and a
61    /// fresh [`StdFilesystem`] handle.
62    ///
63    /// # Errors
64    ///
65    /// Returns a [`miette::Report`] for any discovery, parse, or
66    /// graph-construction failure. The binary maps the report to
67    /// [`crate::EXIT_INTERNAL_OR_CONFIG_ERROR`].
68    pub fn open(cwd: &Path) -> Result<Self> {
69        let (workspace, graph) = load_workspace(cwd)?;
70        Ok(Self {
71            cwd: cwd.to_path_buf(),
72            workspace,
73            graph,
74            fs: StdFilesystem::new(),
75        })
76    }
77
78    /// `haz check` variant of [`Context::open`] that surfaces every
79    /// per-file load diagnostic at once rather than promoting only
80    /// the first parse error, per `AUX-010` / `AUX-011`. See
81    /// `load_workspace_accumulating`.
82    ///
83    /// Used only by the `Cmd::Check` dispatch arm; the other
84    /// subcommands open through [`Context::open`], whose first-error
85    /// short-circuit is spec-compliant for them.
86    ///
87    /// # Errors
88    ///
89    /// As [`Context::open`].
90    pub fn open_accumulating(cwd: &Path) -> Result<Self> {
91        let (workspace, graph) = load_workspace_accumulating(cwd)?;
92        Ok(Self {
93            cwd: cwd.to_path_buf(),
94            workspace,
95            graph,
96            fs: StdFilesystem::new(),
97        })
98    }
99
100    /// The cwd the [`Context`] was opened against.
101    #[must_use]
102    pub fn cwd(&self) -> &Path {
103        &self.cwd
104    }
105
106    /// The effective workspace value object (post-overlay-merge
107    /// per `DAG-001..DAG-006`).
108    #[must_use]
109    pub fn workspace(&self) -> &Workspace {
110        &self.workspace
111    }
112
113    /// The validated task graph (`DAG-008`/`DAG-009`/`DAG-013` edges
114    /// over `DAG-007` nodes).
115    #[must_use]
116    pub fn graph(&self) -> &TaskGraph {
117        &self.graph
118    }
119
120    /// The host filesystem the binary speaks against.
121    ///
122    /// Exposed for callers that need a `&StdFilesystem` outside the
123    /// cache surface (notably the `haz_exec` `RunContext`'s `fs`
124    /// field, which borrows it through the cache writer in
125    /// [`commands::run`]).
126    ///
127    /// [`commands::run`]: crate::commands::run
128    #[must_use]
129    pub fn fs(&self) -> &StdFilesystem {
130        &self.fs
131    }
132
133    /// The bearing project for this invocation, per `EXEC-022`.
134    ///
135    /// Resolved against [`Context::cwd`].
136    #[must_use]
137    pub fn bearing(&self) -> Option<&ProjectName> {
138        bearing_project(&self.cwd, &self.workspace)
139    }
140
141    /// The hash algorithm in effect for this workspace, per
142    /// `CFG-027` (`cache.hash`).
143    ///
144    /// Defaults to `HashAlgo::Blake3` when the workspace omits
145    /// the setting (the `WorkspaceSettings::default` path used by
146    /// implicit-mode workspaces and by explicit-mode workspaces
147    /// whose `workspace.yml` omits the `cache:` mapping).
148    #[must_use]
149    pub fn hash_algo(&self) -> HashAlgo {
150        self.workspace.settings.cache.hash
151    }
152
153    /// Build a fresh [`CacheReader`] bound to this workspace's
154    /// cache root and hash algorithm.
155    ///
156    /// Read-only consumers (`haz cache info` per `AUX-018`, the
157    /// cache-introspection step of `haz why` per `AUX-014`) take
158    /// this handle and gain the compile-time read-only guarantee
159    /// of the cache reader / writer split.
160    #[must_use]
161    pub fn cache_reader(&self) -> CacheReader<StdFilesystem> {
162        CacheReader::new(self.fs, self.workspace.root.as_path(), self.hash_algo())
163    }
164
165    /// Build a fresh [`CacheWriter`] bound to this workspace's
166    /// cache root and hash algorithm.
167    ///
168    /// Write-side consumers (`haz run` for stores; `haz cache
169    /// clear` and `haz cache clean` for invalidation) take this
170    /// handle.
171    #[must_use]
172    pub fn cache_writer(&self) -> CacheWriter<StdFilesystem> {
173        CacheWriter::new(self.fs, self.workspace.root.as_path(), self.hash_algo())
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::Context;
180    use haz_domain::settings::cache::HashAlgo;
181    use std::fs;
182    use std::path::Path;
183
184    fn write_file(path: &Path, contents: &str) {
185        if let Some(parent) = path.parent() {
186            fs::create_dir_all(parent).unwrap();
187        }
188        fs::write(path, contents).unwrap();
189    }
190
191    fn explicit_two_project_workspace(root: &Path) {
192        write_file(
193            &root.join(".haz/workspace.yml"),
194            "projects:\n  - /lib_core\n  - /apps/web\n",
195        );
196        write_file(
197            &root.join("lib_core/haz.yml"),
198            "name: lib_core\ntasks:\n  build:\n    command: [\"true\"]\n",
199        );
200        write_file(
201            &root.join("apps/web/haz.yml"),
202            "name: web\ntasks:\n  bundle:\n    command: [\"true\"]\n    deps:\n      - lib_core:build\n",
203        );
204    }
205
206    #[test]
207    fn open_loads_workspace_and_graph() {
208        let tempdir = tempfile::TempDir::new().unwrap();
209        let root = tempdir.path();
210        explicit_two_project_workspace(root);
211
212        let ctx = Context::open(root).unwrap();
213        assert_eq!(ctx.workspace().projects.len(), 2);
214        assert_eq!(ctx.graph().nodes.len(), 2);
215        // lib_core:build -> web:bundle (hard edge).
216        assert_eq!(ctx.graph().edges.len(), 1);
217    }
218
219    #[test]
220    fn open_surfaces_workspace_load_errors() {
221        let tempdir = tempfile::TempDir::new().unwrap();
222        // No `.haz/` and no `.git/`: the discovery walk reaches the
223        // filesystem root without finding a marker (`DISC-002`).
224        assert!(Context::open(tempdir.path()).is_err());
225    }
226
227    #[test]
228    fn open_accumulating_surfaces_every_parse_error() {
229        // AUX-010 / AUX-011: the `haz check` constructor surfaces
230        // every broken project file at once, not just the first.
231        let tempdir = tempfile::TempDir::new().unwrap();
232        let root = tempdir.path();
233        write_file(
234            &root.join(".haz/workspace.yml"),
235            "projects:\n  - /alpha\n  - /beta\n",
236        );
237        write_file(
238            &root.join("alpha/haz.yml"),
239            "name: alpha\ntasks:\n  build:\n    command: \"AAA\"\n",
240        );
241        write_file(
242            &root.join("beta/haz.yml"),
243            "name: beta\ntasks:\n  build:\n    command: \"BBB\"\n",
244        );
245
246        let err = Context::open_accumulating(root).unwrap_err();
247        let rendered = format!("{err:?}");
248        assert!(
249            rendered.contains("AAA") && rendered.contains("BBB"),
250            "both broken files must surface through the check context: {rendered}",
251        );
252    }
253
254    #[test]
255    fn bearing_inside_a_project_picks_that_project() {
256        let tempdir = tempfile::TempDir::new().unwrap();
257        let root = tempdir.path();
258        explicit_two_project_workspace(root);
259        fs::create_dir_all(root.join("lib_core/src")).unwrap();
260
261        let ctx = Context::open(&root.join("lib_core/src")).unwrap();
262        let bearing = ctx.bearing().unwrap();
263        assert_eq!(bearing.to_string(), "lib_core");
264    }
265
266    #[test]
267    fn bearing_at_workspace_root_in_explicit_mode_is_none() {
268        let tempdir = tempfile::TempDir::new().unwrap();
269        let root = tempdir.path();
270        explicit_two_project_workspace(root);
271
272        let ctx = Context::open(root).unwrap();
273        assert!(ctx.bearing().is_none());
274    }
275
276    #[test]
277    fn cwd_accessor_returns_the_value_passed_to_open() {
278        let tempdir = tempfile::TempDir::new().unwrap();
279        let root = tempdir.path();
280        explicit_two_project_workspace(root);
281        fs::create_dir_all(root.join("lib_core/src")).unwrap();
282        let starting = root.join("lib_core/src");
283
284        let ctx = Context::open(&starting).unwrap();
285        assert_eq!(ctx.cwd(), starting.as_path());
286    }
287
288    #[test]
289    fn hash_algo_defaults_to_blake3() {
290        // The workspace's `cache:` mapping is absent, so
291        // `WorkspaceSettings::default` populates
292        // `cache.hash = Blake3` per `CFG-027`.
293        let tempdir = tempfile::TempDir::new().unwrap();
294        let root = tempdir.path();
295        explicit_two_project_workspace(root);
296
297        let ctx = Context::open(root).unwrap();
298        assert_eq!(ctx.hash_algo(), HashAlgo::Blake3);
299    }
300
301    #[test]
302    fn hash_algo_honours_workspace_cache_hash_setting() {
303        // CFG-027: `cache.hash: sha256` flows through to
304        // `Context::hash_algo`. This is the only place the CLI
305        // reads the setting, so a regression here would silently
306        // produce manifests under the wrong algorithm.
307        let tempdir = tempfile::TempDir::new().unwrap();
308        let root = tempdir.path();
309        write_file(
310            &root.join(".haz/workspace.yml"),
311            "projects:\n  - /lib\ncache:\n  hash: sha256\n",
312        );
313        write_file(
314            &root.join("lib/haz.yml"),
315            "name: lib\ntasks:\n  build:\n    command: [\"true\"]\n",
316        );
317
318        let ctx = Context::open(root).unwrap();
319        assert_eq!(ctx.hash_algo(), HashAlgo::Sha256);
320    }
321
322    #[test]
323    fn cache_reader_is_bound_to_workspace_root() {
324        let tempdir = tempfile::TempDir::new().unwrap();
325        let root = tempdir.path();
326        explicit_two_project_workspace(root);
327
328        let ctx = Context::open(root).unwrap();
329        let reader = ctx.cache_reader();
330        assert_eq!(reader.cache_root(), root.join(".haz/cache"));
331        assert_eq!(reader.hash_algo(), HashAlgo::Blake3);
332    }
333
334    #[test]
335    fn cache_writer_is_bound_to_workspace_root_and_hash() {
336        // CFG-027: the writer carries the configured hash so a
337        // store under sha256 lands in a manifest whose
338        // `hash_function` matches.
339        let tempdir = tempfile::TempDir::new().unwrap();
340        let root = tempdir.path();
341        write_file(
342            &root.join(".haz/workspace.yml"),
343            "projects:\n  - /lib\ncache:\n  hash: sha256\n",
344        );
345        write_file(
346            &root.join("lib/haz.yml"),
347            "name: lib\ntasks:\n  build:\n    command: [\"true\"]\n",
348        );
349
350        let ctx = Context::open(root).unwrap();
351        let writer = ctx.cache_writer();
352        assert_eq!(writer.cache_root(), root.join(".haz/cache"));
353        assert_eq!(writer.hash_algo(), HashAlgo::Sha256);
354    }
355}