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::{anyhow, Context, Result};
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 {}. 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
383fn list_crate_paths(
384    toplevel: impl AsRef<Path>,
385    exclusion_prefixes: &[String],
386) -> Result<Vec<String>> {
387    let s = std::fs::read_to_string(toplevel.as_ref())?;
388    let toml_doc = s.parse::<DocumentMut>()?;
389    Ok(toml_doc["workspace"]["members"]
390        .as_array()
391        .ok_or_else(|| anyhow!("workplace.members is not an array!?"))?
392        .iter()
393        .map(|v| {
394            v.as_str()
395                .expect("Some member of workplace.members is not a string!?")
396                .to_owned()
397        })
398        .filter(|s| {
399            // Iterate through all exclusion prefixes and check if there isn't one that `s` starts with.
400            !exclusion_prefixes
401                .iter()
402                .any(|prefix| s.starts_with(prefix))
403        })
404        .collect())
405}
406
407fn main() -> Result<()> {
408    let mut pargs = pico_args::Arguments::from_env();
409    const HELP: &str =
410        "fixup-features [--no-annotate] [--exclude <PREFIX1> --exclude <PREFIX2> ...] <toplevel Cargo.toml>";
411
412    if pargs.contains(["-h", "--help"]) {
413        println!("{}", HELP);
414        return Ok(());
415    }
416    let no_annotate = pargs.contains("--no-annotate");
417    let exclusion_prefixes: Vec<String> = pargs.values_from_str("--exclude").unwrap();
418    let toplevel_toml_file: PathBuf = pargs.free_from_str()?;
419    if !pargs.finish().is_empty() {
420        println!("{}", HELP);
421        return Ok(());
422    }
423
424    let toplevel_dir = toplevel_toml_file
425        .parent()
426        .expect("How is your Cargo.toml file `/`?")
427        .to_path_buf();
428    let mut crates = Vec::new();
429    let mut crate_info = HashMap::new();
430    for p in list_crate_paths(&toplevel_toml_file, &exclusion_prefixes)? {
431        let mut crate_toml_path = toplevel_dir.clone();
432        crate_toml_path.push(p);
433        crate_toml_path.push("Cargo.toml");
434        let cr =
435            Crate::load(&crate_toml_path).with_context(|| format!("In {crate_toml_path:?}"))?;
436        crate_info.insert(cr.name.clone(), cr.info());
437        crates.push(cr);
438    }
439
440    for cr in crates.iter_mut() {
441        for w in cr
442            .fix(no_annotate, &crate_info)
443            .with_context(|| format!("In {}", cr.name))?
444        {
445            println!("{}: {}", cr.name, w.0);
446        }
447        cr.save_if_changed()?;
448    }
449
450    Ok(())
451}