knope_versioning/
package.rs

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