1use std::path::Path;
9
10use serde::Deserialize;
11
12#[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#[derive(Debug, Clone, Deserialize)]
26struct PluginMeta {
27 name: String,
28 version: String,
29 protocol_compat: String,
30}
31
32#[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#[derive(Debug)]
43pub struct PluginCompat {
44 pub name: String,
45 pub version: String,
46 pub compat_range: String,
47 pub compatible: bool,
48}
49
50fn 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
77fn 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
99pub 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
111pub 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
150pub fn cmd_upgrade(execute: bool) -> anyhow::Result<()> {
154 let current_cli = env!("CARGO_PKG_VERSION");
155
156 println!("checking for updates...\n");
157
158 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 let ralph_check = match query_latest_version("room-ralph") {
177 Ok(latest) => {
178 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 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 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 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
274fn 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}