Skip to main content

callisto_model/
package.rs

1use std::collections::HashSet;
2use std::path::{Path, PathBuf};
3
4use schemars::JsonSchema;
5use serde::{Deserialize, Serialize};
6
7use crate::{
8    workspace_relative, Ecosystem, ModelError, PackageId, PublishTarget, ReleaseTrigger, TagTemplate, VersionGrammar,
9};
10
11/// A package in the workspace graph.
12#[derive(Clone, Debug, PartialEq, Eq)]
13pub struct Package {
14    pub id: PackageId,
15    pub manifests: Vec<ManifestDecl>,
16    pub changelog: Option<PathBuf>,
17    pub release_trigger: ReleaseTrigger,
18    pub publish_to: Vec<PublishTarget>,
19    pub tag_template: Option<TagTemplate>,
20}
21
22impl Package {
23    pub fn canonical_manifests(&self) -> impl Iterator<Item = &ManifestDecl> {
24        self.manifests.iter().filter(|m| m.role == ManifestRole::Canonical)
25    }
26
27    pub fn platform_manifests(&self) -> impl Iterator<Item = &ManifestDecl> {
28        self.manifests
29            .iter()
30            .filter(|m| matches!(m.role, ManifestRole::Platform { .. }))
31    }
32
33    pub fn lockfiles(&self) -> impl Iterator<Item = &ManifestDecl> {
34        self.manifests.iter().filter(|m| m.role == ManifestRole::Lockfile)
35    }
36
37    pub fn version_grammar(&self) -> Result<VersionGrammar, ModelError> {
38        let grammars: Vec<_> = self
39            .canonical_manifests()
40            .map(|m| m.format.ecosystem().version_grammar())
41            .collect();
42
43        if grammars.is_empty() {
44            return Err(ModelError::no_canonical_manifest(&self.id));
45        }
46
47        let first = grammars[0];
48        if grammars.iter().any(|&g| g != first) {
49            let unique: HashSet<_> = grammars.into_iter().collect();
50            return Err(ModelError::mixed_version_grammars(
51                &self.id,
52                unique.into_iter().collect(),
53            ));
54        }
55
56        Ok(first)
57    }
58
59    pub fn is_release_point(&self) -> bool {
60        self.publish_to.iter().any(|t| !matches!(t, PublishTarget::None))
61    }
62
63    pub fn is_dual_published(&self) -> bool {
64        let canonical_ecosystems: HashSet<_> = self.canonical_manifests().map(|m| m.format.ecosystem()).collect();
65        canonical_ecosystems.len() >= 2
66    }
67}
68
69/// Declaration of a manifest file.
70#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
71#[serde(rename_all = "camelCase")]
72pub struct ManifestDecl {
73    pub path: PathBuf,
74    pub role: ManifestRole,
75    pub format: ManifestFormat,
76}
77
78impl ManifestDecl {
79    pub fn new(path: impl AsRef<Path>, role: ManifestRole, format: ManifestFormat) -> Result<Self, ModelError> {
80        let rel_path = workspace_relative(path)?;
81
82        if format.is_lockfile() && role != ManifestRole::Lockfile {
83            return Err(ModelError::invalid_role_for_format(&role, &format));
84        }
85
86        if role == ManifestRole::Lockfile && !format.is_lockfile() {
87            return Err(ModelError::invalid_role_for_format(&role, &format));
88        }
89
90        if matches!(role, ManifestRole::Platform { .. }) && format == ManifestFormat::CargoToml {
91            return Err(ModelError::invalid_role_for_format(&role, &format));
92        }
93
94        Ok(ManifestDecl {
95            path: rel_path,
96            role,
97            format,
98        })
99    }
100
101    pub fn ecosystem(&self) -> Ecosystem {
102        self.format.ecosystem()
103    }
104}
105
106/// Role of a manifest file in a package.
107#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, JsonSchema)]
108#[serde(rename_all = "camelCase", tag = "kind")]
109pub enum ManifestRole {
110    Canonical,
111    Platform {
112        platform: String,
113        arch: String,
114        abi: Option<String>,
115    },
116    Lockfile,
117}
118
119/// Format of a manifest file.
120#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
121#[serde(rename_all = "camelCase")]
122#[non_exhaustive]
123pub enum ManifestFormat {
124    CargoToml,
125    PackageJson,
126    PyprojectToml,
127    SetupCfg,
128    GoMod,
129    PomXml,
130    GradleVersionCatalog,
131    SettingsGradle,
132    VersionSbt,
133    DenoJson,
134    CargoLock,
135    PackageLockJson,
136    PnpmLockYaml,
137    YarnLock,
138}
139
140impl ManifestFormat {
141    pub fn ecosystem(&self) -> Ecosystem {
142        match self {
143            ManifestFormat::CargoToml | ManifestFormat::CargoLock => Ecosystem::Cargo,
144            ManifestFormat::PackageJson
145            | ManifestFormat::PackageLockJson
146            | ManifestFormat::PnpmLockYaml
147            | ManifestFormat::YarnLock => Ecosystem::Npm,
148            ManifestFormat::PyprojectToml | ManifestFormat::SetupCfg => Ecosystem::Pypi,
149            ManifestFormat::GoMod => Ecosystem::Go,
150            ManifestFormat::PomXml
151            | ManifestFormat::GradleVersionCatalog
152            | ManifestFormat::SettingsGradle
153            | ManifestFormat::VersionSbt => Ecosystem::Maven,
154            ManifestFormat::DenoJson => Ecosystem::Deno,
155        }
156    }
157
158    pub fn is_lockfile(&self) -> bool {
159        matches!(
160            self,
161            ManifestFormat::CargoLock
162                | ManifestFormat::PackageLockJson
163                | ManifestFormat::PnpmLockYaml
164                | ManifestFormat::YarnLock
165        )
166    }
167
168    pub fn is_writable(&self) -> bool {
169        !matches!(
170            self,
171            ManifestFormat::SetupCfg
172                | ManifestFormat::CargoLock
173                | ManifestFormat::PackageLockJson
174                | ManifestFormat::PnpmLockYaml
175                | ManifestFormat::YarnLock
176                | ManifestFormat::SettingsGradle
177                | ManifestFormat::VersionSbt
178        )
179    }
180
181    pub fn file_name(&self) -> &'static str {
182        match self {
183            ManifestFormat::CargoToml => "Cargo.toml",
184            ManifestFormat::PackageJson => "package.json",
185            ManifestFormat::PyprojectToml => "pyproject.toml",
186            ManifestFormat::SetupCfg => "setup.cfg",
187            ManifestFormat::GoMod => "go.mod",
188            ManifestFormat::PomXml => "pom.xml",
189            ManifestFormat::GradleVersionCatalog => "libs.versions.toml",
190            ManifestFormat::SettingsGradle => "settings.gradle",
191            ManifestFormat::VersionSbt => "build.sbt",
192            ManifestFormat::DenoJson => "deno.json",
193            ManifestFormat::CargoLock => "Cargo.lock",
194            ManifestFormat::PackageLockJson => "package-lock.json",
195            ManifestFormat::PnpmLockYaml => "pnpm-lock.yaml",
196            ManifestFormat::YarnLock => "yarn.lock",
197        }
198    }
199
200    pub fn from_path(p: &std::path::Path) -> Result<Self, ModelError> {
201        let name = p
202            .file_name()
203            .and_then(|n| n.to_str())
204            .ok_or_else(|| ModelError::UnknownManifestFormat { path: p.to_path_buf() })?;
205        match name {
206            "Cargo.toml" => Ok(ManifestFormat::CargoToml),
207            "package.json" => Ok(ManifestFormat::PackageJson),
208            "pyproject.toml" => Ok(ManifestFormat::PyprojectToml),
209            "setup.cfg" => Ok(ManifestFormat::SetupCfg),
210            "go.mod" => Ok(ManifestFormat::GoMod),
211            "pom.xml" => Ok(ManifestFormat::PomXml),
212            "deno.json" | "deno.jsonc" => Ok(ManifestFormat::DenoJson),
213            _ => Err(ModelError::UnknownManifestFormat { path: p.to_path_buf() }),
214        }
215    }
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    #[test]
223    fn validates_manifest_role_and_format() {
224        let decl = ManifestDecl::new("Cargo.toml", ManifestRole::Canonical, ManifestFormat::CargoToml);
225        assert!(decl.is_ok());
226
227        let invalid = ManifestDecl::new("Cargo.toml", ManifestRole::Lockfile, ManifestFormat::CargoToml);
228        assert!(invalid.is_err());
229    }
230}