xtask\tasks\fmt/
workspace.rs

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