1use std::{borrow::Cow, cmp::Ordering, fmt, str::FromStr};
2
3use camino::Utf8PathBuf;
4use semver::Version;
5use serde::{Deserialize, Deserializer, Serialize};
6
7use crate::Dependency;
8
9#[derive(Clone, Debug, Deserialize, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
11#[serde(transparent)]
12pub struct PackageId(String);
13
14impl PackageId {
15 #[must_use]
16 pub fn new(value: impl Into<String>) -> Self {
17 Self(value.into())
18 }
19
20 #[must_use]
21 pub fn as_str(&self) -> &str {
22 &self.0
23 }
24}
25
26impl fmt::Display for PackageId {
27 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
28 formatter.write_str(&self.0)
29 }
30}
31
32#[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)]
34#[serde(transparent)]
35pub struct EcosystemId(Cow<'static, str>);
36
37impl EcosystemId {
38 pub const MAX_LENGTH: usize = 128;
39 pub const RUST: Self = Self(Cow::Borrowed("rust"));
40 pub const NODE: Self = Self(Cow::Borrowed("nodejs"));
41 pub const PYTHON: Self = Self(Cow::Borrowed("python"));
42 pub const CPP: Self = Self(Cow::Borrowed("cpp"));
43
44 #[allow(non_upper_case_globals)]
45 #[deprecated(note = "use EcosystemId::RUST")]
46 pub const Rust: Self = Self::RUST;
47 #[allow(non_upper_case_globals)]
48 #[deprecated(note = "use EcosystemId::NODE")]
49 pub const Node: Self = Self::NODE;
50 #[allow(non_upper_case_globals)]
51 #[deprecated(note = "use EcosystemId::PYTHON")]
52 pub const Python: Self = Self::PYTHON;
53 #[allow(non_upper_case_globals)]
54 #[deprecated(note = "use EcosystemId::CPP")]
55 pub const Cpp: Self = Self::CPP;
56
57 pub fn new(value: impl Into<String>) -> Result<Self, EcosystemIdError> {
62 let value = value.into();
63 validate_ecosystem_id(&value)?;
64 Ok(Self(Cow::Owned(value)))
65 }
66
67 #[must_use]
68 pub fn as_str(&self) -> &str {
69 &self.0
70 }
71
72 #[must_use]
73 pub fn is_builtin(&self) -> bool {
74 matches!(self.as_str(), "rust" | "nodejs" | "python" | "cpp")
75 }
76
77 #[must_use]
78 pub fn display_name(&self) -> &str {
79 match self.as_str() {
80 "rust" => "Rust",
81 "nodejs" => "Node.js",
82 "python" => "Python",
83 "cpp" => "Cpp",
84 _ => self.as_str(),
85 }
86 }
87}
88
89impl fmt::Display for EcosystemId {
90 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
91 formatter.write_str(self.as_str())
92 }
93}
94
95impl Ord for EcosystemId {
96 fn cmp(&self, other: &Self) -> Ordering {
97 ecosystem_sort_key(self)
98 .cmp(&ecosystem_sort_key(other))
99 .then_with(|| self.as_str().cmp(other.as_str()))
100 }
101}
102
103impl PartialOrd for EcosystemId {
104 fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
105 Some(self.cmp(other))
106 }
107}
108
109fn ecosystem_sort_key(ecosystem: &EcosystemId) -> u8 {
110 match ecosystem.as_str() {
111 "rust" => 0,
112 "nodejs" => 1,
113 "python" => 2,
114 "cpp" => 3,
115 _ => 4,
116 }
117}
118
119impl FromStr for EcosystemId {
120 type Err = EcosystemIdError;
121
122 fn from_str(value: &str) -> Result<Self, Self::Err> {
123 Self::new(value)
124 }
125}
126
127impl TryFrom<String> for EcosystemId {
128 type Error = EcosystemIdError;
129
130 fn try_from(value: String) -> Result<Self, Self::Error> {
131 Self::new(value)
132 }
133}
134
135impl<'de> Deserialize<'de> for EcosystemId {
136 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
137 where
138 D: Deserializer<'de>,
139 {
140 Self::new(String::deserialize(deserializer)?).map_err(serde::de::Error::custom)
141 }
142}
143
144#[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)]
145pub enum EcosystemIdError {
146 #[error("ecosystem id must not be empty")]
147 Empty,
148 #[error("ecosystem id is {length} bytes; the maximum is {maximum}")]
149 TooLong { length: usize, maximum: usize },
150 #[error("invalid ecosystem id {value:?}; expected lowercase ASCII segments separated by dots")]
151 InvalidFormat { value: String },
152}
153
154fn validate_ecosystem_id(value: &str) -> Result<(), EcosystemIdError> {
155 if value.is_empty() {
156 return Err(EcosystemIdError::Empty);
157 }
158 if value.len() > EcosystemId::MAX_LENGTH {
159 return Err(EcosystemIdError::TooLong {
160 length: value.len(),
161 maximum: EcosystemId::MAX_LENGTH,
162 });
163 }
164
165 let mut at_segment_start = true;
166 let mut previous_was_hyphen = false;
167 for character in value.chars() {
168 if at_segment_start {
169 if !character.is_ascii_lowercase() {
170 return Err(EcosystemIdError::InvalidFormat {
171 value: value.to_string(),
172 });
173 }
174 at_segment_start = false;
175 previous_was_hyphen = false;
176 } else if character == '.' {
177 if previous_was_hyphen {
178 return Err(EcosystemIdError::InvalidFormat {
179 value: value.to_string(),
180 });
181 }
182 at_segment_start = true;
183 } else if character == '-' {
184 previous_was_hyphen = true;
185 } else if character.is_ascii_lowercase() || character.is_ascii_digit() {
186 previous_was_hyphen = false;
187 } else {
188 return Err(EcosystemIdError::InvalidFormat {
189 value: value.to_string(),
190 });
191 }
192 }
193
194 if at_segment_start || previous_was_hyphen {
195 return Err(EcosystemIdError::InvalidFormat {
196 value: value.to_string(),
197 });
198 }
199 Ok(())
200}
201
202pub type Ecosystem = EcosystemId;
204
205#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
207#[serde(tag = "kind", rename_all = "snake_case")]
208pub enum VersionSource {
209 PackageManifest,
210 Shared { source: VersionSourceId },
211}
212
213#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)]
214pub struct VersionSourceId {
215 pub manifest: Utf8PathBuf,
216 pub field: String,
217}
218
219#[derive(Clone, Debug, Eq, PartialEq)]
221pub struct PackageSnapshot {
222 pub id: PackageId,
223 pub manifest_name: String,
224 pub version: Version,
225 pub version_source: VersionSource,
226 pub ecosystem: EcosystemId,
227 pub path: Utf8PathBuf,
228 pub publishable: bool,
229 pub dependencies: Vec<Dependency>,
230}
231
232#[cfg(test)]
233mod tests {
234 use super::{Ecosystem, EcosystemId, EcosystemIdError};
235
236 #[test]
237 fn ecosystem_ids_validate_and_serialize_as_stable_strings() {
238 let id = EcosystemId::new("com.example-game.engine2").unwrap();
239
240 assert_eq!(id.as_str(), "com.example-game.engine2");
241 assert_eq!(
242 serde_json::to_string(&id).unwrap(),
243 r#""com.example-game.engine2""#
244 );
245 assert_eq!(
246 serde_json::from_str::<EcosystemId>(r#""com.example-game.engine2""#).unwrap(),
247 id
248 );
249 }
250
251 #[test]
252 fn ecosystem_ids_reject_non_canonical_values() {
253 for value in [
254 "",
255 "Example",
256 "example_kit",
257 ".example",
258 "example.",
259 "example-.kit",
260 ] {
261 assert!(EcosystemId::new(value).is_err(), "{value}");
262 }
263 assert!(matches!(
264 EcosystemId::new("x".repeat(EcosystemId::MAX_LENGTH + 1)),
265 Err(EcosystemIdError::TooLong { .. })
266 ));
267 }
268
269 #[test]
270 fn built_in_ecosystems_use_config_aligned_serialized_ids() {
271 let cases = [
272 (Ecosystem::RUST, "rust"),
273 (Ecosystem::NODE, "nodejs"),
274 (Ecosystem::PYTHON, "python"),
275 (Ecosystem::CPP, "cpp"),
276 ];
277
278 for (ecosystem, expected) in cases {
279 assert_eq!(ecosystem.as_str(), expected);
280 assert!(ecosystem.is_builtin());
281 }
282 assert_eq!(EcosystemId::RUST.display_name(), "Rust");
283 assert_eq!(
284 EcosystemId::new("com.example.engine")
285 .unwrap()
286 .display_name(),
287 "com.example.engine"
288 );
289 }
290
291 #[test]
292 fn ecosystem_order_preserves_built_ins_then_sorts_plugins_by_id() {
293 let mut ecosystems = [
294 EcosystemId::new("org.example.zeta").unwrap(),
295 EcosystemId::CPP,
296 EcosystemId::NODE,
297 EcosystemId::new("com.example.alpha").unwrap(),
298 EcosystemId::RUST,
299 EcosystemId::PYTHON,
300 ];
301
302 ecosystems.sort();
303
304 assert_eq!(
305 ecosystems
306 .iter()
307 .map(EcosystemId::as_str)
308 .collect::<Vec<_>>(),
309 [
310 "rust",
311 "nodejs",
312 "python",
313 "cpp",
314 "com.example.alpha",
315 "org.example.zeta"
316 ]
317 );
318 }
319}