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