knope_versioning/
package.rs

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