1use std::{
2 borrow::{Borrow, Cow},
3 fmt,
4 fmt::{Debug, Display},
5 ops::Deref,
6};
7
8use changesets::PackageChange;
9use itertools::Itertools;
10#[cfg(feature = "miette")]
11use miette::Diagnostic;
12use relative_path::RelativePathBuf;
13use serde::{Deserialize, Serialize};
14use thiserror::Error;
15use tracing::debug;
16
17use crate::{
18 PackageNewError::CargoLockNoDependency,
19 action::Action,
20 changes::{
21 CHANGESET_DIR, Change, ChangeSource, GitInfo,
22 conventional_commit::{Commit, changes_from_commit_messages},
23 },
24 release_notes::{ReleaseNotes, TimeError},
25 semver::{Label, PackageVersions, PreReleaseNotFound, Rule, StableRule, Version},
26 versioned_file,
27 versioned_file::{Config, Format, GoVersioning, SetError, VersionedFile, cargo},
28};
29
30#[derive(Clone, Debug)]
31pub struct Package {
32 pub name: Name,
33 pub versions: PackageVersions,
34 versioned_files: Vec<Config>,
35 pub release_notes: ReleaseNotes,
36 scopes: Option<Vec<String>>,
37}
38
39impl Package {
40 pub fn new<S: AsRef<str> + Debug>(
46 name: Name,
47 git_tags: &[S],
48 versioned_files_tracked: Vec<Config>,
49 all_versioned_files: &[VersionedFile],
50 release_notes: ReleaseNotes,
51 scopes: Option<Vec<String>>,
52 ) -> Result<Self, Box<NewError>> {
53 let (versioned_files, version_from_files) =
54 validate_versioned_files(versioned_files_tracked, all_versioned_files)?;
55
56 debug!("Looking for Git tags matching package name.");
57 let mut versions = PackageVersions::from_tags(name.as_custom(), git_tags);
58 if let Some(version_from_files) = version_from_files {
59 versions.update_version(version_from_files);
60 }
61
62 Ok(Self {
63 name,
64 versions,
65 versioned_files,
66 release_notes,
67 scopes,
68 })
69 }
70
71 pub fn bump_version(
85 &mut self,
86 version: &Version,
87 go_versioning: GoVersioning,
88 versioned_files: Vec<VersionedFile>,
89 ) -> Result<Vec<VersionedFile>, BumpError> {
90 versioned_files
91 .into_iter()
92 .map(|mut file| {
93 let configs = self
94 .versioned_files
95 .iter()
96 .filter(|config| *config == file.path())
97 .collect_vec();
98 for config in configs {
99 file = file
100 .set_version(version, config.dependency.as_deref(), go_versioning)
101 .map_err(BumpError::SetError)?;
102 }
103 Ok(file)
104 })
105 .collect()
106 }
107
108 #[must_use]
109 pub fn get_changes<'a>(
110 &self,
111 changeset: impl IntoIterator<Item = (&'a PackageChange, Option<GitInfo>)>,
112 commit_messages: &[Commit],
113 ) -> Vec<Change> {
114 changes_from_commit_messages(
115 commit_messages,
116 self.scopes.as_ref(),
117 &self.release_notes.sections,
118 )
119 .chain(Change::from_changeset(changeset))
120 .collect()
121 }
122
123 pub fn apply_changes(
132 &mut self,
133 changes: &[Change],
134 versioned_files: Vec<VersionedFile>,
135 config: ChangeConfig,
136 ) -> Result<(Vec<VersionedFile>, Vec<Action>), BumpError> {
137 if let Name::Custom(package_name) = &self.name {
138 debug!("Determining new version for {package_name}");
139 }
140
141 let (version, go_versioning) = match config {
142 ChangeConfig::Force(version) => {
143 debug!("Using overridden version {version}");
144 (version, GoVersioning::BumpMajor)
145 }
146 ChangeConfig::Calculate {
147 prerelease_label,
148 go_versioning,
149 } => {
150 let stable_rule = StableRule::from(changes);
151 let rule = if let Some(pre_label) = prerelease_label {
152 Rule::Pre {
153 label: pre_label.clone(),
154 stable_rule,
155 }
156 } else {
157 stable_rule.into()
158 };
159 (self.versions.bump(rule)?, go_versioning)
160 }
161 };
162
163 let updated = self.bump_version(&version, go_versioning, versioned_files)?;
164 let mut actions: Vec<Action> = changes
165 .iter()
166 .filter_map(|change| {
167 if let ChangeSource::ChangeFile { id } = &change.original_source {
168 if version.is_prerelease() {
169 None
170 } else {
171 Some(Action::RemoveFile {
172 path: RelativePathBuf::from(CHANGESET_DIR).join(id.to_file_name()),
173 })
174 }
175 } else {
176 None
177 }
178 })
179 .collect();
180
181 actions.extend(
182 self.release_notes
183 .create_release(version, changes, &self.name)?,
184 );
185
186 Ok((updated, actions))
187 }
188}
189
190fn validate_versioned_files(
195 versioned_files_tracked: Vec<Config>,
196 all_versioned_files: &[VersionedFile],
197) -> Result<(Vec<Config>, Option<Version>), Box<NewError>> {
198 let relevant_files: Vec<(Config, &VersionedFile)> = versioned_files_tracked
199 .into_iter()
200 .map(|path| {
201 all_versioned_files
202 .iter()
203 .find(|f| f.path() == &path)
204 .ok_or_else(|| NewError::NotFound(path.as_path()))
205 .map(|f| (path, f))
206 })
207 .collect::<Result<_, _>>()?;
208
209 let mut first_with_version: Option<(&VersionedFile, Version)> = None;
210 let mut validated_files = Vec::with_capacity(relevant_files.len());
211
212 for (config, versioned_file) in relevant_files.clone() {
213 let config = validate_dependency(config, &relevant_files)?;
214 let is_dep = config.dependency.is_some();
215 validated_files.push(config);
216 if is_dep {
217 continue;
219 }
220 let version = versioned_file.version().map_err(NewError::VersionedFile)?;
221 debug!("{path} has version {version}", path = versioned_file.path());
222 if let Some((first_versioned_file, first_version)) = first_with_version.as_ref() {
223 if *first_version != version {
224 return Err(NewError::InconsistentVersions {
225 first_path: first_versioned_file.path().clone(),
226 first_version: first_version.clone(),
227 second_path: versioned_file.path().clone(),
228 second_version: version,
229 }
230 .into());
231 }
232 } else {
233 first_with_version = Some((versioned_file, version));
234 }
235 }
236
237 Ok((
238 validated_files,
239 first_with_version.map(|(_, version)| version),
240 ))
241}
242
243fn validate_dependency(
244 mut config: Config,
245 versioned_files: &[(Config, &VersionedFile)],
246) -> Result<Config, Box<NewError>> {
247 match (&config.format, config.dependency.is_some()) {
248 (
249 Format::Cargo
250 | Format::PackageJson
251 | Format::PackageLockJson
252 | Format::DenoJson
253 | Format::DenoLock,
254 _,
255 )
256 | (Format::CargoLock, true) => Ok(config),
257 (Format::CargoLock, false) => {
258 let cargo_package_name = versioned_files
261 .iter()
262 .find_map(|(config, file)| match file {
263 VersionedFile::Cargo(file) if config.dependency.is_none() => {
264 cargo::name_from_document(&file.document)
265 }
266 _ => None,
267 })
268 .ok_or(CargoLockNoDependency)?;
269 config.dependency = Some(cargo_package_name.to_string());
270 Ok(config)
271 }
272 (_, true) => Err(NewError::UnsupportedDependency(
273 config.path.file_name().unwrap_or_default().to_string(),
274 )
275 .into()),
276 (_, false) => Ok(config),
277 }
278}
279
280pub enum ChangeConfig {
281 Force(Version),
282 Calculate {
283 prerelease_label: Option<Label>,
284 go_versioning: GoVersioning,
285 },
286}
287
288#[derive(Debug, Error)]
289#[cfg_attr(feature = "miette", derive(Diagnostic))]
290pub enum NewError {
291 #[error(
292 "Found inconsistent versions in package: {first_path} had {first_version} and {second_path} had {second_version}"
293 )]
294 #[cfg_attr(
295 feature = "miette",
296 diagnostic(
297 code = "knope_versioning::inconsistent_versions",
298 url = "https://knope.tech/reference/concepts/package/#version",
299 help = "All files in a package must have the same version"
300 )
301 )]
302 InconsistentVersions {
303 first_path: RelativePathBuf,
304 first_version: Version,
305 second_path: RelativePathBuf,
306 second_version: Version,
307 },
308 #[error("Versioned file not found: {0}")]
309 #[cfg_attr(
310 feature = "miette",
311 diagnostic(
312 code = "knope_versioning::package::versioned_file_not_found",
313 help = "this is likely a bug, please report it",
314 url = "https://github.com/knope-dev/knope/issues/new",
315 )
316 )]
317 NotFound(RelativePathBuf),
318 #[error("Dependencies are not supported in {0} files")]
319 #[cfg_attr(
320 feature = "miette",
321 diagnostic(
322 code(knope_versioning::package::unsupported_dependency),
323 help("Dependencies aren't supported in every file type."),
324 url("https://knope.tech/reference/config-file/packages#versioned_files")
325 )
326 )]
327 UnsupportedDependency(String),
328 #[error("Cargo.lock must specify a dependency")]
329 #[cfg_attr(
330 feature = "miette",
331 diagnostic(
332 code = "knope_versioning::package::cargo_lock_no_dependency",
333 help = "To use `Cargo.lock` in `versioned_files`, you must either manually specify \
334 `dependency` or define a `Cargo.toml` with a `package.name` in the same array.",
335 url = "https://knope.tech/reference/config-file/packages/#cargolock"
336 )
337 )]
338 CargoLockNoDependency,
339 #[error("Packages must have at least one versioned file")]
340 NoPackages,
341 #[error(transparent)]
342 #[cfg_attr(feature = "miette", diagnostic(transparent))]
343 VersionedFile(#[from] versioned_file::Error),
344}
345
346#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
347#[serde(untagged)]
348pub enum Name {
349 Custom(String),
350 #[default]
351 Default,
352}
353
354impl Name {
355 const DEFAULT: &'static str = "default";
356
357 #[must_use]
358 pub fn as_custom(&self) -> Option<&str> {
359 match self {
360 Self::Custom(name) => Some(name),
361 Self::Default => None,
362 }
363 }
364}
365
366impl Display for Name {
367 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
368 match self {
369 Self::Custom(name) => write!(f, "{name}"),
370 Self::Default => write!(f, "{}", Self::DEFAULT),
371 }
372 }
373}
374
375impl AsRef<str> for Name {
376 fn as_ref(&self) -> &str {
377 match self {
378 Self::Custom(name) => name,
379 Self::Default => Self::DEFAULT,
380 }
381 }
382}
383
384impl Deref for Name {
385 type Target = str;
386
387 fn deref(&self) -> &Self::Target {
388 match self {
389 Self::Custom(name) => name,
390 Self::Default => Self::DEFAULT,
391 }
392 }
393}
394
395impl From<&str> for Name {
396 fn from(name: &str) -> Self {
397 Self::Custom(name.to_string())
398 }
399}
400
401impl From<String> for Name {
402 fn from(name: String) -> Self {
403 Self::Custom(name)
404 }
405}
406
407impl From<Cow<'_, str>> for Name {
408 fn from(name: Cow<str>) -> Self {
409 Self::Custom(name.into_owned())
410 }
411}
412
413impl Borrow<str> for Name {
414 fn borrow(&self) -> &str {
415 match self {
416 Self::Custom(name) => name,
417 Self::Default => Self::DEFAULT,
418 }
419 }
420}
421
422impl PartialEq<String> for Name {
423 fn eq(&self, str: &String) -> bool {
424 str == self.as_ref()
425 }
426}
427
428#[derive(Debug, Error)]
429#[cfg_attr(feature = "miette", derive(Diagnostic))]
430pub enum BumpError {
431 #[error(transparent)]
432 #[cfg_attr(feature = "miette", diagnostic(transparent))]
433 SetError(#[from] SetError),
434 #[error(transparent)]
435 PreReleaseNotFound(#[from] PreReleaseNotFound),
436 #[error(transparent)]
437 #[cfg_attr(feature = "miette", diagnostic(transparent))]
438 Time(#[from] TimeError),
439}