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