Skip to main content

callisto_model/
ecosystem.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Serialize};
3
4use crate::{NpmAccess, RegistryKey, VersionGrammar};
5
6/// Ecosystem supported by callisto.
7#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
8#[serde(rename_all = "lowercase")]
9#[non_exhaustive]
10pub enum Ecosystem {
11    Cargo,
12    Npm,
13    // Demand-gated ecosystems:
14    Pypi,
15    Go,
16    Maven,
17    NuGet,
18    Deno,
19    Jsr,
20}
21
22impl Ecosystem {
23    pub fn prefix(&self) -> &'static str {
24        match self {
25            Ecosystem::Cargo => "cargo",
26            Ecosystem::Npm => "npm",
27            Ecosystem::Pypi => "pypi",
28            Ecosystem::Go => "go",
29            Ecosystem::Maven => "maven",
30            Ecosystem::NuGet => "nuget",
31            Ecosystem::Deno => "deno",
32            Ecosystem::Jsr => "jsr",
33        }
34    }
35
36    pub fn from_prefix(s: &str) -> Option<Self> {
37        match s.to_ascii_lowercase().as_str() {
38            "cargo" => Some(Ecosystem::Cargo),
39            "npm" => Some(Ecosystem::Npm),
40            "pypi" => Some(Ecosystem::Pypi),
41            "go" => Some(Ecosystem::Go),
42            "maven" => Some(Ecosystem::Maven),
43            "nuget" => Some(Ecosystem::NuGet),
44            "deno" => Some(Ecosystem::Deno),
45            "jsr" => Some(Ecosystem::Jsr),
46            _ => None,
47        }
48    }
49
50    pub fn version_grammar(&self) -> VersionGrammar {
51        match self {
52            Ecosystem::Cargo | Ecosystem::Npm | Ecosystem::Deno | Ecosystem::Jsr | Ecosystem::NuGet | Ecosystem::Go => {
53                VersionGrammar::SemVer
54            }
55            Ecosystem::Pypi => VersionGrammar::Pep440,
56            Ecosystem::Maven => VersionGrammar::Maven,
57        }
58    }
59
60    pub fn is_implemented(&self) -> bool {
61        matches!(self, Ecosystem::Cargo | Ecosystem::Npm | Ecosystem::Pypi)
62    }
63}
64
65/// Target registry or release location.
66#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
67#[serde(rename_all = "camelCase")]
68#[non_exhaustive]
69pub enum PublishTarget {
70    CratesIo,
71    Npm {
72        registry: Option<String>,
73        /// The operator's explicit `publishConfig.access` from `package.json`,
74        /// if set. `None` means npm's `publishConfig.access` was absent --
75        /// distinct from an explicit `"public"`, which a bare bool couldn't
76        /// represent (both collapsed to `false`, silently dropping an
77        /// unscoped package's explicit `"public"` setting). When `Some`,
78        /// `plan_publish` passes the corresponding `--access` flag rather
79        /// than falling back to the `@scope/name`-implies-public heuristic.
80        access: Option<NpmAccess>,
81    },
82    Pypi {
83        index: Option<String>,
84    },
85    NuGet {
86        source: Option<String>,
87    },
88    GitHubRelease,
89    None,
90}
91
92impl PublishTarget {
93    pub fn registry_key(&self) -> Option<RegistryKey> {
94        match self {
95            PublishTarget::CratesIo => Some(RegistryKey(RegistryKey::CRATES_IO.to_string())),
96            PublishTarget::Npm { .. } => Some(RegistryKey(RegistryKey::NPM.to_string())),
97            PublishTarget::Pypi { .. } => Some(RegistryKey(RegistryKey::PYPI.to_string())),
98            PublishTarget::NuGet { .. } => Some(RegistryKey(RegistryKey::NUGET.to_string())),
99            PublishTarget::GitHubRelease | PublishTarget::None => None,
100        }
101    }
102
103    pub fn ecosystem(&self) -> Option<Ecosystem> {
104        match self {
105            PublishTarget::CratesIo => Some(Ecosystem::Cargo),
106            PublishTarget::Npm { .. } => Some(Ecosystem::Npm),
107            PublishTarget::Pypi { .. } => Some(Ecosystem::Pypi),
108            PublishTarget::NuGet { .. } => Some(Ecosystem::NuGet),
109            PublishTarget::GitHubRelease | PublishTarget::None => None,
110        }
111    }
112
113    /// The `publish-to` config string this variant parses from, mirroring
114    /// `parse_publish_target` in `callisto_graph::config::resolve`. Used to
115    /// name the mismatched target in diagnostics/errors without leaking the
116    /// `Debug` representation of the variant's payload.
117    pub fn config_str(&self) -> &'static str {
118        match self {
119            PublishTarget::CratesIo => "crates-io",
120            PublishTarget::Npm { .. } => "npm",
121            PublishTarget::Pypi { .. } => "pypi",
122            PublishTarget::NuGet { .. } => "nuget",
123            PublishTarget::GitHubRelease => "github-release",
124            PublishTarget::None => "none",
125        }
126    }
127}
128
129/// PEP 503 / PEP 427 style Python package-name normalization: lowercase,
130/// then collapse any run of hyphens, dots, or underscores into a single
131/// underscore. This makes name-equality comparisons and wheel-filename
132/// construction agree that "my-package", "my_package", "my.package", and
133/// "My--Package" all refer to the same PyPI distribution -- PEP 503's own
134/// canonicalization rule (`re.sub(r"[-_.]+", "-", name).lower()`), joined
135/// with `_` to match PEP 427's wheel-filename escaping instead of PEP
136/// 503's own `-` (the join character doesn't affect equality comparisons,
137/// only which convention a caller building a filename needs).
138pub fn normalize_pypi_package_name(name: &str) -> String {
139    name.to_lowercase()
140        .split(['-', '.', '_'])
141        .filter(|s| !s.is_empty())
142        .collect::<Vec<_>>()
143        .join("_")
144}
145
146/// Trigger mechanism for generating releases.
147#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
148#[serde(rename_all = "lowercase")]
149pub enum ReleaseTrigger {
150    #[default]
151    Changeset,
152    Auto,
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn maps_ecosystem_prefixes() {
161        assert_eq!(Ecosystem::from_prefix("cargo"), Some(Ecosystem::Cargo));
162        assert_eq!(Ecosystem::from_prefix("npm"), Some(Ecosystem::Npm));
163        assert_eq!(Ecosystem::Cargo.prefix(), "cargo");
164    }
165
166    #[test]
167    fn identifies_implemented_ecosystems() {
168        assert!(Ecosystem::Cargo.is_implemented());
169        assert!(Ecosystem::Npm.is_implemented());
170        assert!(Ecosystem::Pypi.is_implemented());
171    }
172
173    #[test]
174    fn normalize_pypi_package_name_treats_hyphen_underscore_dot_as_equivalent() {
175        assert_eq!(normalize_pypi_package_name("my-package"), "my_package");
176        assert_eq!(normalize_pypi_package_name("my_package"), "my_package");
177        assert_eq!(normalize_pypi_package_name("my.package"), "my_package");
178        assert_eq!(normalize_pypi_package_name("My--Package"), "my_package");
179        assert_eq!(normalize_pypi_package_name("MY.PACKAGE"), "my_package");
180    }
181
182    #[test]
183    fn normalize_pypi_package_name_collapses_mixed_runs() {
184        assert_eq!(normalize_pypi_package_name("foo-.-bar"), "foo_bar");
185        assert_eq!(normalize_pypi_package_name("foo___bar"), "foo_bar");
186    }
187}