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