Skip to main content

cabin_core/
condition.rs

1//! Typed model for `cfg(...)` target-condition expressions.
2//!
3//! Cabin manifests can declare *target-specific* dependency
4//! tables, e.g.
5//!
6//! ```toml
7//! [target.'cfg(os = "linux")'.dependencies]
8//! epoll = "^1"
9//! ```
10//!
11//! The condition string between the parentheses is parsed into a
12//! [`Condition`] AST and evaluated against a [`TargetPlatform`]
13//! describing the current evaluation context (the host build
14//! platform in this step). Parsing and evaluation are pure,
15//! deterministic, and side-effect-free.
16//!
17//! Supported keys are intentionally narrow — `os`, `arch`,
18//! `family`, `env`, `abi`, `target` — and listed by the
19//! [`ConditionKey`] enum. Any other key is rejected at parse
20//! time so manifests do not silently rely on a future
21//! detection layer.
22//!
23//! Public syntax is preserved as the canonical inner-expression
24//! string when round-tripped (see the `Display` impl on
25//! [`Condition`]); the manifest layer wraps it in `cfg(...)` and
26//! the metadata layer emits the bare inner form so JSON /
27//! on-disk shapes stay compact.
28
29use std::collections::BTreeSet;
30use std::fmt;
31use std::str::FromStr;
32
33use serde::{Deserialize, Deserializer, Serialize, Serializer};
34use thiserror::Error;
35
36/// Typed AST for a `cfg(...)` target condition.
37///
38/// The wire format matches the manifest text: a key/value
39/// (`key = "value"`) leaf, or one of the `all` / `any` / `not`
40/// combinators. Equality and ordering are structural, so
41/// identical expressions always compare equal regardless of
42/// whitespace or quote style in the original source.
43#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
44pub enum Condition {
45    /// `key = "value"`. The key is restricted to the
46    /// [`ConditionKey`] set; the value is a free-form ASCII
47    /// string interpreted by [`evaluate`](Self::evaluate).
48    KeyValue { key: ConditionKey, value: String },
49    /// `feature = "name"`. Evaluates against the *enabled-feature
50    /// set of the package the condition belongs to*, not the
51    /// platform. Feature conditions are only meaningful — and only
52    /// accepted — in flag tables (`[target.'cfg(...)'.profile]`);
53    /// the manifest layer rejects a feature-referencing `cfg` that
54    /// gates a dependency table, because feature resolution itself
55    /// runs over the dependency graph and a feature→dependency edge
56    /// would be circular.
57    Feature(String),
58    /// `all(<conditions>)`. Empty `all()` is rejected at parse
59    /// time.
60    All(Vec<Condition>),
61    /// `any(<conditions>)`. Empty `any()` is rejected at parse
62    /// time.
63    Any(Vec<Condition>),
64    /// `not(<single condition>)`.
65    Not(Box<Condition>),
66}
67
68impl Condition {
69    /// Parse a full `cfg(...)` expression. The wrapping
70    /// `cfg(...)` is required so the parser is symmetric with
71    /// the manifest text users write.
72    ///
73    /// # Errors
74    /// Returns [`ConditionParseError::ExpectedCfgPrefix`] when the input is not
75    /// wrapped in `cfg(`, [`ConditionParseError::UnbalancedParens`] when the
76    /// trailing `)` is missing, and propagates any [`ConditionParseError`] from
77    /// parsing the inner expression.
78    pub fn parse_cfg(input: &str) -> Result<Self, ConditionParseError> {
79        let trimmed = input.trim();
80        let inner = trimmed
81            .strip_prefix("cfg")
82            .ok_or_else(|| ConditionParseError::ExpectedCfgPrefix(trimmed.to_owned()))?
83            .trim_start();
84        let inner = inner
85            .strip_prefix('(')
86            .ok_or_else(|| ConditionParseError::ExpectedCfgPrefix(trimmed.to_owned()))?;
87        let inner = inner
88            .strip_suffix(')')
89            .ok_or_else(|| ConditionParseError::UnbalancedParens(trimmed.to_owned()))?;
90        Self::parse_inner(inner)
91    }
92
93    /// Parse the inner expression of a `cfg(...)` form (no
94    /// `cfg(` prefix or trailing `)`). Useful for the metadata
95    /// round-trip path, where we store the inner form.
96    ///
97    /// # Errors
98    /// Returns a [`ConditionParseError`] when the expression is malformed —
99    /// e.g. an unsupported key, a missing `=` or quoted value, an empty
100    /// `all()`/`any()`, a `not()` of wrong arity, unbalanced parentheses, or
101    /// trailing input after the expression.
102    pub fn parse_inner(input: &str) -> Result<Self, ConditionParseError> {
103        let mut parser = Parser::new(input);
104        let cond = parser.parse_condition()?;
105        parser.expect_eof()?;
106        Ok(cond)
107    }
108
109    /// Evaluate this condition against `platform` and the set of
110    /// `features` enabled on the owning package. The result is
111    /// fully determined by those inputs and the condition's AST —
112    /// no global state, no environment lookup, no I/O.
113    ///
114    /// Platform contexts that carry no feature information (every
115    /// dependency-gating call) pass an empty set; this is
116    /// correct-by-construction because a feature-referencing `cfg`
117    /// is rejected on dependency tables at manifest-load time, so a
118    /// `Feature` leaf can only be reached here through a flag table
119    /// that threaded the real enabled-feature set in.
120    pub fn evaluate(&self, platform: &TargetPlatform, features: &BTreeSet<String>) -> bool {
121        match self {
122            Condition::KeyValue { key, value } => key.lookup(platform) == value,
123            Condition::Feature(name) => features.contains(name),
124            Condition::All(items) => items.iter().all(|c| c.evaluate(platform, features)),
125            Condition::Any(items) => items.iter().any(|c| c.evaluate(platform, features)),
126            Condition::Not(inner) => !inner.evaluate(platform, features),
127        }
128    }
129
130    /// Whether this condition references any `feature = "..."`
131    /// leaf. Used by the manifest layer to reject feature
132    /// conditions on dependency tables (where they would be
133    /// circular) while allowing them on flag tables.
134    pub fn references_feature(&self) -> bool {
135        match self {
136            Condition::Feature(_) => true,
137            Condition::KeyValue { .. } => false,
138            Condition::All(items) | Condition::Any(items) => {
139                items.iter().any(Condition::references_feature)
140            }
141            Condition::Not(inner) => inner.references_feature(),
142        }
143    }
144}
145
146impl fmt::Display for Condition {
147    /// Canonical string form. Round-trips through
148    /// [`Condition::parse_inner`].
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        match self {
151            Condition::KeyValue { key, value } => write!(f, "{} = \"{}\"", key.as_str(), value),
152            Condition::Feature(name) => write!(f, "feature = \"{name}\""),
153            Condition::All(items) => {
154                f.write_str("all(")?;
155                write_list(f, items)?;
156                f.write_str(")")
157            }
158            Condition::Any(items) => {
159                f.write_str("any(")?;
160                write_list(f, items)?;
161                f.write_str(")")
162            }
163            Condition::Not(inner) => write!(f, "not({inner})"),
164        }
165    }
166}
167
168fn write_list(f: &mut fmt::Formatter<'_>, items: &[Condition]) -> fmt::Result {
169    for (i, c) in items.iter().enumerate() {
170        if i > 0 {
171            f.write_str(", ")?;
172        }
173        write!(f, "{c}")?;
174    }
175    Ok(())
176}
177
178/// Recognized target-condition keys. Anything else is rejected
179/// at parse time.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
181pub enum ConditionKey {
182    /// Operating system name (`linux`, `macos`, `windows`, …).
183    Os,
184    /// CPU architecture (`x86_64`, `aarch64`, …).
185    Arch,
186    /// OS family (`unix`, `windows`, …).
187    Family,
188    /// Toolchain environment (`gnu`, `musl`, `msvc`, …).
189    Env,
190    /// Application binary interface flavor (`eabi`, …).
191    Abi,
192    /// Full normalized target triple, when available.
193    Target,
194}
195
196impl ConditionKey {
197    pub const fn as_str(self) -> &'static str {
198        match self {
199            ConditionKey::Os => "os",
200            ConditionKey::Arch => "arch",
201            ConditionKey::Family => "family",
202            ConditionKey::Env => "env",
203            ConditionKey::Abi => "abi",
204            ConditionKey::Target => "target",
205        }
206    }
207
208    /// All recognized keys, in canonical declaration order.
209    pub const fn all() -> &'static [ConditionKey] {
210        &[
211            ConditionKey::Os,
212            ConditionKey::Arch,
213            ConditionKey::Family,
214            ConditionKey::Env,
215            ConditionKey::Abi,
216            ConditionKey::Target,
217        ]
218    }
219
220    fn lookup(self, platform: &TargetPlatform) -> &str {
221        match self {
222            ConditionKey::Os => platform.os.as_str(),
223            ConditionKey::Arch => platform.arch.as_str(),
224            ConditionKey::Family => platform.family.as_str(),
225            ConditionKey::Env => platform.env.as_str(),
226            ConditionKey::Abi => platform.abi.as_str(),
227            ConditionKey::Target => platform.target.as_str(),
228        }
229    }
230}
231
232impl FromStr for ConditionKey {
233    type Err = ();
234
235    fn from_str(s: &str) -> Result<Self, Self::Err> {
236        match s {
237            "os" => Ok(ConditionKey::Os),
238            "arch" => Ok(ConditionKey::Arch),
239            "family" => Ok(ConditionKey::Family),
240            "env" => Ok(ConditionKey::Env),
241            "abi" => Ok(ConditionKey::Abi),
242            "target" => Ok(ConditionKey::Target),
243            _ => Err(()),
244        }
245    }
246}
247
248/// Evaluation context for [`Condition::evaluate`]. Each field
249/// is a stable, normalized lowercase string. Unknown values
250/// flow through as the literal `unknown`, which is matchable in
251/// `cfg(...)` expressions.
252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
253pub struct TargetPlatform {
254    pub os: String,
255    pub arch: String,
256    pub family: String,
257    pub env: String,
258    pub abi: String,
259    pub target: String,
260}
261
262impl TargetPlatform {
263    /// Best-effort detection of the *host* platform — the
264    /// platform commands like `cabin build` execute on. Cabin
265    /// does not yet support cross-compilation; future steps may
266    /// add an explicit target-triple selection layer that wraps
267    /// this constructor.
268    pub fn current() -> Self {
269        let os = normalize_os(std::env::consts::OS);
270        let arch = normalize_arch(std::env::consts::ARCH);
271        let family = normalize_family(std::env::consts::FAMILY, &os);
272        let env = normalize_env(&os);
273        let abi = "unknown".to_owned();
274        let target = format!("{arch}-{family}-{os}");
275        Self {
276            os,
277            arch,
278            family,
279            env,
280            abi,
281            target,
282        }
283    }
284}
285
286fn normalize_os(raw: &str) -> String {
287    match raw {
288        "linux" | "macos" | "windows" | "freebsd" | "openbsd" | "netbsd" | "dragonfly"
289        | "android" | "ios" => raw.to_owned(),
290        // Map common aliases.
291        "darwin" => "macos".to_owned(),
292        "" => "unknown".to_owned(),
293        other => other.to_owned(),
294    }
295}
296
297fn normalize_arch(raw: &str) -> String {
298    match raw {
299        "x86_64" | "aarch64" | "arm" | "riscv64" | "wasm32" => raw.to_owned(),
300        "" => "unknown".to_owned(),
301        other => other.to_owned(),
302    }
303}
304
305fn normalize_family(raw: &str, os: &str) -> String {
306    match raw {
307        "unix" | "windows" | "wasm" => raw.to_owned(),
308        _ => match os {
309            "linux" | "macos" | "freebsd" | "openbsd" | "netbsd" | "dragonfly" | "android"
310            | "ios" => "unix".to_owned(),
311            "windows" => "windows".to_owned(),
312            _ => "unknown".to_owned(),
313        },
314    }
315}
316
317fn normalize_env(os: &str) -> String {
318    // The host environment cannot be detected from the Rust
319    // standard library alone. We map the obvious cases so users
320    // can write `cfg(env = "gnu")` etc., and fall back to
321    // `unknown` everywhere else so unsupported queries are
322    // explicit rather than silently false.
323    match os {
324        "linux" => "gnu".to_owned(),
325        "macos" | "ios" => "apple".to_owned(),
326        "windows" => "msvc".to_owned(),
327        _ => "unknown".to_owned(),
328    }
329}
330
331// ---------------------------------------------------------------
332// Parser.
333// ---------------------------------------------------------------
334
335/// Errors produced while parsing a `cfg(...)` expression.
336#[derive(Debug, Clone, PartialEq, Eq, Error)]
337pub enum ConditionParseError {
338    #[error("expected a `cfg(...)` expression but found {0:?}")]
339    ExpectedCfgPrefix(String),
340
341    #[error("`cfg(...)` expression has unbalanced parentheses: {0:?}")]
342    UnbalancedParens(String),
343
344    #[error(
345        "unsupported target cfg key {key:?}; supported keys are os, arch, family, env, abi, and target"
346    )]
347    UnsupportedKey { key: String },
348
349    #[error("expected `=` after key {key:?} in cfg expression")]
350    ExpectedEquals { key: String },
351
352    #[error("expected a quoted string value for key {key:?} in cfg expression; got {found:?}")]
353    ExpectedQuotedValue { key: String, found: String },
354
355    #[error("unterminated string literal in cfg expression: {0:?}")]
356    UnterminatedString(String),
357
358    #[error("trailing input after cfg expression: {0:?}")]
359    TrailingInput(String),
360
361    #[error("`all()` requires at least one condition")]
362    EmptyAll,
363
364    #[error("`any()` requires at least one condition")]
365    EmptyAny,
366
367    #[error("`not()` takes exactly one condition; found {0}")]
368    NotArity(usize),
369
370    #[error("expected `(` after {0}")]
371    ExpectedOpenParen(&'static str),
372
373    #[error("expected `)` to close {0}")]
374    ExpectedCloseParen(&'static str),
375
376    #[error("unexpected token in cfg expression: {0:?}")]
377    UnexpectedToken(String),
378
379    #[error("empty cfg expression")]
380    Empty,
381}
382
383struct Parser<'a> {
384    src: &'a str,
385    pos: usize,
386}
387
388impl<'a> Parser<'a> {
389    fn new(src: &'a str) -> Self {
390        Self { src, pos: 0 }
391    }
392
393    fn skip_whitespace(&mut self) {
394        while let Some(c) = self.peek_char() {
395            if c.is_whitespace() {
396                self.pos += c.len_utf8();
397            } else {
398                break;
399            }
400        }
401    }
402
403    fn peek_char(&self) -> Option<char> {
404        self.src[self.pos..].chars().next()
405    }
406
407    fn expect_eof(&mut self) -> Result<(), ConditionParseError> {
408        self.skip_whitespace();
409        if self.pos < self.src.len() {
410            Err(ConditionParseError::TrailingInput(
411                self.src[self.pos..].to_owned(),
412            ))
413        } else {
414            Ok(())
415        }
416    }
417
418    fn parse_condition(&mut self) -> Result<Condition, ConditionParseError> {
419        self.skip_whitespace();
420        if self.pos >= self.src.len() {
421            return Err(ConditionParseError::Empty);
422        }
423        // Read an identifier. It is either a combinator (`all`,
424        // `any`, `not`) or a key in the recognized set.
425        let ident = self.read_ident()?;
426        self.skip_whitespace();
427        match ident.as_str() {
428            "all" => {
429                self.expect_open_paren("all")?;
430                let items = self.parse_condition_list()?;
431                self.expect_close_paren("all")?;
432                if items.is_empty() {
433                    return Err(ConditionParseError::EmptyAll);
434                }
435                Ok(Condition::All(items))
436            }
437            "any" => {
438                self.expect_open_paren("any")?;
439                let items = self.parse_condition_list()?;
440                self.expect_close_paren("any")?;
441                if items.is_empty() {
442                    return Err(ConditionParseError::EmptyAny);
443                }
444                Ok(Condition::Any(items))
445            }
446            "not" => {
447                self.expect_open_paren("not")?;
448                let items = self.parse_condition_list()?;
449                self.expect_close_paren("not")?;
450                if items.len() != 1 {
451                    return Err(ConditionParseError::NotArity(items.len()));
452                }
453                let inner = items.into_iter().next().expect("len==1 above");
454                Ok(Condition::Not(Box::new(inner)))
455            }
456            other => {
457                // `feature` and the platform keys share the
458                // `ident = "value"` shape; parse the `= "value"`
459                // tail once, then dispatch on the identifier.
460                self.skip_whitespace();
461                if self.peek_char() != Some('=') {
462                    return Err(ConditionParseError::ExpectedEquals {
463                        key: other.to_owned(),
464                    });
465                }
466                self.pos += 1; // consume '='
467                self.skip_whitespace();
468                let value = self.read_quoted_string(other)?;
469                if other == "feature" {
470                    Ok(Condition::Feature(value))
471                } else {
472                    let key = ConditionKey::from_str(other).map_err(|()| {
473                        ConditionParseError::UnsupportedKey {
474                            key: other.to_owned(),
475                        }
476                    })?;
477                    Ok(Condition::KeyValue { key, value })
478                }
479            }
480        }
481    }
482
483    fn parse_condition_list(&mut self) -> Result<Vec<Condition>, ConditionParseError> {
484        let mut items = Vec::new();
485        self.skip_whitespace();
486        if self.peek_char() == Some(')') {
487            return Ok(items);
488        }
489        loop {
490            let cond = self.parse_condition()?;
491            items.push(cond);
492            self.skip_whitespace();
493            match self.peek_char() {
494                Some(',') => {
495                    self.pos += 1;
496                    self.skip_whitespace();
497                }
498                _ => break,
499            }
500        }
501        Ok(items)
502    }
503
504    fn expect_open_paren(&mut self, what: &'static str) -> Result<(), ConditionParseError> {
505        self.skip_whitespace();
506        if self.peek_char() == Some('(') {
507            self.pos += 1;
508            Ok(())
509        } else {
510            Err(ConditionParseError::ExpectedOpenParen(what))
511        }
512    }
513
514    fn expect_close_paren(&mut self, what: &'static str) -> Result<(), ConditionParseError> {
515        self.skip_whitespace();
516        if self.peek_char() == Some(')') {
517            self.pos += 1;
518            Ok(())
519        } else {
520            Err(ConditionParseError::ExpectedCloseParen(what))
521        }
522    }
523
524    fn read_ident(&mut self) -> Result<String, ConditionParseError> {
525        let start = self.pos;
526        while let Some(c) = self.peek_char() {
527            if c.is_ascii_alphanumeric() || c == '_' {
528                self.pos += c.len_utf8();
529            } else {
530                break;
531            }
532        }
533        if start == self.pos {
534            return Err(ConditionParseError::UnexpectedToken(
535                self.src[self.pos..].to_owned(),
536            ));
537        }
538        Ok(self.src[start..self.pos].to_owned())
539    }
540
541    fn read_quoted_string(&mut self, key: &str) -> Result<String, ConditionParseError> {
542        if self.peek_char() != Some('"') {
543            // Capture the offending token (rest of input up to a
544            // delimiter) so the error message can show what we
545            // saw.
546            let rest_start = self.pos;
547            while let Some(c) = self.peek_char() {
548                if c == ',' || c == ')' || c.is_whitespace() {
549                    break;
550                }
551                self.pos += c.len_utf8();
552            }
553            return Err(ConditionParseError::ExpectedQuotedValue {
554                key: key.to_owned(),
555                found: self.src[rest_start..self.pos].to_owned(),
556            });
557        }
558        self.pos += 1;
559        let start = self.pos;
560        while let Some(c) = self.peek_char() {
561            if c == '"' {
562                let value = self.src[start..self.pos].to_owned();
563                self.pos += 1;
564                return Ok(value);
565            }
566            self.pos += c.len_utf8();
567        }
568        Err(ConditionParseError::UnterminatedString(
569            self.src[start..].to_owned(),
570        ))
571    }
572}
573
574// ---------------------------------------------------------------
575// Serde — Condition serializes as its canonical inner-expression
576// string form so on-disk metadata stays compact and stable.
577// ---------------------------------------------------------------
578
579impl Serialize for Condition {
580    fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
581        s.serialize_str(&self.to_string())
582    }
583}
584
585impl<'de> Deserialize<'de> for Condition {
586    fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
587        let raw = String::deserialize(d)?;
588        Condition::parse_inner(&raw).map_err(serde::de::Error::custom)
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595
596    fn linux_x86_64() -> TargetPlatform {
597        TargetPlatform {
598            os: "linux".into(),
599            arch: "x86_64".into(),
600            family: "unix".into(),
601            env: "gnu".into(),
602            abi: "unknown".into(),
603            target: "x86_64-unix-linux".into(),
604        }
605    }
606
607    fn macos_aarch64() -> TargetPlatform {
608        TargetPlatform {
609            os: "macos".into(),
610            arch: "aarch64".into(),
611            family: "unix".into(),
612            env: "apple".into(),
613            abi: "unknown".into(),
614            target: "aarch64-unix-macos".into(),
615        }
616    }
617
618    #[test]
619    fn parses_simple_key_value() {
620        let cond = Condition::parse_cfg(r#"cfg(os = "linux")"#).unwrap();
621        assert_eq!(
622            cond,
623            Condition::KeyValue {
624                key: ConditionKey::Os,
625                value: "linux".into()
626            }
627        );
628    }
629
630    #[test]
631    fn parses_each_supported_key() {
632        for (raw, key) in [
633            (r#"cfg(os = "linux")"#, ConditionKey::Os),
634            (r#"cfg(arch = "x86_64")"#, ConditionKey::Arch),
635            (r#"cfg(family = "unix")"#, ConditionKey::Family),
636            (r#"cfg(env = "gnu")"#, ConditionKey::Env),
637            (r#"cfg(abi = "eabi")"#, ConditionKey::Abi),
638            (
639                r#"cfg(target = "x86_64-unknown-linux-gnu")"#,
640                ConditionKey::Target,
641            ),
642        ] {
643            let cond = Condition::parse_cfg(raw).unwrap();
644            match cond {
645                Condition::KeyValue { key: k, .. } => assert_eq!(k, key, "{raw}"),
646                other => panic!("{raw}: expected key/value, got {other:?}"),
647            }
648        }
649    }
650
651    #[test]
652    fn parses_all_any_not() {
653        let all = Condition::parse_cfg(r#"cfg(all(os = "linux", arch = "x86_64"))"#).unwrap();
654        let any = Condition::parse_cfg(r#"cfg(any(os = "macos", os = "linux"))"#).unwrap();
655        let not = Condition::parse_cfg(r#"cfg(not(os = "windows"))"#).unwrap();
656        assert!(matches!(all, Condition::All(ref v) if v.len() == 2));
657        assert!(matches!(any, Condition::Any(ref v) if v.len() == 2));
658        assert!(matches!(not, Condition::Not(_)));
659    }
660
661    #[test]
662    fn rejects_unquoted_value() {
663        let err = Condition::parse_cfg(r"cfg(os = linux)").unwrap_err();
664        match err {
665            ConditionParseError::ExpectedQuotedValue { key, .. } => assert_eq!(key, "os"),
666            other => panic!("unexpected: {other:?}"),
667        }
668    }
669
670    #[test]
671    fn rejects_unsupported_key() {
672        let err = Condition::parse_cfg(r#"cfg(compiler = "clang")"#).unwrap_err();
673        match err {
674            ConditionParseError::UnsupportedKey { key } => assert_eq!(key, "compiler"),
675            other => panic!("unexpected: {other:?}"),
676        }
677    }
678
679    #[test]
680    fn rejects_empty_all_and_any() {
681        assert!(matches!(
682            Condition::parse_cfg("cfg(all())").unwrap_err(),
683            ConditionParseError::EmptyAll
684        ));
685        assert!(matches!(
686            Condition::parse_cfg("cfg(any())").unwrap_err(),
687            ConditionParseError::EmptyAny
688        ));
689    }
690
691    #[test]
692    fn rejects_not_with_arity_other_than_one() {
693        let err = Condition::parse_cfg(r#"cfg(not(os = "linux", arch = "x86_64"))"#).unwrap_err();
694        assert!(matches!(err, ConditionParseError::NotArity(2)));
695    }
696
697    #[test]
698    fn rejects_missing_cfg_prefix() {
699        assert!(matches!(
700            Condition::parse_cfg(r#"os = "linux""#).unwrap_err(),
701            ConditionParseError::ExpectedCfgPrefix(_)
702        ));
703    }
704
705    #[test]
706    fn rejects_unbalanced_parens() {
707        assert!(matches!(
708            Condition::parse_cfg("cfg(os = \"linux\"").unwrap_err(),
709            ConditionParseError::UnbalancedParens(_)
710        ));
711    }
712
713    #[test]
714    fn evaluates_simple_key_value() {
715        let linux = linux_x86_64();
716        let macos = macos_aarch64();
717        let cond = Condition::parse_cfg(r#"cfg(os = "linux")"#).unwrap();
718        assert!(cond.evaluate(&linux, &BTreeSet::new()));
719        assert!(!cond.evaluate(&macos, &BTreeSet::new()));
720    }
721
722    #[test]
723    fn evaluates_all_any_not() {
724        let linux = linux_x86_64();
725        let macos = macos_aarch64();
726        let all = Condition::parse_cfg(r#"cfg(all(os = "linux", arch = "x86_64"))"#).unwrap();
727        let any = Condition::parse_cfg(r#"cfg(any(os = "macos", os = "linux"))"#).unwrap();
728        let not = Condition::parse_cfg(r#"cfg(not(os = "windows"))"#).unwrap();
729        assert!(all.evaluate(&linux, &BTreeSet::new()));
730        assert!(!all.evaluate(&macos, &BTreeSet::new()));
731        assert!(any.evaluate(&linux, &BTreeSet::new()));
732        assert!(any.evaluate(&macos, &BTreeSet::new()));
733        assert!(not.evaluate(&linux, &BTreeSet::new()));
734        assert!(not.evaluate(&macos, &BTreeSet::new()));
735    }
736
737    #[test]
738    fn parses_and_evaluates_feature_leaf() {
739        let linux = linux_x86_64();
740        let cond = Condition::parse_cfg(r#"cfg(feature = "simd")"#).unwrap();
741        assert_eq!(cond, Condition::Feature("simd".to_owned()));
742        assert!(cond.references_feature());
743        let enabled: BTreeSet<String> = BTreeSet::from(["simd".to_owned()]);
744        assert!(cond.evaluate(&linux, &enabled));
745        assert!(!cond.evaluate(&linux, &BTreeSet::new()));
746    }
747
748    #[test]
749    fn evaluates_feature_combined_with_platform() {
750        let linux = linux_x86_64();
751        let macos = macos_aarch64();
752        let cond = Condition::parse_cfg(r#"cfg(all(feature = "simd", arch = "x86_64"))"#).unwrap();
753        assert!(cond.references_feature());
754        let enabled: BTreeSet<String> = BTreeSet::from(["simd".to_owned()]);
755        // Both the feature and the platform must hold.
756        assert!(cond.evaluate(&linux, &enabled));
757        assert!(!cond.evaluate(&macos, &enabled)); // wrong arch
758        assert!(!cond.evaluate(&linux, &BTreeSet::new())); // feature off
759    }
760
761    #[test]
762    fn references_feature_is_false_for_platform_only_conditions() {
763        for raw in [
764            r#"cfg(os = "linux")"#,
765            r#"cfg(all(os = "linux", arch = "x86_64"))"#,
766            r#"cfg(not(os = "windows"))"#,
767        ] {
768            assert!(!Condition::parse_cfg(raw).unwrap().references_feature());
769        }
770    }
771
772    #[test]
773    fn display_round_trips_through_parse_inner() {
774        for raw in [
775            r#"os = "linux""#,
776            r#"feature = "simd""#,
777            r#"all(feature = "simd", arch = "x86_64")"#,
778            r#"all(os = "linux", arch = "x86_64")"#,
779            r#"any(os = "macos", os = "linux")"#,
780            r#"not(os = "windows")"#,
781            r#"all(any(os = "linux", os = "macos"), not(arch = "wasm32"))"#,
782        ] {
783            let cond = Condition::parse_inner(raw).unwrap();
784            let rendered = cond.to_string();
785            assert_eq!(rendered, raw, "round-trip should be byte-identical");
786            let again = Condition::parse_inner(&rendered).unwrap();
787            assert_eq!(cond, again);
788        }
789    }
790
791    #[test]
792    fn current_target_platform_is_internally_consistent() {
793        let p = TargetPlatform::current();
794        // Each field is non-empty and lowercase ASCII.
795        for v in [&p.os, &p.arch, &p.family, &p.env, &p.abi, &p.target] {
796            assert!(!v.is_empty());
797            assert!(v.chars().all(|c| !c.is_ascii_uppercase()));
798        }
799    }
800
801    #[test]
802    fn deterministic_serialization_for_metadata_round_trip() {
803        let cond = Condition::parse_cfg(
804            r#"cfg(all(os = "linux", any(arch = "x86_64", arch = "aarch64")))"#,
805        )
806        .unwrap();
807        let json = serde_json::to_string(&cond).unwrap();
808        let parsed: Condition = serde_json::from_str(&json).unwrap();
809        assert_eq!(cond, parsed);
810    }
811}