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 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 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 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
190fn 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 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 (Format::Cargo | Format::PackageJson | Format::PackageLockJson, _)
249 | (Format::CargoLock, true) => Ok(config),
250 (Format::CargoLock, false) => {
251 let cargo_package_name = versioned_files
254 .iter()
255 .find_map(|(config, file)| match file {
256 VersionedFile::Cargo(file) if config.dependency.is_none() => {
257 cargo::name_from_document(&file.document)
258 }
259 _ => None,
260 })
261 .ok_or(CargoLockNoDependency)?;
262 config.dependency = Some(cargo_package_name.to_string());
263 Ok(config)
264 }
265 (_, true) => Err(NewError::UnsupportedDependency(
266 config.path.file_name().unwrap_or_default().to_string(),
267 )
268 .into()),
269 (_, false) => Ok(config),
270 }
271}
272
273pub enum ChangeConfig {
274 Force(Version),
275 Calculate {
276 prerelease_label: Option<Label>,
277 go_versioning: GoVersioning,
278 },
279}
280
281#[derive(Debug, Error)]
282#[cfg_attr(feature = "miette", derive(Diagnostic))]
283pub enum NewError {
284 #[error(
285 "Found inconsistent versions in package: {first_path} had {first_version} and {second_path} had {second_version}"
286 )]
287 #[cfg_attr(
288 feature = "miette",
289 diagnostic(
290 code = "knope_versioning::inconsistent_versions",
291 url = "https://knope.tech/reference/concepts/package/#version",
292 help = "All files in a package must have the same version"
293 )
294 )]
295 InconsistentVersions {
296 first_path: RelativePathBuf,
297 first_version: Version,
298 second_path: RelativePathBuf,
299 second_version: Version,
300 },
301 #[error("Versioned file not found: {0}")]
302 #[cfg_attr(
303 feature = "miette",
304 diagnostic(
305 code = "knope_versioning::package::versioned_file_not_found",
306 help = "this is likely a bug, please report it",
307 url = "https://github.com/knope-dev/knope/issues/new",
308 )
309 )]
310 NotFound(RelativePathBuf),
311 #[error("Dependencies are not supported in {0} files")]
312 #[cfg_attr(
313 feature = "miette",
314 diagnostic(
315 code(knope_versioning::package::unsupported_dependency),
316 help("Dependencies aren't supported in every file type."),
317 url("https://knope.tech/reference/config-file/packages#versioned_files")
318 )
319 )]
320 UnsupportedDependency(String),
321 #[error("Cargo.lock must specify a dependency")]
322 #[cfg_attr(
323 feature = "miette",
324 diagnostic(
325 code = "knope_versioning::package::cargo_lock_no_dependency",
326 help = "To use `Cargo.lock` in `versioned_files`, you must either manually specify \
327 `dependency` or define a `Cargo.toml` with a `package.name` in the same array.",
328 url = "https://knope.tech/reference/config-file/packages/#cargolock"
329 )
330 )]
331 CargoLockNoDependency,
332 #[error("Packages must have at least one versioned file")]
333 NoPackages,
334 #[error(transparent)]
335 #[cfg_attr(feature = "miette", diagnostic(transparent))]
336 VersionedFile(#[from] versioned_file::Error),
337}
338
339#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
340#[serde(untagged)]
341pub enum Name {
342 Custom(String),
343 #[default]
344 Default,
345}
346
347impl Name {
348 const DEFAULT: &'static str = "default";
349
350 #[must_use]
351 pub fn as_custom(&self) -> Option<&str> {
352 match self {
353 Self::Custom(name) => Some(name),
354 Self::Default => None,
355 }
356 }
357}
358
359impl Display for Name {
360 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
361 match self {
362 Self::Custom(name) => write!(f, "{name}"),
363 Self::Default => write!(f, "{}", Self::DEFAULT),
364 }
365 }
366}
367
368impl AsRef<str> for Name {
369 fn as_ref(&self) -> &str {
370 match self {
371 Self::Custom(name) => name,
372 Self::Default => Self::DEFAULT,
373 }
374 }
375}
376
377impl Deref for Name {
378 type Target = str;
379
380 fn deref(&self) -> &Self::Target {
381 match self {
382 Self::Custom(name) => name,
383 Self::Default => Self::DEFAULT,
384 }
385 }
386}
387
388impl From<&str> for Name {
389 fn from(name: &str) -> Self {
390 Self::Custom(name.to_string())
391 }
392}
393
394impl From<String> for Name {
395 fn from(name: String) -> Self {
396 Self::Custom(name)
397 }
398}
399
400impl From<Cow<'_, str>> for Name {
401 fn from(name: Cow<str>) -> Self {
402 Self::Custom(name.into_owned())
403 }
404}
405
406impl Borrow<str> for Name {
407 fn borrow(&self) -> &str {
408 match self {
409 Self::Custom(name) => name,
410 Self::Default => Self::DEFAULT,
411 }
412 }
413}
414
415impl PartialEq<String> for Name {
416 fn eq(&self, str: &String) -> bool {
417 str == self.as_ref()
418 }
419}
420
421#[derive(Debug, Error)]
422#[cfg_attr(feature = "miette", derive(Diagnostic))]
423pub enum BumpError {
424 #[error(transparent)]
425 #[cfg_attr(feature = "miette", diagnostic(transparent))]
426 SetError(#[from] SetError),
427 #[error(transparent)]
428 PreReleaseNotFound(#[from] PreReleaseNotFound),
429 #[error(transparent)]
430 #[cfg_attr(feature = "miette", diagnostic(transparent))]
431 Time(#[from] TimeError),
432}