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