semantic_release_rust/
lib.rs

1// Copyright 2020 Steven Bosnick
2//
3// Licensed under the Apache License, Version 2.0 <LICENSE-APACHE-2.0 or
4// http://www.apache.org/licenses/LICENSE-2.0> or the MIT license
5// <LICENSE-MIT or http://opensource.org/licenses/MIT>, at your
6// option. This file may not be copied, modified, or distributed
7// except according to those terms.
8
9//! Implementation of the sementic release steps to for integraing a cargo-based Rust
10//! project.
11
12#![forbid(unsafe_code)]
13#![deny(warnings, missing_docs)]
14
15use std::{
16    env, fmt, fs,
17    io::{BufRead, Cursor, Write},
18    path::{Path, PathBuf},
19    process::Command,
20    result,
21};
22
23use guppy::{
24    graph::{DependencyDirection, PackageGraph, PackageLink, PackageMetadata, PackageSource},
25    MetadataCommand, PackageId,
26};
27use itertools::Itertools;
28use log::{debug, error, info, log, trace, Level};
29use serde::Serialize;
30use toml_edit::{Document, InlineTable, Item, Table, Value};
31use url::Url;
32
33mod error;
34
35pub use error::{CargoTomlError, Error, Result};
36
37/// Verify that the conditions for a release are satisfied.
38///
39/// The conditions for a release checked by this function are:
40///
41///    1. That the CARGO_REGISTRY_TOKEN environment variable is set and is
42///       non-empty.
43///    2. That it can construct the graph of all of the dependencies in the
44///       workspace.
45///    3. That the dependencies and build-dependencies of all of crates in the
46///       workspace are suitable for publishing to `crates.io`.
47///
48/// If `manifest_path` is provided then it is expect to give the path to the
49/// `Cargo.toml` file for the root of the workspace. If `manifest_path` is `None`
50/// then `verify_conditions` will look for the root of the workspace in a
51/// `Cargo.toml` file in the current directory. If one of the conditions for a
52/// release are not satisfied then an explination for that will be written to
53/// `output`.
54///
55/// This implments the `verifyConditions` step for `sementic-release` for a
56/// Cargo-based rust workspace.
57pub fn verify_conditions(
58    mut output: impl Write,
59    manifest_path: Option<impl AsRef<Path>>,
60) -> Result<()> {
61    info!("Checking CARGO_REGISTRY_TOKEN");
62    env::var_os("CARGO_REGISTRY_TOKEN")
63        .and_then(|val| if val.is_empty() { None } else { Some(()) })
64        .ok_or_else(|| {
65            writeln!(output, "CARGO_REGISTRY_TOKEN empty or not set.")
66                .map_err(Error::output_error)
67                .and_then::<(), _>(|()| {
68                    Err(Error::verify_error("CARGO_REGISTRY_TOKEN empty or not set"))
69                })
70                .unwrap_err()
71        })?;
72
73    info!("Checking that workspace dependencies graph is buildable");
74    let graph = match get_package_graph(manifest_path) {
75        Ok(graph) => graph,
76        Err(err) => {
77            return writeln!(
78                output,
79                "Unable to build workspace dependencies graph: {}",
80                err
81            )
82            .map_err(Error::output_error)
83            .and(Err(err))
84        }
85    };
86
87    info!("Checking that the workspace does not contain any cycles");
88    if let Some(cycle) = graph.cycles().all_cycles().next() {
89        assert!(cycle.len() >= 2);
90        let crate0 = get_crate_name(&graph, cycle[0]);
91        let crate1 = get_crate_name(&graph, cycle[1]);
92        return writeln!(
93            output,
94            "Workspace contains a cycle that includes (at least) {} and {}",
95            crate0, crate1
96        )
97        .map_err(Error::output_error)
98        .and_then(|()| Err(Error::cycle_error(crate0, crate1)));
99    }
100
101    info!("Checking that dependencies are suitable for publishing");
102    for (from, links) in graph
103        .workspace()
104        .iter()
105        .flat_map(|package| package.direct_links())
106        .filter(|link| !link_is_publishable(link))
107        .group_by(PackageLink::from)
108        .into_iter()
109    {
110        debug!("Checking links for package {}", from.name());
111        let cargo = read_cargo_toml(from.manifest_path())?;
112        for link in links {
113            if link.normal().is_present() {
114                dependency_has_version(&cargo, &link, DependencyType::Normal).map_err(|err| {
115                    writeln!(
116                        output,
117                        "Dependancy {0} of {1} makes {1} not publishable.",
118                        link.to().name(),
119                        link.from().name()
120                    )
121                    .map_err(Error::output_error)
122                    .and::<()>(Err(err))
123                    .unwrap_err()
124                })?;
125            }
126            if link.build().is_present() {
127                dependency_has_version(&cargo, &link, DependencyType::Build).map_err(|err| {
128                    writeln!(
129                        output,
130                        "Build dependancy {0} of {1} makes {1} not publishable.",
131                        link.to().name(),
132                        link.from().name()
133                    )
134                    .map_err(Error::output_error)
135                    .and::<()>(Err(err))
136                    .unwrap_err()
137                })?;
138            }
139        }
140    }
141
142    Ok(())
143}
144
145/// Prepare the Rust workspace for a release.
146///
147/// Preparing the release updates the version of each crate in the workspace and of
148/// the intra-workspace dependencies. The `version` field in the `packages` table of
149/// each `Cargo.toml` file in the workspace is set to the supplied version. The
150/// `version` field of each dependency or build-dependency that is otherwise
151/// identified by a workspace-relative path dependencies is also set to the supplied
152/// version (the version filed will be added if it isn't already present).
153///
154/// This implments the `prepare` step for `sementic-release` for a Cargo-based Rust
155/// workspace.
156pub fn prepare(
157    _output: impl Write,
158    manifest_path: Option<impl AsRef<Path>>,
159    version: &str,
160) -> Result<()> {
161    info!("Building package graph");
162    let graph = get_package_graph(manifest_path)?;
163
164    let link_map = graph
165        .workspace()
166        .iter()
167        .flat_map(|package| package.direct_links())
168        .filter(|link| !link.dev_only() && link.to().in_workspace())
169        .map(|link| (link.from().id(), link))
170        .into_group_map();
171
172    info!("Setting version information for packages in the workspace.");
173    for package in graph.workspace().iter() {
174        let path = package.manifest_path();
175        debug!("reading {}", path.display());
176        let mut cargo = read_cargo_toml(path)?;
177
178        debug!("Setting the version for {}", package.name());
179        set_package_version(&mut cargo, version).map_err(|err| err.into_error(path))?;
180
181        if let Some(links) = link_map.get(package.id()) {
182            debug!(
183                "Setting the version for the dependencies of {}",
184                package.name()
185            );
186            for link in links {
187                if link.normal().is_present() {
188                    set_dependencies_version(
189                        &mut cargo,
190                        version,
191                        DependencyType::Normal,
192                        link.to().name(),
193                    )
194                    .map_err(|err| err.into_error(path))?;
195                }
196                if link.build().is_present() {
197                    set_dependencies_version(
198                        &mut cargo,
199                        version,
200                        DependencyType::Build,
201                        link.to().name(),
202                    )
203                    .map_err(|err| err.into_error(path))?;
204                }
205            }
206        }
207
208        debug!("writing {}", path.display());
209        write_cargo_toml(path, cargo)?;
210    }
211
212    Ok(())
213}
214
215/// Publish the publishable crates from the workspace.
216///
217/// The publishable crates are the crates in the workspace other than those
218/// whose `package.publish` field is set to `false` or that includes a registry other
219/// than `crates.io`.
220///
221/// This implments the `publish` step for `sementic-release` for a Cargo-based
222/// Rust workspace.
223pub fn publish(
224    output: impl Write,
225    manifest_path: Option<impl AsRef<Path>>,
226    no_dirty: bool,
227) -> Result<()> {
228    info!("getting the package graph");
229    let graph = get_package_graph(manifest_path)?;
230
231    let mut count = 0;
232    let mut last_id = None;
233
234    process_publishable_packages(&graph, |pkg| {
235        count += 1;
236        last_id = Some(pkg.id().clone());
237        publish_package(pkg, no_dirty)
238    })?;
239
240    let main_crate = match graph.workspace().member_by_path("") {
241        Ok(pkg) if package_is_publishable(&pkg) => Some(pkg.name()),
242        _ => match last_id {
243            Some(id) => Some(
244                graph
245                    .metadata(&id)
246                    .expect("id of a processed package not found in the package graph")
247                    .name(),
248            ),
249            None => None,
250        },
251    };
252
253    if let Some(main_crate) = main_crate {
254        debug!("printing release record with main crate: {}", main_crate);
255        let name = format!("crate.io packages ({} packages published)", count);
256        serde_json::to_writer(output, &Release::new(name, main_crate)?)
257            .map_err(|err| Error::write_release_error(err, main_crate))?;
258    } else {
259        debug!("no release record to print");
260    }
261
262    Ok(())
263}
264
265/// List the packages from the workspace in the order of their dependencies.
266///
267/// The list of pacakges will be written to `output`. If `manifest_path` is provided
268/// then it is expected to give the path to the `Cargo.toml` file for the root of the
269/// workspace. If `manifest_path` is `None` then `list_packages` will look for the
270/// root of the workspace in a `Cargo.toml` file in the current directory.
271///
272/// This is a debuging aid and does not directly correspond to a sementic release
273/// step.
274pub fn list_packages(
275    mut output: impl Write,
276    manifest_path: Option<impl AsRef<Path>>,
277) -> Result<()> {
278    info!("Building package graph");
279    let graph = get_package_graph(manifest_path)?;
280
281    process_publishable_packages(&graph, |pkg| {
282        writeln!(output, "{}({})", pkg.name(), pkg.version()).map_err(Error::output_error)?;
283
284        Ok(())
285    })
286}
287
288fn get_package_graph(manifest_path: Option<impl AsRef<Path>>) -> Result<PackageGraph> {
289    let manifest_path = manifest_path.as_ref().map(|path| path.as_ref());
290
291    let mut command = MetadataCommand::new();
292    if let Some(path) = manifest_path {
293        command.manifest_path(path);
294    }
295
296    debug!("manifest_path: {:?}", manifest_path);
297
298    command.build_graph().map_err(|err| {
299        let path = match manifest_path {
300            Some(path) => path.to_path_buf(),
301            None => env::current_dir()
302                .map(|path| path.join("Cargo.toml"))
303                .unwrap_or_else(|e| {
304                    error!("Unable to get current directory: {}", e);
305                    PathBuf::from("unknown manifest")
306                }),
307        };
308        Error::workspace_error(err, path)
309    })
310}
311
312/// Is the source of the target of a dependencies publishable?
313///
314/// The target of a dependencies must be available on `crates.io` for the depending
315/// package to be publishable. Workspace relative path dependencies will be published
316/// before their depended on crates and the dependencies in the depended on crate
317/// will have their `version` adjusted so those dependencies will be on `crates.io`
318/// by the time the depended on crate is published.
319fn target_source_is_publishable(source: PackageSource) -> bool {
320    source.is_workspace() || source.is_crates_io()
321}
322
323/// Will this link prevent the `link.from()` package from being published.
324///
325/// `dev-dependencies` links will not prevent publication. For all other links the
326/// target of the link must be either already on `crates.io` or it must be a
327/// workspace relative path dependency (which will be published first).
328fn link_is_publishable(link: &PackageLink) -> bool {
329    let result = link.dev_only() || target_source_is_publishable(link.to().source());
330    if result {
331        trace!(
332            "Link from {} to {} is publishable.",
333            link.from().name(),
334            link.to().name()
335        );
336    }
337
338    result
339}
340
341/// Is a particular package publishable.
342///
343/// A package is publishable if either publication is unrestricted or the one
344/// and only registry it is allowed to be published to is "crates.io".
345fn package_is_publishable(pkg: &PackageMetadata) -> bool {
346    let result = pkg.publish().map_or(true, |registries| {
347        registries.len() == 1 && registries[0] == "cratis.io"
348    });
349
350    if result {
351        trace!("package {} is publishable", pkg.name());
352    }
353
354    result
355}
356
357fn process_publishable_packages<F>(graph: &PackageGraph, mut f: F) -> Result<()>
358where
359    F: FnMut(&PackageMetadata) -> Result<()>,
360{
361    info!("iterating the workspace crates in dependency order");
362    for pkg in graph
363        .query_workspace()
364        .resolve_with_fn(|_, link| !link.dev_only())
365        .packages(DependencyDirection::Reverse)
366        .filter(|pkg| pkg.in_workspace() && package_is_publishable(pkg))
367    {
368        f(&pkg)?;
369    }
370
371    Ok(())
372}
373
374// Panics if id is not from graph
375fn get_crate_name<'a>(graph: &'a PackageGraph, id: &PackageId) -> &'a str {
376    graph
377        .metadata(id)
378        .unwrap_or_else(|_| panic!("id {} was not found in the graph {:?}", id, graph))
379        .name()
380}
381
382fn publish_package(pkg: &PackageMetadata, no_dirty: bool) -> Result<()> {
383    debug!("publishing package {}", pkg.name());
384
385    let cargo = env::var("CARGO")
386        .map(PathBuf::from)
387        .unwrap_or_else(|_| PathBuf::from("cargo"));
388
389    let mut command = Command::new(cargo);
390    command
391        .args(&["publish", "--manifest-path"])
392        .arg(pkg.manifest_path());
393    if !no_dirty {
394        command.arg("--allow-dirty");
395    }
396
397    trace!("running: {:?}", command);
398
399    let output = command
400        .output()
401        .map_err(|err| Error::cargo_publish(err, pkg.manifest_path()))?;
402
403    let level = if output.status.success() {
404        Level::Trace
405    } else {
406        Level::Info
407    };
408
409    trace!("cargo publish stdout");
410    trace!("--------------------");
411    log_bytes(Level::Trace, &output.stdout);
412
413    log!(level, "cargo publish stderr");
414    log!(level, "--------------------");
415    log_bytes(level, &output.stderr);
416
417    if output.status.success() {
418        Ok(())
419    } else {
420        error!(
421            "publishing package {} failed: {}",
422            pkg.name(),
423            output.status
424        );
425        Err(Error::cargo_publish_status(
426            output.status,
427            pkg.manifest_path(),
428        ))
429    }
430}
431
432fn log_bytes(level: Level, bytes: &[u8]) {
433    let mut buffer = Cursor::new(bytes);
434    let mut string = String::new();
435
436    while let Ok(size) = buffer.read_line(&mut string) {
437        if size == 0 {
438            return;
439        }
440        log!(level, "{}", string);
441        string.clear();
442    }
443}
444
445fn read_cargo_toml(path: impl AsRef<Path>) -> Result<Document> {
446    let path = path.as_ref();
447    fs::read_to_string(path)
448        .map_err(|err| Error::file_read_error(err, path))?
449        .parse()
450        .map_err(|err| Error::toml_error(err, path))
451}
452
453fn write_cargo_toml(path: impl AsRef<Path>, cargo: Document) -> Result<()> {
454    let path = path.as_ref();
455    fs::write(path, cargo.to_string_in_original_order())
456        .map_err(|err| Error::file_write_error(err, path))
457}
458
459fn get_top_table<'a>(doc: &'a Document, key: &str) -> Option<&'a Table> {
460    doc.as_table().get(key).and_then(Item::as_table)
461}
462
463fn get_top_table_mut<'a>(doc: &'a mut Document, key: &str) -> Option<&'a mut Table> {
464    doc.as_table_mut().entry(key).as_table_mut()
465}
466
467fn table_add_or_update_value(table: &mut Table, key: &str, value: Value) -> Option<()> {
468    let entry = table.entry(key);
469
470    if entry.is_none() {
471        *entry = Item::Value(value);
472        return Some(());
473    }
474
475    match entry {
476        Item::Value(val) => {
477            *val = value;
478            Some(())
479        }
480        _ => None,
481    }
482}
483
484fn inline_table_add_or_update_value(table: &mut InlineTable, key: &str, value: Value) {
485    match table.get_mut(key) {
486        Some(ver) => *ver = value,
487        None => {
488            table.get_or_insert(key, value);
489        }
490    }
491}
492
493fn dependency_has_version(doc: &Document, link: &PackageLink, typ: DependencyType) -> Result<()> {
494    let top_key = match typ {
495        DependencyType::Normal => "dependencies",
496        DependencyType::Build => "build-dependencies",
497    };
498
499    trace!(
500        "Checking for version key for {} in {} section of {}",
501        link.to().name(),
502        top_key,
503        link.from().name()
504    );
505    get_top_table(doc, top_key)
506        .and_then(|deps| deps.get(link.to().name()))
507        .and_then(Item::as_table_like)
508        .and_then(|dep| dep.get("version"))
509        .map(|_| ())
510        .ok_or_else(|| Error::bad_dependency(link, typ))
511}
512
513fn set_package_version(doc: &mut Document, version: &str) -> result::Result<(), CargoTomlError> {
514    let table =
515        get_top_table_mut(doc, "package").ok_or_else(|| CargoTomlError::no_table("package"))?;
516    table_add_or_update_value(table, "version", version.into())
517        .ok_or_else(|| CargoTomlError::no_value("version"))
518}
519
520fn set_dependency_version(table: &mut Table, version: &str, name: &str) -> Option<()> {
521    let mut result = Some(());
522
523    if table.contains_key(name) {
524        let req = table.entry(name);
525        if !req.is_table_like() {
526            return None;
527        }
528        if let Some(req) = req.as_inline_table_mut() {
529            inline_table_add_or_update_value(req, "version", version.into());
530        }
531        if let Some(req) = req.as_table_mut() {
532            result = table_add_or_update_value(req, "version", version.into());
533        }
534    }
535
536    result
537}
538
539fn set_dependencies_version(
540    doc: &mut Document,
541    version: &str,
542    typ: DependencyType,
543    name: &str,
544) -> result::Result<(), CargoTomlError> {
545    if let Some(table) = get_top_table_mut(doc, typ.key()) {
546        set_dependency_version(table, version, name)
547            .ok_or_else(|| CargoTomlError::set_version(name, version))?;
548    }
549
550    if let Some(table) = get_top_table_mut(doc, "target") {
551        let targets = table.iter().map(|(key, _)| key.to_owned()).collect_vec();
552
553        for target in targets {
554            if let Some(target_deps) = table
555                .entry(&target)
556                .as_table_mut()
557                .and_then(|inner| inner[typ.key()].as_table_mut())
558            {
559                set_dependency_version(target_deps, version, name)
560                    .ok_or_else(|| CargoTomlError::set_version(name, version))?;
561            }
562        }
563    };
564
565    Ok(())
566}
567
568/// The type of a dependency for a package.
569#[derive(Debug)]
570pub enum DependencyType {
571    /// A normal dependency (i.e. "dependencies" section of `Cargo.toml`).
572    Normal,
573
574    /// A build dependency (i.e. "build-dependencies" section of `Cargo.toml`).
575    Build,
576}
577
578impl DependencyType {
579    fn key(&self) -> &str {
580        use DependencyType::*;
581
582        match self {
583            Normal => "dependencies",
584            Build => "build-dependencies",
585        }
586    }
587}
588
589impl fmt::Display for DependencyType {
590    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
591        use DependencyType::*;
592
593        match self {
594            Normal => write!(f, "Dependancy"),
595            Build => write!(f, "Build dependency"),
596        }
597    }
598}
599
600#[derive(Debug, Serialize)]
601struct Release {
602    name: String,
603    url: Url,
604}
605
606impl Release {
607    fn new(name: impl AsRef<str>, main_crate: impl AsRef<str>) -> Result<Self> {
608        let base = Url::parse("https://crates.io/crates/").map_err(Error::url_parse_error)?;
609        let url = base
610            .join(main_crate.as_ref())
611            .map_err(Error::url_parse_error)?;
612
613        Ok(Self {
614            name: name.as_ref().to_owned(),
615            url,
616        })
617    }
618}