grex_core/execute/ctx.rs
1//! Read-only execution context threaded through every
2//! [`crate::execute::ActionExecutor`] call.
3//!
4//! Kept deliberately small: a variable environment, two filesystem anchors,
5//! and a platform tag. No interior mutability, no trait objects, no async
6//! machinery. Planner and (eventually) wet-run executors share the same
7//! shape so tests can round-trip either path with the same fixture.
8
9use std::collections::HashSet;
10use std::path::{Path, PathBuf};
11use std::sync::{Arc, Mutex};
12
13use crate::plugin::{PackTypeRegistry, Registry};
14use crate::scheduler::Scheduler;
15use crate::vars::VarEnv;
16
17/// Shared cycle-detection set threaded through
18/// [`crate::plugin::pack_type::MetaPlugin`] recursion.
19///
20/// Elements are canonicalised pack directories: every time `MetaPlugin`
21/// dispatches into a child, it canonicalises the child pack root and
22/// inserts it here. A re-entry check before insertion turns registry-level
23/// cycles into [`crate::execute::ExecError::MetaCycle`] rather than stack
24/// overflow.
25///
26/// `Arc<Mutex<HashSet<PathBuf>>>` (rather than `RefCell`) because `ExecCtx`
27/// is threaded into `async` plugin methods and the M5-2c multi-thread
28/// tokio runtime can dispatch siblings concurrently — the mutex window
29/// is cheap (two hashset lookups) and uncontended in the common
30/// sequential install path.
31pub type MetaVisitedSet = Arc<Mutex<HashSet<PathBuf>>>;
32
33/// OS discriminator used by the planner and `when`/`os` predicate paths.
34///
35/// Kept as a plain C-style enum so `matches!` patterns in the planner stay
36/// exhaustive-checked. The [`Platform::Other`] escape hatch carries a
37/// `&'static str` rather than `String` — unsupported platforms are rare and
38/// don't warrant per-instance allocation.
39///
40/// Marked `#[non_exhaustive]` so dedicated tags for BSD variants, WASM, or
41/// other platforms can land without breaking external match sites.
42#[non_exhaustive]
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Platform {
45 /// Linux (any distro).
46 Linux,
47 /// Apple macOS.
48 MacOs,
49 /// Microsoft Windows.
50 Windows,
51 /// Anything else (BSDs, WASM, etc.). Carries the `cfg!(target_os)` tag.
52 Other(&'static str),
53}
54
55impl Platform {
56 /// Detect the current platform from `cfg!(target_os)`.
57 #[must_use]
58 pub fn current() -> Self {
59 #[cfg(target_os = "linux")]
60 {
61 Self::Linux
62 }
63 #[cfg(target_os = "macos")]
64 {
65 Self::MacOs
66 }
67 #[cfg(target_os = "windows")]
68 {
69 Self::Windows
70 }
71 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
72 {
73 Self::Other(std::env::consts::OS)
74 }
75 }
76
77 /// Return `true` when `token` (a lowercase authored OS tag) matches `self`.
78 ///
79 /// Accepted tokens: `"windows"`, `"linux"`, `"macos"`, and the umbrella
80 /// `"unix"` which covers Linux + macOS. Unknown tokens are conservatively
81 /// rejected (false). The comparison is case-sensitive because action
82 /// manifests are case-normalised upstream.
83 #[must_use]
84 pub fn matches_os_token(&self, token: &str) -> bool {
85 matches!(
86 (self, token),
87 (Self::Windows, "windows")
88 | (Self::Linux, "linux")
89 | (Self::MacOs, "macos")
90 | (Self::Linux | Self::MacOs, "unix")
91 )
92 }
93}
94
95/// Read-only context passed to every [`crate::execute::ActionExecutor::execute`] call.
96///
97/// Lifetimes are carried through rather than cloning so the planner can run
98/// over a borrowed `VarEnv` without incurring a copy per action. The ctx is
99/// `Copy`-cheap in the sense that all fields are `&`-references — callers
100/// typically pass `&ctx` rather than cloning.
101///
102/// ## Why references, not owned data
103///
104/// Executors are stateless by contract; any "state" lives in the caller's
105/// driver. Owning data inside [`ExecCtx`] would either force clones per
106/// action or require interior mutability — both violate the framework goal
107/// of "future-proof, maximally decoupled".
108///
109/// Marked `#[non_exhaustive]` so future slots (plugin registry handle,
110/// scheduler token, teardown hook …) can land without breaking library
111/// consumers who destructure the struct.
112#[non_exhaustive]
113#[derive(Debug)]
114pub struct ExecCtx<'a> {
115 /// Variable lookup table used by every `expand_*` call.
116 pub vars: &'a VarEnv,
117 /// Pack workdir (the pack's on-disk root). Relative `src` paths in
118 /// symlink/exec actions resolve against this directory.
119 pub pack_root: &'a Path,
120 /// Workspace root (the user's configured grex workspace). Relative
121 /// destination paths (though rare — spec encourages absolute) resolve
122 /// here.
123 pub workspace: &'a Path,
124 /// Platform tag. Defaults to [`Platform::current`] but is overridable in
125 /// tests to exercise `when.os` branches deterministically.
126 pub platform: Platform,
127 /// Outer [`Registry`] handle for plugins that recurse into nested
128 /// actions (today: `when`). Populated by the concrete executors
129 /// (`FsExecutor`, `PlanExecutor`) right before plugin dispatch so
130 /// nested `execute` calls go through the caller's registry instead of
131 /// a freshly bootstrapped default. `None` outside an executor-driven
132 /// call (e.g. direct plugin invocation in tests) — plugins must treat
133 /// absence as "no nested dispatch available" and fall back to their
134 /// own bootstrap.
135 pub registry: Option<&'a Arc<Registry>>,
136 /// Outer [`PackTypeRegistry`] handle for pack-type plugins that recurse
137 /// across sibling pack types (today: `meta` dispatching into child
138 /// packs of arbitrary type). Populated by the pack-level driver before
139 /// invoking [`crate::plugin::PackTypePlugin`] methods. `None` outside a
140 /// driver-scoped call — see [`ExecCtx::registry`] for the same pattern
141 /// at the action level.
142 ///
143 /// Stage B (M5-1B) only exposes the slot; the dispatch swap that
144 /// actually threads a pack-type registry through the executor chain
145 /// lands in Stage C.
146 pub pack_type_registry: Option<&'a Arc<PackTypeRegistry>>,
147 /// Shared cycle-detection set owned by the outer sync driver.
148 ///
149 /// M5-2c: [`crate::plugin::pack_type::MetaPlugin`] mutates this set
150 /// under a lock at every recursion boundary. Absent (`None`) means
151 /// no outer driver is tracking recursion — `MetaPlugin` treats that
152 /// as "caller promises a single-level dispatch" and skips the check.
153 /// The sync driver attaches a fresh empty set at the top of every
154 /// install / update / sync run so the first plugin call observes
155 /// an empty history. Teardown runs do NOT attach a set:
156 /// [`crate::sync::teardown`] drives every pack through the
157 /// walker's reverse post-order, so each
158 /// [`crate::plugin::PackTypePlugin::teardown`] invocation
159 /// corresponds to a single pack and has no in-process recursion
160 /// to guard. The cycle-detection set stays defense-in-depth for
161 /// direct plugin callers (e.g. the `meta_recursion` integration
162 /// tests) that recurse through `MetaPlugin::recurse_children`.
163 pub visited_meta: Option<&'a MetaVisitedSet>,
164 /// Bounded parallel [`Scheduler`] handle — feat-m6-1.
165 ///
166 /// Populated by [`crate::sync::run`] at the top of every sync run so
167 /// plugins that fan out can bound in-flight children via the same
168 /// permit pool used by the outer walker. `None` outside a sync-driven
169 /// call (e.g. direct plugin invocation in tests) — plugins that need
170 /// to respect the cap must treat absence as "no bound configured"
171 /// and fall back to unbounded/serial per their own policy.
172 ///
173 /// feat-m6-1 lands the slot and CLI flag; plugin acquisition sites
174 /// land in feat-m6-2 alongside per-pack `.grex-lock` coordination.
175 pub scheduler: Option<&'a Arc<Scheduler>>,
176}
177
178impl<'a> ExecCtx<'a> {
179 /// Build a context with `platform` defaulted to the current target and
180 /// no outer registry attached. Executors attach the registry via
181 /// [`ExecCtx::with_registry`] before invoking plugin dispatch.
182 #[must_use]
183 pub fn new(vars: &'a VarEnv, pack_root: &'a Path, workspace: &'a Path) -> Self {
184 Self {
185 vars,
186 pack_root,
187 workspace,
188 platform: Platform::current(),
189 registry: None,
190 pack_type_registry: None,
191 visited_meta: None,
192 scheduler: None,
193 }
194 }
195
196 /// Override the platform tag (useful for tests and dry-run overrides).
197 #[must_use]
198 pub fn with_platform(mut self, p: Platform) -> Self {
199 self.platform = p;
200 self
201 }
202
203 /// Attach the outer [`Registry`] so plugins that recurse (today:
204 /// `when`) dispatch nested actions through the caller's registry
205 /// instead of a fresh [`Registry::bootstrap`]. Used by
206 /// [`crate::execute::FsExecutor`] and [`crate::execute::PlanExecutor`]
207 /// just before they hand control to a plugin.
208 #[must_use]
209 pub fn with_registry(mut self, reg: &'a Arc<Registry>) -> Self {
210 self.registry = Some(reg);
211 self
212 }
213
214 /// Attach the outer [`PackTypeRegistry`] so pack-type plugins that
215 /// recurse across child packs (today: `meta`) dispatch through the
216 /// caller's registry rather than a fresh
217 /// [`PackTypeRegistry::bootstrap`]. The dispatch swap that exercises
218 /// this slot ships in M5-1 Stage C; Stage B only lands the slot and
219 /// the builder method.
220 #[must_use]
221 pub fn with_pack_type_registry(mut self, reg: &'a Arc<PackTypeRegistry>) -> Self {
222 self.pack_type_registry = Some(reg);
223 self
224 }
225
226 /// Attach the shared cycle-detection set used by
227 /// [`crate::plugin::pack_type::MetaPlugin`] recursion. The sync
228 /// driver builds one empty set per `run()` invocation and threads
229 /// it through every `ExecCtx` it constructs so nested `install` /
230 /// `sync` / `update` / `teardown` calls observe the same history.
231 #[must_use]
232 pub fn with_visited_meta(mut self, visited: &'a MetaVisitedSet) -> Self {
233 self.visited_meta = Some(visited);
234 self
235 }
236
237 /// Attach a bounded parallel [`Scheduler`] handle. The sync driver
238 /// builds one [`Scheduler`] per `run()` invocation (permits ==
239 /// `--parallel N`) and threads the same `Arc` through every
240 /// `ExecCtx` so sibling plugin dispatch shares the permit pool.
241 ///
242 /// feat-m6-1 only plumbs the slot; acquisition sites land in
243 /// feat-m6-2. Callers may still attach a scheduler today — it is
244 /// observably inert until the per-pack lock wiring lands.
245 #[must_use]
246 pub fn with_scheduler(mut self, scheduler: &'a Arc<Scheduler>) -> Self {
247 self.scheduler = Some(scheduler);
248 self
249 }
250}