fixup_features/
main.rs

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
51mod changes;
52mod graph;
53
54use anyhow::{Context, Result, anyhow};
55use std::collections::{HashMap, HashSet};
56use std::path::{Path, PathBuf};
57use toml_edit::{DocumentMut, Item, Table, Value};
58
59use 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)]
65struct 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)]
72struct Dependency {
73    name: String,
74    optional: bool,
75    version: Option<String>,
76}
77
78/// Stored information about a crate.
79#[derive(Debug, Clone)]
80struct 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)]
93struct 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.
101fn 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
135impl 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 {wanted}. Fixing."));
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!(
240                    "{f} is marked as __is_experimental, but is not reachable from experimental. Fixing."
241                ));
242                changes.push(Change::AddEdge("experimental".into(), f.clone()))
243            }
244
245            &in_experimental | &is_experimental
246        };
247
248        // Enforce rule 4: Every non-meta feature is reachable from full, or
249        // from experimental, or is nonadditive.
250        {
251            let complaint: &str = "# XX\x58X Mark as full, experimental, or non-additive!\n";
252
253            let all_features: HashSet<_> = graph.all_features().collect();
254            let meta: HashSet<_> = [
255                "__is_nonadditive",
256                "__is_experimental",
257                "full",
258                "default",
259                "experimental",
260            ]
261            .into_iter()
262            .map(String::from)
263            .collect();
264
265            let mut not_found = all_features;
266            for set in [
267                &reachable_from_full,
268                &meta,
269                &reachable_from_experimental,
270                &nonadditive,
271            ] {
272                not_found = &not_found - set;
273            }
274
275            for f in not_found {
276                w(format!(
277                    "{f} is not experimental, reachable from full, or nonadditive."
278                ));
279                changes.push(Change::Annotate(f.clone(), complaint.to_string()));
280            }
281        }
282
283        // 5. Every feature reachable from `default` is reachable from `full`.
284        {
285            let complaint = "# XX\x58X This is reachable from 'default', but from 'full'.\n";
286            let default: HashSet<_> = graph.edges_from("default").collect();
287            for f in default.difference(&reachable_from_full) {
288                if all_features.contains(f) {
289                    w(format!("{f} is reachable from default, but not from full."));
290                    changes.push(Change::Annotate(f.clone(), complaint.to_string()));
291                }
292            }
293        }
294
295        // 6. No non-additive feature is reachable from `full` or
296        //    `experimental`.
297        {
298            let complaint = "# XX\x58X This is non-additive, but reachable from 'full'.\n";
299            for f in nonadditive.intersection(&reachable_from_full) {
300                w(format!("nonadditive feature {f} is reachable from full."));
301                changes.push(Change::Annotate(f.clone(), complaint.to_string()));
302            }
303            let complaint = "# XX\x58X This is non-additive, but reachable from 'experimental'.\n";
304            for f in nonadditive.intersection(&reachable_from_experimental) {
305                w(format!(
306                    "nonadditive feature {f} is reachable from experimental."
307                ));
308                changes.push(Change::Annotate(f.clone(), complaint.to_string()));
309            }
310        }
311
312        // 7. No experimental is reachable from `full`.
313        {
314            let complaint = "# XX\x58X This is experimental, but reachable from 'full'.\n";
315            for f in reachable_from_full.intersection(&defined_experimental) {
316                w(format!("experimental feature {f} is reachable from full!"));
317                changes.push(Change::Annotate(f.clone(), complaint.to_string()));
318            }
319        }
320
321        // 8. Every dependency is a real version.
322        for dep in &dependencies {
323            match dep.version.as_deref() {
324                Some("*") => w(format!(
325                    "Dependency for {:?} is given as version='*'",
326                    &dep.name
327                )),
328                None => w(format!(
329                    "No version found for dependency on {:?}",
330                    &dep.name
331                )),
332                _ => {}
333            }
334        }
335
336        // Enforce rule 9. (every arti crate we depend on is published if
337        // we are published.)
338        if my_info.published {
339            println!("in {}", self.name);
340            for dep in &dependencies {
341                match other_crates.get(&dep.name) {
342                    None => w(format!(
343                        "Dependency on crate {:?}, which I could not find.",
344                        &dep.name
345                    )),
346                    Some(info) if !info.published => w(format!(
347                        "Dependency on crate {:?}, which has `publish = false`",
348                        &dep.name
349                    )),
350                    _ => {}
351                }
352            }
353        }
354
355        if no_annotate {
356            changes.drop_annotations();
357        }
358        // We have to look this up again, or else it isn't &mut.
359        let features = self
360            .toml_doc
361            .get_mut("features")
362            .ok_or_else(|| anyhow!("I thought we added 'features' earlier!"))?
363            .as_table_mut()
364            .ok_or_else(|| anyhow!("Features was not table"))?;
365        changes.apply(features)?;
366
367        Ok(warnings)
368    }
369
370    /// If we made changes to this crate's cargo.toml, flush it to disk.
371    fn save_if_changed(&self) -> Result<()> {
372        let old_text = self.toml_doc_orig.to_string();
373        let new_text = self.toml_doc.to_string();
374        if new_text != old_text {
375            println!("{} changed. Replacing.", self.name);
376            let tmpname = self.toml_file.with_extension("toml.tmp");
377            std::fs::write(&tmpname, new_text.as_str())?;
378            std::fs::rename(&tmpname, &self.toml_file)?;
379        }
380        Ok(())
381    }
382}
383
384/// Look at a toplevel Cargo.toml and find all of the paths in workplace.members
385fn list_crate_paths(
386    toplevel: impl AsRef<Path>,
387    exclusion_prefixes: &[String],
388) -> Result<Vec<String>> {
389    let s = std::fs::read_to_string(toplevel.as_ref())?;
390    let toml_doc = s.parse::<DocumentMut>()?;
391    Ok(toml_doc["workspace"]["members"]
392        .as_array()
393        .ok_or_else(|| anyhow!("workplace.members is not an array!?"))?
394        .iter()
395        .map(|v| {
396            v.as_str()
397                .expect("Some member of workplace.members is not a string!?")
398                .to_owned()
399        })
400        .filter(|s| {
401            // Iterate through all exclusion prefixes and check if there isn't one that `s` starts with.
402            !exclusion_prefixes
403                .iter()
404                .any(|prefix| s.starts_with(prefix))
405        })
406        .collect())
407}
408
409fn main() -> Result<()> {
410    let mut pargs = pico_args::Arguments::from_env();
411    const HELP: &str = "fixup-features [--no-annotate] [--exclude <PREFIX1> --exclude <PREFIX2> ...] <toplevel Cargo.toml>";
412
413    if pargs.contains(["-h", "--help"]) {
414        println!("{HELP}");
415        return Ok(());
416    }
417    let no_annotate = pargs.contains("--no-annotate");
418    let exclusion_prefixes: Vec<String> = pargs.values_from_str("--exclude").unwrap();
419    let toplevel_toml_file: PathBuf = pargs.free_from_str()?;
420    if !pargs.finish().is_empty() {
421        println!("{HELP}");
422        return Ok(());
423    }
424
425    let toplevel_dir = toplevel_toml_file
426        .parent()
427        .expect("How is your Cargo.toml file `/`?")
428        .to_path_buf();
429    let mut crates = Vec::new();
430    let mut crate_info = HashMap::new();
431    for p in list_crate_paths(&toplevel_toml_file, &exclusion_prefixes)? {
432        let mut crate_toml_path = toplevel_dir.clone();
433        crate_toml_path.push(p);
434        crate_toml_path.push("Cargo.toml");
435        let cr =
436            Crate::load(&crate_toml_path).with_context(|| format!("In {crate_toml_path:?}"))?;
437        crate_info.insert(cr.name.clone(), cr.info());
438        crates.push(cr);
439    }
440
441    for cr in crates.iter_mut() {
442        for w in cr
443            .fix(no_annotate, &crate_info)
444            .with_context(|| format!("In {}", cr.name))?
445        {
446            println!("{}: {}", cr.name, w.0);
447        }
448        cr.save_if_changed()?;
449    }
450
451    Ok(())
452}