1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use std::{
7 error::Error,
8 fmt,
9 path::{Path, PathBuf},
10};
11
12use serde::{Deserialize, Serialize};
13use use_crate::{
14 CrateMetadata, expected_docs_url, expected_repository_url, is_use_prefixed, is_valid_crate_name,
15};
16use use_rust_cargo::{CargoManifest, CargoManifestError, find_workspace_root, load_manifest};
17use use_version::{
18 ReleaseLevel, Version, VersionBump, VersionError, next_major, next_minor, next_patch,
19 parse_version,
20};
21
22#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
24pub enum ReleaseCheck {
25 CargoTomlExists,
26 ReadmeExists,
27 LicenseFilesExist,
28 DescriptionPresent,
29 LicensePresent,
30 RepositoryPresent,
31 DocumentationPresent,
32 HomepagePresent,
33 Publishable,
34 VersionValid,
35 CrateNameValid,
36 RustUseNaming,
37}
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
41pub enum ReleaseStatus {
42 Ready,
43 HasIssues,
44}
45
46#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ReleaseIssue {
49 pub check: ReleaseCheck,
50 pub message: String,
51}
52
53#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
55pub struct ReleaseReport {
56 package_name: Option<String>,
57 status: ReleaseStatus,
58 issues: Vec<ReleaseIssue>,
59}
60
61impl ReleaseReport {
62 pub fn check(path: impl AsRef<Path>) -> Result<Self, ReleaseError> {
64 let package_root = normalize_package_root(path.as_ref());
65 let manifest_path = package_root.join("Cargo.toml");
66 let readme_path = package_root.join("README.md");
67 let mut issues = Vec::new();
68 let mut package_name = None;
69
70 if !manifest_path.is_file() {
71 issues.push(issue(
72 ReleaseCheck::CargoTomlExists,
73 "Cargo.toml is missing",
74 ));
75 }
76
77 if !readme_path.is_file() {
78 issues.push(issue(ReleaseCheck::ReadmeExists, "README.md is missing"));
79 }
80
81 if !has_license_files(&package_root) {
82 issues.push(issue(
83 ReleaseCheck::LicenseFilesExist,
84 "license files are missing from the package or workspace root",
85 ));
86 }
87
88 if manifest_path.is_file() {
89 let manifest = load_manifest(&manifest_path)?;
90 package_name = manifest.package_name().map(ToOwned::to_owned);
91 append_manifest_issues(&manifest, &mut issues)?;
92 }
93
94 let status = if issues.is_empty() {
95 ReleaseStatus::Ready
96 } else {
97 ReleaseStatus::HasIssues
98 };
99
100 Ok(Self {
101 package_name,
102 status,
103 issues,
104 })
105 }
106
107 #[must_use]
109 pub fn package_name(&self) -> Option<&str> {
110 self.package_name.as_deref()
111 }
112
113 #[must_use]
115 pub fn status(&self) -> ReleaseStatus {
116 self.status
117 }
118
119 #[must_use]
121 pub fn is_ready(&self) -> bool {
122 self.status == ReleaseStatus::Ready
123 }
124
125 #[must_use]
127 pub fn issues(&self) -> &[ReleaseIssue] {
128 &self.issues
129 }
130}
131
132#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
134pub struct ReleasePlan {
135 pub package_name: String,
136 pub current_version: Version,
137 pub next_version: Version,
138 pub level: ReleaseLevel,
139}
140
141impl ReleasePlan {
142 #[must_use]
144 pub fn from_bump(
145 package_name: impl Into<String>,
146 current_version: Version,
147 bump: VersionBump,
148 ) -> Self {
149 let (next_version, level) = match bump {
150 VersionBump::Patch => (next_patch(¤t_version), ReleaseLevel::Patch),
151 VersionBump::Minor => (next_minor(¤t_version), ReleaseLevel::Minor),
152 VersionBump::Major => (next_major(¤t_version), ReleaseLevel::Major),
153 };
154
155 Self {
156 package_name: package_name.into(),
157 current_version,
158 next_version,
159 level,
160 }
161 }
162}
163
164#[derive(Debug)]
166pub enum ReleaseError {
167 Manifest(CargoManifestError),
168 Version(VersionError),
169}
170
171impl fmt::Display for ReleaseError {
172 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
173 match self {
174 Self::Manifest(error) => write!(formatter, "failed to inspect Cargo manifest: {error}"),
175 Self::Version(error) => {
176 write!(formatter, "failed to inspect version metadata: {error}")
177 },
178 }
179 }
180}
181
182impl Error for ReleaseError {
183 fn source(&self) -> Option<&(dyn Error + 'static)> {
184 match self {
185 Self::Manifest(error) => Some(error),
186 Self::Version(error) => Some(error),
187 }
188 }
189}
190
191impl From<CargoManifestError> for ReleaseError {
192 fn from(error: CargoManifestError) -> Self {
193 Self::Manifest(error)
194 }
195}
196
197impl From<VersionError> for ReleaseError {
198 fn from(error: VersionError) -> Self {
199 Self::Version(error)
200 }
201}
202
203fn append_manifest_issues(
204 manifest: &CargoManifest,
205 issues: &mut Vec<ReleaseIssue>,
206) -> Result<(), ReleaseError> {
207 if manifest.description().is_none() {
208 issues.push(issue(
209 ReleaseCheck::DescriptionPresent,
210 "package.description is missing",
211 ));
212 }
213
214 if manifest.license().is_none() {
215 issues.push(issue(
216 ReleaseCheck::LicensePresent,
217 "package.license is missing",
218 ));
219 }
220
221 if manifest.repository().is_none() {
222 issues.push(issue(
223 ReleaseCheck::RepositoryPresent,
224 "package.repository is missing",
225 ));
226 }
227
228 if manifest.documentation().is_none() {
229 issues.push(issue(
230 ReleaseCheck::DocumentationPresent,
231 "package.documentation is missing",
232 ));
233 }
234
235 if manifest.homepage().is_none() {
236 issues.push(issue(
237 ReleaseCheck::HomepagePresent,
238 "package.homepage is missing",
239 ));
240 }
241
242 if !manifest.is_publishable() {
243 issues.push(issue(
244 ReleaseCheck::Publishable,
245 "package is not publishable under current Cargo metadata",
246 ));
247 }
248
249 match manifest.package_version() {
250 Some(version) => {
251 if parse_version(version).is_err() {
252 issues.push(issue(
253 ReleaseCheck::VersionValid,
254 "package.version is not valid semantic versioning",
255 ));
256 }
257 },
258 None => issues.push(issue(
259 ReleaseCheck::VersionValid,
260 "package.version is missing",
261 )),
262 }
263
264 match manifest.package_name() {
265 Some(name) => {
266 if !is_valid_crate_name(name) {
267 issues.push(issue(
268 ReleaseCheck::CrateNameValid,
269 "package.name is not a valid crate name",
270 ));
271 }
272
273 if !is_use_prefixed(name) {
274 issues.push(issue(
275 ReleaseCheck::RustUseNaming,
276 "package.name does not follow the RustUse use-* naming convention",
277 ));
278 }
279
280 if let Some(repository) = manifest.repository() {
281 let expected = expected_repository_url(name);
282 if repository != expected.as_str() {
283 issues.push(issue(
284 ReleaseCheck::RustUseNaming,
285 &format!("package.repository should be {}", expected.as_str()),
286 ));
287 }
288 }
289
290 if let Some(documentation) = manifest.documentation() {
291 let expected = expected_docs_url(name);
292 if documentation != expected.as_str() {
293 issues.push(issue(
294 ReleaseCheck::RustUseNaming,
295 &format!("package.documentation should be {}", expected.as_str()),
296 ));
297 }
298 }
299
300 if let Some(homepage) = manifest.homepage() {
301 if homepage != "https://rustuse.org" {
302 issues.push(issue(
303 ReleaseCheck::RustUseNaming,
304 "package.homepage should be https://rustuse.org",
305 ));
306 }
307 }
308
309 if let Some(metadata) =
310 CrateMetadata::from_manifest_path(manifest.path().as_path().as_std_path())
311 {
312 for message in use_crate::validate_crate_metadata(&metadata) {
313 issues.push(issue(ReleaseCheck::RustUseNaming, &message));
314 }
315 }
316 },
317 None => issues.push(issue(
318 ReleaseCheck::CrateNameValid,
319 "package.name is missing",
320 )),
321 }
322
323 Ok(())
324}
325
326fn issue(check: ReleaseCheck, message: &str) -> ReleaseIssue {
327 ReleaseIssue {
328 check,
329 message: message.to_string(),
330 }
331}
332
333fn normalize_package_root(path: &Path) -> PathBuf {
334 if path.is_dir() {
335 return path.to_path_buf();
336 }
337
338 path.parent()
339 .map_or_else(|| PathBuf::from("."), Path::to_path_buf)
340}
341
342fn has_license_files(package_root: &Path) -> bool {
343 has_license_files_in(package_root)
344 || find_workspace_root(package_root)
345 .map(|root| has_license_files_in(root.as_path().as_std_path()))
346 .unwrap_or(false)
347}
348
349fn has_license_files_in(root: &Path) -> bool {
350 ["LICENSE", "LICENSE.md", "LICENSE-MIT", "LICENSE-APACHE"]
351 .iter()
352 .any(|name| root.join(name).is_file())
353}
354
355#[cfg(test)]
356mod tests {
357 use std::{
358 fs,
359 path::{Path, PathBuf},
360 process,
361 time::{SystemTime, UNIX_EPOCH},
362 };
363
364 use use_version::{VersionBump, parse_version};
365
366 use super::{ReleasePlan, ReleaseReport, ReleaseStatus};
367
368 #[test]
369 fn reports_ready_workspace_member() {
370 let temp_dir = TestDir::new("release-ready");
371 write_file(&temp_dir.path().join("LICENSE-MIT"), "MIT\n");
372 write_file(&temp_dir.path().join("LICENSE-APACHE"), "Apache\n");
373 write_file(
374 &temp_dir.path().join("Cargo.toml"),
375 r#"[workspace]
376members = ["crates/use-demo"]
377"#,
378 );
379 write_file(
380 &temp_dir
381 .path()
382 .join("crates")
383 .join("use-demo")
384 .join("Cargo.toml"),
385 r#"[package]
386name = "use-demo"
387version = "0.1.0"
388edition = "2021"
389description = "demo"
390license = "MIT OR Apache-2.0"
391repository = "https://github.com/RustUse/use-demo"
392documentation = "https://docs.rs/use-demo"
393homepage = "https://rustuse.org"
394readme = "README.md"
395"#,
396 );
397 write_file(
398 &temp_dir
399 .path()
400 .join("crates")
401 .join("use-demo")
402 .join("README.md"),
403 "# use-demo\n",
404 );
405 write_file(
406 &temp_dir
407 .path()
408 .join("crates")
409 .join("use-demo")
410 .join("src")
411 .join("lib.rs"),
412 "pub fn sample() {}\n",
413 );
414
415 let report = ReleaseReport::check(temp_dir.path().join("crates").join("use-demo"))
416 .expect("release report should build");
417
418 assert!(report.is_ready());
419 assert_eq!(report.status(), ReleaseStatus::Ready);
420 assert!(report.issues().is_empty());
421 }
422
423 #[test]
424 fn reports_release_issues_for_missing_metadata() {
425 let temp_dir = TestDir::new("release-issues");
426 write_file(
427 &temp_dir.path().join("Cargo.toml"),
428 r#"[package]
429name = "Bad crate"
430version = "banana"
431edition = "2021"
432"#,
433 );
434
435 let report = ReleaseReport::check(temp_dir.path()).expect("release report should build");
436
437 assert!(!report.is_ready());
438 assert!(report.issues().len() >= 6);
439 }
440
441 #[test]
442 fn creates_release_plans() {
443 let current = parse_version("0.1.0").expect("version should parse");
444 let plan = ReleasePlan::from_bump("use-demo", current, VersionBump::Minor);
445
446 assert_eq!(plan.package_name, "use-demo");
447 assert_eq!(plan.next_version.to_string(), "0.2.0");
448 }
449
450 struct TestDir {
451 path: PathBuf,
452 }
453
454 impl TestDir {
455 fn new(label: &str) -> Self {
456 let mut path = std::env::temp_dir();
457 let nanos = SystemTime::now()
458 .duration_since(UNIX_EPOCH)
459 .expect("system clock should be after UNIX_EPOCH")
460 .as_nanos();
461 path.push(format!(
462 "use-rust-release-{label}-{}-{nanos}",
463 process::id()
464 ));
465 fs::create_dir_all(&path).expect("temporary directory should be created");
466 Self { path }
467 }
468
469 fn path(&self) -> &Path {
470 &self.path
471 }
472 }
473
474 impl Drop for TestDir {
475 fn drop(&mut self) {
476 let _ = fs::remove_dir_all(&self.path);
477 }
478 }
479
480 fn write_file(path: &Path, contents: &str) {
481 if let Some(parent) = path.parent() {
482 fs::create_dir_all(parent).expect("parent directories should be created");
483 }
484
485 fs::write(path, contents).expect("file should be written");
486 }
487}