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