Skip to main content

use_rust_release/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4//! Release-readiness reporting primitives for RustUse crates.
5
6use 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/// A single release-readiness check.
23#[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/// The overall release status.
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
41pub enum ReleaseStatus {
42    Ready,
43    HasIssues,
44}
45
46/// A recorded release-readiness issue.
47#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
48pub struct ReleaseIssue {
49    pub check: ReleaseCheck,
50    pub message: String,
51}
52
53/// A local release-readiness report.
54#[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    /// Checks a crate directory for release-readiness issues.
63    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    /// Returns the package name when known.
108    #[must_use]
109    pub fn package_name(&self) -> Option<&str> {
110        self.package_name.as_deref()
111    }
112
113    /// Returns the overall release status.
114    #[must_use]
115    pub fn status(&self) -> ReleaseStatus {
116        self.status
117    }
118
119    /// Returns `true` when no issues were recorded.
120    #[must_use]
121    pub fn is_ready(&self) -> bool {
122        self.status == ReleaseStatus::Ready
123    }
124
125    /// Returns the recorded issues.
126    #[must_use]
127    pub fn issues(&self) -> &[ReleaseIssue] {
128        &self.issues
129    }
130}
131
132/// A simple release plan derived from a version bump.
133#[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    /// Builds a release plan from a current version and bump type.
143    #[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(&current_version), ReleaseLevel::Patch),
151            VersionBump::Minor => (next_minor(&current_version), ReleaseLevel::Minor),
152            VersionBump::Major => (next_major(&current_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/// Errors that can occur while producing a release report.
165#[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}