1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
#![deny(missing_docs)]

//! # cargo-dist-schema
//!
//! This crate exists to serialize and deserialize the dist-manifest.json produced
//! by cargo-dist. Ideally it should be reasonably forward and backward compatible
//! with different versions of this format.
//!
//! The root type of the schema is [`DistManifest`][].

use std::collections::BTreeMap;

use schemars::JsonSchema;
use semver::Version;
use serde::{Deserialize, Serialize};

/// A local system path on the machine cargo-dist was run.
///
/// This is a String because when deserializing this may be a path format from a different OS!
pub type LocalPath = String;
/// A relative path inside an artifact
///
/// This is a String because when deserializing this may be a path format from a different OS!
///
/// (Should we normalize this one?)
pub type RelPath = String;
/// The unique ID of an Artifact
pub type ArtifactId = String;

/// A report of the releases and artifacts that cargo-dist generated
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DistManifest {
    /// The version of cargo-dist that generated this
    #[serde(default)]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dist_version: Option<String>,
    /// The (git) tag associated with this announcement
    #[serde(default)]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub announcement_tag: Option<String>,
    /// True if --tag wasn't explicitly passed to cargo-dist. This usually indicates
    /// some kind of dry-run state like pr-run-mode=upload. Some third-party tools
    /// may use this as a proxy for "is dry run"
    #[serde(default)]
    pub announcement_tag_is_implicit: bool,
    /// Whether this announcement appears to be a prerelease
    #[serde(default)]
    pub announcement_is_prerelease: bool,
    /// A title for the announcement
    #[serde(default)]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub announcement_title: Option<String>,
    /// A changelog for the announcement
    #[serde(default)]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub announcement_changelog: Option<String>,
    /// A Github Releases body for the announcement
    #[serde(default)]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub announcement_github_body: Option<String>,
    /// Info about the toolchain used to build this announcement
    #[serde(default)]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub system_info: Option<SystemInfo>,
    /// App releases we're distributing
    #[serde(default)]
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub releases: Vec<Release>,
    /// The artifacts included in this Announcement, referenced by releases.
    #[serde(default)]
    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
    pub artifacts: BTreeMap<ArtifactId, Artifact>,
    /// Whether to publish prereleases to package managers
    #[serde(default)]
    pub publish_prereleases: bool,
    /// ci backend info
    #[serde(default)]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub ci: Option<CiInfo>,
    /// Data about dynamic linkage in the built libraries
    pub linkage: Vec<Linkage>,
}

/// CI backend info
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CiInfo {
    /// GitHub CI backend
    #[serde(skip_serializing_if = "Option::is_none")]
    pub github: Option<GithubCiInfo>,
}

/// Github CI backend
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GithubCiInfo {
    /// Github CI Matrix for upload-artifacts
    #[serde(skip_serializing_if = "Option::is_none")]
    pub artifacts_matrix: Option<GithubMatrix>,

    /// What kind of job to run on pull request
    #[serde(skip_serializing_if = "Option::is_none")]
    pub pr_run_mode: Option<PrRunMode>,
}

/// Github CI Matrix
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GithubMatrix {
    /// define each task manually rather than doing cross-product stuff
    #[serde(default)]
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub include: Vec<GithubMatrixEntry>,
}

impl GithubMatrix {
    /// Gets if the matrix has no entries
    ///
    /// this is useful for checking if there should be No matrix
    pub fn is_empty(&self) -> bool {
        self.include.is_empty()
    }
}

/// Entry for a github matrix
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GithubMatrixEntry {
    /// Targets to build for
    #[serde(skip_serializing_if = "Option::is_none")]
    pub targets: Option<Vec<String>>,
    /// Github Runner to user
    #[serde(skip_serializing_if = "Option::is_none")]
    pub runner: Option<String>,
    /// Expression to execute to install cargo-dist
    #[serde(skip_serializing_if = "Option::is_none")]
    pub install_dist: Option<String>,
    /// Arguments to pass to cargo-dist
    #[serde(skip_serializing_if = "Option::is_none")]
    pub dist_args: Option<String>,
    /// Command to run to install dependencies
    #[serde(skip_serializing_if = "Option::is_none")]
    pub packages_install: Option<String>,
}

/// Type of job to run on pull request
#[derive(
    Debug, Copy, Clone, Serialize, Deserialize, JsonSchema, Default, PartialEq, Eq, PartialOrd, Ord,
)]
pub enum PrRunMode {
    /// Do not run on pull requests at all
    #[serde(rename = "skip")]
    Skip,
    /// Only run the plan step
    #[default]
    #[serde(rename = "plan")]
    Plan,
    /// Build and upload artifacts
    #[serde(rename = "upload")]
    Upload,
}

impl std::fmt::Display for PrRunMode {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            PrRunMode::Skip => write!(f, "skip"),
            PrRunMode::Plan => write!(f, "plan"),
            PrRunMode::Upload => write!(f, "upload"),
        }
    }
}

/// Info about the system/toolchain used to build this announcement.
///
/// Note that this is info from the machine that generated this file,
/// which *ideally* should be similar to the machines that built all the artifacts, but
/// we can't guarantee that.
///
/// dist-manifest.json is by default generated at the start of the build process,
/// and typically on a linux machine because that's usually the fastest/cheapest
/// part of CI infra.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SystemInfo {
    /// The version of Cargo used (first line of cargo -vV)
    ///
    /// Note that this is the version used on the machine that generated this file,
    /// which presumably should be the same version used on all the machines that
    /// built all the artifacts, but maybe not! It's more likely to be correct
    /// if rust-toolchain.toml is used with a specific pinned version.
    pub cargo_version_line: Option<String>,
}

/// A Release of an Application
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Release {
    /// The name of the app
    pub app_name: String,
    /// The version of the app
    // FIXME: should be a Version but JsonSchema doesn't support (yet?)
    pub app_version: String,
    /// The artifacts for this release (zips, debuginfo, metadata...)
    #[serde(default)]
    #[serde(skip_serializing_if = "Vec::is_empty")]
    pub artifacts: Vec<ArtifactId>,
    /// Hosting info
    #[serde(default)]
    #[serde(skip_serializing_if = "Hosting::is_empty")]
    pub hosting: Hosting,
}

/// A distributable artifact that's part of a Release
///
/// i.e. a zip or installer
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Artifact {
    /// The unique name of the artifact (e.g. `myapp-v1.0.0-x86_64-pc-windows-msvc.zip`)
    ///
    /// If this is missing then that indicates the artifact is purely informative and has
    /// no physical files associated with it. This may be used (in the future) to e.g.
    /// indicate you can install the application with `cargo install` or `npm install`.
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(default)]
    pub name: Option<String>,
    /// The kind of artifact this is (e.g. "executable-zip")
    #[serde(flatten)]
    pub kind: ArtifactKind,
    /// The target triple of the bundle
    #[serde(skip_serializing_if = "Vec::is_empty")]
    #[serde(default)]
    pub target_triples: Vec<String>,
    /// The location of the artifact on the local system
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(default)]
    pub path: Option<LocalPath>,
    /// Assets included in the bundle (like executables and READMEs)
    #[serde(skip_serializing_if = "Vec::is_empty")]
    #[serde(default)]
    pub assets: Vec<Asset>,
    /// A string describing how to install this
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(default)]
    pub install_hint: Option<String>,
    /// A brief description of what this artifact is
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(default)]
    pub description: Option<String>,
    /// id of an that contains the checksum for this artifact
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(default)]
    pub checksum: Option<String>,
}

/// An asset contained in an artifact (executable, license, etc.)
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Asset {
    /// The high-level name of the asset
    #[serde(default)]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    /// The path of the asset relative to the root of the artifact
    #[serde(default)]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub path: Option<RelPath>,
    /// The kind of asset this is
    #[serde(flatten)]
    pub kind: AssetKind,
}

/// An artifact included in a Distributable
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind")]
#[non_exhaustive]
pub enum AssetKind {
    /// An executable artifact
    #[serde(rename = "executable")]
    Executable(ExecutableAsset),
    /// A README file
    #[serde(rename = "readme")]
    Readme,
    /// A LICENSE file
    #[serde(rename = "license")]
    License,
    /// A CHANGELOG or RELEASES file
    #[serde(rename = "changelog")]
    Changelog,
    /// Unknown to this version of cargo-dist-schema
    ///
    /// This is a fallback for forward/backward-compat
    #[serde(other)]
    #[serde(rename = "unknown")]
    Unknown,
}

/// A kind of Artifact
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(tag = "kind")]
#[non_exhaustive]
pub enum ArtifactKind {
    /// A zip or a tarball
    #[serde(rename = "executable-zip")]
    ExecutableZip,
    /// Standalone Symbols/Debuginfo for a build
    #[serde(rename = "symbols")]
    Symbols,
    /// Installer
    #[serde(rename = "installer")]
    Installer,
    /// A checksum of another artifact
    #[serde(rename = "checksum")]
    Checksum,
    /// A tarball containing the source code
    #[serde(rename = "source-tarball")]
    SourceTarball,
    /// Some form of extra artifact produced by a sidecar build
    #[serde(rename = "extra-artifact")]
    ExtraArtifact,
    /// An updater executable
    #[serde(rename = "updater")]
    Updater,
    /// Unknown to this version of cargo-dist-schema
    ///
    /// This is a fallback for forward/backward-compat
    #[serde(other)]
    #[serde(rename = "unknown")]
    Unknown,
}

/// An executable artifact (exe/binary)
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ExecutableAsset {
    /// The name of the Artifact containing symbols for this executable
    #[serde(skip_serializing_if = "Option::is_none")]
    #[serde(default)]
    pub symbols_artifact: Option<String>,
}

/// Info about a manifest version
pub struct VersionInfo {
    /// The version
    pub version: Version,
    /// The rough epoch of the format
    pub format: Format,
}

/// The current version of cargo-dist-schema
pub const SELF_VERSION: &str = env!("CARGO_PKG_VERSION");
/// The first epoch of cargo-dist, after this version a bunch of things changed
/// and we don't support that design anymore!
pub const DIST_EPOCH_1_MAX: &str = "0.0.3-prerelease8";
/// Second epoch of cargo-dist, after this we stopped putting versions in artifact ids.
/// This changes the download URL, but everything else works the same.
pub const DIST_EPOCH_2_MAX: &str = "0.0.6-prerelease6";

/// More coarse-grained version info, indicating periods when significant changes were made
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Format {
    /// THE BEFORE TIMES -- Unsupported
    Epoch1,
    /// First stable versions; during this epoch artifact names/ids contained their version numbers.
    Epoch2,
    /// Same as Epoch2, but now artifact names/ids don't include the version number,
    /// making /latest/ a stable path/url you can perma-link. This only affects download URLs.
    Epoch3,
    /// The version is newer than this version of cargo-dist-schema, so we don't know. Most
    /// likely it's compatible/readable, but maybe a breaking change was made?
    Future,
}

impl Format {
    /// Whether this format is too old to be supported
    pub fn unsupported(&self) -> bool {
        self <= &Format::Epoch1
    }
    /// Whether this format has version numbers in artifact names
    pub fn artifact_names_contain_versions(&self) -> bool {
        self <= &Format::Epoch2
    }
}

impl DistManifest {
    /// Create a new DistManifest
    pub fn new(releases: Vec<Release>, artifacts: BTreeMap<String, Artifact>) -> Self {
        Self {
            dist_version: None,
            announcement_tag: None,
            announcement_tag_is_implicit: false,
            announcement_is_prerelease: false,
            announcement_title: None,
            announcement_changelog: None,
            announcement_github_body: None,
            system_info: None,
            releases,
            artifacts,
            publish_prereleases: false,
            ci: None,
            linkage: vec![],
        }
    }

    /// Get the JSON Schema for a DistManifest
    pub fn json_schema() -> schemars::schema::RootSchema {
        schemars::schema_for!(DistManifest)
    }

    /// Get the format of the manifest
    ///
    /// If anything goes wrong we'll default to Format::Future
    pub fn format(&self) -> Format {
        self.dist_version
            .as_ref()
            .and_then(|v| v.parse().ok())
            .map(|v| format_of_version(&v))
            .unwrap_or(Format::Future)
    }

    /// Convenience for iterating artifacts
    pub fn artifacts_for_release<'a>(
        &'a self,
        release: &'a Release,
    ) -> impl Iterator<Item = (&'a str, &'a Artifact)> {
        release
            .artifacts
            .iter()
            .filter_map(|k| Some((&**k, self.artifacts.get(k)?)))
    }

    /// Look up a release by its name
    pub fn release_by_name(&self, name: &str) -> Option<&Release> {
        self.releases.iter().find(|r| r.app_name == name)
    }

    /// Update the download url for an Axo Release (to a prettier one)
    pub fn update_release_axodotdev_artifact_download_url(&mut self, name: &str, new_url: String) {
        // Find the release
        let release = self.releases.iter_mut().find(|r| r.app_name == name);
        let Some(release) = release else {
            return;
        };

        // Swap the new URL in
        let mut old_url = None;
        if let Some(host) = &mut release.hosting.axodotdev {
            old_url = host.set_download_url.take();
            host.set_download_url = Some(new_url.clone());
        }

        // If the url changed, update install_hints
        if let Some(old_url) = old_url {
            for artifact_name in &release.artifacts {
                let artifact = self
                    .artifacts
                    .get_mut(artifact_name)
                    .expect("release referenced non-existent artifacts");
                if let Some(hint) = &mut artifact.install_hint {
                    *hint = hint.replace(&old_url, &new_url);
                }
            }
        }
    }

    /// Either get the release with the given name, or make a minimal one
    /// with no hosting/artifacts (to be populated)
    pub fn ensure_release(&mut self, name: String, version: String) -> &mut Release {
        // Written slightly awkwardly to make the borrowchecker happy :/
        if let Some(position) = self.releases.iter().position(|r| r.app_name == name) {
            &mut self.releases[position]
        } else {
            self.releases.push(Release {
                app_name: name,
                app_version: version,
                artifacts: vec![],
                hosting: Hosting::default(),
            });
            self.releases.last_mut().unwrap()
        }
    }
}

impl Release {
    /// Get the base URL that artifacts should be downloaded from (append the artifact name to the URL)
    pub fn artifact_download_url(&self) -> Option<&str> {
        self.hosting.artifact_download_url()
    }
}

/// Possible hosting providers
#[derive(Clone, Debug, Default, Deserialize, Serialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
pub struct Hosting {
    /// Hosted on Github Releases
    #[serde(default)]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub github: Option<GithubHosting>,
    /// Hosted on Axo Releases
    #[serde(default)]
    #[serde(skip_serializing_if = "Option::is_none")]
    pub axodotdev: Option<gazenot::ArtifactSet>,
}

/// Github Hosting
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
pub struct GithubHosting {
    /// The URL of the Github Release's artifact downloads
    pub artifact_download_url: String,
}

impl Hosting {
    /// Get the base URL that artifacts should be downloaded from (append the artifact name to the URL)
    pub fn artifact_download_url(&self) -> Option<&str> {
        let Hosting { axodotdev, github } = &self;
        // Prefer axodotdev is present, otherwise github
        if let Some(host) = &axodotdev {
            return host.set_download_url.as_deref();
        }
        if let Some(host) = &github {
            return Some(&host.artifact_download_url);
        }
        None
    }
    /// Gets whether there's no hosting
    pub fn is_empty(&self) -> bool {
        let Hosting { axodotdev, github } = &self;
        axodotdev.is_none() && github.is_none()
    }
}

/// Information about dynamic libraries used by a binary
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
pub struct Linkage {
    /// The filename of the binary
    pub binary: String,
    /// The target triple for which the binary was built
    pub target: String,
    /// Libraries included with the operating system
    pub system: Vec<Library>,
    /// Libraries provided by the Homebrew package manager
    pub homebrew: Vec<Library>,
    /// Public libraries not provided by the system and not managed by any package manager
    pub public_unmanaged: Vec<Library>,
    /// Libraries which don't fall into any other categories
    pub other: Vec<Library>,
    /// Frameworks, only used on macOS
    pub frameworks: Vec<Library>,
}

/// Represents a dynamic library located somewhere on the system
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
pub struct Library {
    /// The path to the library; on platforms without that information, it will be a basename instead
    pub path: String,
    /// The package from which a library comes, if relevant
    #[serde(skip_serializing_if = "Option::is_none")]
    pub source: Option<String>,
}

/// Helper to read the raw version from serialized json
fn dist_version(input: &str) -> Option<Version> {
    #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
    struct PartialDistManifest {
        /// The version of cargo-dist that generated this
        #[serde(default)]
        #[serde(skip_serializing_if = "Option::is_none")]
        pub dist_version: Option<String>,
    }

    let manifest: PartialDistManifest = serde_json::from_str(input).ok()?;
    let version: Version = manifest.dist_version?.parse().ok()?;
    Some(version)
}

/// Take serialized json and minimally parse out version info
pub fn check_version(input: &str) -> Option<VersionInfo> {
    let version = dist_version(input)?;
    let format = format_of_version(&version);
    Some(VersionInfo { version, format })
}

/// Get the format for a given version
pub fn format_of_version(version: &Version) -> Format {
    let epoch1 = Version::parse(DIST_EPOCH_1_MAX).unwrap();
    let epoch2 = Version::parse(DIST_EPOCH_2_MAX).unwrap();
    let self_ver = Version::parse(SELF_VERSION).unwrap();
    if version > &self_ver {
        Format::Future
    } else if version > &epoch2 {
        Format::Epoch3
    } else if version > &epoch1 {
        Format::Epoch2
    } else {
        Format::Epoch1
    }
}

#[test]
fn emit() {
    let schema = DistManifest::json_schema();
    let json_schema = serde_json::to_string_pretty(&schema).unwrap();
    insta::assert_snapshot!(json_schema);
}