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#[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#[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#[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#[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}