1use semver::{Version, VersionReq};
4use serde::{Deserialize, Serialize};
5
6pub const SCHEMA_1_MARS_FLOOR: &str = "0.20.0";
9
10pub 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
31pub 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 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
68pub 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
78pub 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
133pub enum ManifestClassification {
134 Schema1,
135 SchemaNewer {
136 schema: u64,
137 },
138 MarsLegacy,
139 ConsumerConfig,
142 Invalid {
143 message: String,
144 },
145 NoManifest,
146}
147
148pub 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}