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!(target.os.as_str(), "linux" | "macos" | "freebsd" | "netbsd" | "openbsd"),
182        "windows" => target.os == "windows",
183        "macos" => target.os == "macos",
184        "linux" => target.os == "linux",
185        s if s.starts_with("target_os") => {
186            // target_os = "linux" — extract the quoted name.
187            s.split('"').nth(1).is_some_and(|os| os == target.os)
188        }
189        s if s.starts_with("target_arch") => s.split('"').nth(1).is_some_and(|cpu| cpu == target.cpu),
190        // Conservative default: unknown predicates are treated as
191        // active. Engine has the full evaluator; this stub returns
192        // true so deps don't get silently dropped.
193        _ => true,
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use super::*;
200
201    fn linux_x86() -> Target {
202        Target {
203            os: "linux".into(),
204            cpu: "x86_64".into(),
205            libc: Some("gnu".into()),
206            engines: indexmap::IndexMap::new(),
207            python_env_markers: indexmap::IndexMap::new(),
208        }
209    }
210
211    fn macos_arm() -> Target {
212        Target {
213            os: "macos".into(),
214            cpu: "aarch64".into(),
215            libc: None,
216            engines: indexmap::IndexMap::new(),
217            python_env_markers: indexmap::IndexMap::new(),
218        }
219    }
220
221    #[test]
222    fn cargo_cfg_unix_matches_linux_and_macos() {
223        let p = TargetPredicate::cargo_cfg("cfg(unix)");
224        assert!(p.matches(&linux_x86()));
225        assert!(p.matches(&macos_arm()));
226    }
227
228    #[test]
229    fn cargo_cfg_target_os_matches_correct_os() {
230        let p = TargetPredicate::cargo_cfg("cfg(target_os = \"linux\")");
231        assert!(p.matches(&linux_x86()));
232        assert!(!p.matches(&macos_arm()));
233    }
234
235    #[test]
236    fn os_list_matches_when_target_in_list() {
237        let p = TargetPredicate::os_list(["linux", "macos"]);
238        assert!(p.matches(&linux_x86()));
239        assert!(p.matches(&macos_arm()));
240    }
241
242    #[test]
243    fn cpu_list_matches_when_target_in_list() {
244        let p = TargetPredicate::cpu_list(["aarch64"]);
245        assert!(p.matches(&macos_arm()));
246        assert!(!p.matches(&linux_x86()));
247    }
248
249    #[test]
250    fn compound_all_requires_every_sub_predicate() {
251        let p = CompoundTargetPredicate {
252            combinator: PredicateCombinator::All,
253            atoms: vec![
254                TargetPredicate::os_list(["linux"]),
255                TargetPredicate::cpu_list(["x86_64"]),
256            ],
257        };
258        assert!(p.matches(&linux_x86()));
259        assert!(!p.matches(&macos_arm()));
260    }
261
262    #[test]
263    fn compound_any_requires_at_least_one_sub_predicate() {
264        let p = CompoundTargetPredicate {
265            combinator: PredicateCombinator::Any,
266            atoms: vec![
267                TargetPredicate::os_list(["linux"]),
268                TargetPredicate::os_list(["macos"]),
269            ],
270        };
271        assert!(p.matches(&linux_x86()));
272        assert!(p.matches(&macos_arm()));
273    }
274
275    #[test]
276    fn compound_none_inverts_all() {
277        let p = CompoundTargetPredicate {
278            combinator: PredicateCombinator::None,
279            atoms: vec![TargetPredicate::os_list(["windows"])],
280        };
281        assert!(p.matches(&linux_x86()));
282    }
283
284    #[test]
285    fn engine_min_compares_string_versions() {
286        let mut t = linux_x86();
287        t.engines.insert("node".into(), "20".into());
288        let p = TargetPredicate::EngineMin {
289            engine: "node".into(),
290            min: "18".into(),
291        };
292        assert!(p.matches(&t));
293        let p_too_high = TargetPredicate::EngineMin {
294            engine: "node".into(),
295            min: "21".into(),
296        };
297        assert!(!p_too_high.matches(&t));
298    }
299
300    #[test]
301    fn host_builder_returns_known_os() {
302        let h = Target::host();
303        assert!(matches!(h.os.as_str(), "linux" | "macos" | "windows" | "unknown"));
304    }
305}