Skip to main content

cargo_v5/commands/migrate/
mod.rs

1use std::{
2    borrow::Cow,
3    env::{self, home_dir},
4    fmt::Display,
5    io::ErrorKind,
6    path::{Path, PathBuf},
7};
8
9use fs_err::tokio as fs;
10use miette::Diagnostic;
11use semver::Version;
12use supports_color::Stream;
13use thiserror::Error;
14use tokio::{process::Command, task::block_in_place};
15use toml_edit::{Document, DocumentMut, Item, Table, Value, table};
16
17use crate::errors::CliError;
18
19mod source_code;
20mod vfs;
21
22/// Applies all available upgrades to the workspace.
23pub async fn migrate_workspace(root: &Path) -> Result<(), CliError> {
24    let metadata_task = block_in_place(|| {
25        cargo_metadata::MetadataCommand::new()
26            .current_dir(root)
27            .exec()
28            .ok()
29    });
30
31    let Some(metadata) = metadata_task else {
32        return Err(MigrateError::Metadata.into());
33    };
34
35    let mut ctx = ChangesCtx::new(&metadata.workspace_root);
36
37    update_vexide(&mut ctx).await?;
38    update_rust(&mut ctx).await?;
39    update_cargo_config(&mut ctx).await?;
40    source_code::update_targets(&mut ctx, &metadata).await?;
41
42    // Print pending changes - in the future we will apply them too.
43    let highlight = supports_color::on_cached(Stream::Stdout).is_some();
44
45    println!(
46        "The upgrade tool will now update your project configuration to the vexide 0.8.0 recommended defaults."
47    );
48    println!(
49        "After applying these changes, make sure to check out the upgrade guide on the vexide website"
50    );
51    println!("for instructions on how to update your project's code!");
52    println!("Changes Summary:");
53    for desc in &ctx.description {
54        println!("  - {desc}");
55    }
56    if ctx.description.is_empty() {
57        println!("  - (No changes)");
58        println!();
59        return Ok(());
60    }
61    println!();
62
63    loop {
64        let confirmation: inquire::Select<'_, ConfirmOptions> = inquire::Select::new(
65            "Apply changes?",
66            vec![
67                ConfirmOptions::Confirm,
68                ConfirmOptions::ViewDiff,
69                ConfirmOptions::Abort,
70            ],
71        );
72
73        let reply = block_in_place(|| confirmation.prompt_skippable())?.unwrap_or_default();
74
75        match reply {
76            ConfirmOptions::Confirm => {
77                ctx.apply().await?;
78                break;
79            }
80            ConfirmOptions::ViewDiff => println!("{}", ctx.fs.display(true, highlight).await),
81            ConfirmOptions::Abort => {
82                break;
83            }
84        }
85    }
86
87    Ok(())
88}
89
90#[derive(Default)]
91enum ConfirmOptions {
92    Confirm,
93    ViewDiff,
94    #[default]
95    Abort,
96}
97
98impl Display for ConfirmOptions {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        f.write_str(match self {
101            ConfirmOptions::Confirm => "Confirm",
102            ConfirmOptions::ViewDiff => "View Changes",
103            ConfirmOptions::Abort => "Abort",
104        })
105    }
106}
107
108async fn update_rust(ctx: &mut ChangesCtx) -> Result<(), CliError> {
109    ctx.edit_toml("rust-toolchain.toml", |mut ctx| {
110        let latest = "nightly-2025-11-26";
111
112        let toolchain = ctx.document.table("toolchain");
113        toolchain["channel"] = latest.into();
114        ctx.explain_change(format!("Updated to Rust {}", latest));
115    })
116    .await?;
117
118    let has_override = rustup_has_override_for_path(ctx.fs.root())
119        .await
120        .unwrap_or(false);
121    if has_override {
122        ctx.will_disable_rustup_override = has_override;
123        ctx.describe("Disabled the Rustup override for this project.");
124    }
125
126    Ok(())
127}
128
129async fn rustup_has_override_for_path(path: &Path) -> Option<bool> {
130    let absolute_path = fs::canonicalize(path).await.ok()?;
131
132    let mut rustup_home = env::var("RUSTUP_HOME").ok().map(PathBuf::from);
133    if rustup_home.is_none() {
134        rustup_home = home_dir().map(|dir| dir.join(".rustup"));
135    }
136
137    let settings_path = rustup_home?.join("settings.toml");
138    let contents = fs::read_to_string(settings_path).await.ok()?;
139
140    let settings = Document::parse(contents).ok()?;
141
142    let overrides = settings.get("overrides")?.as_table()?;
143
144    let has_override_for_path = overrides.contains_key(absolute_path.to_str()?);
145
146    Some(has_override_for_path)
147}
148
149/// Updates the user's Cargo config to use the Rust `armv7a-vex-v5` target
150/// and deletes their old target JSON file.
151async fn update_cargo_config(ctx: &mut ChangesCtx) -> Result<(), CliError> {
152    ctx.edit_toml(".cargo/config.toml", |mut ctx| {
153        // Disable forced target.
154        let build = ctx.document.table("build");
155        build.remove("target");
156        ctx.explain_change("Enabled desktop unit testing");
157
158        // Move/add all required rustflags to target config.
159
160        let rustflags = vec!["-Clink-arg=-Tvexide.ld"];
161
162        let build = ctx.document.table("build");
163        if let Some(old_rustflags) = build.get_mut("rustflags")
164            && let Some(flag_array) = old_rustflags.as_array_mut()
165        {
166            // If the normal rustflags have any of these items, just remove them because
167            // that's probably a mistake.
168
169            #[rustfmt::skip]
170            flag_array.retain(|item| {
171                // Only keep items that aren't vexide-specific.
172
173                let is_vexide_flag = rustflags.iter().any(|&vexide_flag| {
174                    item.as_str().is_some_and(|flag| flag == vexide_flag)
175                });
176
177                !is_vexide_flag
178            });
179
180            if flag_array.is_empty() {
181                build.remove("rustflags");
182            }
183        }
184
185        // Now set up the target table and put the rustflags in.
186        let target = ctx.document.table("target");
187        target.set_position(-1); // should be at start
188
189        let this_target = target.table(r#"cfg(target_os = "vexos")"#);
190        this_target["rustflags"] = Value::from_iter(rustflags).into();
191
192        ctx.explain_change("Enabled the vexide v0.8.0 memory layout");
193
194        // Build-std config.
195        let unstable = ctx.document.table("unstable");
196        unstable["build-std"] = Value::from_iter(vec!["std", "panic_abort"]).into();
197        unstable["build-std-features"] = Value::from_iter(vec!["compiler-builtins-mem"]).into();
198        ctx.explain_change("Added the Rust Standard Library as a dependency");
199    })
200    .await?;
201
202    ctx.fs.delete_if_exists("armv7a-vex-v5.json").await?;
203
204    Ok(())
205}
206
207async fn update_vexide(ctx: &mut ChangesCtx) -> Result<(), CliError> {
208    let latest = "0.8.0";
209
210    ctx.edit_toml("Cargo.toml", |mut ctx| {
211        // Update to Rust 2024 edition (required by 0.8.0).
212        _ = ctx
213            .document
214            .table("package")
215            .insert("edition", "2024".to_string().into());
216        ctx.explain_change("Updated to Rust 2024 edition");
217
218        let old_entry = ctx
219            .document
220            .get("dependencies")
221            .and_then(|d| d.get("vexide"));
222
223        let old_version = old_entry
224            .and_then(|v| v.get("version"))
225            .and_then(|d| d.as_str());
226
227        if let Some(old_version) = old_version
228            && let Ok(current) = Version::parse(old_version)
229        {
230            let supported_by_tool = Version::new(0, 7, 0);
231            let latest = Version::parse(latest).unwrap();
232
233            let is_eligible = current < latest && current >= supported_by_tool;
234            println!("eligible for upgrade: {is_eligible}");
235            if !is_eligible {
236                log::warn!("vexide v{current} not eligible for upgrade");
237                return;
238            }
239        }
240
241        let old_features_array = old_entry
242            .and_then(|v| v.get("features"))
243            .and_then(|d| d.as_array());
244
245        let default_features = old_entry
246            .and_then(|v| v.get("default-features"))
247            .and_then(|d| d.as_bool())
248            .unwrap_or(true);
249
250        let mut features = Vec::<Value>::new();
251        let mut use_default_sdk = default_features;
252
253        if default_features {
254            features.push("full".into());
255        }
256
257        // Add features that were already enabled so the user doesn't have to
258        // turn them back on manually.
259        if let Some(old_features_array) = old_features_array {
260            for item in old_features_array {
261                let Some(mut feature) = item.as_str() else {
262                    continue;
263                };
264
265                // Apply renames.
266                feature = match feature {
267                    "dangerous_motor_tuning" => "dangerous-motor-tuning",
268                    "backtraces" => "backtrace",
269                    "macro" => "macros",
270                    "display_panics" => "panic-hook",
271                    "force_rust_libm" | "smart_leds_trait" | "panic" => continue, // Removed
272                    other => other,
273                };
274
275                if feature == "startup" {
276                    use_default_sdk = true;
277                }
278
279                features.push(feature.into());
280            }
281        }
282
283        if use_default_sdk {
284            // Remove all vex-sdk features because we're going to use the default sdk
285            features.retain(|f| f.as_str().is_none_or(|s| !s.starts_with("vex-sdk")));
286            features.push("default-sdk".into());
287        }
288
289        // Remove any two features that are both the same string
290        features.dedup_by(|l_feature, r_feature| {
291            l_feature
292                .as_str()
293                .is_some_and(|l| r_feature.as_str() == Some(l))
294        });
295
296        let dependencies = ctx.document.table("dependencies");
297
298        dependencies.remove("vexide");
299
300        let mut vexide = Table::new();
301
302        println!("new version: {latest}");
303        vexide["version"] = latest.into();
304        vexide["features"] = Value::from_iter(features).into();
305        if !default_features {
306            vexide["default-features"] = default_features.into();
307        }
308
309        dependencies["vexide"] = vexide.into_inline_table().into();
310
311        ctx.explain_change(format!("Updated to vexide {latest}"));
312    })
313    .await
314}
315
316#[derive(Debug, Error, Diagnostic)]
317pub enum MigrateError {
318    #[error("failed to parse toml file")]
319    #[diagnostic(code(cargo_v5::upgrade::invalid_toml_file))]
320    TomlParse(#[from] toml_edit::TomlError),
321    #[error("Cannot determine the current Cargo workspace")]
322    #[diagnostic(code(cargo_v5::upgrade::no_metadata))]
323    Metadata,
324}
325
326struct ChangesCtx {
327    fs: vfs::FileOperationStore,
328    will_disable_rustup_override: bool,
329    description: Vec<String>,
330}
331
332impl ChangesCtx {
333    pub fn new(root: impl Into<PathBuf>) -> Self {
334        Self {
335            fs: vfs::FileOperationStore::new(root),
336            will_disable_rustup_override: false,
337            description: vec![],
338        }
339    }
340
341    pub async fn edit_toml(
342        &mut self,
343        path: impl AsRef<Path>,
344        editor: impl for<'a> FnOnce(EditTomlCtx<'a>),
345    ) -> Result<(), CliError> {
346        let path = path.as_ref();
347        let (mut doc, old_contents) = open_or_create_toml(&mut self.fs, path).await?;
348
349        let ctx = EditTomlCtx {
350            changes: self,
351            document: &mut doc,
352            previous_version: Cow::Borrowed(old_contents.as_deref().unwrap_or_default()),
353        };
354        editor(ctx);
355
356        let new_file = doc.to_string();
357        if old_contents.as_ref() == Some(&new_file) {
358            return Ok(()); // Avoid marking file as changed; hides diff.
359        }
360
361        self.fs.write(path, new_file).await?;
362
363        Ok(())
364    }
365
366    pub fn describe(&mut self, change: impl Into<String>) {
367        self.description.push(change.into());
368    }
369
370    pub async fn apply(&mut self) -> Result<(), CliError> {
371        self.fs.apply().await?;
372
373        if self.will_disable_rustup_override {
374            let mut cmd = Command::new("rustup");
375
376            cmd.arg("override")
377                .arg("unset")
378                .arg("--path")
379                .arg(self.fs.root());
380
381            let status = cmd.spawn()?.wait().await?;
382            if !status.success() {
383                log::warn!(
384                    "Disabling the rustup override for the project directory was unsuccessful"
385                );
386            }
387        }
388
389        Ok(())
390    }
391}
392
393struct EditTomlCtx<'a> {
394    pub changes: &'a mut ChangesCtx,
395    pub document: &'a mut DocumentMut,
396    previous_version: Cow<'a, str>,
397}
398
399impl EditTomlCtx<'_> {
400    /// Describes the most recent changes to the document.
401    ///
402    /// If there were no changes since the last call to this function,
403    /// this is a no-op.
404    pub fn explain_change(&mut self, change: impl Into<String>) {
405        let new_version = self.document.to_string();
406
407        if self.previous_version == new_version {
408            return; // Avoid explaining changes if none were required.
409        }
410
411        self.changes.describe(change);
412        self.previous_version = Cow::Owned(new_version);
413    }
414}
415
416trait TableExt {
417    fn table(&mut self, key: &str) -> &mut Table;
418}
419
420impl TableExt for Table {
421    fn table(&mut self, key: &str) -> &mut Table {
422        let value = self.entry(key).or_insert_with(table);
423
424        // Cast to table
425        *value = std::mem::take(value)
426            .into_table()
427            .unwrap_or_default()
428            .into();
429
430        let table_ref = value.as_table_mut().unwrap();
431        table_ref.set_implicit(true);
432        table_ref
433    }
434}
435
436async fn open_or_create_toml(
437    files: &mut vfs::FileOperationStore,
438    path: &Path,
439) -> Result<(DocumentMut, Option<String>), CliError> {
440    let file = files.read_to_string(&path).await;
441
442    // If the config file is missing, make a new one.
443    let doc = match file {
444        Ok(contents) => {
445            let toml = contents
446                .parse::<DocumentMut>()
447                .map_err(MigrateError::from)?;
448            (toml, Some(contents))
449        }
450        Err(err) if err.kind() == ErrorKind::NotFound => (DocumentMut::new(), None),
451        Err(other) => return Err(other)?,
452    };
453
454    Ok(doc)
455}
456
457#[allow(unused)]
458fn toml_item_eq_strings(toml: Option<&Item>, strings: &[&str]) -> bool {
459    toml.and_then(|f| f.as_array())
460        .map(|array| {
461            array
462                .into_iter()
463                .map(|f| f.as_str().unwrap_or_default())
464                .eq(strings.iter().cloned())
465        })
466        .unwrap_or_default()
467}