1use 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
118pub 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(®istry) {
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}