cairo_toolchain_xtasks/
upgrade.rs

1//! Update toolchain crates properly.
2
3use anyhow::{bail, Result};
4use clap::{Parser, ValueEnum};
5use semver::Version;
6use std::mem;
7use std::path::PathBuf;
8use std::sync::OnceLock;
9use toml_edit::{DocumentMut, InlineTable, Value};
10use xshell::{cmd, Shell};
11
12/// Update toolchain crates properly.
13#[derive(Parser)]
14pub struct Args {
15    /// Name of toolchain dependency (group) to update.
16    dep: DepName,
17
18    #[command(flatten)]
19    spec: Spec,
20
21    /// Do not edit any files, just inform what would be done.
22    #[arg(long, default_value_t = false)]
23    dry_run: bool,
24}
25
26#[derive(ValueEnum, Copy, Clone, Debug)]
27enum DepName {
28    Cairo,
29    #[value(name = "cairols")]
30    CairoLS,
31    #[value(name = "cairolint")]
32    CairoLint,
33    #[value(name = "stwo-cairo")]
34    StwoCairo,
35}
36
37#[derive(clap::Args, Clone, Default)]
38#[group(required = true, multiple = true)]
39struct Spec {
40    /// Source the dependency from crates.io and use a specific version.
41    version: Option<Version>,
42
43    /// Source the dependency from the GitHub repository and use a specific commit/ref.
44    #[arg(short, long, conflicts_with = "branch")]
45    rev: Option<String>,
46
47    /// Source the dependency from the GitHub repository and use a specific branch.
48    #[arg(short, long)]
49    branch: Option<String>,
50
51    /// Source the dependency from a local filesystem.
52    ///
53    /// This is useful for local development, but avoid commiting this to the repository.
54    #[arg(short, long, conflicts_with_all = ["rev", "branch"])]
55    path: Option<PathBuf>,
56}
57
58pub fn main(args: Args) -> Result<()> {
59    let sh = Shell::new()?;
60
61    let mut cargo_toml = sh.read_file("Cargo.toml")?.parse::<DocumentMut>()?;
62
63    edit_dependencies(&mut cargo_toml, "dependencies", &args);
64    edit_dependencies(&mut cargo_toml, "dev-dependencies", &args);
65    edit_dependencies(&mut cargo_toml, "workspace.dependencies", &args);
66    edit_patch(&mut cargo_toml, &args);
67
68    if !args.dry_run {
69        sh.write_file("Cargo.toml", cargo_toml.to_string())?;
70
71        cmd!(sh, "cargo fetch").run()?;
72
73        purge_unused_patches(&mut cargo_toml)?;
74        sh.write_file("Cargo.toml", cargo_toml.to_string())?;
75
76        cmd!(sh, "cargo xtask sync-version").run()?;
77    }
78
79    Ok(())
80}
81
82fn edit_dependencies(cargo_toml: &mut DocumentMut, table_path: &str, args: &Args) {
83    let Some(deps) = table_path
84        .split('.')
85        .try_fold(cargo_toml.as_item_mut(), |doc, key| doc.get_mut(key))
86    else {
87        return;
88    };
89    if deps.is_none() {
90        return;
91    }
92    let deps = deps.as_table_mut().unwrap();
93
94    for (_, dep) in deps.iter_mut().filter(|(key, _)| args.tool_owns_crate(key)) {
95        let dep = dep.as_value_mut().unwrap();
96
97        // Always use crates.io requirements so that we can reliably patch them with the
98        // `[patch.crates-io]` table.
99        let mut new_dep = InlineTable::from_iter([(
100            "version",
101            match &args.spec.version {
102                Some(version) => Value::from(version.to_string()),
103                None => Value::from("*"),
104            },
105        )]);
106
107        copy_dependency_features(&mut new_dep, dep);
108
109        *dep = new_dep.into();
110        simplify_dependency_table(dep)
111    }
112
113    deps.fmt();
114    deps.sort_values();
115
116    eprintln!("[{table_path}]");
117    for (key, dep) in deps.iter().filter(|(key, _)| args.tool_owns_crate(key)) {
118        eprintln!("{key} = {dep}");
119    }
120}
121
122fn edit_patch(cargo_toml: &mut DocumentMut, args: &Args) {
123    let patch = cargo_toml["patch"].as_table_mut().unwrap()["crates-io"]
124        .as_table_mut()
125        .unwrap();
126
127    // Clear any existing entries for this dependency.
128    for crate_name in args.tool_crates() {
129        patch.remove(crate_name);
130    }
131
132    // Leave this section as-if if we are requested to just use a specific version.
133    if args.spec.rev.is_some() || args.spec.branch.is_some() || args.spec.path.is_some() {
134        // Patch all Cairo crates that exist, even if this project does not directly depend on them,
135        // to avoid any duplicates in transient dependencies.
136        for &dep_name in args.tool_crates() {
137            let mut dep = InlineTable::new();
138
139            // Add a Git branch or revision reference if requested.
140            if args.spec.rev.is_some() || args.spec.branch.is_some() {
141                dep.insert("git", args.tool_repo().into());
142            }
143
144            if let Some(branch) = &args.spec.branch {
145                dep.insert("branch", branch.as_str().into());
146            }
147
148            if let Some(rev) = &args.spec.rev {
149                dep.insert("rev", rev.as_str().into());
150            }
151
152            // Add local path reference if requested.
153            // For local path sources, Cargo is not looking for crates recursively therefore, we
154            // need to manually provide full paths to Cairo workspace member crates.
155            if let Some(path) = &args.spec.path {
156                dep.insert(
157                    "path",
158                    path.join("crates")
159                        .join(dep_name)
160                        .to_string_lossy()
161                        .into_owned()
162                        .into(),
163                );
164            }
165
166            patch.insert(dep_name, dep.into());
167        }
168    }
169
170    patch.fmt();
171    patch.sort_values();
172
173    eprintln!("[patch.crates-io]");
174    for (key, dep) in patch.iter() {
175        eprintln!("{key} = {dep}");
176    }
177}
178
179impl Args {
180    fn tool_crates(&self) -> &'static [&'static str] {
181        static CAIRO_CACHE: OnceLock<Vec<&str>> = OnceLock::new();
182        match self.dep {
183            DepName::Cairo => CAIRO_CACHE.get_or_init(|| {
184                pull_cairo_packages_from_cairo_repository(&self.spec)
185                    .unwrap()
186                    .into_iter()
187                    .map(|s| s.leak() as &str)
188                    .collect()
189            }),
190            DepName::CairoLS => &["cairo-language-server"],
191            DepName::CairoLint => &["cairo-lint"],
192            DepName::StwoCairo => &["stwo_cairo_prover", "stwo-cairo-adapter"],
193        }
194    }
195
196    fn tool_owns_crate(&self, crate_name: &str) -> bool {
197        self.tool_crates().contains(&crate_name)
198    }
199
200    fn tool_repo(&self) -> &'static str {
201        match self.dep {
202            DepName::Cairo => "https://github.com/starkware-libs/cairo",
203            DepName::CairoLS => "https://github.com/software-mansion/cairols",
204            DepName::CairoLint => "https://github.com/software-mansion/cairo-lint",
205            DepName::StwoCairo => "https://github.com/starkware-libs/stwo-cairo",
206        }
207    }
208}
209
210/// Copies features from source dependency spec to new dependency table, if exists.
211fn copy_dependency_features(dest: &mut InlineTable, src: &Value) {
212    if let Some(dep) = src.as_inline_table() {
213        if let Some(features) = dep.get("features") {
214            dest.insert("features", features.clone());
215        }
216    }
217}
218
219/// Simplifies a `{ version = "V" }` dependency spec to shorthand `"V"` if possible.
220fn simplify_dependency_table(dep: &mut Value) {
221    *dep = match mem::replace(dep, false.into()) {
222        Value::InlineTable(mut table) => {
223            if table.len() == 1 {
224                table.remove("version").unwrap_or_else(|| table.into())
225            } else {
226                table.into()
227            }
228        }
229
230        dep => dep,
231    }
232}
233
234/// Remove any unused patches from the `[patch.crates-io]` table.
235///
236/// We are adding patch entries for **all** Cairo crates existing, and some may end up being unused.
237/// Cargo is emitting warnings about unused patches and keeps a record of them in the `Cargo.lock`.
238/// The goal of this function is to resolve these warnings.
239fn purge_unused_patches(cargo_toml: &mut DocumentMut) -> Result<()> {
240    let sh = Shell::new()?;
241    let cargo_lock = sh.read_file("Cargo.lock")?.parse::<DocumentMut>()?;
242
243    if let Some(unused_patches) = find_unused_patches(&cargo_lock) {
244        let patch = cargo_toml["patch"].as_table_mut().unwrap()["crates-io"]
245            .as_table_mut()
246            .unwrap();
247
248        // Remove any patches that are not for Cairo crates.
249        patch.retain(|key, _| !unused_patches.contains(&key.to_owned()));
250    }
251
252    Ok(())
253}
254
255/// Extracts names of unused patches from the `[[patch.unused]]` array from the `Cargo.lock` file.
256fn find_unused_patches(cargo_lock: &DocumentMut) -> Option<Vec<String>> {
257    Some(
258        cargo_lock
259            .get("patch")?
260            .get("unused")?
261            .as_array_of_tables()?
262            .iter()
263            .flat_map(|table| Some(table.get("name")?.as_str()?.to_owned()))
264            .collect(),
265    )
266}
267
268/// Pulls names of crates published from the `starkware-libs/cairo` repository.
269///
270/// The list is obtained by parsing the `scripts/release_crates.sh` script in that repo.
271/// The resulting vector is sorted alphabetically.
272fn pull_cairo_packages_from_cairo_repository(spec: &Spec) -> Result<Vec<String>> {
273    let sh = Shell::new()?;
274
275    let release_crates_sh = if let Some(path) = &spec.path {
276        sh.read_file(path.join("scripts").join("release_crates.sh"))?
277    } else {
278        let rev = if let Some(version) = &spec.version {
279            format!("refs/tags/v{version}")
280        } else if let Some(rev) = &spec.rev {
281            rev.to_string()
282        } else if let Some(branch) = &spec.branch {
283            format!("refs/heads/{branch}")
284        } else {
285            "refs/heads/main".to_string()
286        };
287        let url = format!("https://raw.githubusercontent.com/starkware-libs/cairo/{rev}/scripts/release_crates.sh");
288        cmd!(sh, "curl -sSfL {url}").read()?
289    };
290
291    let Some((_, source_list)) = release_crates_sh.split_once("CRATES_TO_PUBLISH=(") else {
292        bail!("failed to extract start of `CRATES_TO_PUBLISH` from `scripts/release_crates.sh`");
293    };
294    let Some((source_list, _)) = source_list.split_once(")") else {
295        bail!("failed to extract end of `CRATES_TO_PUBLISH` from `scripts/release_crates.sh`");
296    };
297
298    let mut crates: Vec<String> = source_list
299        .split_whitespace()
300        .filter(|s| s.starts_with("cairo-lang-"))
301        .map(|s| s.into())
302        .collect();
303    crates.sort();
304    Ok(crates)
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_pull_cairo_packages_from_cairo_repository() {
313        let list = pull_cairo_packages_from_cairo_repository(&Spec::default()).unwrap();
314        assert!(!list.is_empty());
315        assert!(list.contains(&"cairo-lang-compiler".to_owned()));
316        assert!(!list.contains(&"cairo-test".to_owned()));
317        assert!(list.is_sorted());
318    }
319}