Skip to main content

semifold_core/
package.rs

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/// Stable identity used by Semifold configuration and workspace graphs.
10#[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/// Stable, serializable identity for a built-in or plugin-provided ecosystem.
33#[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    /// Creates and validates an ecosystem identifier.
58    ///
59    /// Identifiers use lowercase ASCII segments separated by dots. Segments may contain digits and
60    /// hyphens, but must start with a letter and end with a letter or digit.
61    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
202/// Backwards-compatible name for the ecosystem identity type.
203pub type Ecosystem = EcosystemId;
204
205/// Physical manifest location that owns a package's version value.
206#[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/// Immutable package data collected from an ecosystem manifest.
220#[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}