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 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 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 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
198fn 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 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 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}