Skip to main content

room_cli/
upgrade.rs

1//! `room upgrade` command — checks for newer versions of room-cli and room-ralph
2//! on crates.io, verifies plugin compatibility, and executes the upgrade.
3//!
4//! Reads `~/.room/plugins/*.meta.json` to determine installed plugin protocol
5//! compatibility ranges. If any plugin would be incompatible with the new
6//! room-protocol version, the upgrade is blocked with a warning.
7
8use std::path::Path;
9
10use serde::Deserialize;
11
12/// Response from crates.io `/api/v1/crates/<name>` endpoint.
13#[derive(Debug, Deserialize)]
14struct CrateResponse {
15    #[serde(rename = "crate")]
16    krate: CrateInfo,
17}
18
19#[derive(Debug, Deserialize)]
20struct CrateInfo {
21    max_stable_version: Option<String>,
22}
23
24/// Plugin metadata sidecar (mirrors `plugin_cmd::PluginMeta` when available).
25#[derive(Debug, Clone, Deserialize)]
26struct PluginMeta {
27    name: String,
28    version: String,
29    protocol_compat: String,
30}
31
32/// Result of checking a single binary for upgrades.
33#[derive(Debug)]
34pub struct UpgradeCheck {
35    pub crate_name: String,
36    pub current: String,
37    pub latest: String,
38    pub needs_upgrade: bool,
39}
40
41/// Result of checking plugin compatibility against a new protocol version.
42#[derive(Debug)]
43pub struct PluginCompat {
44    pub name: String,
45    pub version: String,
46    pub compat_range: String,
47    pub compatible: bool,
48}
49
50/// Scan `~/.room/plugins/` for installed plugin meta files.
51fn scan_plugin_metas(dir: &Path) -> Vec<PluginMeta> {
52    let entries = match std::fs::read_dir(dir) {
53        Ok(e) => e,
54        Err(_) => return Vec::new(),
55    };
56
57    let mut plugins = Vec::new();
58    for entry in entries.flatten() {
59        let path = entry.path();
60        if path
61            .file_name()
62            .and_then(|n| n.to_str())
63            .map(|n| n.ends_with(".meta.json"))
64            .unwrap_or(false)
65        {
66            if let Ok(data) = std::fs::read_to_string(&path) {
67                if let Ok(meta) = serde_json::from_str::<PluginMeta>(&data) {
68                    plugins.push(meta);
69                }
70            }
71        }
72    }
73    plugins.sort_by(|a, b| a.name.cmp(&b.name));
74    plugins
75}
76
77/// Query crates.io for the latest stable version of a crate.
78///
79/// Uses curl to avoid adding a heavy HTTP client dependency.
80fn query_latest_version(crate_name: &str) -> anyhow::Result<String> {
81    let url = format!("https://crates.io/api/v1/crates/{crate_name}");
82    let output = std::process::Command::new("curl")
83        .args(["-sS", "-H", "User-Agent: room-cli upgrade check", &url])
84        .output()
85        .map_err(|e| anyhow::anyhow!("failed to run curl: {e}"))?;
86
87    if !output.status.success() {
88        let stderr = String::from_utf8_lossy(&output.stderr);
89        anyhow::bail!("curl failed for {crate_name}: {stderr}");
90    }
91
92    let resp: CrateResponse = serde_json::from_slice(&output.stdout)
93        .map_err(|e| anyhow::anyhow!("failed to parse crates.io response for {crate_name}: {e}"))?;
94    resp.krate
95        .max_stable_version
96        .ok_or_else(|| anyhow::anyhow!("no stable version found for {crate_name}"))
97}
98
99/// Compare two semver strings. Returns true if `latest` > `current`.
100pub fn is_newer(current: &str, latest: &str) -> bool {
101    let parse = |s: &str| -> (u64, u64, u64) {
102        let mut parts = s.split('.');
103        let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
104        let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
105        let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
106        (major, minor, patch)
107    };
108    parse(latest) > parse(current)
109}
110
111/// Check whether a plugin's protocol_compat range includes a target version.
112///
113/// Supports simple semver ranges like `>=3.0.0, <4.0.0` and `>=3.0.0`.
114/// For simplicity, parses `>=X.Y.Z` as minimum and `<X.Y.Z` as exclusive max.
115pub fn check_compat(protocol_compat: &str, target_version: &str) -> bool {
116    let target = parse_semver(target_version);
117    let mut min: Option<(u64, u64, u64)> = None;
118    let mut max_exclusive: Option<(u64, u64, u64)> = None;
119
120    for constraint in protocol_compat.split(',') {
121        let constraint = constraint.trim();
122        if let Some(rest) = constraint.strip_prefix(">=") {
123            min = Some(parse_semver(rest.trim()));
124        } else if let Some(rest) = constraint.strip_prefix('<') {
125            max_exclusive = Some(parse_semver(rest.trim()));
126        }
127    }
128
129    if let Some(min_v) = min {
130        if target < min_v {
131            return false;
132        }
133    }
134    if let Some(max_v) = max_exclusive {
135        if target >= max_v {
136            return false;
137        }
138    }
139    true
140}
141
142fn parse_semver(s: &str) -> (u64, u64, u64) {
143    let mut parts = s.split('.');
144    let major = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
145    let minor = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
146    let patch = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
147    (major, minor, patch)
148}
149
150/// Run the upgrade check and display the plan.
151///
152/// If `execute` is true, runs `cargo install` for binaries that need upgrading.
153pub fn cmd_upgrade(execute: bool) -> anyhow::Result<()> {
154    let current_cli = env!("CARGO_PKG_VERSION");
155
156    println!("checking for updates...\n");
157
158    // Check room-cli
159    let cli_check = match query_latest_version("room-cli") {
160        Ok(latest) => {
161            let needs = is_newer(current_cli, &latest);
162            Some(UpgradeCheck {
163                crate_name: "room-cli".to_owned(),
164                current: current_cli.to_owned(),
165                latest,
166                needs_upgrade: needs,
167            })
168        }
169        Err(e) => {
170            eprintln!("  warning: could not check room-cli: {e}");
171            None
172        }
173    };
174
175    // Check room-ralph
176    let ralph_check = match query_latest_version("room-ralph") {
177        Ok(latest) => {
178            // We don't know the installed ralph version from here.
179            // Use "unknown" and always suggest checking.
180            Some(UpgradeCheck {
181                crate_name: "room-ralph".to_owned(),
182                current: "unknown".to_owned(),
183                latest,
184                needs_upgrade: true,
185            })
186        }
187        Err(e) => {
188            eprintln!("  warning: could not check room-ralph: {e}");
189            None
190        }
191    };
192
193    // Display binary upgrade plan
194    println!("binaries:");
195    let mut any_upgrade = false;
196    for check in [&cli_check, &ralph_check].into_iter().flatten() {
197        let status = if check.needs_upgrade {
198            any_upgrade = true;
199            format!("{} -> {} (upgrade available)", check.current, check.latest)
200        } else {
201            format!("{} (up to date)", check.current)
202        };
203        println!("  {:<15} {status}", check.crate_name);
204    }
205
206    // Check plugin compatibility
207    let plugins_dir = plugins_dir();
208    let plugins = scan_plugin_metas(&plugins_dir);
209    if !plugins.is_empty() {
210        println!("\nplugins:");
211        let target_protocol = cli_check
212            .as_ref()
213            .map(|c| c.latest.as_str())
214            .unwrap_or(current_cli);
215
216        let mut all_compatible = true;
217        for p in &plugins {
218            let compatible = check_compat(&p.protocol_compat, target_protocol);
219            let status = if compatible {
220                "compatible"
221            } else {
222                all_compatible = false;
223                "INCOMPATIBLE"
224            };
225            println!(
226                "  {:<20} v{:<10} {} (requires {})",
227                p.name, p.version, status, p.protocol_compat
228            );
229        }
230
231        if !all_compatible {
232            eprintln!("\nwarning: some plugins are incompatible with the target version.");
233            eprintln!("run `room plugin update <name>` after upgrading to fix compatibility.");
234        }
235    } else {
236        println!("\nplugins: none installed");
237    }
238
239    if !any_upgrade {
240        println!("\neverything is up to date.");
241        return Ok(());
242    }
243
244    if !execute {
245        println!("\nrun `room upgrade --execute` to apply the upgrade.");
246        return Ok(());
247    }
248
249    // Execute upgrades
250    println!("\nupgrading...");
251    for check in [&cli_check, &ralph_check].into_iter().flatten() {
252        if !check.needs_upgrade {
253            continue;
254        }
255        println!("  installing {} v{}...", check.crate_name, check.latest);
256        let status = std::process::Command::new("cargo")
257            .args(["install", &check.crate_name, "--force"])
258            .status()?;
259        if status.success() {
260            println!("  {} upgraded to v{}", check.crate_name, check.latest);
261        } else {
262            eprintln!(
263                "  error: cargo install {} failed (exit {})",
264                check.crate_name,
265                status.code().unwrap_or(-1)
266            );
267        }
268    }
269
270    println!("\nupgrade complete.");
271    Ok(())
272}
273
274/// Return the plugin directory path (`~/.room/plugins/`).
275fn plugins_dir() -> std::path::PathBuf {
276    let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_owned());
277    std::path::PathBuf::from(home).join(".room").join("plugins")
278}
279
280#[cfg(test)]
281mod tests {
282    use super::*;
283
284    #[test]
285    fn is_newer_detects_major_bump() {
286        assert!(is_newer("3.1.0", "4.0.0"));
287    }
288
289    #[test]
290    fn is_newer_detects_minor_bump() {
291        assert!(is_newer("3.1.0", "3.2.0"));
292    }
293
294    #[test]
295    fn is_newer_detects_patch_bump() {
296        assert!(is_newer("3.1.0", "3.1.1"));
297    }
298
299    #[test]
300    fn is_newer_same_version_is_false() {
301        assert!(!is_newer("3.1.0", "3.1.0"));
302    }
303
304    #[test]
305    fn is_newer_older_is_false() {
306        assert!(!is_newer("3.2.0", "3.1.0"));
307    }
308
309    #[test]
310    fn check_compat_within_range() {
311        assert!(check_compat(">=3.0.0, <4.0.0", "3.5.0"));
312    }
313
314    #[test]
315    fn check_compat_at_minimum() {
316        assert!(check_compat(">=3.0.0, <4.0.0", "3.0.0"));
317    }
318
319    #[test]
320    fn check_compat_below_minimum() {
321        assert!(!check_compat(">=3.0.0, <4.0.0", "2.9.9"));
322    }
323
324    #[test]
325    fn check_compat_at_exclusive_max() {
326        assert!(!check_compat(">=3.0.0, <4.0.0", "4.0.0"));
327    }
328
329    #[test]
330    fn check_compat_above_max() {
331        assert!(!check_compat(">=3.0.0, <4.0.0", "5.0.0"));
332    }
333
334    #[test]
335    fn check_compat_open_ended_min_only() {
336        assert!(check_compat(">=3.0.0", "99.0.0"));
337        assert!(!check_compat(">=3.0.0", "2.0.0"));
338    }
339
340    #[test]
341    fn scan_empty_dir() {
342        let dir = tempfile::tempdir().unwrap();
343        let plugins = scan_plugin_metas(dir.path());
344        assert!(plugins.is_empty());
345    }
346
347    #[test]
348    fn scan_nonexistent_dir() {
349        let plugins = scan_plugin_metas(Path::new("/nonexistent/path"));
350        assert!(plugins.is_empty());
351    }
352
353    #[test]
354    fn scan_finds_meta_files() {
355        let dir = tempfile::tempdir().unwrap();
356        let meta = serde_json::json!({
357            "name": "test-plugin",
358            "version": "1.0.0",
359            "crate_name": "room-plugin-test",
360            "protocol_compat": ">=3.0.0, <4.0.0",
361            "lib_file": "libroom_plugin_test.so"
362        });
363        std::fs::write(
364            dir.path().join("test-plugin.meta.json"),
365            serde_json::to_string(&meta).unwrap(),
366        )
367        .unwrap();
368        let plugins = scan_plugin_metas(dir.path());
369        assert_eq!(plugins.len(), 1);
370        assert_eq!(plugins[0].name, "test-plugin");
371    }
372
373    #[test]
374    fn scan_skips_invalid_json() {
375        let dir = tempfile::tempdir().unwrap();
376        std::fs::write(dir.path().join("bad.meta.json"), "not json").unwrap();
377        let plugins = scan_plugin_metas(dir.path());
378        assert!(plugins.is_empty());
379    }
380
381    #[test]
382    fn parse_semver_basic() {
383        assert_eq!(parse_semver("3.1.0"), (3, 1, 0));
384        assert_eq!(parse_semver("0.0.0"), (0, 0, 0));
385        assert_eq!(parse_semver("10.20.30"), (10, 20, 30));
386    }
387
388    #[test]
389    fn parse_semver_malformed() {
390        assert_eq!(parse_semver("garbage"), (0, 0, 0));
391        assert_eq!(parse_semver(""), (0, 0, 0));
392    }
393}