xtask/tasks/fmt/
workspace.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4use crate::Xtask;
5use crate::shell::XtaskShell;
6use anyhow::Context;
7use clap::Parser;
8use rayon::prelude::*;
9use serde::Deserialize;
10use std::cell::Cell;
11use std::collections::HashSet;
12use std::path::PathBuf;
13use toml_edit::Item;
14use toml_edit::TableLike;
15use toml_edit::Value;
16
17#[derive(Parser)]
18#[clap(about = "Verify that all Cargo.toml files are valid and in the workspace")]
19pub struct VerifyWorkspace;
20
21/// List of exceptions to using workspace package declarations.
22static WORKSPACE_EXCEPTIONS: &[(&str, &[&str])] = &[
23    // Allow disk_blob to use tokio for now, but no one else.
24    //
25    // disk_blob eventually will remove its tokio dependency.
26    ("disk_blob", &["tokio"]),
27    // Allow mesh_rpc to use tokio, since h2 depends on it for the tokio IO
28    // trait definitions. Hopefully this can be resolved upstream once async IO
29    // trait "vocabulary types" move to a common crate.
30    ("mesh_rpc", &["tokio"]),
31];
32
33impl Xtask for VerifyWorkspace {
34    fn run(self, ctx: crate::XtaskCtx) -> anyhow::Result<()> {
35        let excluded = {
36            // will always be root Cargo.toml, as xtasks run from project root
37            let contents = fs_err::read_to_string("Cargo.toml")?;
38            let parsed = contents.parse::<toml_edit::DocumentMut>()?;
39
40            if let Some(excluded) = parsed
41                .as_table()
42                .get("workspace")
43                .and_then(|w| w.get("exclude"))
44                .and_then(|e| e.as_array())
45            {
46                let mut exclude = Vec::new();
47                for entry in excluded {
48                    let entry = entry.as_str().unwrap();
49                    exclude.push(
50                        std::path::absolute(entry)
51                            .with_context(|| format!("cannot exclude {}", entry))?,
52                    );
53                }
54                exclude
55            } else {
56                Vec::new()
57            }
58        };
59
60        // Find directory entries.
61        let entries = ignore::WalkBuilder::new(ctx.root)
62            .filter_entry(move |e| {
63                for path in excluded.iter() {
64                    if e.path().starts_with(path) {
65                        return false;
66                    }
67                }
68
69                true
70            })
71            .build()
72            .filter_map(|entry| match entry {
73                Ok(entry) if entry.file_name() == "Cargo.toml" => Some(entry.into_path()),
74                Err(err) => {
75                    log::error!("error when walking over subdirectories: {}", err);
76                    None
77                }
78                _ => None,
79            })
80            .collect::<Vec<_>>();
81
82        let manifests = workspace_manifests()?;
83
84        let all_present = entries.iter().all(|entry| {
85            if !manifests.contains(entry) {
86                log::error!("Error: {} is not present in the workspace", entry.display());
87                false
88            } else {
89                true
90            }
91        });
92
93        let dependencies_valid = manifests.par_iter().all(|entry| {
94            if let Err(err) = verify_dependencies(entry) {
95                log::error!("Error: failed to verify {}: {:#}", entry.display(), err);
96                false
97            } else {
98                true
99            }
100        });
101
102        if !all_present || !dependencies_valid {
103            anyhow::bail!("found invalid Cargo.toml");
104        }
105
106        Ok(())
107    }
108}
109
110#[derive(Deserialize)]
111struct CargoMetadata {
112    packages: Vec<Package>,
113    workspace_root: PathBuf,
114}
115
116#[derive(Deserialize)]
117struct Package {
118    manifest_path: PathBuf,
119}
120
121fn workspace_manifests() -> anyhow::Result<HashSet<PathBuf>> {
122    let json = XtaskShell::new()?
123        .cmd("cargo")
124        .arg("metadata")
125        .arg("--no-deps")
126        .arg("--format-version=1")
127        .read()?;
128    let metadata: CargoMetadata =
129        serde_json::from_str(&json).context("failed to parse JSON result")?;
130
131    Ok(metadata
132        .packages
133        .into_iter()
134        .map(|p| p.manifest_path)
135        .chain([metadata.workspace_root.join("Cargo.toml")])
136        .collect())
137}
138
139fn verify_dependencies(path: &PathBuf) -> Result<(), anyhow::Error> {
140    // TODO: Convert this to a better crate like cargo_toml once it supports inherited dependencies fully.
141    let contents = fs_err::read_to_string(path)?;
142    let parsed = contents.parse::<toml_edit::DocumentMut>()?;
143
144    let package_name = match parsed
145        .as_table()
146        .get("package")
147        .and_then(|p| p.get("name"))
148        .and_then(|n| n.as_str())
149    {
150        Some(name) => name,
151        None => return Ok(()), // Workspace root toml
152    };
153
154    let mut dep_tables = Vec::new();
155    for (name, v) in parsed.iter() {
156        match name {
157            "dependencies" | "build-dependencies" | "dev-dependencies" => {
158                dep_tables.push(v.as_table_like().unwrap())
159            }
160            "target" => {
161                let flattened = v
162                    .as_table_like()
163                    .unwrap()
164                    .iter()
165                    .flat_map(|(_, v)| v.as_table_like().unwrap().iter());
166
167                for (k, v) in flattened {
168                    match k {
169                        "dependencies" | "build-dependencies" | "dev-dependencies" => {
170                            dep_tables.push(v.as_table_like().unwrap())
171                        }
172                        _ => {}
173                    }
174                }
175            }
176            _ => {}
177        }
178    }
179
180    let found_bad_deps = Cell::new(false);
181
182    let handle_non_workspaced_dep = |dep_name| {
183        let allowed = WORKSPACE_EXCEPTIONS
184            .iter()
185            .find_map(|&(p, crates)| (p == package_name).then_some(crates))
186            .unwrap_or(&[]);
187
188        if allowed.contains(&dep_name) {
189            log::debug!(
190                "{} contains non-workspaced dependency {}. Allowed by exception.",
191                package_name,
192                dep_name
193            );
194        } else {
195            found_bad_deps.set(true);
196            log::error!(
197                "{} contains non-workspaced dependency {}. Please move this dependency to the root Cargo.toml.",
198                package_name,
199                dep_name
200            );
201        }
202    };
203    let check_table_like = |t: &dyn TableLike, dep_name| {
204        if t.get("workspace").and_then(|x| x.as_bool()) != Some(true) {
205            handle_non_workspaced_dep(dep_name);
206        }
207    };
208
209    for table in dep_tables {
210        for (dep_name, value) in table.iter() {
211            match value {
212                Item::Value(Value::String(_)) => handle_non_workspaced_dep(dep_name),
213                Item::Value(Value::InlineTable(t)) => {
214                    check_table_like(t, dep_name);
215
216                    if t.len() == 1 {
217                        found_bad_deps.set(true);
218                        log::error!(
219                            "{} uses inline table syntax for its dependency on {}, but only contains one table entry. Please change to the dotted syntax.",
220                            package_name,
221                            dep_name
222                        );
223                    }
224                }
225                Item::Table(t) => check_table_like(t, dep_name),
226
227                _ => unreachable!(),
228            }
229        }
230    }
231
232    if found_bad_deps.get() {
233        Err(anyhow::anyhow!("Found incorrectly defined dependencies."))
234    } else {
235        Ok(())
236    }
237}