Skip to main content

mars_schema/
package.rs

1//! Versioned package metadata and manifest classification.
2
3use semver::{Version, VersionReq};
4use serde::{Deserialize, Serialize};
5
6/// The first Mars release that reads schema 1. An older one reads a `schema = 1` package as
7/// pre-v1, so a schema v1 package's `requires-mars` may not admit one.
8pub const SCHEMA_1_MARS_FLOOR: &str = "0.20.0";
9
10/// Parse a `requires-mars` or `requires-meridian` requirement. A bare version means `>=` it
11/// (`0.19.0` is `>=0.19.0`), as `mars.toml` documents both fields.
12pub fn parse_requirement(raw: &str) -> Result<VersionReq, semver::Error> {
13    let raw = raw.trim();
14    let parts: Vec<&str> = raw.split('.').collect();
15    let normalized = if (1..=3).contains(&parts.len())
16        && parts
17            .iter()
18            .all(|part| !part.is_empty() && part.chars().all(|c| c.is_ascii_digit()))
19    {
20        let mut parts = parts;
21        while parts.len() < 3 {
22            parts.push("0");
23        }
24        format!(">={}", parts.join("."))
25    } else {
26        raw.to_string()
27    };
28    VersionReq::parse(&normalized)
29}
30
31/// Whether `requirement` demands at least `floor`, so a Mars older than one floor cannot read
32/// the package. A range with no lower bound (`<0.25`) does not; an unparseable one cannot say.
33pub fn requirement_has_floor(requirement: Option<&str>, floor: &Version) -> bool {
34    let Some(raw) = requirement else {
35        return false;
36    };
37    let Ok(requirement) = parse_requirement(raw) else {
38        return false;
39    };
40    requirement
41        .comparators
42        .iter()
43        .any(|comparator| comparator_floor(comparator).is_some_and(|minimum| minimum >= *floor))
44}
45
46fn comparator_floor(comparator: &semver::Comparator) -> Option<Version> {
47    use semver::Op;
48
49    // Partial strict bounds advance to the next specified component: `>0.18`
50    // means `>=0.19.0`, unlike the fully specified `>0.18.0`.
51    let (major, minor, patch) = match comparator.op {
52        Op::Greater if comparator.minor.is_none() => (comparator.major.checked_add(1)?, 0, 0),
53        Op::Greater if comparator.patch.is_none() => {
54            (comparator.major, comparator.minor?.checked_add(1)?, 0)
55        }
56        Op::Exact | Op::Greater | Op::GreaterEq | Op::Tilde | Op::Caret | Op::Wildcard => (
57            comparator.major,
58            comparator.minor.unwrap_or(0),
59            comparator.patch.unwrap_or(0),
60        ),
61        _ => return None,
62    };
63    let mut version = Version::new(major, minor, patch);
64    version.pre = comparator.pre.clone();
65    Some(version)
66}
67
68/// The advice `mars check` prints for a schema v1 package's `requires-mars`, which `mars migrate`
69/// prints with it when it leaves an authored requirement alone.
70pub fn schema_1_requires_mars_warning(requirement: Option<&str>) -> Option<String> {
71    requires_mars_floor_warning(
72        requirement,
73        SCHEMA_1_MARS_FLOOR,
74        "older Mars versions read a `schema = 1` package as pre-v1; a floor keeps them on the last release they can read",
75    )
76}
77
78/// The advice for a requirement that is missing or admits a Mars older than `floor`. `None` when
79/// it does not, or when it is not a requirement this Mars can read: a broken requirement is its
80/// own error.
81pub fn requires_mars_floor_warning(
82    requirement: Option<&str>,
83    floor: &str,
84    reason: &str,
85) -> Option<String> {
86    if let Some(raw) = requirement {
87        parse_requirement(raw).ok()?;
88    }
89    let floor = Version::parse(floor).expect("a Mars floor is valid semver");
90    (!requirement_has_floor(requirement, &floor)).then(|| {
91        format!("[package] `requires-mars` should be `\">={floor}\"` or a higher floor; {reason}")
92    })
93}
94
95/// Package metadata carried by `mars.toml`.
96#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
97pub struct PackageMetadata {
98    pub name: String,
99    #[serde(default)]
100    pub version: String,
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub description: Option<String>,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub authors: Option<toml::Value>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub homepage: Option<toml::Value>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub license: Option<toml::Value>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub schema: Option<u64>,
111    #[serde(
112        default,
113        rename = "uses-providers",
114        skip_serializing_if = "Vec::is_empty"
115    )]
116    pub uses_providers: Vec<String>,
117    #[serde(
118        default,
119        rename = "requires-mars",
120        skip_serializing_if = "Option::is_none"
121    )]
122    pub requires_mars: Option<String>,
123    #[serde(
124        default,
125        rename = "requires-meridian",
126        skip_serializing_if = "Option::is_none"
127    )]
128    pub requires_meridian: Option<String>,
129}
130
131/// The schema dispatch result for a package root's optional `mars.toml`.
132#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum ManifestClassification {
134    Schema1,
135    SchemaNewer {
136        schema: u64,
137    },
138    MarsLegacy,
139    /// A `mars.toml` with no `[package]`: a consumer project's configuration, which makes the
140    /// directory a project and not a package.
141    ConsumerConfig,
142    Invalid {
143        message: String,
144    },
145    NoManifest,
146}
147
148/// Classify manifest bytes before any package content is lifted or discovered.
149pub fn classify_manifest(bytes: Option<&[u8]>) -> ManifestClassification {
150    let Some(bytes) = bytes else {
151        return ManifestClassification::NoManifest;
152    };
153    let text = match std::str::from_utf8(bytes) {
154        Ok(text) => text,
155        Err(error) => {
156            return ManifestClassification::Invalid {
157                message: format!("mars.toml is not UTF-8: {error}"),
158            };
159        }
160    };
161    let manifest: toml::Value = match toml::from_str(text) {
162        Ok(manifest) => manifest,
163        Err(error) => {
164            return ManifestClassification::Invalid {
165                message: format!("failed to parse mars.toml: {error}"),
166            };
167        }
168    };
169    let Some(package) = manifest.get("package") else {
170        return ManifestClassification::ConsumerConfig;
171    };
172    let Some(package) = package.as_table() else {
173        return ManifestClassification::Invalid {
174            message: "mars.toml at a package root must contain [package]".to_string(),
175        };
176    };
177    let Some(schema) = package.get("schema") else {
178        return ManifestClassification::MarsLegacy;
179    };
180    let Some(schema) = schema
181        .as_integer()
182        .and_then(|value| u64::try_from(value).ok())
183    else {
184        return ManifestClassification::Invalid {
185            message: "[package] schema must be a positive integer".to_string(),
186        };
187    };
188    match schema {
189        0 => ManifestClassification::Invalid {
190            message: "[package] schema must be a positive integer".to_string(),
191        },
192        1 => ManifestClassification::Schema1,
193        schema => ManifestClassification::SchemaNewer { schema },
194    }
195}