Skip to main content

oxicode/
managed_install.rs

1//! Managed install layout — `~/.oxi/oxicode/{bin,versions}`.
2//!
3//! Mirrors the ecosystem binary standard that every Oxi app uses:
4//! `~/.oxi/<app>/bin/<app>` is a launcher symlink to
5//! `../versions/<v>/<app>`, with newer versions kept on disk and old
6//! ones pruned. Oxicode's *fetch* channel stays `cargo install`
7//! (crates.io + binstall handle signing, prebuilt binaries, and the
8//! platform matrix); [`handle_update`](crate::cli::commands::misc::handle_update)
9//! calls into this module after a successful cargo install so the
10//! freshly-installed binary is adopted into the managed layout, the
11//! launcher is flipped to it, and the cargo bin copy is repointed at
12//! the launcher. The result: a single canonical binary under
13//! `~/.oxi/oxicode/versions/` and one symlink at every PATH entry.
14
15use anyhow::{Context, Result};
16use std::path::{Path, PathBuf};
17
18#[cfg(unix)]
19use std::os::unix::fs::PermissionsExt;
20
21/// The launcher symlink PATH entries point at: `<home>/bin/oxicode`.
22pub fn launcher_path(home: &Path) -> PathBuf {
23    home.join("bin").join("oxicode")
24}
25
26/// Per-release binaries: `<home>/versions/<v>/oxicode`.
27pub fn versions_dir(home: &Path) -> PathBuf {
28    home.join("versions")
29}
30
31/// Strict SemVer triple for version-dir names — no leading `v`, no
32/// pre-release/build metadata (mirrors oxios's `managed_install`).
33pub 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
41/// Pull a bare `MAJOR.MINOR.PATCH` out of a `--version` banner
42/// (`oxicode 0.79.0`, `oxicode 0.79.0 (commit…)`).
43pub 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
51/// Move `binary` into `<home>/versions/<version>/oxicode` (atomic rename
52/// when on the same volume, copy+remove when crossing volumes), flip
53/// the launcher at `<home>/bin/oxicode`, optionally repoint a cargo/PATH
54/// copy at the launcher, and prune older version dirs (keep 2). Returns
55/// the launcher path.
56pub 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    // Same-volume rename is atomic; EXDEV (cross-volume) falls back to
75    // copy + remove so a split home/<cargo> never strands the binary.
76    if let Err(e) = std::fs::rename(binary, &target) {
77        if e.raw_os_error() == Some(18 /* EXDEV */) {
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        // Already pointing at the launcher (by basename — `~/.cargo/bin/oxicode`
148        // lives in a different directory than `<home>/bin/oxicode`, so we
149        // match on file name) or at the same target the launcher itself
150        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
208/// Path of the cargo-installed `oxicode` for the current user
209/// (`$CARGO_HOME/bin/oxicode` or the `$HOME/.cargo/bin/oxicode`
210/// default). May not exist; the caller decides.
211pub 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
225/// Run `<binary> --version` and parse a bare version out.
226pub 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(); // adopt must replace
280
281        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        // The moved binary lives at the version dir; the relink is a symlink.
296        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        // Three installs ended with the launcher on 1.0.0. Keep 2 →
309        // 0.78.0 should be pruned, 0.79.0 + 1.0.0 retained.
310        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        // The relink path lives in a separate dir from the cargo bin
321        // (we simulate ~/.cargo/bin/oxicode).
322        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        // Second call must not error or rewrite the link.
327        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}