Skip to main content

hygiene/
terminal.rs

1//! Finds the crates of the terminal in the dependencies of a crate
2//! (01M54216PBE319RASGNMY9F0MR). `riff-client` holds no terminal code:
3//! the test `riff_client_has_no_terminal_crate` runs [`found`] on its
4//! `Cargo.toml` and on the `Cargo.lock` of the workspace.
5//!
6//! The roots are the normal dependencies of the manifest, also those of
7//! a target and the build dependencies: not its dev dependencies. Then
8//! [`found`] follows each root through the lock file, so a crate of the
9//! terminal that comes through another crate counts too.
10//!
11//! ```
12//! let manifest = "[dependencies]\nratatui.workspace = true\n";
13//! assert_eq!(hygiene::terminal::found(manifest, ""), ["ratatui"]);
14//!
15//! let manifest = "[dependencies]\nui = \"1\"\n\n[dev-dependencies]\nclap = \"4\"\n";
16//! let lock = "[[package]]\nname = \"ui\"\ndependencies = [\n \"crossterm 0.29.0\",\n]\n";
17//! assert_eq!(hygiene::terminal::found(manifest, lock), ["crossterm"]);
18//! ```
19
20use std::collections::{BTreeMap, BTreeSet};
21
22/// The crates of the terminal.
23pub const TERMINAL: [&str; 4] = ["ratatui", "crossterm", "clap", "rustyline"];
24
25/// The crates of [`TERMINAL`] that the crate of `manifest` (its
26/// `Cargo.toml`) depends on, through the packages of `lock` (a
27/// `Cargo.lock`). Sorted, each one time.
28pub fn found(manifest: &str, lock: &str) -> Vec<String> {
29    let graph = packages(lock);
30    let mut seen = BTreeSet::new();
31    let mut next: Vec<String> = dependencies(manifest);
32    while let Some(name) = next.pop() {
33        if seen.insert(name.clone())
34            && let Some(more) = graph.get(&name)
35        {
36            next.extend(more.iter().cloned());
37        }
38    }
39    seen.into_iter()
40        .filter(|name| TERMINAL.contains(&name.as_str()))
41        .collect()
42}
43
44/// The names of the normal and the build dependencies of `manifest`,
45/// also those of a target. Not its dev dependencies.
46///
47/// ```
48/// let manifest = "[package]\nname = \"a\"\n\n[dependencies]\nb.workspace = true\n\
49///     c = { path = \"../c\" }\n\n[dev-dependencies]\nd = \"1\"\n\n\
50///     [target.'cfg(unix)'.dependencies]\ne = \"1\"\n\n[dependencies.f]\nversion = \"1\"\n";
51/// assert_eq!(hygiene::terminal::dependencies(manifest), ["b", "c", "e", "f"]);
52/// ```
53pub fn dependencies(manifest: &str) -> Vec<String> {
54    let mut names = Vec::new();
55    let mut in_table = false;
56    for line in manifest.lines().map(str::trim) {
57        if let Some(header) = line.strip_prefix('[') {
58            let header = header.trim_end_matches(']');
59            let (table, crate_name) = match header.rsplit_once("dependencies.") {
60                Some((table, name)) => (format!("{table}dependencies"), Some(name)),
61                None => (header.to_owned(), None),
62            };
63            let normal = (table == "dependencies" || table.ends_with(".dependencies"))
64                || table == "build-dependencies"
65                || table.ends_with(".build-dependencies");
66            match crate_name {
67                Some(name) if normal => {
68                    names.push(name.trim_matches('"').to_owned());
69                    in_table = false;
70                }
71                _ => in_table = normal,
72            }
73            continue;
74        }
75        if !in_table || line.is_empty() || line.starts_with('#') {
76            continue;
77        }
78        if let Some((key, _)) = line.split_once('=') {
79            let name = key.trim().split('.').next().unwrap_or_default();
80            names.push(name.trim_matches('"').to_owned());
81        }
82    }
83    names
84}
85
86/// Each package of `lock` with the names of its dependencies.
87fn packages(lock: &str) -> BTreeMap<String, BTreeSet<String>> {
88    let mut graph: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
89    let mut name = String::new();
90    let mut in_list = false;
91    for line in lock.lines().map(str::trim) {
92        if line == "[[package]]" {
93            name.clear();
94            in_list = false;
95        } else if let Some(value) = line.strip_prefix("name = ") {
96            name = value.trim_matches('"').to_owned();
97            graph.entry(name.clone()).or_default();
98        } else if line.starts_with("dependencies = [") {
99            in_list = true;
100        } else if in_list && line == "]" {
101            in_list = false;
102        } else if in_list {
103            let dependency = line.trim_matches(|c| c == '"' || c == ',');
104            let dependency = dependency.split(' ').next().unwrap_or_default();
105            graph
106                .entry(name.clone())
107                .or_default()
108                .insert(dependency.to_owned());
109        }
110    }
111    graph
112}
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    #[test]
119    fn a_crate_of_the_terminal_through_two_crates_counts() {
120        let manifest = "[dependencies]\na = \"1\"\n";
121        let lock = concat!(
122            "[[package]]\nname = \"a\"\nversion = \"1.0.0\"\ndependencies = [\n \"b\",\n]\n\n",
123            "[[package]]\nname = \"b\"\nversion = \"1.0.0\"\ndependencies = [\n",
124            " \"rustyline 18.0.1\",\n \"serde\",\n]\n\n",
125            "[[package]]\nname = \"rustyline\"\nversion = \"18.0.1\"\n",
126        );
127        assert_eq!(found(manifest, lock), ["rustyline"]);
128    }
129
130    #[test]
131    fn a_dev_dependency_does_not_count() {
132        let manifest = "[dependencies]\na = \"1\"\n\n[dev-dependencies]\nratatui = \"0.30\"\n";
133        assert!(found(manifest, "").is_empty());
134    }
135
136    #[test]
137    fn a_build_dependency_and_a_target_dependency_count() {
138        let manifest = "[build-dependencies]\nclap = \"4\"\n\n\
139                        [target.'cfg(unix)'.dependencies]\ncrossterm = \"0.29\"\n";
140        assert_eq!(found(manifest, ""), ["clap", "crossterm"]);
141    }
142}