Skip to main content

stuffr_cli/compat/
links.rs

1//! `stuffr install-links`: opt-in compatibility symlinks.
2//!
3//! Each name in [`super::names`] becomes a symlink in one directory, pointing
4//! at the stuffr binary, so that invoking it dispatches to the compatibility
5//! family of that name. Nothing here runs unless the user asks for it.
6
7use std::path::{Path, PathBuf};
8
9/// What `install_links` did, or with `dry_run` would do, to one link.
10#[derive(Debug, Clone, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum LinkAction {
13    /// A new link was created.
14    Created(PathBuf),
15    /// The link already points at this stuffr; nothing was changed. `text`
16    /// is the link's own text, which may differ from the target.
17    AlreadyPresent {
18        /// The link.
19        path: PathBuf,
20        /// What `read_link` returned for it.
21        text: PathBuf,
22    },
23    /// An existing entry was replaced: a stale stuffr link, or with
24    /// `--force` a file or foreign link.
25    Replaced(PathBuf),
26    /// One of stuffr's links was removed (`--remove`).
27    Removed(PathBuf),
28    /// Dry run: the link would be created.
29    WouldCreate(PathBuf),
30    /// Dry run: an existing entry would be replaced.
31    WouldReplace(PathBuf),
32    /// Dry run: the link would be removed.
33    WouldRemove(PathBuf),
34    /// `--remove` left this entry alone, for the stated reason.
35    Left(PathBuf, &'static str),
36}
37
38impl LinkAction {
39    /// The one output line for this action, given the link target.
40    pub fn describe(&self, target: &Path) -> String {
41        let t = target.display();
42        match self {
43            LinkAction::Created(p) => format!("created {} -> {t}", p.display()),
44            LinkAction::AlreadyPresent { path, text } => {
45                format!("already present {} -> {}", path.display(), text.display())
46            }
47            LinkAction::Replaced(p) => format!("replaced {} -> {t}", p.display()),
48            LinkAction::Removed(p) => format!("removed {}", p.display()),
49            LinkAction::WouldCreate(p) => format!("would create {} -> {t}", p.display()),
50            LinkAction::WouldReplace(p) => format!("would replace {} -> {t}", p.display()),
51            LinkAction::WouldRemove(p) => format!("would remove {}", p.display()),
52            LinkAction::Left(p, why) => format!("left {}: {why}", p.display()),
53        }
54    }
55}
56
57/// The path links should point at: this binary, by a path that survives
58/// an upgrade where one exists (see [`stable_target`]).
59pub fn current_target() -> stuffr::Result<PathBuf> {
60    let exe = std::env::current_exe()?.canonicalize()?;
61    Ok(stable_target(&exe, |p| p.canonicalize().ok()))
62}
63
64/// Choose the link target for the binary at the canonical path `exe`.
65///
66/// Homebrew installs into `<prefix>/Cellar/stuffr/<ver>/bin/stuffr`, and
67/// `brew upgrade` plus cleanup deletes that directory, so a link to it
68/// dangles after every upgrade. Homebrew's stable alias for the current
69/// version is `<prefix>/opt/stuffr/bin/stuffr`: when `exe` has the Cellar
70/// shape and `resolve` (canonicalisation, injected for testing) takes the
71/// opt path to `exe` itself, that is the target. Otherwise it is `exe`.
72pub fn stable_target(exe: &Path, resolve: impl Fn(&Path) -> Option<PathBuf>) -> PathBuf {
73    use std::path::Component;
74    let parts: Vec<Component<'_>> = exe.components().collect();
75    let n = parts.len();
76    let is = |c: &Component<'_>, s: &str| c.as_os_str() == s;
77    if n >= 5
78        && is(&parts[n - 5], "Cellar")
79        && is(&parts[n - 4], "stuffr")
80        && matches!(parts[n - 3], Component::Normal(_))
81        && is(&parts[n - 2], "bin")
82        && is(&parts[n - 1], "stuffr")
83    {
84        let prefix: PathBuf = parts[..n - 5].iter().collect();
85        let opt = prefix.join("opt/stuffr/bin/stuffr");
86        if resolve(&opt).is_some_and(|r| r == exe) {
87            return opt;
88        }
89    }
90    exe.to_path_buf()
91}
92
93/// Create (or with `remove`, delete) the compatibility links `names` in `dir`,
94/// each pointing at `target`. Every name is checked before anything is
95/// changed, so a refusal leaves the directory as it was.
96///
97/// An existing link is stuffr's when its text is `target`, when it resolves
98/// to the same file as `target`, or when it dangles and its target's file
99/// name is `stuffr` (a link into a version an upgrade has since deleted).
100/// Only those are kept, replaced without `--force`, or removed.
101#[cfg(unix)]
102pub fn install_links(
103    dir: &Path,
104    names: &[&str],
105    force: bool,
106    remove: bool,
107    dry_run: bool,
108    target: &Path,
109) -> stuffr::Result<Vec<LinkAction>> {
110    use std::io::ErrorKind;
111    use stuffr::Error;
112
113    let mut seen = std::collections::HashSet::new();
114    let names: Vec<&str> = names.iter().copied().filter(|n| seen.insert(*n)).collect();
115    for n in &names {
116        if !super::names().contains(n) {
117            return Err(Error::Usage(format!(
118                "`{n}` is not a compatibility name (known: {})",
119                super::names().join(", ")
120            )));
121        }
122    }
123    let dir = match dir.canonicalize() {
124        Ok(d) if d.is_dir() => d,
125        Ok(_) => {
126            return Err(Error::Usage(format!(
127                "{} is not a directory",
128                dir.display()
129            )));
130        }
131        Err(e) if e.kind() == ErrorKind::NotFound => {
132            return Err(Error::Usage(format!(
133                "directory {} does not exist",
134                dir.display()
135            )));
136        }
137        Err(e) => return Err(at(dir, e)),
138    };
139    let file = target.canonicalize().ok();
140
141    /// What an existing symlink is, as far as stuffr is concerned.
142    enum Kind {
143        /// Its text is `target`, or it resolves to the same file.
144        Live,
145        /// It dangles, and names a file called `stuffr`.
146        Stale,
147        /// Anything else: the user's own.
148        Foreign,
149    }
150    let classify = |p: &Path| -> Kind {
151        let Ok(text) = std::fs::read_link(p) else {
152            return Kind::Foreign;
153        };
154        if text == target {
155            return Kind::Live;
156        }
157        match p.canonicalize() {
158            Ok(c) => {
159                if file.as_ref() == Some(&c) {
160                    Kind::Live
161                } else {
162                    Kind::Foreign
163                }
164            }
165            Err(e)
166                if e.kind() == ErrorKind::NotFound
167                    && text.file_name().is_some_and(|f| f == "stuffr") =>
168            {
169                Kind::Stale
170            }
171            Err(_) => Kind::Foreign,
172        }
173    };
174
175    enum Step {
176        Create,
177        Keep,
178        Replace,
179        Remove,
180        Leave(&'static str),
181    }
182    let mut plan = Vec::new();
183    for &name in &names {
184        let path = dir.join(name);
185        let meta = match std::fs::symlink_metadata(&path) {
186            Ok(m) => Some(m),
187            Err(e) if e.kind() == ErrorKind::NotFound => None,
188            Err(e) => return Err(at(&path, e)),
189        };
190        let kind = match &meta {
191            Some(m) if m.file_type().is_symlink() => Some(classify(&path)),
192            _ => None,
193        };
194        let step = match (&meta, remove) {
195            (None, true) => continue,
196            (None, false) => Step::Create,
197            (Some(_), true) => match kind {
198                Some(Kind::Live | Kind::Stale) => Step::Remove,
199                Some(Kind::Foreign) => Step::Leave("not a stuffr link"),
200                None => Step::Leave("not a link"),
201            },
202            (Some(m), false) => match kind {
203                Some(Kind::Live) => Step::Keep,
204                Some(Kind::Stale) => Step::Replace,
205                _ if m.is_dir() => {
206                    return Err(Error::Usage(format!(
207                        "{} is a directory; refusing to replace it",
208                        path.display()
209                    )));
210                }
211                _ if force => Step::Replace,
212                _ => {
213                    return Err(Error::Usage(format!(
214                        "{} already exists and is not a link to this stuffr; \
215                         pass --force to replace it",
216                        path.display()
217                    )));
218                }
219            },
220        };
221        plan.push((path, step));
222    }
223
224    let mut actions = Vec::new();
225    for (path, step) in plan {
226        actions.push(match step {
227            Step::Keep => {
228                let text = std::fs::read_link(&path).map_err(|e| at(&path, e))?;
229                LinkAction::AlreadyPresent { path, text }
230            }
231            Step::Leave(why) => LinkAction::Left(path, why),
232            Step::Create if dry_run => LinkAction::WouldCreate(path),
233            Step::Replace if dry_run => LinkAction::WouldReplace(path),
234            Step::Remove if dry_run => LinkAction::WouldRemove(path),
235            Step::Create => {
236                std::os::unix::fs::symlink(target, &path).map_err(|e| at(&path, e))?;
237                LinkAction::Created(path)
238            }
239            Step::Replace => {
240                // Atomic: build the new link beside the old entry, then
241                // rename over it, so a failure leaves the original intact.
242                let name = path.file_name().unwrap_or_default().to_string_lossy();
243                let tmp = path.with_file_name(format!(".{name}.stuffr-tmp-{}", std::process::id()));
244                let _ = std::fs::remove_file(&tmp);
245                std::os::unix::fs::symlink(target, &tmp).map_err(|e| at(&tmp, e))?;
246                if let Err(e) = std::fs::rename(&tmp, &path) {
247                    let _ = std::fs::remove_file(&tmp);
248                    return Err(at(&path, e));
249                }
250                LinkAction::Replaced(path)
251            }
252            Step::Remove => {
253                std::fs::remove_file(&path).map_err(|e| at(&path, e))?;
254                LinkAction::Removed(path)
255            }
256        });
257    }
258    Ok(actions)
259}
260
261/// An I/O failure on `path`, with the path in its message. Still
262/// [`stuffr::Error::Io`], exit 1: an unwritable directory is the
263/// environment refusing, not hostile input.
264#[cfg(unix)]
265fn at(path: &Path, e: std::io::Error) -> stuffr::Error {
266    stuffr::Error::Io(std::io::Error::new(
267        e.kind(),
268        format!("{}: {e}", path.display()),
269    ))
270}
271
272/// Symlinks are not available here.
273#[cfg(not(unix))]
274pub fn install_links(
275    _dir: &Path,
276    _names: &[&str],
277    _force: bool,
278    _remove: bool,
279    _dry_run: bool,
280    _target: &Path,
281) -> stuffr::Result<Vec<LinkAction>> {
282    Err(stuffr::Error::Unsupported(
283        "install-links needs symlinks; Windows is not supported".into(),
284    ))
285}
286
287#[cfg(test)]
288mod tests {
289    use super::stable_target;
290    use std::path::{Path, PathBuf};
291
292    const CELLAR: &str = "/opt/homebrew/Cellar/stuffr/0.11.0/bin/stuffr";
293    const OPT: &str = "/opt/homebrew/opt/stuffr/bin/stuffr";
294
295    #[test]
296    fn a_cellar_path_whose_opt_resolves_to_it_targets_opt() {
297        let got = stable_target(Path::new(CELLAR), |p| {
298            (p == Path::new(OPT)).then(|| PathBuf::from(CELLAR))
299        });
300        assert_eq!(got, Path::new(OPT));
301    }
302
303    #[test]
304    fn a_cellar_path_whose_opt_points_elsewhere_stays_canonical() {
305        let elsewhere = |_: &Path| {
306            Some(PathBuf::from(
307                "/opt/homebrew/Cellar/stuffr/0.10.4/bin/stuffr",
308            ))
309        };
310        assert_eq!(
311            stable_target(Path::new(CELLAR), elsewhere),
312            Path::new(CELLAR)
313        );
314        assert_eq!(
315            stable_target(Path::new(CELLAR), |_| None),
316            Path::new(CELLAR)
317        );
318    }
319
320    #[test]
321    fn a_non_homebrew_path_stays_canonical() {
322        let any = |_: &Path| Some(PathBuf::from("/usr/local/bin/stuffr"));
323        for p in [
324            "/usr/local/bin/stuffr",
325            "/home/u/.cargo/bin/stuffr",
326            // Right words, wrong shape: not matched by substring.
327            "/x/Cellar/stuffr/bin/stuffr",
328            "/x/Cellar/other/0.1/bin/stuffr",
329            "/x/NotCellar/stuffr/0.1/bin/stuffr",
330            "/x/Cellar/stuffr/0.1/libexec/stuffr",
331        ] {
332            assert_eq!(stable_target(Path::new(p), any), Path::new(p), "{p}");
333        }
334    }
335
336    #[test]
337    fn the_prefix_is_whatever_precedes_cellar() {
338        let exe = "/usr/local/Cellar/stuffr/1.0/bin/stuffr";
339        let opt = "/usr/local/opt/stuffr/bin/stuffr";
340        let got = stable_target(Path::new(exe), |p| {
341            (p == Path::new(opt)).then(|| PathBuf::from(exe))
342        });
343        assert_eq!(got, Path::new(opt));
344    }
345}