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    /// 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}