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 action::Action,
18 changes::{
19 conventional_commit::changes_from_commit_messages, Change, ChangeSource, CHANGESET_DIR,
20 },
21 release_notes::{ReleaseNotes, TimeError},
22 semver::{Label, PackageVersions, PreReleaseNotFound, Rule, StableRule, Version},
23 versioned_file,
24 versioned_file::{cargo, Config, Format, GoVersioning, SetError, VersionedFile},
25 PackageNewError::CargoLockNoDependency,
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 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 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 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
196fn 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 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, _) | (Format::CargoLock, true) => Ok(config),
257 (Format::CargoLock, false) => {
258 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("Found inconsistent versions in package: {first_path} had {first_version} and {second_path} had {second_version}")]
289 #[cfg_attr(
290 feature = "miette",
291 diagnostic(
292 code = "knope_versioning::inconsistent_versions",
293 url = "https://knope.tech/reference/concepts/package/#version",
294 help = "All files in a package must have the same version"
295 )
296 )]
297 InconsistentVersions {
298 first_path: RelativePathBuf,
299 first_version: Version,
300 second_path: RelativePathBuf,
301 second_version: Version,
302 },
303 #[error("Versioned file not found: {0}")]
304 #[cfg_attr(
305 feature = "miette",
306 diagnostic(
307 code = "knope_versioning::package::versioned_file_not_found",
308 help = "this is likely a bug, please report it",
309 url = "https://github.com/knope-dev/knope/issues/new",
310 )
311 )]
312 NotFound(RelativePathBuf),
313 #[error("Dependencies are not supported in {0} files")]
314 #[cfg_attr(
315 feature = "miette",
316 diagnostic(
317 code(knope_versioning::package::unsupported_dependency),
318 help("Dependencies aren't supported in every file type."),
319 url("https://knope.tech/reference/config-file/packages#versioned_files")
320 )
321 )]
322 UnsupportedDependency(&'static str),
323 #[error("Cargo.lock must specify a dependency")]
324 #[cfg_attr(
325 feature = "miette",
326 diagnostic(
327 code = "knope_versioning::package::cargo_lock_no_dependency",
328 help = "To use `Cargo.lock` in `versioned_files`, you must either manually specify \
329 `dependency` or define a `Cargo.toml` with a `package.name` in the same array.",
330 url = "https://knope.tech/reference/config-file/packages/#cargolock"
331 )
332 )]
333 CargoLockNoDependency,
334 #[error("Packages must have at least one versioned file")]
335 NoPackages,
336 #[error(transparent)]
337 #[cfg_attr(feature = "miette", diagnostic(transparent))]
338 VersionedFile(#[from] versioned_file::Error),
339}
340
341#[derive(Clone, Debug, Default, Deserialize, Eq, Hash, PartialEq, Serialize)]
342#[serde(untagged)]
343pub enum Name {
344 Custom(String),
345 #[default]
346 Default,
347}
348
349impl Name {
350 const DEFAULT: &'static str = "default";
351
352 #[must_use]
353 pub fn as_custom(&self) -> Option<&str> {
354 match self {
355 Self::Custom(name) => Some(name),
356 Self::Default => None,
357 }
358 }
359}
360
361impl Display for Name {
362 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
363 match self {
364 Self::Custom(name) => write!(f, "{name}"),
365 Self::Default => write!(f, "{}", Self::DEFAULT),
366 }
367 }
368}
369
370impl AsRef<str> for Name {
371 fn as_ref(&self) -> &str {
372 match self {
373 Self::Custom(name) => name,
374 Self::Default => Self::DEFAULT,
375 }
376 }
377}
378
379impl Deref for Name {
380 type Target = str;
381
382 fn deref(&self) -> &Self::Target {
383 match self {
384 Self::Custom(name) => name,
385 Self::Default => Self::DEFAULT,
386 }
387 }
388}
389
390impl From<&str> for Name {
391 fn from(name: &str) -> Self {
392 Self::Custom(name.to_string())
393 }
394}
395
396impl From<String> for Name {
397 fn from(name: String) -> Self {
398 Self::Custom(name)
399 }
400}
401
402impl From<Cow<'_, str>> for Name {
403 fn from(name: Cow<str>) -> Self {
404 Self::Custom(name.into_owned())
405 }
406}
407
408impl Borrow<str> for Name {
409 fn borrow(&self) -> &str {
410 match self {
411 Self::Custom(name) => name,
412 Self::Default => Self::DEFAULT,
413 }
414 }
415}
416
417impl PartialEq<String> for Name {
418 fn eq(&self, str: &String) -> bool {
419 str == self.as_ref()
420 }
421}
422
423pub enum Bump {
424 Manual(Version),
425 Rule(Rule),
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}