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    pub 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 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    /// Apply changes to the package, updating the internal version and returning the list of
124    /// actions to take to complete the changes.
125    ///
126    /// # Errors
127    ///
128    /// If the file is a `go.mod`, there are rules about what versions are allowed.
129    ///
130    /// If serialization of some sort fails, which is a bug, then this will return an error.
131    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
190/// Run through the provided versioned files and make sure they meet all requirements in context.
191///
192/// Returns the potentially modified versioned files (e.g., setting defaults for lockfiles) and
193/// the package version according to those files (if any).
194fn 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            // Dependencies don't have package versions
218            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            // `Cargo.lock` needs to target a dependency. If there is a `Cargo.toml` file which is
259            // _not_ a dependency, we default to that one.
260            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}