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 // v1.3.0: pack added as additive sibling. workspace retained for ABI stability through v1.x. Both hold identical value.
125 /// Pack root sibling of [`Self::workspace`]. Additive in v1.3.0:
126 /// every constructor populates it with the same value as
127 /// [`Self::workspace`]. Plugin authors writing new code SHOULD prefer
128 /// `pack`; legacy readers of `workspace` keep working unchanged.
129 /// v2 will remove `workspace` once the rename has propagated.
130 pub pack: &'a Path,
131 /// Platform tag. Defaults to [`Platform::current`] but is overridable in
132 /// tests to exercise `when.os` branches deterministically.
133 pub platform: Platform,
134 /// Outer [`Registry`] handle for plugins that recurse into nested
135 /// actions (today: `when`). Populated by the concrete executors
136 /// (`FsExecutor`, `PlanExecutor`) right before plugin dispatch so
137 /// nested `execute` calls go through the caller's registry instead of
138 /// a freshly bootstrapped default. `None` outside an executor-driven
139 /// call (e.g. direct plugin invocation in tests) — plugins must treat
140 /// absence as "no nested dispatch available" and fall back to their
141 /// own bootstrap.
142 pub registry: Option<&'a Arc<Registry>>,
143 /// Outer [`PackTypeRegistry`] handle for pack-type plugins that recurse
144 /// across sibling pack types (today: `meta` dispatching into child
145 /// packs of arbitrary type). Populated by the pack-level driver before
146 /// invoking [`crate::plugin::PackTypePlugin`] methods. `None` outside a
147 /// driver-scoped call — see [`ExecCtx::registry`] for the same pattern
148 /// at the action level.
149 ///
150 /// Stage B (M5-1B) only exposes the slot; the dispatch swap that
151 /// actually threads a pack-type registry through the executor chain
152 /// lands in Stage C.
153 pub pack_type_registry: Option<&'a Arc<PackTypeRegistry>>,
154 /// Shared cycle-detection set owned by the outer sync driver.
155 ///
156 /// M5-2c: [`crate::plugin::pack_type::MetaPlugin`] mutates this set
157 /// under a lock at every recursion boundary. Absent (`None`) means
158 /// no outer driver is tracking recursion — `MetaPlugin` treats that
159 /// as "caller promises a single-level dispatch" and skips the check.
160 /// The sync driver attaches a fresh empty set at the top of every
161 /// install / update / sync run so the first plugin call observes
162 /// an empty history. Teardown runs do NOT attach a set:
163 /// [`crate::sync::teardown`] drives every pack through the
164 /// walker's reverse post-order, so each
165 /// [`crate::plugin::PackTypePlugin::teardown`] invocation
166 /// corresponds to a single pack and has no in-process recursion
167 /// to guard. The cycle-detection set stays defense-in-depth for
168 /// direct plugin callers (e.g. the `meta_recursion` integration
169 /// tests) that recurse through `MetaPlugin::recurse_children`.
170 pub visited_meta: Option<&'a MetaVisitedSet>,
171 /// Bounded parallel [`Scheduler`] handle — feat-m6-1.
172 ///
173 /// Populated by [`crate::sync::run`] at the top of every sync run so
174 /// plugins that fan out can bound in-flight children via the same
175 /// permit pool used by the outer walker. `None` outside a sync-driven
176 /// call (e.g. direct plugin invocation in tests) — plugins that need
177 /// to respect the cap must treat absence as "no bound configured"
178 /// and fall back to unbounded/serial per their own policy.
179 ///
180 /// feat-m6-1 lands the slot and CLI flag; plugin acquisition sites
181 /// land in feat-m6-2 alongside per-pack `.grex-lock` coordination.
182 pub scheduler: Option<&'a Arc<Scheduler>>,
183}
184
185impl<'a> ExecCtx<'a> {
186 /// Build a context with `platform` defaulted to the current target and
187 /// no outer registry attached. Executors attach the registry via
188 /// [`ExecCtx::with_registry`] before invoking plugin dispatch.
189 #[must_use]
190 pub fn new(vars: &'a VarEnv, pack_root: &'a Path, workspace: &'a Path) -> Self {
191 Self {
192 vars,
193 pack_root,
194 workspace,
195 // v1.3.0: pack added as additive sibling. workspace retained for ABI stability through v1.x. Both hold identical value.
196 pack: workspace,
197 platform: Platform::current(),
198 registry: None,
199 pack_type_registry: None,
200 visited_meta: None,
201 scheduler: None,
202 }
203 }
204
205 /// Override the platform tag (useful for tests and dry-run overrides).
206 #[must_use]
207 pub fn with_platform(mut self, p: Platform) -> Self {
208 self.platform = p;
209 self
210 }
211
212 /// Attach the outer [`Registry`] so plugins that recurse (today:
213 /// `when`) dispatch nested actions through the caller's registry
214 /// instead of a fresh [`Registry::bootstrap`]. Used by
215 /// [`crate::execute::FsExecutor`] and [`crate::execute::PlanExecutor`]
216 /// just before they hand control to a plugin.
217 #[must_use]
218 pub fn with_registry(mut self, reg: &'a Arc<Registry>) -> Self {
219 self.registry = Some(reg);
220 self
221 }
222
223 /// Attach the outer [`PackTypeRegistry`] so pack-type plugins that
224 /// recurse across child packs (today: `meta`) dispatch through the
225 /// caller's registry rather than a fresh
226 /// [`PackTypeRegistry::bootstrap`]. The dispatch swap that exercises
227 /// this slot ships in M5-1 Stage C; Stage B only lands the slot and
228 /// the builder method.
229 #[must_use]
230 pub fn with_pack_type_registry(mut self, reg: &'a Arc<PackTypeRegistry>) -> Self {
231 self.pack_type_registry = Some(reg);
232 self
233 }
234
235 /// Attach the shared cycle-detection set used by
236 /// [`crate::plugin::pack_type::MetaPlugin`] recursion. The sync
237 /// driver builds one empty set per `run()` invocation and threads
238 /// it through every `ExecCtx` it constructs so nested `install` /
239 /// `sync` / `update` / `teardown` calls observe the same history.
240 #[must_use]
241 pub fn with_visited_meta(mut self, visited: &'a MetaVisitedSet) -> Self {
242 self.visited_meta = Some(visited);
243 self
244 }
245
246 /// Attach a bounded parallel [`Scheduler`] handle. The sync driver
247 /// builds one [`Scheduler`] per `run()` invocation (permits ==
248 /// `--parallel N`) and threads the same `Arc` through every
249 /// `ExecCtx` so sibling plugin dispatch shares the permit pool.
250 ///
251 /// feat-m6-1 only plumbs the slot; acquisition sites land in
252 /// feat-m6-2. Callers may still attach a scheduler today — it is
253 /// observably inert until the per-pack lock wiring lands.
254 #[must_use]
255 pub fn with_scheduler(mut self, scheduler: &'a Arc<Scheduler>) -> Self {
256 self.scheduler = Some(scheduler);
257 self
258 }
259}