cairo_toolchain_xtasks/
upgrade.rs

1//! Update toolchain crates properly.
2
3use anyhow::{Result, bail};
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::{Shell, cmd};
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 avoids 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"].ensure_table()["crates-io"].ensure_table();
124
125    // Clear any existing entries for this dependency.
126    for crate_name in args.tool_crates() {
127        patch.remove(crate_name);
128    }
129
130    // Leave this section as-if if we are requested to just use a specific version.
131    if args.spec.rev.is_some() || args.spec.branch.is_some() || args.spec.path.is_some() {
132        // Patch all Cairo crates that exist, even if this project does not directly depend on them,
133        // to avoid any duplicates in transient dependencies.
134        for &dep_name in args.tool_crates() {
135            let mut dep = InlineTable::new();
136
137            // Add a Git branch or revision reference if requested.
138            if args.spec.rev.is_some() || args.spec.branch.is_some() {
139                dep.insert("git", args.tool_repo().into());
140            }
141
142            if let Some(branch) = &args.spec.branch {
143                dep.insert("branch", branch.as_str().into());
144            }
145
146            if let Some(rev) = &args.spec.rev {
147                dep.insert("rev", rev.as_str().into());
148            }
149
150            // Add local path reference if requested.
151            // For local path sources, Cargo is not looking for crates recursively therefore, we
152            // need to manually provide full paths to Cairo workspace member crates.
153            if let Some(path) = &args.spec.path {
154                dep.insert(
155                    "path",
156                    path.join("crates")
157                        .join(dep_name)
158                        .to_string_lossy()
159                        .into_owned()
160                        .into(),
161                );
162            }
163
164            patch.insert(dep_name, dep.into());
165        }
166    }
167
168    patch.fmt();
169    patch.sort_values();
170
171    eprintln!("[patch.crates-io]");
172    for (key, dep) in patch.iter() {
173        eprintln!("{key} = {dep}");
174    }
175}
176
177impl Args {
178    fn tool_crates(&self) -> &'static [&'static str] {
179        static CAIRO_CACHE: OnceLock<Vec<&str>> = OnceLock::new();
180        match self.dep {
181            DepName::Cairo => CAIRO_CACHE.get_or_init(|| {
182                pull_cairo_packages_from_cairo_repository(&self.spec)
183                    .unwrap()
184                    .into_iter()
185                    .map(|s| s.leak() as &str)
186                    .collect()
187            }),
188            DepName::CairoLS => &["cairo-language-server"],
189            DepName::CairoLint => &["cairo-lint"],
190            DepName::StwoCairo => &["stwo_cairo_prover", "stwo-cairo-adapter"],
191        }
192    }
193
194    fn tool_owns_crate(&self, crate_name: &str) -> bool {
195        self.tool_crates().contains(&crate_name)
196    }
197
198    fn tool_repo(&self) -> &'static str {
199        match self.dep {
200            DepName::Cairo => "https://github.com/starkware-libs/cairo",
201            DepName::CairoLS => "https://github.com/software-mansion/cairols",
202            DepName::CairoLint => "https://github.com/software-mansion/cairo-lint",
203            DepName::StwoCairo => "https://github.com/starkware-libs/stwo-cairo",
204        }
205    }
206}
207
208/// Copies features from source dependency spec to new dependency table, if exists.
209fn copy_dependency_features(dest: &mut InlineTable, src: &Value) {
210    if let Some(dep) = src.as_inline_table() {
211        if let Some(features) = dep.get("features") {
212            dest.insert("features", features.clone());
213        }
214    }
215}
216
217/// Simplifies a `{ version = "V" }` dependency spec to shorthand `"V"` if possible.
218fn simplify_dependency_table(dep: &mut Value) {
219    *dep = match mem::replace(dep, false.into()) {
220        Value::InlineTable(mut table) => {
221            if table.len() == 1 {
222                table.remove("version").unwrap_or_else(|| table.into())
223            } else {
224                table.into()
225            }
226        }
227
228        dep => dep,
229    }
230}
231
232/// Remove any unused patches from the `[patch.crates-io]` table.
233///
234/// We are adding patch entries for **all** Cairo crates existing, and some may end up being unused.
235/// Cargo is emitting warnings about unused patches and keeps a record of them in the `Cargo.lock`.
236/// The goal of this function is to resolve these warnings.
237fn purge_unused_patches(cargo_toml: &mut DocumentMut) -> Result<()> {
238    let sh = Shell::new()?;
239    let cargo_lock = sh.read_file("Cargo.lock")?.parse::<DocumentMut>()?;
240
241    if let Some(unused_patches) = find_unused_patches(&cargo_lock)
242        && let Some(patch) = cargo_toml["patch"].as_table_mut()
243        && let Some(patch) = patch["crates-io"].as_table_mut()
244    {
245        // Remove any patches that are not for Cairo crates.
246        patch.retain(|key, _| !unused_patches.contains(&key.to_owned()));
247    }
248
249    Ok(())
250}
251
252/// Extracts names of unused patches from the `[[patch.unused]]` array from the `Cargo.lock` file.
253fn find_unused_patches(cargo_lock: &DocumentMut) -> Option<Vec<String>> {
254    Some(
255        cargo_lock
256            .get("patch")?
257            .get("unused")?
258            .as_array_of_tables()?
259            .iter()
260            .flat_map(|table| Some(table.get("name")?.as_str()?.to_owned()))
261            .collect(),
262    )
263}
264
265/// Pulls names of crates published from the `starkware-libs/cairo` repository.
266///
267/// The list is obtained by parsing the `scripts/release_crates.sh` script in that repo.
268/// The resulting vector is sorted alphabetically.
269fn pull_cairo_packages_from_cairo_repository(spec: &Spec) -> Result<Vec<String>> {
270    let sh = Shell::new()?;
271
272    let release_crates_sh = if let Some(path) = &spec.path {
273        sh.read_file(path.join("scripts").join("release_crates.sh"))?
274    } else {
275        let rev = if let Some(version) = &spec.version {
276            format!("refs/tags/v{version}")
277        } else if let Some(rev) = &spec.rev {
278            rev.to_string()
279        } else if let Some(branch) = &spec.branch {
280            format!("refs/heads/{branch}")
281        } else {
282            "refs/heads/main".to_string()
283        };
284        let url = format!(
285            "https://raw.githubusercontent.com/starkware-libs/cairo/{rev}/scripts/release_crates.sh"
286        );
287        cmd!(sh, "curl -sSfL {url}").read()?
288    };
289
290    let Some((_, source_list)) = release_crates_sh.split_once("CRATES_TO_PUBLISH=(") else {
291        bail!("failed to extract start of `CRATES_TO_PUBLISH` from `scripts/release_crates.sh`");
292    };
293    let Some((source_list, _)) = source_list.split_once(")") else {
294        bail!("failed to extract end of `CRATES_TO_PUBLISH` from `scripts/release_crates.sh`");
295    };
296
297    let mut crates: Vec<String> = source_list
298        .split_whitespace()
299        .filter(|s| s.starts_with("cairo-lang-"))
300        .map(|s| s.into())
301        .collect();
302    crates.sort();
303    Ok(crates)
304}
305
306trait ItemEx {
307    fn ensure_table(&mut self) -> &mut toml_edit::Table;
308}
309
310impl ItemEx for toml_edit::Item {
311    fn ensure_table(&mut self) -> &mut toml_edit::Table {
312        if !self.is_table() {
313            let mut table = toml_edit::Table::new();
314            table.set_implicit(true);
315            *self = table.into();
316        }
317        self.as_table_mut().unwrap()
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn test_pull_cairo_packages_from_cairo_repository() {
327        let list = pull_cairo_packages_from_cairo_repository(&Spec::default()).unwrap();
328        assert!(!list.is_empty());
329        assert!(list.contains(&"cairo-lang-compiler".to_owned()));
330        assert!(!list.contains(&"cairo-test".to_owned()));
331        assert!(list.is_sorted());
332    }
333}