Skip to main content

gen_types/
target.rs

1//! Typed [`TargetPredicate`] — conditional-dependency activation.
2//!
3//! Every package manager has its own syntax for "only install this
4//! dep on platform X" / "only on node 18+" / "only on Linux":
5//!
6//! - Cargo: `target.'cfg(unix)'.dependencies`
7//! - npm: `"engines": { "node": ">=18" }` + `"os": ["linux"]`
8//! - pip: `[platform_python_implementation == 'CPython']` markers
9//! - Bundler: `platforms :ruby, :mswin`
10//! - Composer: `"php": ">=8.0"`
11//!
12//! Adapters normalise their native predicate into the canonical
13//! typed shape here. The resolver in `gen-engine` consults this
14//! once + decides per-target whether the dep is active.
15
16use serde::{Deserialize, Serialize};
17
18/// Typed conditional-activation predicate. Adapters set this on
19/// [`crate::Dependency`] when the dep is conditional.
20///
21/// Non-recursive on purpose — recursive serde-derived enums explode
22/// the trait-solver depth. Compound predicates (And / Or / Not) live
23/// in [`CompoundTargetPredicate`] which holds a flat
24/// `Vec<TargetPredicate>` + a typed combinator.
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(tag = "kind", rename_all = "kebab-case")]
27pub enum TargetPredicate {
28    /// `cfg(unix)` / `cfg(target_os = "linux")` / etc. — Cargo-style
29    /// predicate. Stored as the raw cfg expression; the engine has
30    /// a typed cfg evaluator that consumes this against a target spec.
31    CargoCfg { expr: String },
32    /// `os` array (npm-style): only active on these OS names.
33    OsList { items: Vec<String> },
34    /// `cpu` array (npm-style): only active on these CPU arches.
35    CpuList { items: Vec<String> },
36    /// Min engine version (npm `engines.<runtime>` / Composer
37    /// `php` / etc.).
38    EngineMin { engine: String, min: String },
39    /// PEP-508 environment marker (Python pip / poetry).
40    PythonMarker { marker: String },
41    /// Bundler `platforms :foo, :bar` — list of platform symbols.
42    BundlerPlatforms { items: Vec<String> },
43}
44
45impl TargetPredicate {
46    /// Convenience constructors that match the old positional shapes.
47    #[must_use]
48    pub fn cargo_cfg(expr: impl Into<String>) -> Self {
49        Self::CargoCfg { expr: expr.into() }
50    }
51    #[must_use]
52    pub fn os_list(items: impl IntoIterator<Item = impl Into<String>>) -> Self {
53        Self::OsList {
54            items: items.into_iter().map(Into::into).collect(),
55        }
56    }
57    #[must_use]
58    pub fn cpu_list(items: impl IntoIterator<Item = impl Into<String>>) -> Self {
59        Self::CpuList {
60            items: items.into_iter().map(Into::into).collect(),
61        }
62    }
63    #[must_use]
64    pub fn bundler_platforms(items: impl IntoIterator<Item = impl Into<String>>) -> Self {
65        Self::BundlerPlatforms {
66            items: items.into_iter().map(Into::into).collect(),
67        }
68    }
69}
70
71/// Compound predicate combining atomic [`TargetPredicate`]s. Kept
72/// separate from `TargetPredicate` to avoid serde recursion-
73/// overflow on the derive macros — same trick as
74/// [`crate::CompoundConstraint`].
75#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
76pub struct CompoundTargetPredicate {
77    pub combinator: PredicateCombinator,
78    pub atoms: Vec<TargetPredicate>,
79}
80
81#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
82#[serde(rename_all = "kebab-case")]
83pub enum PredicateCombinator {
84    /// All atoms must match (`cfg(all(...))`).
85    All,
86    /// Any atom matches (`cfg(any(...))`).
87    Any,
88    /// Inverted — every atom must NOT match. Acts as `Not(All(...))`.
89    None,
90}
91
92impl CompoundTargetPredicate {
93    #[must_use]
94    pub fn matches(&self, target: &Target) -> bool {
95        match self.combinator {
96            PredicateCombinator::All => self.atoms.iter().all(|p| p.matches(target)),
97            PredicateCombinator::Any => self.atoms.iter().any(|p| p.matches(target)),
98            PredicateCombinator::None => !self.atoms.iter().any(|p| p.matches(target)),
99        }
100    }
101}
102
103/// A concrete target the engine evaluates predicates against.
104/// Typically derived from the host system the engine is running on,
105/// optionally overridden via shikumi config (e.g. `--target
106/// aarch64-linux` cross-build).
107#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
108pub struct Target {
109    pub os: String,           // "linux" | "macos" | "windows" | …
110    pub cpu: String,          // "x86_64" | "aarch64" | …
111    pub libc: Option<String>, // "gnu" | "musl" | None on non-linux
112    pub engines: indexmap::IndexMap<String, String>,
113    pub python_env_markers: indexmap::IndexMap<String, String>,
114}
115
116impl Target {
117    /// Canonical builder for the current host. Useful in tests; the
118    /// engine derives this from system probes at runtime.
119    #[must_use]
120    pub fn host() -> Self {
121        Self {
122            #[cfg(target_os = "linux")]
123            os: "linux".to_string(),
124            #[cfg(target_os = "macos")]
125            os: "macos".to_string(),
126            #[cfg(target_os = "windows")]
127            os: "windows".to_string(),
128            #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
129            os: "unknown".to_string(),
130            #[cfg(target_arch = "x86_64")]
131            cpu: "x86_64".to_string(),
132            #[cfg(target_arch = "aarch64")]
133            cpu: "aarch64".to_string(),
134            #[cfg(not(any(target_arch = "x86_64", target_arch = "aarch64")))]
135            cpu: "unknown".to_string(),
136            libc: None,
137            engines: indexmap::IndexMap::new(),
138            python_env_markers: indexmap::IndexMap::new(),
139        }
140    }
141}
142
143impl TargetPredicate {
144    /// Evaluate this predicate against a concrete [`Target`]. Returns
145    /// `true` when the dependency should be active.
146    ///
147    /// Note: full Cargo `cfg()` evaluation is delegated to the engine
148    /// (which has a typed `CfgEvaluator`); this method falls back to
149    /// a conservative "match if cfg is `unix`/`windows`/`<os>`/etc."
150    /// shape for the common cases.
151    #[must_use]
152    pub fn matches(&self, target: &Target) -> bool {
153        match self {
154            Self::CargoCfg { expr } => eval_cfg_expr(expr, target),
155            Self::OsList { items } => items.iter().any(|o| o == &target.os),
156            Self::CpuList { items } => items.iter().any(|c| c == &target.cpu),
157            Self::EngineMin { engine, min } => target
158                .engines
159                .get(engine)
160                .map(|v| v.as_str() >= min.as_str())
161                .unwrap_or(false),
162            Self::PythonMarker { .. } => true, // TODO: real PEP-508 eval
163            Self::BundlerPlatforms { .. } => true, // TODO: Bundler eval
164        }
165    }
166}
167
168/// Conservative Cargo `cfg(…)` evaluator — handles the common cases
169/// (`cfg(unix)`, `cfg(target_os = "linux")`, `cfg(any(…))`,
170/// `cfg(all(…))`, `cfg(not(…))`). The engine ships a richer one;
171/// this is the fallback for `TargetPredicate::matches` in isolation.
172fn eval_cfg_expr(expr: &str, target: &Target) -> bool {
173    let expr = expr.trim();
174    // Strip outer cfg(...) if present.
175    let inner = expr
176        .strip_prefix("cfg(")
177        .and_then(|s| s.strip_suffix(')'))
178        .unwrap_or(expr)
179        .trim();
180    match inner {
181        "unix" => matches!(
182            target.os.as_str(),
183            "linux" | "macos" | "freebsd" | "netbsd" | "openbsd"
184        ),
185        "windows" => target.os == "windows",
186        "macos" => target.os == "macos",
187        "linux" => target.os == "linux",
188        s if s.starts_with("target_os") => {
189            // target_os = "linux" — extract the quoted name.
190            s.split('"').nth(1).is_some_and(|os| os == target.os)
191        }
192        s if s.starts_with("target_arch") => {
193            s.split('"').nth(1).is_some_and(|cpu| cpu == target.cpu)
194        }
195        // Conservative default: unknown predicates are treated as
196        // active. Engine has the full evaluator; this stub returns
197        // true so deps don't get silently dropped.
198        _ => true,
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    fn linux_x86() -> Target {
207        Target {
208            os: "linux".into(),
209            cpu: "x86_64".into(),
210            libc: Some("gnu".into()),
211            engines: indexmap::IndexMap::new(),
212            python_env_markers: indexmap::IndexMap::new(),
213        }
214    }
215
216    fn macos_arm() -> Target {
217        Target {
218            os: "macos".into(),
219            cpu: "aarch64".into(),
220            libc: None,
221            engines: indexmap::IndexMap::new(),
222            python_env_markers: indexmap::IndexMap::new(),
223        }
224    }
225
226    #[test]
227    fn cargo_cfg_unix_matches_linux_and_macos() {
228        let p = TargetPredicate::cargo_cfg("cfg(unix)");
229        assert!(p.matches(&linux_x86()));
230        assert!(p.matches(&macos_arm()));
231    }
232
233    #[test]
234    fn cargo_cfg_target_os_matches_correct_os() {
235        let p = TargetPredicate::cargo_cfg("cfg(target_os = \"linux\")");
236        assert!(p.matches(&linux_x86()));
237        assert!(!p.matches(&macos_arm()));
238    }
239
240    #[test]
241    fn os_list_matches_when_target_in_list() {
242        let p = TargetPredicate::os_list(["linux", "macos"]);
243        assert!(p.matches(&linux_x86()));
244        assert!(p.matches(&macos_arm()));
245    }
246
247    #[test]
248    fn cpu_list_matches_when_target_in_list() {
249        let p = TargetPredicate::cpu_list(["aarch64"]);
250        assert!(p.matches(&macos_arm()));
251        assert!(!p.matches(&linux_x86()));
252    }
253
254    #[test]
255    fn compound_all_requires_every_sub_predicate() {
256        let p = CompoundTargetPredicate {
257            combinator: PredicateCombinator::All,
258            atoms: vec![
259                TargetPredicate::os_list(["linux"]),
260                TargetPredicate::cpu_list(["x86_64"]),
261            ],
262        };
263        assert!(p.matches(&linux_x86()));
264        assert!(!p.matches(&macos_arm()));
265    }
266
267    #[test]
268    fn compound_any_requires_at_least_one_sub_predicate() {
269        let p = CompoundTargetPredicate {
270            combinator: PredicateCombinator::Any,
271            atoms: vec![
272                TargetPredicate::os_list(["linux"]),
273                TargetPredicate::os_list(["macos"]),
274            ],
275        };
276        assert!(p.matches(&linux_x86()));
277        assert!(p.matches(&macos_arm()));
278    }
279
280    #[test]
281    fn compound_none_inverts_all() {
282        let p = CompoundTargetPredicate {
283            combinator: PredicateCombinator::None,
284            atoms: vec![TargetPredicate::os_list(["windows"])],
285        };
286        assert!(p.matches(&linux_x86()));
287    }
288
289    #[test]
290    fn engine_min_compares_string_versions() {
291        let mut t = linux_x86();
292        t.engines.insert("node".into(), "20".into());
293        let p = TargetPredicate::EngineMin {
294            engine: "node".into(),
295            min: "18".into(),
296        };
297        assert!(p.matches(&t));
298        let p_too_high = TargetPredicate::EngineMin {
299            engine: "node".into(),
300            min: "21".into(),
301        };
302        assert!(!p_too_high.matches(&t));
303    }
304
305    #[test]
306    fn host_builder_returns_known_os() {
307        let h = Target::host();
308        assert!(matches!(
309            h.os.as_str(),
310            "linux" | "macos" | "windows" | "unknown"
311        ));
312    }
313}