1use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum LinkAction {
13 Created(PathBuf),
15 AlreadyPresent(PathBuf),
17 Replaced(PathBuf),
20 Removed(PathBuf),
22 WouldCreate(PathBuf),
24 WouldReplace(PathBuf),
26 WouldRemove(PathBuf),
28 Left(PathBuf, &'static str),
30}
31
32impl LinkAction {
33 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
49pub 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
56pub 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#[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 enum Kind {
135 Live,
137 Stale,
139 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 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#[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#[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 "/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}