1use anyhow::{Context, Result};
16use std::path::{Path, PathBuf};
17
18#[cfg(unix)]
19use std::os::unix::fs::PermissionsExt;
20
21pub fn launcher_path(home: &Path) -> PathBuf {
23 home.join("bin").join("oxicode")
24}
25
26pub fn versions_dir(home: &Path) -> PathBuf {
28 home.join("versions")
29}
30
31pub fn parse_version_dir(name: &str) -> Option<(u64, u64, u64)> {
34 let mut parts = name.split('.');
35 let major = parts.next()?.parse().ok()?;
36 let minor = parts.next()?.parse().ok()?;
37 let patch = parts.next()?.parse().ok()?;
38 parts.next().is_none().then_some((major, minor, patch))
39}
40
41pub fn version_from_version_output(text: &str) -> Option<String> {
44 let raw = text.trim().trim_start_matches('v');
45 let token = raw
46 .split_whitespace()
47 .find(|s| parse_version_dir(s).is_some())?;
48 Some(token.to_string())
49}
50
51pub fn adopt_binary(
57 home: &Path,
58 binary: &Path,
59 version: &str,
60 relink: Option<&Path>,
61) -> Result<PathBuf> {
62 if parse_version_dir(version).is_none() {
63 anyhow::bail!("unusable version {version:?} — expected MAJOR.MINOR.PATCH");
64 }
65 let versions = versions_dir(home);
66 std::fs::create_dir_all(&versions)
67 .with_context(|| format!("create versions dir {}", versions.display()))?;
68 let launcher = launcher_path(home);
69 let version_dir = versions.join(version);
70 std::fs::create_dir_all(&version_dir)
71 .with_context(|| format!("create version dir {}", version_dir.display()))?;
72 let target = version_dir.join("oxicode");
73
74 if let Err(e) = std::fs::rename(binary, &target) {
77 if e.raw_os_error() == Some(18 ) {
78 std::fs::copy(binary, &target)
79 .with_context(|| format!("copy {} → {}", binary.display(), target.display()))?;
80 let _ = std::fs::remove_file(binary);
81 } else {
82 return Err(e)
83 .with_context(|| format!("move {} → {}", binary.display(), target.display()));
84 }
85 }
86 #[cfg(unix)]
87 {
88 let mut perms = std::fs::metadata(&target)?.permissions();
89 perms.set_mode(0o755);
90 std::fs::set_permissions(&target, perms)
91 .with_context(|| format!("chmod 0755 {}", target.display()))?;
92 }
93
94 flip_launcher(&launcher, version)?;
95
96 if let Some(rel) = relink
97 && rel != target
98 && rel != binary
99 {
100 repoint(rel, &launcher)?;
101 }
102
103 prune_versions(home, version, 2)?;
104 Ok(launcher)
105}
106
107pub(crate) fn flip_launcher(launcher: &Path, version: &str) -> Result<()> {
108 let bin_dir = launcher
109 .parent()
110 .context("launcher path has no parent directory")?;
111 std::fs::create_dir_all(bin_dir)
112 .with_context(|| format!("create bin dir {}", bin_dir.display()))?;
113 let target = PathBuf::from("../versions").join(version).join("oxicode");
114 let tmp_link = bin_dir.join(".oxicode.link.tmp");
115 let _ = std::fs::remove_file(&tmp_link);
116 #[cfg(unix)]
117 {
118 std::os::unix::fs::symlink(&target, &tmp_link)
119 .with_context(|| format!("symlink {} → {}", tmp_link.display(), target.display()))?;
120 }
121 #[cfg(not(unix))]
122 {
123 let source = bin_dir
124 .parent()
125 .context("launcher is not two levels below the app root")?
126 .join("versions")
127 .join(version)
128 .join("oxicode");
129 std::fs::copy(&source, &tmp_link)
130 .with_context(|| format!("copy {} → {}", source.display(), tmp_link.display()))?;
131 }
132 std::fs::rename(&tmp_link, launcher).with_context(|| {
133 format!(
134 "atomic launcher flip {} → {}",
135 tmp_link.display(),
136 launcher.display()
137 )
138 })?;
139 Ok(())
140}
141
142pub(crate) fn repoint(path: &Path, launcher: &Path) -> Result<()> {
143 if !path.exists() {
144 return Ok(());
145 }
146 if let Ok(target) = std::fs::read_link(path) {
147 if launcher
151 .file_name()
152 .is_some_and(|name| std::path::Path::new(name) == target.as_path())
153 {
154 return Ok(());
155 }
156 }
157 let parent = path.parent().context("relink path has no parent")?;
158 let tmp = parent.join(".oxicode.link.tmp");
159 let _ = std::fs::remove_file(&tmp);
160 #[cfg(unix)]
161 {
162 let launcher_name = launcher.file_name().context("launcher has no file name")?;
163 std::os::unix::fs::symlink(launcher_name, &tmp)
164 .with_context(|| format!("symlink {} → {}", tmp.display(), launcher_name.display()))?;
165 }
166 #[cfg(not(unix))]
167 {
168 std::fs::copy(launcher, &tmp)
169 .with_context(|| format!("copy {} → {}", launcher.display(), tmp.display()))?;
170 }
171 std::fs::rename(&tmp, path)
172 .with_context(|| format!("atomic relink {} → {}", tmp.display(), path.display()))?;
173 Ok(())
174}
175
176pub(crate) fn prune_versions(home: &Path, current: &str, keep: usize) -> Result<Vec<String>> {
177 let versions = versions_dir(home);
178 let mut installed: Vec<((u64, u64, u64), String)> = Vec::new();
179 if !versions.exists() {
180 return Ok(vec![]);
181 }
182 for entry in
183 std::fs::read_dir(&versions).with_context(|| format!("read_dir {}", versions.display()))?
184 {
185 let entry = entry.with_context(|| format!("read_dir entry in {}", versions.display()))?;
186 if !entry.file_type().is_ok_and(|t| t.is_dir()) {
187 continue;
188 }
189 let name = entry.file_name().to_string_lossy().into_owned();
190 if name == current {
191 continue;
192 }
193 if let Some(v) = parse_version_dir(&name) {
194 installed.push((v, name));
195 }
196 }
197 installed.sort();
198 let mut removed = Vec::new();
199 while installed.len() >= keep {
200 let (_, name) = installed.remove(0);
201 std::fs::remove_dir_all(versions.join(&name))
202 .with_context(|| format!("prune version dir {}", name))?;
203 removed.push(name);
204 }
205 Ok(removed)
206}
207
208pub fn cargo_oxicode_bin() -> Option<PathBuf> {
212 let home = std::env::var_os("HOME")?;
213 let cargo_root = if let Some(d) = std::env::var_os("CARGO_HOME") {
214 if d.is_empty() {
215 PathBuf::from(home).join(".cargo")
216 } else {
217 PathBuf::from(d)
218 }
219 } else {
220 PathBuf::from(home).join(".cargo")
221 };
222 Some(cargo_root.join("bin").join("oxicode"))
223}
224
225pub fn version_of(binary: &Path) -> Option<String> {
227 let out = std::process::Command::new(binary)
228 .arg("--version")
229 .stdin(std::process::Stdio::null())
230 .output()
231 .ok()?;
232 let s = String::from_utf8_lossy(&out.stdout).into_owned();
233 version_from_version_output(&s)
234}
235
236#[cfg(test)]
237mod tests {
238 use super::*;
239 use std::os::unix::fs::PermissionsExt;
240 use std::path::PathBuf;
241
242 fn write_exe(dir: &Path, name: &str, body: &[u8]) -> PathBuf {
243 let p = dir.join(name);
244 std::fs::write(&p, body).unwrap();
245 let mut perms = std::fs::metadata(&p).unwrap().permissions();
246 perms.set_mode(0o755);
247 std::fs::set_permissions(&p, perms).unwrap();
248 p
249 }
250
251 #[test]
252 fn parse_version_dir_round_trip() {
253 assert_eq!(parse_version_dir("0.79.0"), Some((0, 79, 0)));
254 assert_eq!(parse_version_dir("1.2.3"), Some((1, 2, 3)));
255 assert_eq!(parse_version_dir("v0.1.0"), None);
256 assert_eq!(parse_version_dir("0.1.0-beta"), None);
257 assert_eq!(parse_version_dir(""), None);
258 }
259
260 #[test]
261 fn version_from_version_output_handles_clap_banner() {
262 assert_eq!(
263 version_from_version_output("oxicode 0.79.0\n"),
264 Some("0.79.0".to_string())
265 );
266 assert_eq!(
267 version_from_version_output("oxicode 0.79.0 (abc123)"),
268 Some("0.79.0".to_string())
269 );
270 assert_eq!(version_from_version_output("garbage"), None);
271 }
272
273 #[test]
274 fn adopt_binary_builds_managed_layout_and_relinks() {
275 let home = tempfile::tempdir().unwrap();
276 let binaries = tempfile::tempdir().unwrap();
277 let bin = write_exe(binaries.path(), "oxicode", b"#!/bin/sh\n");
278 let relink = binaries.path().join("cargo-oxicode");
279 std::fs::write(&relink, b"placeholder").unwrap(); let launcher = adopt_binary(home.path(), &bin, "0.79.0", Some(&relink)).unwrap();
282
283 assert!(home.path().join("versions/0.79.0/oxicode").is_file());
284 assert!(launcher.is_file());
285 assert_eq!(
286 std::fs::read_link(&launcher).unwrap(),
287 std::path::Path::new("../versions/0.79.0/oxicode")
288 );
289 let relink_target = std::fs::read_link(&relink).unwrap();
290 assert_eq!(
291 relink_target,
292 std::path::Path::new("oxicode"),
293 "relink should target the launcher (same-dir basename)"
294 );
295 assert!(!bin.exists(), "the original binary path was moved");
297 }
298
299 #[test]
300 fn adopt_prunes_older_versions_to_two() {
301 let home = tempfile::tempdir().unwrap();
302 let staging = tempfile::tempdir().unwrap();
303
304 for v in ["0.78.0", "0.79.0", "1.0.0"] {
305 let bin = write_exe(staging.path(), "oxicode", b"#!/bin/sh\n");
306 adopt_binary(home.path(), &bin, v, None).unwrap();
307 }
308 assert!(!home.path().join("versions/0.78.0").exists());
311 assert!(home.path().join("versions/0.79.0").exists());
312 assert!(home.path().join("versions/1.0.0").exists());
313 }
314
315 #[test]
316 fn repoint_is_idempotent_on_converged_links() {
317 let home = tempfile::tempdir().unwrap();
318 let staging = tempfile::tempdir().unwrap();
319 let bin = write_exe(staging.path(), "oxicode", b"#!/bin/sh\n");
320 let relink = staging.path().join("cargo-bin/oxicode");
323 std::fs::create_dir_all(relink.parent().unwrap()).unwrap();
324 std::fs::write(&relink, b"old").unwrap();
325 adopt_binary(home.path(), &bin, "0.1.0", Some(&relink)).unwrap();
326 let first = std::fs::read_link(&relink).unwrap();
328 repoint(&relink, &launcher_path(home.path())).unwrap();
329 let second = std::fs::read_link(&relink).unwrap();
330 assert_eq!(first, second);
331 }
332
333 #[test]
334 fn rejects_invalid_version_strings() {
335 let home = tempfile::tempdir().unwrap();
336 let staging = tempfile::tempdir().unwrap();
337 let bin = write_exe(staging.path(), "oxicode", b"#!/bin/sh\n");
338 assert!(adopt_binary(home.path(), &bin, "v0.1.0", None).is_err());
339 assert!(adopt_binary(home.path(), &bin, "not-a-version", None).is_err());
340 }
341}