Skip to main content

knope_versioning/
package.rs

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    /// Try and combine a bunch of versioned files into one logical package.
41    ///
42    /// # Errors
43    ///
44    /// There must be at least one versioned file, and all files must have the same version.
45    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    /// Returns the actions that must be taken to set this package to the new version, along
72    /// with the version it was set to.
73    ///
74    /// The version can either be calculated from a semver rule or specified manually.
75    ///
76    /// # Errors
77    ///
78    /// If the file is a `go.mod`, there are rules about what versions are allowed.
79    ///
80    /// If serialization of some sort fails, which is a bug, then this will return an error.
81    ///
82    /// If the [`Rule::Release`] is specified, but there is no current prerelease, that's an
83    /// error too.
84    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    /// Apply changes to the package, updating the internal version and returning the list of
126    /// actions to take to complete the changes.
127    ///
128    /// # Errors
129    ///
130    /// If the file is a `go.mod`, there are rules about what versions are allowed.
131    ///
132    /// If serialization of some sort fails, which is a bug, then this will return an error.
133    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    /// Apply a Rule to a [`PackageVersion`], incrementing & resetting the correct components.
197    ///
198    /// # Versions 0.x
199    ///
200    /// Versions with major component 0 have special meaning in Semantic Versioning and therefore have
201    /// different behavior:
202    /// 1. [`Rule::Major`] will bump the minor component.
203    /// 2. [`Stable(Minor)`] will bump the patch component.
204    ///
205    /// # Errors
206    ///
207    /// Can fail if trying to run [`Rule::Release`] when there is no pre-release.
208    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
215/// Run through the provided versioned files and make sure they meet all requirements in context.
216///
217/// Returns the potentially modified versioned files (e.g., setting defaults for lockfiles) and
218/// the package version according to those files (if any).
219fn 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            // Dependencies don't have package versions
243            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            // `Cargo.lock` needs to target a dependency. If there is a `Cargo.toml` file which is
284            // _not_ a dependency, we default to that one.
285            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}