Skip to main content

oxicode/foundation/
compatibility.rs

1//! `foundation.json` parsing and host-version negotiation.
2
3use std::collections::BTreeMap;
4use std::path::Path;
5
6use serde::{Deserialize, Serialize};
7
8use super::FoundationError;
9
10const SUPPORTED_SCHEMA_VERSION: u32 = 1;
11
12/// Typed representation of `foundation.json`.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct FoundationManifest {
15    /// MUST be `1`.
16    pub schema_version: u32,
17    /// Host compatibility ranges. Unknown hosts are silently ignored.
18    #[serde(default)]
19    pub host_compatibility: BTreeMap<String, String>,
20}
21
22impl FoundationManifest {
23    /// Validate that this `oxicode` build is compatible. The check
24    /// uses a narrow semver range parser (we accept only the
25    /// `>=x.y.z` form rather than the full semver grammar).
26    pub fn validate_oxicode(&self) -> Result<(), FoundationError> {
27        if self.schema_version != SUPPORTED_SCHEMA_VERSION {
28            return Err(FoundationError::UnsupportedSchema(self.schema_version));
29        }
30        let Some(spec) = self.host_compatibility.get("oxicode") else {
31            // No requirement declared — accept by default.
32            return Ok(());
33        };
34        let required = parse_minimum_version(spec).ok_or_else(|| {
35            FoundationError::IncompatibleHost(format!("unparsable spec {spec:?}"))
36        })?;
37        let current = current_pkg_version();
38        if current_compare(&current, &required) {
39            Ok(())
40        } else {
41            Err(FoundationError::IncompatibleHost(format!(
42                "oxicode {}.{}.{} < required {spec}",
43                current.0, current.1, current.2
44            )))
45        }
46    }
47}
48
49/// Read and validate `foundation.json`. Returns a typed error on
50/// schema mismatch, malformed JSON, or host incompatibility.
51pub fn read(path: &Path) -> Result<FoundationManifest, FoundationError> {
52    let manifest: FoundationManifest = serde_json::from_slice(&std::fs::read(path)?)?;
53    manifest.validate_oxicode()?;
54    Ok(manifest)
55}
56
57/// Parse the minimal `>=x.y.z` form. Returns the lower bound.
58fn parse_minimum_version(spec: &str) -> Option<(u64, u64, u64)> {
59    let trimmed = spec.trim();
60    let rest = trimmed.strip_prefix(">=")?.trim();
61    let mut parts = rest.split('.');
62    let major = parts.next()?.parse().ok()?;
63    let minor = parts.next()?.parse().ok()?;
64    let patch = parts.next()?.parse().ok()?;
65    if parts.next().is_some() {
66        return None;
67    }
68    Some((major, minor, patch))
69}
70
71/// Read the current oxicode version from `CARGO_PKG_VERSION`.
72fn current_pkg_version() -> (u64, u64, u64) {
73    let s = env!("CARGO_PKG_VERSION");
74    let mut parts = s.split('.');
75    let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
76    let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
77    let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
78    (major, minor, patch)
79}
80
81/// `current >= required` lexical comparison.
82fn current_compare(current: &(u64, u64, u64), required: &(u64, u64, u64)) -> bool {
83    current.0 > required.0
84        || (current.0 == required.0 && current.1 > required.1)
85        || (current.0 == required.0 && current.1 == required.1 && current.2 >= required.2)
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn parse_min_version_accepts_supported() {
94        assert_eq!(parse_minimum_version(">=0.75.0"), Some((0, 75, 0)));
95        assert_eq!(parse_minimum_version(">=1.0.0"), Some((1, 0, 0)));
96        assert_eq!(parse_minimum_version(">=  0.75.0  "), Some((0, 75, 0)));
97    }
98
99    #[test]
100    fn parse_min_version_rejects_other_forms() {
101        assert_eq!(parse_minimum_version("^0.75.0"), None);
102        assert_eq!(parse_minimum_version("~0.75.0"), None);
103        assert_eq!(parse_minimum_version("0.75.0"), None);
104        assert_eq!(parse_minimum_version(">=0.75"), None);
105        assert_eq!(parse_minimum_version(">=0.75.0.1"), None);
106    }
107
108    #[test]
109    fn read_rejects_missing_schema_version() {
110        let tmp = tempfile::tempdir().unwrap();
111        let path = tmp.path().join("foundation.json");
112        std::fs::write(&path, "{}").unwrap();
113        let err = read(&path).unwrap_err();
114        assert!(matches!(err, FoundationError::Parse(_)));
115    }
116
117    #[test]
118    fn read_rejects_unsupported_schema() {
119        let tmp = tempfile::tempdir().unwrap();
120        let path = tmp.path().join("foundation.json");
121        std::fs::write(&path, r#"{"schema_version": 99}"#).unwrap();
122        let err = read(&path).unwrap_err();
123        assert!(matches!(err, FoundationError::UnsupportedSchema(99)));
124    }
125
126    #[test]
127    fn read_accepts_compatible_host() {
128        let tmp = tempfile::tempdir().unwrap();
129        let path = tmp.path().join("foundation.json");
130        std::fs::write(
131            &path,
132            r#"{"schema_version": 1, "host_compatibility": {"oxicode": ">=0.50.0"}}"#,
133        )
134        .unwrap();
135        let m = read(&path).unwrap();
136        assert_eq!(m.schema_version, 1);
137    }
138
139    #[test]
140    fn read_rejects_incompatible_host() {
141        let tmp = tempfile::tempdir().unwrap();
142        let path = tmp.path().join("foundation.json");
143        std::fs::write(
144            &path,
145            r#"{"schema_version": 1, "host_compatibility": {"oxicode": ">=999.0.0"}}"#,
146        )
147        .unwrap();
148        let err = read(&path).unwrap_err();
149        assert!(matches!(err, FoundationError::IncompatibleHost(_)));
150    }
151}