1use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11#[non_exhaustive]
12pub enum LinkAction {
13 Created(PathBuf),
15 AlreadyPresent {
18 path: PathBuf,
20 text: PathBuf,
22 },
23 Replaced(PathBuf),
26 Removed(PathBuf),
28 WouldCreate(PathBuf),
30 WouldReplace(PathBuf),
32 WouldRemove(PathBuf),
34 Left(PathBuf, &'static str),
36}
37
38impl LinkAction {
39 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
57pub 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
64pub 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#[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 enum Kind {
143 Live,
145 Stale,
147 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 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#[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#[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 "/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}