1
//! A quick and dirty command-line tool to enforce certain properties about
2
//! Arti's Cargo.toml files.
3
//!
4
//!
5
//! Definitions.
6
//!
7
//! - An **experimental** feature is one for which we do not provide semver guarantees.
8
//! - A **non-additive** feature is one whose behavior does something other than
9
//!   add functionality to its crate.  (For example, building statically or
10
//!   switching out a default is non-additive.)
11
//! - The **meta** features are `default`, `full`, `experimental`,
12
//!   `__is_nonadditive`, and `__is_experimental`.
13
//! - The **toplevel** features are `default`, `full`, and `experimental`.
14
//! - A feature A "is reachable from" some feature B if there is a nonempty path from A
15
//!   to B in the feature graph.
16
//! - A feature A "directly depends on" some feature B if there is an edge from
17
//!   A to B in the feature graph.  We also say that feature B "is listed in"
18
//!   feature A.
19
//!
20
//! The properties that we want to enforce are:
21
//!
22
//! 1. Every crate has a "full" feature.
23
//! 2. For every crate within Arti, if we depend on that crate, our "full"
24
//!    includes that crate's "full".
25
//! 3. Every feature listed in `experimental` depends on `__is_experimental`.
26
//!    Every feature that depends on `__is_experimental` is reachable from `experimental`.
27
//!    Call such features "experimental" features.
28
//! 4. Call a feature "non-additive" if and only if it depends directly on `__is_nonadditive`.
29
//!    Every non-meta feature we declare is reachable from "full" or "experimental",
30
//!    or it is non-additive.
31
//! 5. Every feature reachable from `default` is reachable from `full`.
32
//! 6. No non-additive feature is reachable from `full` or `experimental`.
33
//! 7. No experimental is reachable from `full`.
34
//! 8. No in-workspace dependency uses the `*` wildcard version.
35
//! 9. Only unpublished crates may depend on unpublished crates.
36
//!
37
//! This tool can edit Cargo.toml files to enforce the rules 1-3
38
//! automatically.  For rules 4-7, it can annotate any offending features with
39
//! comments complaining about how they need to be fixed. For rules 8 and 9,
40
//! it generates warnings.
41
//!
42
//! # To use:
43
//!
44
//! Run this tool with the top-level Cargo.toml as an argument.
45
//! Run with `--no-annotate` if you don't want any comments added.
46
//!
47
//! # Limitations
48
//!
49
//! This is not very efficient, and is not trying to be.
50

            
51
mod changes;
52
mod graph;
53

            
54
use anyhow::{anyhow, Context, Result};
55
use std::collections::{HashMap, HashSet};
56
use std::path::{Path, PathBuf};
57
use toml_edit::{DocumentMut, Item, Table, Value};
58

            
59
use changes::{Change, Changes};
60

            
61
/// A warning we return from our linter.
62
///
63
/// It's a newtype so I don't confuse it with other strings.
64
#[derive(Debug, Clone)]
65
struct Warning(String);
66

            
67
/// A dependency from a crate.
68
///
69
/// All we care about is the dependency's name, its version,
70
/// and whether it is optional.
71
#[derive(Debug, Clone)]
72
struct Dependency {
73
    name: String,
74
    optional: bool,
75
    version: Option<String>,
76
}
77

            
78
/// Stored information about a crate.
79
#[derive(Debug, Clone)]
80
struct Crate {
81
    /// name of the crate
82
    name: String,
83
    /// path to the crate's Cargo.toml
84
    toml_file: PathBuf,
85
    /// Parsed and manipulated copy of Cargo.toml
86
    toml_doc: DocumentMut,
87
    /// Parsed and un-manipulated copy of Cargo.toml.
88
    toml_doc_orig: DocumentMut,
89
}
90

            
91
/// Information about a crate that we use in other crates.
92
#[derive(Debug, Clone)]
93
struct CrateInfo {
94
    published: bool,
95
}
96

            
97
/// Given a `[dependencies]` table from a Cargo.toml, find all of the
98
/// dependencies that are also part of arti.
99
///
100
/// We do this by looking for ones that have `path` set.
101
fn arti_dependencies(dependencies: &Table) -> Vec<Dependency> {
102
    let mut deps = Vec::new();
103

            
104
    for (depname, info) in dependencies {
105
        let table = match info {
106
            // Cloning is "inefficient", but we don't care.
107
            Item::Value(Value::InlineTable(info)) => info.clone().into_table(),
108
            Item::Table(info) => info.clone(),
109
            _ => continue, // Not part of arti.
110
        };
111
        if !table.contains_key("path") {
112
            continue; // Not part of arti.
113
        }
114
        let optional = table
115
            .get("optional")
116
            .and_then(Item::as_value)
117
            .and_then(Value::as_bool)
118
            .unwrap_or(false);
119
        let version = table
120
            .get("version")
121
            .and_then(Item::as_value)
122
            .and_then(Value::as_str)
123
            .map(str::to_string);
124

            
125
        deps.push(Dependency {
126
            name: depname.to_string(),
127
            optional,
128
            version,
129
        });
130
    }
131

            
132
    deps
133
}
134

            
135
impl Crate {
136
    /// Try to read a crate's Cargo.toml from a given filename.
137
    fn load(p: impl AsRef<Path>) -> Result<Self> {
138
        let toml_file = p.as_ref().to_owned();
139
        let s = std::fs::read_to_string(&toml_file)?;
140
        let toml_doc = s.parse::<DocumentMut>()?;
141
        let toml_doc_orig = toml_doc.clone();
142
        let name = toml_doc["package"]["name"]
143
            .as_str()
144
            .ok_or_else(|| anyhow!("package.name was not a string"))?
145
            .to_string();
146
        Ok(Crate {
147
            name,
148
            toml_file,
149
            toml_doc,
150
            toml_doc_orig,
151
        })
152
    }
153

            
154
    /// Extract information about this crate that other crates will need.
155
    fn info(&self) -> CrateInfo {
156
        let package = self
157
            .toml_doc
158
            .get("package")
159
            .expect("no package table!")
160
            .as_table()
161
            .expect("[package] was not a table");
162
        let publish_option = package
163
            .get("publish")
164
            .and_then(Item::as_value)
165
            .and_then(Value::as_bool);
166
        let published = publish_option != Some(false);
167
        CrateInfo { published }
168
    }
169

            
170
    /// Try to fix all the issues we find with a Cargo.toml.  Return a list of warnings.
171
    fn fix(
172
        &mut self,
173
        no_annotate: bool,
174
        other_crates: &HashMap<String, CrateInfo>,
175
    ) -> Result<Vec<Warning>> {
176
        let mut warnings = Vec::new();
177
        let my_info = self.info();
178
        let mut w = |s| warnings.push(Warning(s));
179
        let dependencies = self
180
            .toml_doc
181
            .entry("dependencies")
182
            .or_insert_with(|| Item::Table(Table::new()));
183
        let dependencies = arti_dependencies(
184
            dependencies
185
                .as_table()
186
                .ok_or_else(|| anyhow!("dependencies was not a table"))?,
187
        );
188
        let features = self
189
            .toml_doc
190
            .entry("features")
191
            .or_insert_with(|| Item::Table(Table::new()))
192
            .as_table_mut()
193
            .ok_or_else(|| anyhow!("Features was not table"))?;
194
        let graph = graph::FeatureGraph::from_features_table(features)?;
195
        let mut changes = Changes::default();
196

            
197
        // Build a few sets that will be useful a few times below.
198
        let all_features: HashSet<_> = graph.all_features().collect();
199
        let reachable_from_experimental: HashSet<_> =
200
            graph.all_reachable_from("experimental").collect();
201
        let nonadditive: HashSet<_> = graph.edges_to("__is_nonadditive").collect();
202
        let reachable_from_full: HashSet<_> = graph.all_reachable_from("full").collect();
203

            
204
        // Enforce rule 1.  (There is a "Full" feature.)
205
        if !graph.contains_feature("full") {
206
            w("full feature does not exist. Adding.".to_string());
207
            changes.push(Change::AddFeature("full".to_string()));
208
        }
209

            
210
        // Enforce rule 2. (for every arti crate that we depend on, our 'full' should include that crate's full.
211
        for dep in dependencies.iter() {
212
            let wanted = if dep.optional {
213
                format!("{}?/full", dep.name)
214
            } else {
215
                format!("{}/full", dep.name)
216
            };
217

            
218
            if !graph.contains_edge("full", wanted.as_str()) {
219
                w(format!("full should contain {}. Fixing.", wanted));
220
                changes.push(Change::AddExternalEdge("full".to_string(), wanted));
221
            }
222
        }
223

            
224
        // Enforce rule 3 (relationship between "experimental" and
225
        // "__is_experimental")
226
        let defined_experimental: HashSet<_> = {
227
            let in_experimental: HashSet<_> = graph.edges_from("experimental").collect();
228
            let is_experimental: HashSet<_> = graph.edges_to("__is_experimental").collect();
229

            
230
            // Every feature listed in `experimental` depends on `__is_experimental`.
231
            for f in in_experimental.difference(&is_experimental) {
232
                if all_features.contains(f) {
233
                    w(format!("{f} should depend on __is_experimental. Fixing."));
234
                    changes.push(Change::AddEdge(f.clone(), "__is_experimental".into()));
235
                }
236
            }
237
            // Every feature that depends on `__is_experimental` is reachable from `experimental`.
238
            for f in is_experimental.difference(&reachable_from_experimental) {
239
                w(format!("{f} is marked as __is_experimental, but is not reachable from experimental. Fixing."));
240
                changes.push(Change::AddEdge("experimental".into(), f.clone()))
241
            }
242

            
243
            &in_experimental | &is_experimental
244
        };
245

            
246
        // Enforce rule 4: Every non-meta feature is reachable from full, or
247
        // from experimental, or is nonadditive.
248
        {
249
            let complaint: &str = "# XX\x58X Mark as full, experimental, or non-additive!\n";
250

            
251
            let all_features: HashSet<_> = graph.all_features().collect();
252
            let meta: HashSet<_> = [
253
                "__is_nonadditive",
254
                "__is_experimental",
255
                "full",
256
                "default",
257
                "experimental",
258
            ]
259
            .into_iter()
260
            .map(String::from)
261
            .collect();
262

            
263
            let mut not_found = all_features;
264
            for set in [
265
                &reachable_from_full,
266
                &meta,
267
                &reachable_from_experimental,
268
                &nonadditive,
269
            ] {
270
                not_found = &not_found - set;
271
            }
272

            
273
            for f in not_found {
274
                w(format!(
275
                    "{f} is not experimental, reachable from full, or nonadditive."
276
                ));
277
                changes.push(Change::Annotate(f.clone(), complaint.to_string()));
278
            }
279
        }
280

            
281
        // 5. Every feature reachable from `default` is reachable from `full`.
282
        {
283
            let complaint = "# XX\x58X This is reachable from 'default', but from 'full'.\n";
284
            let default: HashSet<_> = graph.edges_from("default").collect();
285
            for f in default.difference(&reachable_from_full) {
286
                if all_features.contains(f) {
287
                    w(format!("{f} is reachable from default, but not from full."));
288
                    changes.push(Change::Annotate(f.clone(), complaint.to_string()));
289
                }
290
            }
291
        }
292

            
293
        // 6. No non-additive feature is reachable from `full` or
294
        //    `experimental`.
295
        {
296
            let complaint = "# XX\x58X This is non-additive, but reachable from 'full'.\n";
297
            for f in nonadditive.intersection(&reachable_from_full) {
298
                w(format!("nonadditive feature {f} is reachable from full."));
299
                changes.push(Change::Annotate(f.clone(), complaint.to_string()));
300
            }
301
            let complaint = "# XX\x58X This is non-additive, but reachable from 'experimental'.\n";
302
            for f in nonadditive.intersection(&reachable_from_experimental) {
303
                w(format!(
304
                    "nonadditive feature {f} is reachable from experimental."
305
                ));
306
                changes.push(Change::Annotate(f.clone(), complaint.to_string()));
307
            }
308
        }
309

            
310
        // 7. No experimental is reachable from `full`.
311
        {
312
            let complaint = "# XX\x58X This is experimental, but reachable from 'full'.\n";
313
            for f in reachable_from_full.intersection(&defined_experimental) {
314
                w(format!("experimental feature {f} is reachable from full!"));
315
                changes.push(Change::Annotate(f.clone(), complaint.to_string()));
316
            }
317
        }
318

            
319
        // 8. Every dependency is a real version.
320
        for dep in &dependencies {
321
            match dep.version.as_deref() {
322
                Some("*") => w(format!(
323
                    "Dependency for {:?} is given as version='*'",
324
                    &dep.name
325
                )),
326
                None => w(format!(
327
                    "No version found for dependency on {:?}",
328
                    &dep.name
329
                )),
330
                _ => {}
331
            }
332
        }
333

            
334
        // Enforce rule 9. (every arti crate we depend on is published if
335
        // we are published.)
336
        if my_info.published {
337
            println!("in {}", self.name);
338
            for dep in &dependencies {
339
                match other_crates.get(&dep.name) {
340
                    None => w(format!(
341
                        "Dependency on crate {:?}, which I could not find.",
342
                        &dep.name
343
                    )),
344
                    Some(info) if !info.published => w(format!(
345
                        "Dependency on crate {:?}, which has `publish = false`",
346
                        &dep.name
347
                    )),
348
                    _ => {}
349
                }
350
            }
351
        }
352

            
353
        if no_annotate {
354
            changes.drop_annotations();
355
        }
356
        // We have to look this up again, or else it isn't &mut.
357
        let features = self
358
            .toml_doc
359
            .get_mut("features")
360
            .ok_or_else(|| anyhow!("I thought we added 'features' earlier!"))?
361
            .as_table_mut()
362
            .ok_or_else(|| anyhow!("Features was not table"))?;
363
        changes.apply(features)?;
364

            
365
        Ok(warnings)
366
    }
367

            
368
    /// If we made changes to this crate's cargo.toml, flush it to disk.
369
    fn save_if_changed(&self) -> Result<()> {
370
        let old_text = self.toml_doc_orig.to_string();
371
        let new_text = self.toml_doc.to_string();
372
        if new_text != old_text {
373
            println!("{} changed. Replacing.", self.name);
374
            let tmpname = self.toml_file.with_extension("toml.tmp");
375
            std::fs::write(&tmpname, new_text.as_str())?;
376
            std::fs::rename(&tmpname, &self.toml_file)?;
377
        }
378
        Ok(())
379
    }
380
}
381

            
382
/// Look at a toplevel Cargo.toml and find all of the paths in workplace.members
383
fn list_crate_paths(toplevel: impl AsRef<Path>) -> Result<Vec<String>> {
384
    let s = std::fs::read_to_string(toplevel.as_ref())?;
385
    let toml_doc = s.parse::<DocumentMut>()?;
386
    Ok(toml_doc["workspace"]["members"]
387
        .as_array()
388
        .ok_or_else(|| anyhow!("workplace.members is not an array!?"))?
389
        .iter()
390
        .map(|v| {
391
            v.as_str()
392
                .expect("Some member of workplace.members is not a string!?")
393
                .to_owned()
394
        })
395
        .collect())
396
}
397

            
398
fn main() -> Result<()> {
399
    let mut pargs = pico_args::Arguments::from_env();
400
    const HELP: &str = "fixup-features [--no-annotate] <toplevel Cargo.toml>";
401

            
402
    if pargs.contains(["-h", "--help"]) {
403
        println!("{}", HELP);
404
        return Ok(());
405
    }
406
    let no_annotate = pargs.contains("--no-annotate");
407
    let toplevel_toml_file: PathBuf = pargs.free_from_str()?;
408
    if !pargs.finish().is_empty() {
409
        println!("{}", HELP);
410
        return Ok(());
411
    }
412

            
413
    let toplevel_dir = toplevel_toml_file
414
        .parent()
415
        .expect("How is your Cargo.toml file `/`?")
416
        .to_path_buf();
417
    let mut crates = Vec::new();
418
    let mut crate_info = HashMap::new();
419
    for p in list_crate_paths(&toplevel_toml_file)? {
420
        let mut crate_toml_path = toplevel_dir.clone();
421
        crate_toml_path.push(p);
422
        crate_toml_path.push("Cargo.toml");
423
        let cr =
424
            Crate::load(&crate_toml_path).with_context(|| format!("In {crate_toml_path:?}"))?;
425
        crate_info.insert(cr.name.clone(), cr.info());
426
        crates.push(cr);
427
    }
428

            
429
    for cr in crates.iter_mut() {
430
        for w in cr
431
            .fix(no_annotate, &crate_info)
432
            .with_context(|| format!("In {}", cr.name))?
433
        {
434
            println!("{}: {}", cr.name, w.0);
435
        }
436
        cr.save_if_changed()?;
437
    }
438

            
439
    Ok(())
440
}