xtask/cmd/release/
utils.rs

1use std::collections::{BTreeMap, HashSet};
2use std::path::{Path, PathBuf};
3use std::process::Stdio;
4use std::sync::{Arc, Mutex};
5
6use anyhow::Context;
7use cargo_metadata::camino::Utf8PathBuf;
8use cargo_metadata::{Dependency, DependencyKind, semver};
9use cargo_platform::Platform;
10use sha2::Digest;
11
12use crate::utils::Command;
13
14#[derive(Clone)]
15pub struct Package {
16    pkg: cargo_metadata::Package,
17    version_slated: Option<semver::Version>,
18    published_versions: Arc<Mutex<Vec<CratesIoVersion>>>,
19    data: Arc<Mutex<PackageData>>,
20    metadata: XTaskPackageMetadata,
21}
22
23impl std::ops::Deref for Package {
24    type Target = cargo_metadata::Package;
25
26    fn deref(&self) -> &Self::Target {
27        &self.pkg
28    }
29}
30
31#[derive(serde_derive::Deserialize, Default, Debug, Clone)]
32#[serde(default, rename_all = "kebab-case")]
33struct GitReleaseMeta {
34    name: Option<String>,
35    tag_name: Option<String>,
36    enabled: Option<bool>,
37    body: Option<String>,
38    artifacts: Vec<GitReleaseArtifact>,
39}
40
41#[derive(serde_derive::Deserialize, Debug, Clone)]
42#[serde(rename_all = "kebab-case", tag = "kind")]
43pub enum GitReleaseArtifact {
44    File {
45        path: String,
46        name: Option<String>,
47    },
48}
49
50#[derive(serde_derive::Deserialize, Default, Debug, Clone)]
51#[serde(default, rename_all = "kebab-case")]
52struct XTaskPackageMetadata {
53    group: Option<String>,
54    git_release: GitReleaseMeta,
55    semver_checks: Option<bool>,
56    min_versions_checks: Option<bool>,
57    private_dependencies: HashSet<String>,
58}
59
60impl XTaskPackageMetadata {
61    fn from_package(package: &cargo_metadata::Package) -> anyhow::Result<Self> {
62        let Some(metadata) = package.metadata.get("xtask").and_then(|v| v.get("release")) else {
63            return Ok(Self::default());
64        };
65
66        serde_json::from_value(metadata.clone()).context("xtask")
67    }
68}
69
70#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug)]
71pub enum VersionBump {
72    Minor = 1,
73    Major = 2,
74}
75
76impl VersionBump {
77    fn bump(&mut self, new: Self) -> &mut Self {
78        *self = new.max(*self);
79        self
80    }
81
82    fn bump_major(&mut self) -> &mut Self {
83        self.bump(Self::Major)
84    }
85
86    fn bump_minor(&mut self) -> &mut Self {
87        self.bump(Self::Minor)
88    }
89
90    pub fn next_semver(&self, version: semver::Version) -> semver::Version {
91        match self {
92            // pre-release always bump that
93            _ if !version.pre.is_empty() => semver::Version {
94                pre: semver::Prerelease::new(&increment_last_identifier(&version.pre))
95                    .expect("pre release increment failed, this is a bug"),
96                ..version
97            },
98            // 0.0.x always bump patch
99            _ if version.major == 0 && version.minor == 0 => semver::Version {
100                patch: version.patch + 1,
101                ..version
102            },
103            // 0.x.y => 0.(x + 1).0
104            Self::Major if version.major == 0 => semver::Version {
105                minor: version.minor + 1,
106                patch: 0,
107                ..version
108            },
109            // x.y.z => (x + 1).0.0
110            Self::Major => semver::Version {
111                major: version.major + 1,
112                minor: 0,
113                patch: 0,
114                ..version
115            },
116            // 0.x.y => 0.x.(y + 1)
117            Self::Minor if version.major == 0 => semver::Version {
118                patch: version.patch + 1,
119                ..version
120            },
121            // x.y.z => x.(y + 1).0
122            Self::Minor => semver::Version {
123                minor: version.minor + 1,
124                patch: 0,
125                ..version
126            },
127        }
128    }
129}
130
131fn increment_last_identifier(release: &str) -> String {
132    match release.rsplit_once('.') {
133        Some((left, right)) => {
134            if let Ok(right_num) = right.parse::<u32>() {
135                format!("{left}.{}", right_num + 1)
136            } else {
137                format!("{release}.1")
138            }
139        }
140        None => format!("{release}.1"),
141    }
142}
143
144#[derive(Clone, Copy)]
145pub enum PackageErrorMissing {
146    Description,
147    License,
148    Readme,
149    Repopository,
150    Author,
151    Documentation,
152    ChangelogEntry,
153}
154
155impl std::fmt::Display for PackageErrorMissing {
156    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
157        match self {
158            Self::Description => f.write_str("description in Cargo.toml"),
159            Self::License => f.write_str("license in Cargo.toml"),
160            Self::Readme => f.write_str("readme file path in Cargo.toml"),
161            Self::Repopository => f.write_str("repository link in Cargo.toml"),
162            Self::Author => f.write_str("authors in Cargo.toml"),
163            Self::Documentation => f.write_str("documentation link in Cargo.toml"),
164            Self::ChangelogEntry => f.write_str("changelog entry"),
165        }
166    }
167}
168
169impl From<PackageErrorMissing> for PackageError {
170    fn from(value: PackageErrorMissing) -> Self {
171        PackageError::Missing(value)
172    }
173}
174
175#[derive(Clone, Copy)]
176pub enum LicenseKind {
177    Mit,
178    Apache2,
179    AGpl3,
180}
181
182impl LicenseKind {
183    pub const AGPL_3: &str = "AGPL-3.0";
184    const APACHE2: &str = "Apache-2.0";
185    const MIT: &str = "MIT";
186    pub const MIT_OR_APACHE2: &str = "MIT OR Apache-2.0";
187
188    pub fn from_text(text: &str) -> Option<Vec<LicenseKind>> {
189        match text {
190            Self::MIT_OR_APACHE2 => Some(vec![LicenseKind::Mit, LicenseKind::Apache2]),
191            Self::AGPL_3 => Some(vec![LicenseKind::AGpl3]),
192            _ => None,
193        }
194    }
195}
196
197impl std::fmt::Display for LicenseKind {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        match self {
200            Self::Mit => f.write_str(Self::MIT),
201            Self::Apache2 => f.write_str(Self::APACHE2),
202            Self::AGpl3 => f.write_str(Self::AGPL_3),
203        }
204    }
205}
206
207#[derive(Clone, Copy)]
208pub enum PackageFile {
209    License(LicenseKind),
210    Readme,
211    Changelog,
212}
213
214impl std::fmt::Display for PackageFile {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        match self {
217            Self::Changelog => f.write_str("CHANGELOG.md"),
218            Self::Readme => f.write_str("README.md"),
219            Self::License(name) => write!(f, "LICENSE.{name}"),
220        }
221    }
222}
223
224impl From<PackageFile> for PackageError {
225    fn from(value: PackageFile) -> Self {
226        PackageError::MissingFile(value)
227    }
228}
229
230#[derive(Clone)]
231pub enum PackageError {
232    Missing(PackageErrorMissing),
233    MissingFile(PackageFile),
234    DependencyMissingVersion {
235        name: String,
236        target: Option<Platform>,
237        kind: DependencyKind,
238    },
239    DevDependencyHasVersion {
240        name: String,
241        target: Option<Platform>,
242    },
243    DependencyGroupedVersion {
244        name: String,
245        target: Option<Platform>,
246        kind: DependencyKind,
247    },
248    DependencyNotPublishable {
249        name: String,
250        target: Option<Platform>,
251        kind: DependencyKind,
252    },
253    GitRelease {
254        error: String,
255    },
256    GitReleaseArtifactFileMissing {
257        path: String,
258    },
259    VersionChanged {
260        from: semver::Version,
261        to: semver::Version,
262    },
263}
264
265impl PackageError {
266    pub fn missing_version(dep: &Dependency) -> Self {
267        Self::DependencyMissingVersion {
268            kind: dep.kind,
269            name: dep.name.clone(),
270            target: dep.target.clone(),
271        }
272    }
273
274    pub fn has_version(dep: &Dependency) -> Self {
275        Self::DevDependencyHasVersion {
276            name: dep.name.clone(),
277            target: dep.target.clone(),
278        }
279    }
280
281    pub fn not_publish(dep: &Dependency) -> Self {
282        Self::DependencyNotPublishable {
283            kind: dep.kind,
284            name: dep.name.clone(),
285            target: dep.target.clone(),
286        }
287    }
288
289    pub fn grouped_version(dep: &Dependency) -> Self {
290        Self::DependencyGroupedVersion {
291            kind: dep.kind,
292            name: dep.name.clone(),
293            target: dep.target.clone(),
294        }
295    }
296
297    pub fn version_changed(from: semver::Version, to: semver::Version) -> Self {
298        Self::VersionChanged { from, to }
299    }
300}
301
302pub fn dep_kind_to_name(kind: &DependencyKind) -> &str {
303    match kind {
304        DependencyKind::Build => "build-dependencies",
305        DependencyKind::Development => "dev-dependencies",
306        DependencyKind::Normal => "dependencies",
307        kind => panic!("unknown dep kind: {kind:?}"),
308    }
309}
310
311impl std::fmt::Display for PackageError {
312    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
313        match self {
314            Self::Missing(item) => write!(f, "{item} must be provided"),
315            Self::DependencyMissingVersion {
316                name,
317                target: Some(Platform::Cfg(cfg)),
318                kind,
319            } => {
320                write!(
321                    f,
322                    "`{name}` must have a version in `[target.'{cfg}'.{kind}]`",
323                    kind = dep_kind_to_name(kind)
324                )
325            }
326            Self::DependencyMissingVersion {
327                name,
328                target: Some(Platform::Name(platform)),
329                kind,
330            } => {
331                write!(
332                    f,
333                    "`{name}` must have a version in `[target.{platform}.{kind}]`",
334                    kind = dep_kind_to_name(kind)
335                )
336            }
337            Self::DependencyMissingVersion {
338                name,
339                target: None,
340                kind,
341            } => {
342                write!(f, "`{name}` must have a version in `[{kind}]`", kind = dep_kind_to_name(kind))
343            }
344            Self::DevDependencyHasVersion {
345                name,
346                target: Some(Platform::Cfg(cfg)),
347            } => {
348                write!(f, "`{name}` must not have a version in `[target.'{cfg}'.dev-dependencies]`",)
349            }
350            Self::DevDependencyHasVersion {
351                name,
352                target: Some(Platform::Name(platform)),
353            } => {
354                write!(
355                    f,
356                    "`{name}` must not have a version in `[target.{platform}.dev-dependencies]`",
357                )
358            }
359            Self::DevDependencyHasVersion { name, target: None } => {
360                write!(f, "`{name}` must not have a version in `[dev-dependencies]`")
361            }
362            Self::DependencyNotPublishable {
363                name,
364                target: Some(Platform::Cfg(cfg)),
365                kind,
366            } => {
367                write!(
368                    f,
369                    "`{name}` is not publishable in `[target.'{cfg}'.{kind}]`",
370                    kind = dep_kind_to_name(kind)
371                )
372            }
373            Self::DependencyNotPublishable {
374                name,
375                target: Some(Platform::Name(platform)),
376                kind,
377            } => {
378                write!(
379                    f,
380                    "{name} is not publishable in [target.{platform}.{kind}]",
381                    kind = dep_kind_to_name(kind)
382                )
383            }
384            Self::DependencyNotPublishable {
385                name,
386                target: None,
387                kind,
388            } => {
389                write!(f, "`{name}` is not publishable in `[{kind}]`", kind = dep_kind_to_name(kind))
390            }
391            Self::DependencyGroupedVersion {
392                name,
393                target: Some(Platform::Name(platform)),
394                kind,
395            } => {
396                write!(
397                    f,
398                    "`{name}` must be pinned to the same version as the current crate in `[target.{platform}.{kind}]`",
399                    kind = dep_kind_to_name(kind)
400                )
401            }
402            Self::DependencyGroupedVersion {
403                name,
404                target: Some(Platform::Cfg(cfg)),
405                kind,
406            } => {
407                write!(
408                    f,
409                    "`{name}` must be pinned to the same version as the current crate in `[target.'{cfg}'.{kind}]`",
410                    kind = dep_kind_to_name(kind)
411                )
412            }
413            Self::DependencyGroupedVersion {
414                name,
415                target: None,
416                kind,
417            } => {
418                write!(
419                    f,
420                    "`{name}` must be pinned to the same version as the current crate in `[{kind}]`",
421                    kind = dep_kind_to_name(kind)
422                )
423            }
424            Self::MissingFile(file) => {
425                write!(f, "missing file {file} in crate")
426            }
427            Self::GitRelease { error } => {
428                write!(f, "error generating git release: {error}")
429            }
430            Self::GitReleaseArtifactFileMissing { path } => {
431                write!(f, "missing file artifact used by git release: {path}")
432            }
433            Self::VersionChanged { from, to } => write!(f, "package version has changed `{from}` -> `{to}`"),
434        }
435    }
436}
437
438#[derive(Default)]
439pub struct PackageData {
440    version_bump: Option<VersionBump>,
441    semver_output: Option<(bool, String)>,
442    min_versions_output: Option<String>,
443    next_version: Option<semver::Version>,
444    issues: Vec<PackageError>,
445}
446
447#[derive(serde_derive::Deserialize, Clone)]
448pub struct CratesIoVersion {
449    pub name: String,
450    pub vers: semver::Version,
451    pub cksum: String,
452}
453
454#[tracing::instrument(skip_all, fields(package = %crate_name))]
455pub fn crates_io_versions(crate_name: &str) -> anyhow::Result<Vec<CratesIoVersion>> {
456    let url = crate_index_url(crate_name);
457
458    tracing::info!(url = %url, "checking on crates.io");
459    let command = Command::new("curl")
460        .arg("-s")
461        .arg("-L")
462        .arg("-w")
463        .arg("\n%{http_code}\n")
464        .arg(url)
465        .stdout(Stdio::piped())
466        .stderr(Stdio::piped())
467        .output()
468        .context("curl")?;
469
470    let stdout = String::from_utf8_lossy(&command.stdout);
471    let stderr = String::from_utf8_lossy(&command.stderr);
472    let lines = stdout.lines().map(|l| l.trim()).filter(|l| !l.is_empty()).collect::<Vec<_>>();
473    let status = lines.last().copied().unwrap_or_default();
474    match status {
475        "200" => {}
476        "404" => return Ok(Vec::new()),
477        status => {
478            anyhow::bail!("curl failed ({status}): {stderr} {stdout}")
479        }
480    }
481
482    let mut versions = Vec::new();
483    for line in lines.iter().take(lines.len() - 1).copied() {
484        versions.push(serde_json::from_str::<CratesIoVersion>(line).context("json")?)
485    }
486
487    versions.sort_by(|a, b| a.vers.cmp(&b.vers));
488
489    Ok(versions)
490}
491
492fn crate_index_url(crate_name: &str) -> String {
493    let name = crate_name.to_lowercase();
494    let len = name.len();
495
496    match len {
497        0 => panic!("Invalid crate name"),
498        1 => format!("https://index.crates.io/1/{name}"),
499        2 => format!("https://index.crates.io/2/{name}"),
500        3 => format!("https://index.crates.io/3/{}/{}", &name[0..1], name),
501        _ => {
502            let prefix = &name[0..2];
503            let suffix = &name[2..4];
504            format!("https://index.crates.io/{prefix}/{suffix}/{name}")
505        }
506    }
507}
508
509#[tracing::instrument(skip_all, fields(name = %version.name, version = %version.vers))]
510pub fn download_crate(version: &CratesIoVersion) -> anyhow::Result<PathBuf> {
511    let crate_file = format!("{}-{}.crate", version.name, version.vers);
512    let home = home::cargo_home().context("home dir")?;
513    let registry_cache = home.join("registry").join("cache");
514    let mut desired_path = home.join("scuffle-xtask-release").join(&crate_file);
515    let is_match = |path: &Path| {
516        tracing::debug!("checking {}", path.display());
517        if let Ok(read) = std::fs::read(path) {
518            let hash = sha2::Sha256::digest(&read);
519            let hash = hex::encode(hash);
520            hash == version.cksum
521        } else {
522            false
523        }
524    };
525
526    if is_match(&desired_path) {
527        tracing::debug!("found {}", desired_path.display());
528        return Ok(desired_path);
529    }
530
531    if registry_cache.exists() {
532        let dirs = std::fs::read_dir(registry_cache).context("read_dir")?;
533        for dir in dirs {
534            let dir = dir?;
535            let file_name = dir.file_name();
536            let Some(file_name) = file_name.to_str() else {
537                continue;
538            };
539
540            if file_name.starts_with("index.crates.io-") {
541                desired_path = dir.path().join(&crate_file);
542                if is_match(&desired_path) {
543                    tracing::debug!("found at {}", desired_path.display());
544                    return Ok(desired_path);
545                }
546            }
547        }
548    }
549
550    let url = format!("https://static.crates.io/crates/{}/{crate_file}", version.name);
551
552    tracing::info!(url = %url, "fetching from crates.io");
553
554    let output = Command::new("curl")
555        .arg("-s")
556        .arg("-L")
557        .arg(url)
558        .arg("-o")
559        .arg(&desired_path)
560        .output()
561        .context("download")?;
562
563    if !output.status.success() {
564        anyhow::bail!("curl failed")
565    }
566
567    Ok(desired_path)
568}
569
570#[derive(Debug, Clone)]
571pub struct GitRelease {
572    pub name: String,
573    pub tag_name: String,
574    pub body: String,
575    pub artifacts: Vec<GitReleaseArtifact>,
576}
577
578impl Package {
579    const DEFAULT_GIT_RELEASE_BODY: &str = include_str!("./git_release_body_tmpl.md");
580    const DEFAULT_GIT_TAG_NAME: &str = "{{ package }}-v{{ version }}";
581
582    pub fn new(workspace_meta: &WorkspaceReleaseMetadata, pkg: cargo_metadata::Package) -> anyhow::Result<Self> {
583        Ok(Self {
584            data: Default::default(),
585            version_slated: workspace_meta.packages.get(pkg.name.as_str()).cloned(),
586            metadata: XTaskPackageMetadata::from_package(&pkg)?,
587            published_versions: Default::default(),
588            pkg,
589        })
590    }
591
592    pub fn should_publish(&self) -> bool {
593        self.pkg.publish.is_none()
594    }
595
596    pub fn group(&self) -> &str {
597        self.metadata.group.as_deref().unwrap_or(&self.pkg.name)
598    }
599
600    pub fn is_dep_public(&self, name: &str) -> bool {
601        !self.metadata.private_dependencies.contains(name)
602    }
603
604    pub fn unreleased_req(&self) -> semver::VersionReq {
605        semver::VersionReq {
606            comparators: vec![semver::Comparator {
607                op: semver::Op::GreaterEq,
608                major: self.version.major,
609                minor: Some(self.version.minor),
610                patch: Some(self.version.patch),
611                pre: self.version.pre.clone(),
612            }],
613        }
614    }
615
616    pub fn changelog_path(&self) -> Option<Utf8PathBuf> {
617        if self.group() == self.pkg.name.as_ref() && self.should_release() {
618            Some(self.pkg.manifest_path.with_file_name("CHANGELOG.md"))
619        } else {
620            None
621        }
622    }
623
624    pub fn should_git_release(&self) -> bool {
625        self.metadata.git_release.enabled.unwrap_or_else(|| self.should_publish()) && self.group() == self.pkg.name.as_ref()
626    }
627
628    pub fn git_release(&self) -> anyhow::Result<Option<GitRelease>> {
629        if !self.should_git_release() {
630            return Ok(None);
631        }
632
633        Ok(Some(GitRelease {
634            body: self.git_release_body().context("body")?,
635            name: self.git_release_name().context("name")?,
636            tag_name: self.git_tag_name().context("tag")?,
637            artifacts: self.metadata.git_release.artifacts.clone(),
638        }))
639    }
640
641    pub fn should_semver_checks(&self) -> bool {
642        self.metadata.semver_checks.unwrap_or(true) && self.should_publish() && self.pkg.targets.iter().any(|t| t.is_lib())
643    }
644
645    pub fn should_min_version_check(&self) -> bool {
646        self.metadata.min_versions_checks.unwrap_or(true)
647            && self.should_publish()
648            && self.pkg.targets.iter().any(|t| t.is_lib())
649    }
650
651    pub fn should_release(&self) -> bool {
652        self.should_git_release() || self.should_publish()
653    }
654
655    pub fn last_published_version(&self) -> Option<CratesIoVersion> {
656        let published_versions = self.published_versions.lock().unwrap();
657        let version = published_versions.binary_search_by(|r| r.vers.cmp(&self.pkg.version));
658        match version {
659            Ok(idx) => Some(published_versions[idx].clone()),
660            Err(idx) => idx.checked_sub(1).and_then(|idx| published_versions.get(idx).cloned()),
661        }
662    }
663
664    pub fn slated_for_release(&self) -> bool {
665        self.should_release()
666            && self.version_slated.as_ref().is_some_and(|v| v == &self.version)
667            && self.last_published_version().is_none_or(|p| p.vers != self.version)
668    }
669
670    fn git_tag_name(&self) -> anyhow::Result<String> {
671        self.git_tag_name_version(&self.pkg.version)
672    }
673
674    fn git_tag_name_version(&self, version: &semver::Version) -> anyhow::Result<String> {
675        let tag_name = self
676            .metadata
677            .git_release
678            .tag_name
679            .as_deref()
680            .unwrap_or(Self::DEFAULT_GIT_TAG_NAME);
681
682        let env = minijinja::Environment::new();
683        let ctx = minijinja::context! {
684            package => &self.pkg.name,
685            version => version,
686        };
687
688        env.render_str(tag_name, ctx).context("render")
689    }
690
691    fn git_release_name(&self) -> anyhow::Result<String> {
692        let tag_name = self
693            .metadata
694            .git_release
695            .name
696            .as_deref()
697            .or(self.metadata.git_release.tag_name.as_deref())
698            .unwrap_or(Self::DEFAULT_GIT_TAG_NAME);
699
700        let env = minijinja::Environment::new();
701        let ctx = minijinja::context! {
702            package => &self.pkg.name,
703            version => &self.pkg.version,
704        };
705
706        env.render_str(tag_name, ctx).context("render")
707    }
708
709    fn git_release_body(&self) -> anyhow::Result<String> {
710        let tag_name = self
711            .metadata
712            .git_release
713            .body
714            .as_deref()
715            .unwrap_or(Self::DEFAULT_GIT_RELEASE_BODY);
716
717        let changelog = if let Some(path) = self.changelog_path() {
718            let changelogs = std::fs::read_to_string(path).context("read changelog")?;
719            changelogs
720                .lines()
721                .skip_while(|s| !s.starts_with("## ")) // skip to the first `## [Unreleased]`
722                .skip(1) // skips the `## [Unreleased]` line
723                .skip_while(|s| !s.starts_with("## ")) // skip to the first `## [{{ version }}]`
724                .skip(1) // skips the `## [{{ version }}]` line
725                .take_while(|s| !s.starts_with("## ")) // takes all lines until the next `## [{{ version }}]`
726                .skip_while(|s| s.is_empty())
727                .map(|s| s.trim()) // removes all whitespace
728                .collect::<Vec<_>>()
729                .join("\n")
730        } else {
731            String::new()
732        };
733
734        let env = minijinja::Environment::new();
735        let ctx = minijinja::context! {
736            package => &self.pkg.name,
737            version => &self.pkg.version,
738            publish => self.should_publish(),
739            changelog => changelog,
740        };
741
742        env.render_str(tag_name, ctx).context("render")
743    }
744
745    pub fn has_branch_changes(&self, base: &str) -> bool {
746        let output = match Command::new("git")
747            .arg("diff")
748            .arg(format!("{base}..HEAD"))
749            .arg("--quiet")
750            .arg("--")
751            .arg(self.pkg.manifest_path.parent().unwrap())
752            .stderr(Stdio::piped())
753            .stdout(Stdio::piped())
754            .output()
755        {
756            Ok(output) => output,
757            Err(err) => {
758                tracing::warn!("git diff failed: {err}");
759                return true;
760            }
761        };
762
763        if !output.status.success() && !output.stderr.is_empty() {
764            tracing::warn!("git diff failed: {}", String::from_utf8_lossy(&output.stderr));
765        }
766
767        !output.status.success()
768    }
769
770    pub fn has_changed_since_publish(&self) -> anyhow::Result<bool> {
771        let last_commit = if self.should_publish() {
772            let Some(last_published) = self.last_published_version() else {
773                return Ok(false);
774            };
775
776            // It only makes sense to check git diffs if we are currently on the latest published version.
777            if last_published.vers != self.pkg.version {
778                return Ok(false);
779            }
780
781            let crate_path = download_crate(&last_published)?;
782            let tar_output = Command::new("tar")
783                .arg("-xOzf")
784                .arg(crate_path)
785                .arg(format!(
786                    "{}-{}/.cargo_vcs_info.json",
787                    last_published.name, last_published.vers
788                ))
789                .stderr(Stdio::piped())
790                .stdout(Stdio::piped())
791                .output()
792                .context("tar get cargo vcs info")?;
793
794            if !tar_output.status.success() {
795                anyhow::bail!("tar extact of crate failed: {}", String::from_utf8_lossy(&tar_output.stderr))
796            }
797
798            #[derive(serde::Deserialize)]
799            struct VscInfo {
800                git: VscInfoGit,
801            }
802
803            #[derive(serde::Deserialize)]
804            struct VscInfoGit {
805                sha1: String,
806            }
807
808            let vsc_info: VscInfo = serde_json::from_slice(&tar_output.stdout).context("invalid vcs info")?;
809            vsc_info.git.sha1
810        } else if self.should_release() {
811            // check if a tag exists.
812            let tag_name = self.git_tag_name().context("tag name")?;
813
814            let output = Command::new("git")
815                .arg("rev-parse")
816                .arg(format!("refs/tags/{tag_name}"))
817                .stderr(Stdio::piped())
818                .stdout(Stdio::piped())
819                .output()
820                .context("git rev-parse for tag")?;
821
822            // tag doesnt exist
823            if !output.status.success() {
824                return Ok(false);
825            }
826
827            String::from_utf8_lossy(&output.stdout).trim().to_owned()
828        } else {
829            return Ok(false);
830        };
831
832        // git diff HEAD~100..HEAD --quiet -- README.md
833        let output = Command::new("git")
834            .arg("diff")
835            .arg(format!("{last_commit}..HEAD"))
836            .arg("--quiet")
837            .arg("--")
838            .arg(self.pkg.manifest_path.parent().unwrap())
839            .stderr(Stdio::piped())
840            .stdout(Stdio::piped())
841            .output()
842            .context("git diff")?;
843
844        if !output.status.success() && !output.stderr.is_empty() {
845            anyhow::bail!("failed to get git diff {}", String::from_utf8_lossy(&output.stderr))
846        }
847
848        Ok(!output.status.success())
849    }
850
851    pub fn next_version(&self) -> Option<semver::Version> {
852        self.data.lock().unwrap().next_version.clone()
853    }
854
855    pub fn set_next_version(&self, version: semver::Version) {
856        if self.version != version {
857            self.data.lock().unwrap().next_version = Some(version);
858        }
859    }
860
861    pub fn report_change(&self) {
862        self.data
863            .lock()
864            .unwrap()
865            .version_bump
866            .get_or_insert(VersionBump::Minor)
867            .bump_minor();
868    }
869
870    pub fn report_breaking_change(&self) {
871        self.data
872            .lock()
873            .unwrap()
874            .version_bump
875            .get_or_insert(VersionBump::Major)
876            .bump_major();
877    }
878
879    pub fn version_bump(&self) -> Option<VersionBump> {
880        self.data.lock().unwrap().version_bump
881    }
882
883    pub fn published_versions(&self) -> Vec<CratesIoVersion> {
884        self.published_versions.lock().unwrap().clone()
885    }
886
887    pub fn fetch_published(&self) -> anyhow::Result<()> {
888        if self.should_publish() {
889            *self.published_versions.lock().unwrap() = crates_io_versions(&self.pkg.name)?;
890        }
891        Ok(())
892    }
893
894    pub fn report_issue(&self, issue: impl Into<PackageError>) {
895        let issue = issue.into();
896        tracing::warn!("{}", issue.to_string().replace("`", ""));
897        self.data.lock().unwrap().issues.push(issue);
898    }
899
900    pub fn set_semver_output(&self, breaking: bool, output: String) {
901        if breaking {
902            self.report_breaking_change();
903        }
904        tracing::debug!(breaking = breaking, "cargo-semver-checks-output: {output}");
905        self.data.lock().unwrap().semver_output = Some((breaking, output));
906    }
907
908    pub fn set_min_versions_output(&self, output: String) {
909        self.data.lock().unwrap().min_versions_output = Some(output);
910    }
911
912    pub fn semver_output(&self) -> Option<(bool, String)> {
913        self.data.lock().unwrap().semver_output.clone()
914    }
915
916    pub fn min_versions_output(&self) -> Option<String> {
917        self.data.lock().unwrap().min_versions_output.clone()
918    }
919
920    pub fn errors(&self) -> Vec<PackageError> {
921        self.data.lock().unwrap().issues.clone()
922    }
923}
924
925#[derive(serde_derive::Deserialize, serde_derive::Serialize, Default)]
926pub struct WorkspaceReleaseMetadata {
927    pub packages: BTreeMap<String, semver::Version>,
928}
929
930impl WorkspaceReleaseMetadata {
931    pub fn from_metadadata(metadata: &cargo_metadata::Metadata) -> anyhow::Result<Self> {
932        let Some(value) = metadata.workspace_metadata.get("xtask").and_then(|x| x.get("release")) else {
933            return Ok(Default::default());
934        };
935
936        serde_json::from_value(value.clone()).context("deserialize")
937    }
938}
939
940pub fn vers_to_comp(vers: semver::Version) -> semver::Comparator {
941    semver::Comparator {
942        op: semver::Op::Caret,
943        major: vers.major,
944        minor: Some(vers.minor),
945        patch: Some(vers.patch),
946        pre: vers.pre,
947    }
948}