Skip to main content

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}