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 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 set_version(
85 &mut self,
86 version: Version,
87 go_versioning: GoVersioning,
88 versioned_files: Vec<VersionedFile>,
89 ) -> Result<Vec<VersionedFile>, BumpError> {
90 let versioned_files = 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::<VersionedFile, BumpError>(file)
104 })
105 .try_collect()?;
106 self.versions = version.into();
107 Ok(versioned_files)
108 }
109
110 #[must_use]
111 pub fn get_changes<'a>(
112 &self,
113 changeset: impl IntoIterator<Item = (&'a PackageChange, Option<GitInfo>)>,
114 commit_messages: &[Commit],
115 ) -> Vec<Change> {
116 changes_from_commit_messages(
117 commit_messages,
118 self.scopes.as_ref(),
119 &self.release_notes.sections,
120 )
121 .chain(Change::from_changeset(changeset))
122 .collect()
123 }
124
125 pub fn apply_changes(
134 &mut self,
135 changes: &[Change],
136 versioned_files: Vec<VersionedFile>,
137 config: ChangeConfig,
138 ) -> Result<(Vec<VersionedFile>, Vec<Action>), BumpError> {
139 if let Name::Custom(package_name) = &self.name {
140 debug!("Determining new version for {package_name}");
141 }
142
143 let (version, go_versioning) = match config {
144 ChangeConfig::Force(version) => {
145 debug!("Using overridden version {version}");
146 (version, GoVersioning::BumpMajor)
147 }
148 ChangeConfig::Calculate {
149 prerelease_label,
150 go_versioning,
151 } => {
152 let stable_rule = StableRule::from(changes);
153 let rule = if let Some(pre_label) = prerelease_label {
154 Rule::Pre {
155 label: pre_label.clone(),
156 stable_rule,
157 }
158 } else {
159 stable_rule.into()
160 };
161 (self.versions.calculate_new_version(rule)?, go_versioning)
162 }
163 };
164
165 let updated = self.set_version(version.clone(), go_versioning, versioned_files)?;
166 let mut actions: Vec<Action> = changes
167 .iter()
168 .filter_map(|change| {
169 if let ChangeSource::ChangeFile { id } = &change.original_source {
170 if version.is_prerelease() {
171 None
172 } else {
173 Some(Action::RemoveFile {
174 path: RelativePathBuf::from(CHANGESET_DIR).join(id.to_file_name()),
175 })
176 }
177 } else {
178 None
179 }
180 })
181 .collect();
182
183 actions.extend(
184 self.release_notes
185 .create_release(version, changes, &self.name)?,
186 );
187
188 Ok((updated, actions))
189 }
190
191 #[must_use]
192 pub fn latest_version(&self) -> Option<Version> {
193 self.versions.latest()
194 }
195
196 pub fn calculate_new_version(&self, rule: Rule) -> Result<Version, BumpError> {
209 self.versions
210 .calculate_new_version(rule)
211 .map_err(BumpError::PreReleaseNotFound)
212 }
213}
214
215fn validate_versioned_files(
220 versioned_files_tracked: Vec<Config>,
221 all_versioned_files: &[VersionedFile],
222) -> Result<(Vec<Config>, Option<Version>), Box<NewError>> {
223 let relevant_files: Vec<(Config, &VersionedFile)> = versioned_files_tracked
224 .into_iter()
225 .map(|path| {
226 all_versioned_files
227 .iter()
228 .find(|f| f.path() == &path)
229 .ok_or_else(|| NewError::NotFound(path.as_path()))
230 .map(|f| (path, f))
231 })
232 .collect::<Result<_, _>>()?;
233
234 let mut first_with_version: Option<(&VersionedFile, Version)> = None;
235 let mut validated_files = Vec::with_capacity(relevant_files.len());
236
237 for (config, versioned_file) in relevant_files.clone() {
238 let config = validate_dependency(config, &relevant_files)?;
239 let is_dep = config.dependency.is_some();
240 validated_files.push(config);
241 if is_dep {
242 continue;
244 }
245 let version = versioned_file.version().map_err(NewError::VersionedFile)?;
246 debug!("{path} has version {version}", path = versioned_file.path());
247 if let Some((first_versioned_file, first_version)) = first_with_version.as_ref() {
248 if *first_version != version {
249 return Err(NewError::InconsistentVersions {
250 first_path: first_versioned_file.path().clone(),
251 first_version: first_version.clone(),
252 second_path: versioned_file.path().clone(),
253 second_version: version,
254 }
255 .into());
256 }
257 } else {
258 first_with_version = Some((versioned_file, version));
259 }
260 }
261
262 Ok((
263 validated_files,
264 first_with_version.map(|(_, version)| version),
265 ))
266}
267
268fn validate_dependency(
269 mut config: Config,
270 versioned_files: &[(Config, &VersionedFile)],
271) -> Result<Config, Box<NewError>> {
272 match (&config.format, config.dependency.is_some()) {
273 (
274 Format::Cargo
275 | Format::PackageJson
276 | Format::PackageLockJson
277 | Format::DenoJson
278 | Format::DenoLock,
279 _,
280 )
281 | (Format::CargoLock, true) => Ok(config),
282 (Format::CargoLock, false) => {
283 let cargo_package_name = versioned_files
286 .iter()
287 .find_map(|(config, file)| match file {
288 VersionedFile::Cargo(file) if config.dependency.is_none() => {
289 cargo::name_from_document(&file.document)
290 }
291 _ => None,
292 })
293 .ok_or(CargoLockNoDependency)?;
294 config.dependency = Some(cargo_package_name.to_string());
295 Ok(config)
296 }
297 (_, true) => Err(NewError::UnsupportedDependency(
298 config.path.file_name().unwrap_or_default().to_string(),
299 )
300 .into()),
301 (_, false) => Ok(config),
302 }
303}
304
305pub enum ChangeConfig {
306 Force(Version),
307 Calculate {
308 prerelease_label: Option<Label>,
309 go_versioning: GoVersioning,
310 },
311}
312
313#[derive(Debug, Error)]
314#[cfg_attr(feature = "miette", derive(Diagnostic))]
315pub enum NewError {
316 #[error(
317 "Found inconsistent versions in package: {first_path} had {first_version} and {second_path} had {second_version}"
318 )]
319 #[cfg_attr(
320 feature = "miette",
321 diagnostic(
322 code = "knope_versioning::inconsistent_versions",
323 url = "https://knope.tech/reference/concepts/package/#version",
324 help = "All files in a package must have the same version"
325 )
326 )]
327 InconsistentVersions {
328 first_path: RelativePathBuf,
329 first_version: Version,
330 second_path: RelativePathBuf,
331 second_version: Version,
332 },
333 #[error("Versioned file not found: {0}")]
334 #[cfg_attr(
335 feature = "miette",
336 diagnostic(
337 code = "knope_versioning::package::versioned_file_not_found",
338 help = "this is likely a bug, please report it",
339 url = "https://github.com/knope-dev/knope/issues/new",
340 )
341 )]
342 NotFound(RelativePathBuf),
343 #[error("Dependencies are not supported in {0} files")]
344 #[cfg_attr(
345 feature = "miette",
346 diagnostic(
347 code(knope_versioning::package::unsupported_dependency),
348 help("Dependencies aren't supported in every file type."),
349 url("https://knope.tech/reference/config-file/packages#versioned_files")
350 )
351 )]
352 UnsupportedDependency(String),
353 #[error("Cargo.lock must specify a dependency")]
354 #[cfg_attr(
355 feature = "miette",
356 diagnostic(
357 code = "knope_versioning::package::cargo_lock_no_dependency",
358 help = "To use `Cargo.lock` in `versioned_files`, you must either manually specify \
359 `dependency` or define a `Cargo.toml` with a `package.name` in the same array.",
360 url = "https://knope.tech/reference/config-file/packages/#cargolock"
361 )
362 )]
363 CargoLockNoDependency,
364 #[error("Packages must have at least one versioned file")]
365 NoPackages,
366 #[error(transparent)]
367 #[cfg_attr(feature = "miette", diagnostic(transparent))]
368 VersionedFile(#[from] versioned_file::Error),
369}
370
371#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
372#[serde(untagged)]
373pub enum Name {
374 Custom(String),
375 #[default]
376 Default,
377}
378
379impl Name {
380 const DEFAULT: &'static str = "default";
381
382 #[must_use]
383 pub fn as_custom(&self) -> Option<&str> {
384 match self {
385 Self::Custom(name) => Some(name),
386 Self::Default => None,
387 }
388 }
389}
390
391impl Display for Name {
392 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
393 match self {
394 Self::Custom(name) => write!(f, "{name}"),
395 Self::Default => write!(f, "{}", Self::DEFAULT),
396 }
397 }
398}
399
400impl AsRef<str> for Name {
401 fn as_ref(&self) -> &str {
402 match self {
403 Self::Custom(name) => name,
404 Self::Default => Self::DEFAULT,
405 }
406 }
407}
408
409impl Deref for Name {
410 type Target = str;
411
412 fn deref(&self) -> &Self::Target {
413 match self {
414 Self::Custom(name) => name,
415 Self::Default => Self::DEFAULT,
416 }
417 }
418}
419
420impl From<&str> for Name {
421 fn from(name: &str) -> Self {
422 Self::Custom(name.to_string())
423 }
424}
425
426impl From<String> for Name {
427 fn from(name: String) -> Self {
428 Self::Custom(name)
429 }
430}
431
432impl From<Cow<'_, str>> for Name {
433 fn from(name: Cow<str>) -> Self {
434 Self::Custom(name.into_owned())
435 }
436}
437
438impl Borrow<str> for Name {
439 fn borrow(&self) -> &str {
440 match self {
441 Self::Custom(name) => name,
442 Self::Default => Self::DEFAULT,
443 }
444 }
445}
446
447impl PartialEq<String> for Name {
448 fn eq(&self, str: &String) -> bool {
449 str == self.as_ref()
450 }
451}
452
453#[derive(Debug, Error)]
454#[cfg_attr(feature = "miette", derive(Diagnostic))]
455pub enum BumpError {
456 #[error(transparent)]
457 #[cfg_attr(feature = "miette", diagnostic(transparent))]
458 SetError(#[from] SetError),
459 #[error(transparent)]
460 PreReleaseNotFound(#[from] PreReleaseNotFound),
461 #[error(transparent)]
462 #[cfg_attr(feature = "miette", diagnostic(transparent))]
463 Time(#[from] TimeError),
464}