Skip to main content

run_stack/
self_update.rs

1//! `rst self-update`: refresh a cargo-installed binary and migrate configs.
2//!
3//! The npm package's self-update lives in its Node CLI. This binary is
4//! installed via cargo, so the equivalent is `cargo install run-stack --force`,
5//! then JSON→TOML and essential-services config migration.
6
7use std::env;
8use std::fs;
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use anyhow::{Context, Result};
13
14use crate::config::Config;
15use crate::workspace::Workspace;
16
17const CRATE_NAME: &str = "run-stack";
18
19pub fn run(verbose: bool) -> Result<i32> {
20    let current = env!("CARGO_PKG_VERSION");
21    let latest = latest_crates_version(CRATE_NAME);
22
23    match latest.as_deref() {
24        Some(latest) if latest == current => {
25            println!("Already on latest ({current}). Nothing to install.");
26        }
27        Some(latest) if !is_newer(latest, current) => {
28            println!(
29                "Local {current} is newer than crates.io {latest} — skipping install."
30            );
31        }
32        Some(latest) => {
33            println!("Updating {CRATE_NAME} {current} → {latest} via cargo ...");
34            let code = cargo_install(CRATE_NAME, Some(latest), verbose)?;
35            if code != 0 {
36                return Ok(code);
37            }
38            println!();
39            println!("Updated to {latest}.");
40        }
41        None => {
42            println!(
43                "Updating {CRATE_NAME} from {current} via cargo \
44                 (could not resolve latest, using crates.io default) ..."
45            );
46            let code = cargo_install(CRATE_NAME, None, verbose)?;
47            if code != 0 {
48                return Ok(code);
49            }
50            println!();
51            println!("Updated to latest.");
52        }
53    }
54
55    let migrated = migrate_configs()?;
56    if migrated > 0 {
57        println!("Migrated config in {migrated} workspace(s).");
58    } else {
59        println!("Config-layout workspaces pick up new defaults on the next command.");
60    }
61    println!("Verify: rst --version");
62    Ok(0)
63}
64
65fn cargo_install(name: &str, version: Option<&str>, verbose: bool) -> Result<i32> {
66    let mut args = vec!["install".to_string(), name.to_string(), "--force".into()];
67    if let Some(version) = version {
68        args.push("--version".into());
69        args.push(version.to_string());
70    }
71    if verbose {
72        args.push("--verbose".into());
73    }
74    println!("  $ cargo {}", args.join(" "));
75    let status = Command::new("cargo")
76        .args(&args)
77        .status()
78        .context("running cargo — is the Rust toolchain installed?")?;
79    Ok(status.code().unwrap_or(1))
80}
81
82fn latest_crates_version(name: &str) -> Option<String> {
83    let output = Command::new("cargo")
84        .args(["search", name, "--limit", "1"])
85        .env("CARGO_TERM_COLOR", "never")
86        .output()
87        .ok()?;
88    if !output.status.success() {
89        return None;
90    }
91    let text = String::from_utf8_lossy(&output.stdout);
92    let line = text.lines().next()?;
93    let start = line.find('"')? + 1;
94    let end = line[start..].find('"')? + start;
95    let version = line[start..end].to_string();
96    if parse_version(&version).is_some() {
97        Some(version)
98    } else {
99        None
100    }
101}
102
103fn parse_version(text: &str) -> Option<(u64, u64, u64)> {
104    let mut parts = text.split('.');
105    let major = parts.next()?.parse().ok()?;
106    let minor = parts.next()?.parse().ok()?;
107    let patch = parts.next().unwrap_or("0").parse().ok()?;
108    Some((major, minor, patch))
109}
110
111fn is_newer(candidate: &str, current: &str) -> bool {
112    match (parse_version(candidate), parse_version(current)) {
113        (Some(left), Some(right)) => left > right,
114        _ => candidate != current,
115    }
116}
117
118/// JSON→TOML and missing `essential`, for every known workspace.
119pub fn migrate_configs() -> Result<usize> {
120    let mut count = 0;
121    for root in known_workspace_roots() {
122        if migrate_workspace(&root)? {
123            count += 1;
124        }
125    }
126    Ok(count)
127}
128
129fn migrate_workspace(root: &Path) -> Result<bool> {
130    let run_dir = root.join(".run");
131    let toml_path = run_dir.join("run.config.toml");
132    let json_path = run_dir.join("run.config.json");
133
134    let source = if toml_path.is_file() {
135        toml_path.clone()
136    } else if json_path.is_file() {
137        json_path.clone()
138    } else {
139        return Ok(false);
140    };
141
142    let mut config = Config::load(&source)?;
143    let mut changed = config.ensure_essential();
144    let from_json = source
145        .extension()
146        .is_some_and(|ext| ext.eq_ignore_ascii_case("json"));
147    if from_json || changed || source != toml_path {
148        config.save(&toml_path)?;
149        changed = true;
150        if from_json {
151            let _ = fs::remove_file(&source);
152            eprintln!(
153                "Migrated {} → {}",
154                source.display(),
155                toml_path.display()
156            );
157        } else if changed {
158            eprintln!("Updated {}", toml_path.display());
159        }
160    }
161    Ok(changed)
162}
163
164fn known_workspace_roots() -> Vec<PathBuf> {
165    let mut roots = Vec::new();
166    if let Ok(cwd) = env::current_dir() {
167        if let Ok(workspace) = Workspace::find(&cwd) {
168            roots.push(workspace.root);
169        }
170    }
171    if let Some(home) = env::var_os("HOME") {
172        let registry = PathBuf::from(home).join(".run/services.json");
173        if let Ok(text) = fs::read_to_string(&registry) {
174            if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
175                if let Some(object) = value.as_object() {
176                    for key in object.keys() {
177                        let root = PathBuf::from(key);
178                        let has = root.join(".run/run.config.toml").is_file()
179                            || root.join(".run/run.config.json").is_file();
180                        if has && !roots.iter().any(|existing| existing == &root) {
181                            roots.push(root);
182                        }
183                    }
184                }
185            }
186        }
187    }
188    roots
189}