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
#![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>,
    /// 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>,
}

/// 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>,
}

/// 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,
    /// 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_is_prerelease: false,
            announcement_title: None,
            announcement_changelog: None,
            announcement_github_body: None,
            system_info: None,
            releases,
            artifacts,
        }
    }

    /// 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)?)))
    }
}

/// 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);
}