1use std::io;
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28
29use ashlar_command::{CommandError, CommandSpec, Host, Param, Params};
30use serde::Serialize;
31use serde_json::{Value, json};
32
33pub const SYSTEM_BIN_DIR: &str = "/usr/local/bin";
35pub const ENV_BIN_DIR: &str = "ASHLAR_CLI_BIN_DIR";
37pub const ENV_FALLBACK_DIR: &str = "ASHLAR_CLI_FALLBACK_DIR";
39
40pub const INSTALL: &str = "app.installCommandLineTool";
42pub const UNINSTALL: &str = "app.uninstallCommandLineTool";
43pub const STATUS: &str = "app.commandLineToolStatus";
44
45#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
47pub enum CliToolError {
48 #[error("the command-line tool isn't in this copy of the app (expected at {0})")]
50 HelperMissing(PathBuf),
51 #[error("{} already exists and {}; nothing was changed{}", link.display(), what, if *replaceable { " (pass `replace: true` to replace it)" } else { "" })]
53 Conflict { link: PathBuf, what: String, replaceable: bool },
54 #[error("invalid: {0}")]
56 Invalid(String),
57 #[error("not supported on this platform")]
59 Unsupported,
60 #[error("{0}")]
62 Os(String),
63}
64
65impl CliToolError {
66 pub fn is_conflict(&self) -> bool {
67 matches!(self, Self::Conflict { .. })
68 }
69}
70
71impl From<CliToolError> for CommandError {
72 fn from(e: CliToolError) -> Self {
73 match e {
74 CliToolError::Invalid(_) => CommandError::bad_params("", e.to_string()),
75 _ => CommandError::failed("", e.to_string()),
76 }
77 }
78}
79
80type Result<T> = std::result::Result<T, CliToolError>;
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
84#[serde(tag = "state", rename_all = "camelCase")]
85pub enum LinkState {
86 Missing,
88 Ours,
90 Broken { target: PathBuf },
93 OtherCopy { target: PathBuf },
95 ForeignLink { target: PathBuf },
97 File,
99 Folder,
101}
102
103impl LinkState {
104 fn is_ours_or_stale(&self) -> bool {
105 matches!(self, Self::Ours | Self::Broken { .. })
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
111#[serde(rename_all = "camelCase")]
112pub struct Status {
113 pub link: PathBuf,
116 #[serde(flatten)]
117 pub state: LinkState,
118 pub fallback: bool,
120 pub helper: PathBuf,
121}
122
123impl Status {
124 pub fn is_installed(&self) -> bool {
125 self.state == LinkState::Ours
126 }
127 pub fn needs_repair(&self) -> bool {
129 matches!(self.state, LinkState::Broken { .. })
130 }
131 pub fn message(&self, name: &str) -> String {
133 let link = self.link.display();
134 match &self.state {
135 LinkState::Missing => format!("`{name}` isn't installed."),
136 LinkState::Ours => format!("`{name}` is installed at {link}."),
137 LinkState::Broken { target } => format!("{link} points to {}, which no longer exists. Install again to repair it.", target.display()),
138 LinkState::OtherCopy { target } => format!("{link} points to another copy of the app ({}).", target.display()),
139 LinkState::ForeignLink { target } => format!("{link} is a link to {}, which isn't this app's tool.", target.display()),
140 LinkState::File => format!("{link} is a file that isn't this app's tool."),
141 LinkState::Folder => format!("{link} is a folder."),
142 }
143 }
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
148#[serde(rename_all = "camelCase")]
149pub struct Installed {
150 pub link: PathBuf,
151 pub fallback: bool,
153 pub unchanged: bool,
155 pub repaired: bool,
157 pub replaced: Option<String>,
159 pub elevated: bool,
161 pub path_hint: Option<String>,
163}
164
165impl Installed {
166 pub fn message(&self, name: &str) -> String {
167 let link = self.link.display();
168 let mut text = if self.unchanged {
169 format!("`{name}` is already installed at {link}.")
170 } else if self.repaired {
171 format!("Repaired `{name}` at {link}.")
172 } else {
173 format!("Installed `{name}` at {link}.")
174 };
175 if let Some(r) = &self.replaced {
176 text.push_str(&format!(" It replaced {r}."));
177 }
178 if let Some(hint) = &self.path_hint {
179 text.push_str(&format!(" If your shell can't find `{name}`, add this line to ~/.zshrc: {hint}"));
180 }
181 text
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)]
187#[serde(rename_all = "camelCase")]
188pub struct Removed {
189 pub removed: Vec<PathBuf>,
190 pub kept: Vec<(PathBuf, String)>,
192}
193
194impl Removed {
195 pub fn message(&self, name: &str) -> String {
196 let mut parts = vec![];
197 if self.removed.is_empty() && self.kept.is_empty() {
198 parts.push(format!("`{name}` wasn't installed."));
199 }
200 for p in &self.removed {
201 parts.push(format!("Removed {}.", p.display()));
202 }
203 for (p, why) in &self.kept {
204 parts.push(format!("Left {} in place: {why}.", p.display()));
205 }
206 parts.join(" ")
207 }
208}
209
210#[derive(Debug, Clone, PartialEq, Eq)]
212pub enum Elevated {
213 Done,
215 Declined,
217 Failed(String),
219}
220
221pub type Elevator = Arc<dyn Fn(&str) -> Elevated + Send + Sync>;
224
225#[derive(Clone)]
227pub enum Elevation {
228 Prompt,
230 Never,
232 Custom(Elevator),
234}
235
236impl std::fmt::Debug for Elevation {
237 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238 f.write_str(match self {
239 Self::Prompt => "Prompt",
240 Self::Never => "Never",
241 Self::Custom(_) => "Custom",
242 })
243 }
244}
245
246#[derive(Debug, Clone)]
248pub struct CliTool {
249 name: String,
250 helper: PathBuf,
251 bin_dir: PathBuf,
252 fallback_dir: Option<PathBuf>,
253 elevation: Elevation,
254 prompt: String,
255}
256
257const MAX_NAME: usize = 64;
259
260pub fn check_name(name: &str) -> Result<()> {
262 let ok = !name.is_empty()
263 && name.len() <= MAX_NAME
264 && !name.starts_with(['-', '.'])
265 && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.'));
266 if ok {
267 Ok(())
268 } else {
269 Err(CliToolError::Invalid(format!("`{}` is not a usable command name", name.chars().take(MAX_NAME).collect::<String>())))
270 }
271}
272
273pub fn bundled_helper(name: &str) -> Result<PathBuf> {
276 check_name(name)?;
277 let exe = std::env::current_exe().map_err(|e| CliToolError::Os(crate::os_text(e)))?;
278 let contents = exe.parent().and_then(Path::parent).ok_or_else(|| CliToolError::Os("the app's own path has no parent folder".into()))?;
279 Ok(contents.join("Helpers").join(name))
280}
281
282impl CliTool {
283 pub fn new(name: &str, helper: impl Into<PathBuf>) -> Result<Self> {
287 check_name(name)?;
288 let env_dir = |key: &str| std::env::var_os(key).filter(|v| !v.is_empty()).map(PathBuf::from);
289 let (bin, fallback) = (env_dir(ENV_BIN_DIR), env_dir(ENV_FALLBACK_DIR));
290 let overridden = bin.is_some() || fallback.is_some();
291 let home_fallback = std::env::var_os("HOME").filter(|h| !h.is_empty()).map(|h| PathBuf::from(h).join(".local/bin"));
292 Ok(Self {
293 name: name.to_string(),
294 helper: helper.into(),
295 bin_dir: bin.unwrap_or_else(|| PathBuf::from(SYSTEM_BIN_DIR)),
296 fallback_dir: fallback.or(home_fallback),
297 elevation: if overridden { Elevation::Never } else { Elevation::Prompt },
298 prompt: format!("Install the `{name}` command."),
299 })
300 }
301
302 pub fn for_current_app(name: &str) -> Result<Self> {
304 Self::new(name, bundled_helper(name)?)
305 }
306
307 pub fn bin_dir(mut self, dir: impl Into<PathBuf>) -> Self {
309 self.bin_dir = dir.into();
310 self
311 }
312
313 pub fn fallback_dir(mut self, dir: Option<PathBuf>) -> Self {
315 self.fallback_dir = dir;
316 self
317 }
318
319 pub fn elevation(mut self, elevation: Elevation) -> Self {
320 self.elevation = elevation;
321 self
322 }
323
324 pub fn prompt(mut self, text: impl Into<String>) -> Self {
326 self.prompt = text.into();
327 self
328 }
329
330 pub fn name(&self) -> &str {
331 &self.name
332 }
333
334 pub fn helper(&self) -> &Path {
335 &self.helper
336 }
337
338 pub fn link(&self) -> PathBuf {
340 self.bin_dir.join(&self.name)
341 }
342
343 fn fallback_link(&self) -> Option<PathBuf> {
344 self.fallback_dir.as_ref().map(|d| d.join(&self.name))
345 }
346
347 pub fn helper_present(&self) -> bool {
349 self.helper.is_file()
350 }
351
352 pub fn state_of(&self, link: &Path) -> LinkState {
354 let Ok(meta) = std::fs::symlink_metadata(link) else { return LinkState::Missing };
355 if meta.is_dir() {
356 return LinkState::Folder;
357 }
358 if !meta.file_type().is_symlink() {
359 return LinkState::File;
360 }
361 let Ok(raw) = std::fs::read_link(link) else { return LinkState::File };
362 let target = if raw.is_absolute() { raw } else { link.parent().map_or(raw.clone(), |p| p.join(&raw)) };
363 if same_file(&target, &self.helper) {
364 return LinkState::Ours;
365 }
366 let ends_like_ours = target.file_name().is_some_and(|n| n == self.name.as_str())
367 && target.parent().and_then(Path::file_name).is_some_and(|n| n == "Helpers")
368 && target.parent().and_then(Path::parent).and_then(Path::file_name).is_some_and(|n| n == "Contents");
369 match (ends_like_ours, target.exists()) {
370 (true, false) => LinkState::Broken { target },
371 (true, true) => LinkState::OtherCopy { target },
372 (false, _) => LinkState::ForeignLink { target },
373 }
374 }
375
376 pub fn status(&self) -> Status {
379 let link = self.link();
380 let state = self.state_of(&link);
381 if state == LinkState::Missing
382 && let Some(fb) = self.fallback_link()
383 {
384 let fb_state = self.state_of(&fb);
385 if fb_state != LinkState::Missing {
386 return Status { link: fb, state: fb_state, fallback: true, helper: self.helper.clone() };
387 }
388 }
389 Status { link, state, fallback: false, helper: self.helper.clone() }
390 }
391
392 pub fn install(&self, replace: bool) -> Result<Installed> {
396 if !self.helper_present() {
397 return Err(CliToolError::HelperMissing(self.helper.clone()));
398 }
399 let link = self.link();
400 let state = self.state_of(&link);
401 let done = |link: PathBuf, fallback: bool, elevated: bool, state: &LinkState| Installed {
402 link,
403 fallback,
404 unchanged: false,
405 repaired: matches!(state, LinkState::Broken { .. }),
406 replaced: replaced_text(state),
407 elevated,
408 path_hint: None,
409 };
410 if state == LinkState::Ours {
411 let mut out = done(link, false, false, &state);
412 out.unchanged = true;
413 return Ok(out);
414 }
415 self.check_replaceable(&link, &state, replace)?;
416 match link_atomically(&self.helper, &link) {
417 Ok(()) => return Ok(done(link, false, false, &state)),
418 Err(e) if e.kind() == io::ErrorKind::Unsupported => return Err(CliToolError::Unsupported),
419 Err(e) if e.kind() != io::ErrorKind::PermissionDenied => return Err(CliToolError::Os(crate::os_text(e))),
420 Err(_) => {}
421 }
422 let script = format!("/bin/mkdir -p {} && /bin/ln -sfn {} {}", sh_quote(&self.bin_dir)?, sh_quote(&self.helper)?, sh_quote(&link)?);
423 match self.elevate(&script) {
424 Elevated::Done if self.state_of(&link) == LinkState::Ours => return Ok(done(link, false, true, &state)),
425 Elevated::Done => return Err(CliToolError::Os(format!("{} wasn't created as administrator", link.display()))),
426 Elevated::Failed(why) => return Err(CliToolError::Os(why)),
427 Elevated::Declined => {}
428 }
429 let Some(fb) = self.fallback_link() else {
430 return Err(CliToolError::Os(format!("{} isn't writable and administrator rights weren't given", self.bin_dir.display())));
431 };
432 let fb_state = self.state_of(&fb);
433 let hint = Some(path_hint(fb.parent().unwrap_or(&fb)));
434 if fb_state == LinkState::Ours {
435 let mut out = done(fb, true, false, &fb_state);
436 out.unchanged = true;
437 out.path_hint = hint;
438 return Ok(out);
439 }
440 self.check_replaceable(&fb, &fb_state, replace)?;
441 link_atomically(&self.helper, &fb).map_err(|e| CliToolError::Os(crate::os_text(e)))?;
442 let mut out = done(fb, true, false, &fb_state);
443 out.path_hint = hint;
444 Ok(out)
445 }
446
447 pub fn repair(&self) -> Result<Option<Installed>> {
449 if self.status().needs_repair() { self.install(false).map(Some) } else { Ok(None) }
450 }
451
452 pub fn uninstall(&self) -> Result<Removed> {
454 let mut out = Removed::default();
455 let links = std::iter::once(self.link()).chain(self.fallback_link());
456 for link in links {
457 let state = self.state_of(&link);
458 if state == LinkState::Missing {
459 continue;
460 }
461 if !state.is_ours_or_stale() {
462 let why = match &state {
463 LinkState::OtherCopy { .. } => "it belongs to another copy of the app",
464 _ => "it isn't this app's tool",
465 };
466 out.kept.push((link, why.into()));
467 continue;
468 }
469 match std::fs::remove_file(&link) {
470 Ok(()) => out.removed.push(link),
471 Err(e) if e.kind() == io::ErrorKind::NotFound => {}
472 Err(e) if e.kind() == io::ErrorKind::PermissionDenied => match self.elevate(&format!("/bin/rm -f {}", sh_quote(&link)?)) {
473 Elevated::Done if self.state_of(&link) == LinkState::Missing => out.removed.push(link),
474 Elevated::Done => out.kept.push((link, "it couldn't be removed as administrator".into())),
475 Elevated::Declined => out.kept.push((link, "removing it needs administrator rights".into())),
476 Elevated::Failed(why) => out.kept.push((link, why)),
477 },
478 Err(e) => out.kept.push((link, crate::os_text(e))),
479 }
480 }
481 Ok(out)
482 }
483
484 fn check_replaceable(&self, link: &Path, state: &LinkState, replace: bool) -> Result<()> {
485 let what = match state {
486 LinkState::Missing | LinkState::Ours | LinkState::Broken { .. } => return Ok(()),
487 LinkState::Folder => return Err(CliToolError::Conflict { link: link.into(), what: "is a folder".into(), replaceable: false }),
488 _ if replace => return Ok(()),
489 LinkState::OtherCopy { target } => format!("points to another copy of the app ({})", target.display()),
490 LinkState::ForeignLink { target } => format!("is a link to {}", target.display()),
491 LinkState::File => "is a file that isn't this app's tool".into(),
492 };
493 Err(CliToolError::Conflict { link: link.into(), what, replaceable: true })
494 }
495
496 fn elevate(&self, script: &str) -> Elevated {
497 match &self.elevation {
498 Elevation::Never => Elevated::Declined,
499 Elevation::Custom(f) => f(script),
500 Elevation::Prompt => crate::modal::around(|| admin_prompt(script, &self.prompt)),
501 }
502 }
503}
504
505fn replaced_text(state: &LinkState) -> Option<String> {
506 match state {
507 LinkState::OtherCopy { target } | LinkState::ForeignLink { target } => Some(format!("a link to {}", target.display())),
508 LinkState::File => Some("a file".into()),
509 _ => None,
510 }
511}
512
513fn same_file(a: &Path, b: &Path) -> bool {
515 match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
516 (Ok(x), Ok(y)) => x == y,
517 _ => a == b,
518 }
519}
520
521fn path_hint(dir: &Path) -> String {
525 let home = std::env::var_os("HOME").map(PathBuf::from);
526 let shown = match home.as_deref().and_then(|h| dir.strip_prefix(h).ok()) {
527 Some(rest) if rest.as_os_str().is_empty() => Ok("\"$HOME\"".to_string()),
528 Some(rest) => sh_quote(rest).map(|q| format!("\"$HOME\"/{q}")),
529 None => sh_quote(dir),
530 };
531 let shown = shown.unwrap_or_else(|_| format!("'{}'", dir.display().to_string().replace(|c: char| c == '\'' || c.is_control(), "")));
534 format!("export PATH={shown}:\"$PATH\"")
535}
536
537pub fn sh_quote(p: &Path) -> Result<String> {
539 let s = p.to_str().ok_or_else(|| CliToolError::Invalid(format!("{} is not valid UTF-8", p.display())))?;
540 if s.is_empty() || s.chars().any(char::is_control) {
541 return Err(CliToolError::Invalid(format!("{} can't be passed to the shell", p.display())));
542 }
543 Ok(format!("'{}'", s.replace('\'', r"'\''")))
544}
545
546#[cfg(unix)]
549fn link_atomically(target: &Path, link: &Path) -> io::Result<()> {
550 let dir = link.parent().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the link has no folder"))?;
551 std::fs::create_dir_all(dir)?;
552 let name = link.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
553 let tmp = dir.join(format!(".{name}.{}.tmp", std::process::id()));
554 let _ = std::fs::remove_file(&tmp);
555 std::os::unix::fs::symlink(target, &tmp)?;
556 std::fs::rename(&tmp, link).inspect_err(|_| {
557 let _ = std::fs::remove_file(&tmp);
558 })
559}
560
561#[cfg(not(unix))]
562fn link_atomically(_: &Path, _: &Path) -> io::Result<()> {
563 Err(io::Error::new(io::ErrorKind::Unsupported, "command-line tool links need a Unix system"))
564}
565
566#[cfg(target_os = "macos")]
569fn admin_prompt(script: &str, prompt: &str) -> Elevated {
570 let out = std::process::Command::new("/usr/bin/osascript")
571 .args([
572 "-e",
573 "on run argv",
574 "-e",
575 "do shell script (item 1 of argv) with prompt (item 2 of argv) with administrator privileges",
576 "-e",
577 "end run",
578 script,
579 prompt,
580 ])
581 .output();
582 match out {
583 Ok(o) if o.status.success() => Elevated::Done,
584 Ok(o) => {
585 let err = String::from_utf8_lossy(&o.stderr);
586 if err.contains("-128") { Elevated::Declined } else { Elevated::Failed(crate::os_text(err)) }
587 }
588 Err(e) => Elevated::Failed(crate::os_text(e)),
589 }
590}
591
592#[cfg(not(target_os = "macos"))]
593fn admin_prompt(_: &str, _: &str) -> Elevated {
594 Elevated::Declined
595}
596
597pub trait CliToolHost: Host<Error: From<CommandError>> {
604 fn cli_tool(&self) -> Option<&CliTool>;
605}
606
607const INSTALL_PARAMS: &[Param] = &[Param::bool("replace").about("replace a file or link that isn't this app's tool")];
608
609pub fn commands<S: CliToolHost>(menu: &'static [&'static str]) -> Vec<CommandSpec<S>> {
613 vec![
614 CommandSpec::new(INSTALL, "Install Command Line Tool…", run_install::<S>)
615 .menu(menu)
616 .params(Params::Static(INSTALL_PARAMS))
617 .enabled(can_install::<S>)
618 .query(),
619 CommandSpec::new(UNINSTALL, "Uninstall Command Line Tool", run_uninstall::<S>).menu(menu).enabled(has_tool::<S>).query(),
620 CommandSpec::new(STATUS, "Command Line Tool Status", run_status::<S>).enabled(has_tool::<S>).query(),
621 ]
622}
623
624fn tool<S: CliToolHost>(s: &S) -> std::result::Result<&CliTool, S::Error> {
625 s.cli_tool().ok_or_else(|| CommandError::failed("", "this build of the app has no command-line tool").into())
626}
627
628fn has_tool<S: CliToolHost>(s: &S) -> std::result::Result<(), String> {
629 match s.cli_tool() {
630 Some(_) => Ok(()),
631 None => Err("this build of the app has no command-line tool".into()),
632 }
633}
634
635fn can_install<S: CliToolHost>(s: &S) -> std::result::Result<(), String> {
636 has_tool(s)?;
637 match s.cli_tool() {
638 Some(t) if t.helper_present() => Ok(()),
639 _ => Err("the command-line tool is missing from this copy of the app".into()),
640 }
641}
642
643fn to_value<T: Serialize>(v: &T) -> Value {
644 serde_json::to_value(v).unwrap_or(Value::Null)
645}
646
647fn with_message(mut v: Value, message: String) -> Value {
648 if let Some(m) = v.as_object_mut() {
649 m.insert("message".into(), Value::String(message));
650 }
651 v
652}
653
654pub fn run(tool: &CliTool, id: &str, params: &Value) -> Option<Result<Value>> {
658 Some(match id {
659 INSTALL => {
660 let replace = params.get("replace").and_then(Value::as_bool).unwrap_or(false);
661 tool.install(replace).map(|out| with_message(to_value(&out), out.message(tool.name())))
662 }
663 UNINSTALL => tool.uninstall().map(|out| {
664 let kept: Vec<Value> = out.kept.iter().map(|(p, why)| json!({"path": p, "reason": why})).collect();
665 json!({"removed": out.removed, "kept": kept, "message": out.message(tool.name())})
666 }),
667 STATUS => {
668 let st = tool.status();
669 Ok(with_message(to_value(&st), st.message(tool.name())))
670 }
671 _ => return None,
672 })
673}
674
675fn run_command<S: CliToolHost>(s: &mut S, id: &str, p: &Value) -> std::result::Result<Value, S::Error> {
676 let t = tool(s)?;
677 match run(t, id, p) {
678 Some(r) => r.map_err(|e| S::Error::from(CommandError::from(e))),
679 None => Err(CommandError::failed(id, "not a command-line tool command").into()),
680 }
681}
682
683fn run_install<S: CliToolHost>(s: &mut S, p: &Value) -> std::result::Result<Value, S::Error> {
684 run_command(s, INSTALL, p)
685}
686
687fn run_uninstall<S: CliToolHost>(s: &mut S, p: &Value) -> std::result::Result<Value, S::Error> {
688 run_command(s, UNINSTALL, p)
689}
690
691fn run_status<S: CliToolHost>(s: &mut S, p: &Value) -> std::result::Result<Value, S::Error> {
692 run_command(s, STATUS, p)
693}
694
695#[cfg(test)]
696#[cfg(unix)]
697#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
698mod tests {
699 use std::os::unix::fs::{PermissionsExt, symlink};
700 use std::sync::Mutex;
701
702 use ashlar_command::{Journal, Registry};
703
704 use super::*;
705
706 struct Fixture {
707 _root: tempfile::TempDir,
708 app: PathBuf,
709 helper: PathBuf,
710 bin: PathBuf,
711 fallback: PathBuf,
712 }
713
714 fn fixture() -> Fixture {
715 let root = tempfile::tempdir().unwrap();
716 let app = root.path().join("Some App.app");
717 let helper = app.join("Contents/Helpers/tool");
718 std::fs::create_dir_all(helper.parent().unwrap()).unwrap();
719 std::fs::write(&helper, "#!/bin/sh\necho tool 1.0\n").unwrap();
720 std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o755)).unwrap();
721 let bin = root.path().join("usr local/bin");
722 let fallback = root.path().join("home/.local/bin");
723 Fixture { app, helper, bin, fallback, _root: root }
724 }
725
726 fn tool_at(f: &Fixture, elevation: Elevation) -> CliTool {
727 CliTool::new("tool", &f.helper).unwrap().bin_dir(&f.bin).fallback_dir(Some(f.fallback.clone())).elevation(elevation)
728 }
729
730 fn read_only(dir: &Path) {
731 std::fs::create_dir_all(dir).unwrap();
732 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o555)).unwrap();
733 }
734
735 fn writable(dir: &Path) {
736 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755)).unwrap();
737 }
738
739 fn running_as_root() -> bool {
741 let d = tempfile::tempdir().unwrap();
742 read_only(d.path());
743 let can = std::fs::write(d.path().join("probe"), "").is_ok();
744 writable(d.path());
745 can
746 }
747
748 #[test]
749 fn names_are_checked() {
750 for ok in ["tool", "my-tool", "a.b_c2"] {
751 assert!(check_name(ok).is_ok(), "{ok}");
752 }
753 for bad in ["", "-x", ".x", "a/b", "A", "a b", "a;b", &"x".repeat(65)] {
754 assert!(check_name(bad).is_err(), "{bad}");
755 }
756 }
757
758 #[test]
759 fn quoting_survives_quotes_and_spaces_and_refuses_control_characters() {
760 let p = Path::new("/tmp/it's a \"dir\"/$(x)`y`");
761 let q = sh_quote(p).unwrap();
762 let out = std::process::Command::new("/bin/sh").arg("-c").arg(format!("printf %s {q}")).output().unwrap();
763 assert_eq!(String::from_utf8(out.stdout).unwrap(), p.to_str().unwrap());
764 assert!(sh_quote(Path::new("/tmp/a\nb")).is_err());
765 assert!(sh_quote(Path::new("")).is_err());
766 }
767
768 #[test]
769 fn installs_into_a_missing_folder_then_reports_installed_and_is_idempotent() {
770 let f = fixture();
771 let t = tool_at(&f, Elevation::Never);
772 assert_eq!(t.status().state, LinkState::Missing);
773 let out = t.install(false).unwrap();
774 assert_eq!(out.link, f.bin.join("tool"));
775 assert!(!out.fallback && !out.unchanged && !out.elevated && out.path_hint.is_none());
776 assert_eq!(std::fs::read_link(f.bin.join("tool")).unwrap(), f.helper);
777 assert!(t.status().is_installed());
778 let again = t.install(false).unwrap();
779 assert!(again.unchanged);
780 let leftovers: Vec<_> = std::fs::read_dir(&f.bin).unwrap().map(|e| e.unwrap().file_name()).collect();
781 assert_eq!(leftovers, vec![std::ffi::OsString::from("tool")], "no temporary links left behind");
782 }
783
784 #[test]
785 fn a_missing_helper_is_an_error_and_nothing_is_created() {
786 let f = fixture();
787 std::fs::remove_file(&f.helper).unwrap();
788 let t = tool_at(&f, Elevation::Never);
789 assert!(matches!(t.install(false), Err(CliToolError::HelperMissing(_))));
790 assert!(!f.bin.exists());
791 }
792
793 #[test]
794 fn never_replaces_what_isnt_ours_without_replace() {
795 let f = fixture();
796 std::fs::create_dir_all(&f.bin).unwrap();
797 let link = f.bin.join("tool");
798 std::fs::write(&link, "someone else's").unwrap();
799 let t = tool_at(&f, Elevation::Never);
800 assert_eq!(t.status().state, LinkState::File);
801 let err = t.install(false).unwrap_err();
802 assert!(err.is_conflict(), "{err}");
803 assert!(err.to_string().contains("replace"), "{err}");
804 assert_eq!(std::fs::read_to_string(&link).unwrap(), "someone else's");
805 let out = t.install(true).unwrap();
806 assert_eq!(out.replaced.as_deref(), Some("a file"));
807 assert!(out.message("tool").contains("replaced a file"));
808 assert!(t.status().is_installed());
809 }
810
811 #[test]
812 fn a_link_to_something_else_is_foreign_and_is_kept_on_uninstall() {
813 let f = fixture();
814 std::fs::create_dir_all(&f.bin).unwrap();
815 let link = f.bin.join("tool");
816 symlink("/bin/echo", &link).unwrap();
817 let t = tool_at(&f, Elevation::Never);
818 assert_eq!(t.status().state, LinkState::ForeignLink { target: "/bin/echo".into() });
819 assert!(t.install(false).unwrap_err().is_conflict());
820 let r = t.uninstall().unwrap();
821 assert!(r.removed.is_empty());
822 assert_eq!(r.kept.len(), 1);
823 assert_eq!(std::fs::read_link(&link).unwrap(), Path::new("/bin/echo"));
824 }
825
826 #[test]
827 fn a_folder_is_never_replaced() {
828 let f = fixture();
829 std::fs::create_dir_all(f.bin.join("tool")).unwrap();
830 let t = tool_at(&f, Elevation::Never);
831 let err = t.install(true).unwrap_err();
832 assert!(matches!(err, CliToolError::Conflict { replaceable: false, .. }), "{err}");
833 }
834
835 #[test]
836 fn a_link_left_by_a_moved_app_is_broken_and_repaired_without_asking() {
837 let f = fixture();
838 std::fs::create_dir_all(&f.bin).unwrap();
839 let old = f.app.parent().unwrap().join("Old Place/Some App.app/Contents/Helpers/tool");
840 symlink(&old, f.bin.join("tool")).unwrap();
841 let t = tool_at(&f, Elevation::Never);
842 let st = t.status();
843 assert!(st.needs_repair(), "{st:?}");
844 assert!(st.message("tool").contains("repair"));
845 let out = t.repair().unwrap().unwrap();
846 assert!(out.repaired && out.replaced.is_none());
847 assert!(t.status().is_installed());
848 assert!(t.repair().unwrap().is_none(), "nothing left to repair");
849 }
850
851 #[test]
852 fn a_link_to_another_copy_needs_replace() {
853 let f = fixture();
854 let other = f.app.parent().unwrap().join("Copy.app/Contents/Helpers/tool");
855 std::fs::create_dir_all(other.parent().unwrap()).unwrap();
856 std::fs::write(&other, "").unwrap();
857 std::fs::create_dir_all(&f.bin).unwrap();
858 symlink(&other, f.bin.join("tool")).unwrap();
859 let t = tool_at(&f, Elevation::Never);
860 assert_eq!(t.status().state, LinkState::OtherCopy { target: other.clone() });
861 assert!(t.install(false).unwrap_err().is_conflict());
862 let r = t.uninstall().unwrap();
863 assert!(r.removed.is_empty() && r.kept.len() == 1);
864 assert!(t.install(true).unwrap().replaced.unwrap().contains("Copy.app"));
865 }
866
867 #[test]
868 fn a_read_only_folder_asks_for_admin_and_uses_the_answer() {
869 if running_as_root() {
870 return;
871 }
872 let f = fixture();
873 read_only(&f.bin);
874 let scripts: Arc<Mutex<Vec<String>>> = Arc::default();
875 let seen = scripts.clone();
876 let bin = f.bin.clone();
877 let accept: Elevator = Arc::new(move |script: &str| {
879 seen.lock().unwrap().push(script.to_string());
880 writable(&bin);
881 let ok = std::process::Command::new("/bin/sh").arg("-c").arg(script).status().unwrap().success();
882 if ok { Elevated::Done } else { Elevated::Failed("script failed".into()) }
883 });
884 let t = tool_at(&f, Elevation::Custom(accept));
885 let out = t.install(false).unwrap();
886 assert!(out.elevated && !out.fallback);
887 assert!(t.status().is_installed());
888 let script = scripts.lock().unwrap().first().cloned().unwrap();
889 assert!(script.starts_with("/bin/mkdir -p '") && script.contains("/bin/ln -sfn '"), "{script}");
890 assert!(script.contains("'") && script.contains("usr local/bin"), "paths are quoted: {script}");
891 read_only(&f.bin);
893 let r = t.uninstall().unwrap();
894 assert_eq!(r.removed, vec![f.bin.join("tool")]);
895 assert!(scripts.lock().unwrap().last().unwrap().starts_with("/bin/rm -f '"));
896 writable(&f.bin);
897 }
898
899 #[test]
900 fn a_declined_prompt_falls_back_to_the_user_folder_with_a_path_hint() {
901 if running_as_root() {
902 return;
903 }
904 let f = fixture();
905 read_only(&f.bin);
906 let asked = Arc::new(Mutex::new(0));
907 let n = asked.clone();
908 let decline: Elevator = Arc::new(move |_: &str| {
909 *n.lock().unwrap() += 1;
910 Elevated::Declined
911 });
912 let t = tool_at(&f, Elevation::Custom(decline));
913 let out = t.install(false).unwrap();
914 assert_eq!(*asked.lock().unwrap(), 1);
915 assert!(out.fallback && !out.elevated);
916 assert_eq!(out.link, f.fallback.join("tool"));
917 let hint = out.path_hint.clone().unwrap();
918 assert!(hint.starts_with("export PATH=") && hint.ends_with(":\"$PATH\""), "{hint}");
919 assert!(out.message("tool").contains("~/.zshrc"));
920 let st = t.status();
921 assert!(st.fallback && st.is_installed(), "{st:?}");
922 let r = t.uninstall().unwrap();
924 assert_eq!(r.removed, vec![f.fallback.join("tool")]);
925 assert_eq!(*asked.lock().unwrap(), 1);
926 writable(&f.bin);
927 }
928
929 #[test]
930 fn path_hint_quotes_shell_characters() {
931 let h = path_hint(Path::new("/opt/a $b `c` 'd\"/bin"));
932 assert_eq!(h, r#"export PATH='/opt/a $b `c` '\''d"/bin':"$PATH""#);
933 if let Some(home) = std::env::var_os("HOME").map(PathBuf::from).filter(|h| h.is_absolute()) {
934 assert_eq!(path_hint(&home.join("x$y/bin")), r#"export PATH="$HOME"/'x$y/bin':"$PATH""#);
935 }
936 }
937
938 #[test]
939 fn without_a_fallback_a_declined_prompt_is_an_error() {
940 if running_as_root() {
941 return;
942 }
943 let f = fixture();
944 read_only(&f.bin);
945 let t = tool_at(&f, Elevation::Never).fallback_dir(None);
946 assert!(matches!(t.install(false), Err(CliToolError::Os(_))));
947 writable(&f.bin);
948 }
949
950 #[test]
951 fn a_failed_admin_script_is_reported_not_hidden() {
952 if running_as_root() {
953 return;
954 }
955 let f = fixture();
956 read_only(&f.bin);
957 let fail: Elevator = Arc::new(|_: &str| Elevated::Failed("the disk said no".into()));
958 let t = tool_at(&f, Elevation::Custom(fail));
959 assert_eq!(t.install(false).unwrap_err(), CliToolError::Os("the disk said no".into()));
960 assert!(!f.fallback.join("tool").exists());
961 writable(&f.bin);
962 }
963
964 #[test]
965 fn without_env_overrides_the_system_folder_and_the_prompt_are_used() {
966 let f = fixture();
967 let t = CliTool::new("tool", &f.helper).unwrap();
968 if std::env::var_os(ENV_BIN_DIR).is_none() && std::env::var_os(ENV_FALLBACK_DIR).is_none() {
969 assert_eq!(t.link(), Path::new(SYSTEM_BIN_DIR).join("tool"));
970 assert!(matches!(t.elevation, Elevation::Prompt));
971 } else {
972 assert!(matches!(t.elevation, Elevation::Never));
973 }
974 }
975
976 #[test]
977 fn bundled_helper_is_next_to_the_executables_folder() {
978 let p = bundled_helper("tool").unwrap();
979 assert!(p.ends_with("Helpers/tool"), "{}", p.display());
980 assert!(bundled_helper("../x").is_err());
981 }
982
983 struct App {
986 journal: Journal,
987 tool: Option<CliTool>,
988 }
989
990 impl Host for App {
991 type Error = CommandError;
992 fn journal(&mut self) -> &mut Journal {
993 &mut self.journal
994 }
995 }
996
997 impl CliToolHost for App {
998 fn cli_tool(&self) -> Option<&CliTool> {
999 self.tool.as_ref()
1000 }
1001 }
1002
1003 #[test]
1004 fn commands_install_report_and_uninstall() {
1005 let f = fixture();
1006 let reg = Registry::new(commands::<App>(&["App"]));
1007 let mut app = App { journal: Journal::default(), tool: Some(tool_at(&f, Elevation::Never)) };
1008 let ids: Vec<_> = reg.specs().iter().map(|s| s.id).collect();
1009 assert_eq!(ids, [INSTALL, UNINSTALL, STATUS]);
1010 assert_eq!(reg.find(INSTALL).unwrap().menu, &["App"]);
1011 assert!(reg.find(STATUS).unwrap().menu.is_empty());
1012
1013 let st = reg.execute(&mut app, STATUS, json!({})).unwrap();
1014 assert_eq!(st["state"], "missing");
1015 let out = reg.execute(&mut app, INSTALL, json!({})).unwrap();
1016 assert!(out["message"].as_str().unwrap().starts_with("Installed `tool`"), "{out}");
1017 assert_eq!(reg.execute(&mut app, STATUS, json!({})).unwrap()["state"], "ours");
1018 let out = reg.execute(&mut app, UNINSTALL, json!({})).unwrap();
1019 assert_eq!(out["removed"].as_array().unwrap().len(), 1, "{out}");
1020 assert!(!f.bin.join("tool").exists());
1021 }
1022
1023 #[test]
1024 fn commands_refuse_a_conflict_with_a_reason_and_accept_replace() {
1025 let f = fixture();
1026 std::fs::create_dir_all(&f.bin).unwrap();
1027 std::fs::write(f.bin.join("tool"), "x").unwrap();
1028 let reg = Registry::new(commands::<App>(&["App"]));
1029 let mut app = App { journal: Journal::default(), tool: Some(tool_at(&f, Elevation::Never)) };
1030 let err = reg.execute(&mut app, INSTALL, json!({})).unwrap_err();
1031 assert!(err.to_string().contains("nothing was changed"), "{err}");
1032 let out = reg.execute(&mut app, INSTALL, json!({"replace": true})).unwrap();
1033 assert_eq!(out["replaced"], "a file");
1034 }
1035
1036 #[test]
1037 fn run_routes_the_three_ids_and_nothing_else() {
1038 let f = fixture();
1039 let t = tool_at(&f, Elevation::Never);
1040 assert!(run(&t, "app.quit", &json!({})).is_none());
1041 assert_eq!(run(&t, STATUS, &json!({})).unwrap().unwrap()["state"], "missing");
1042 let out = run(&t, INSTALL, &json!({})).unwrap().unwrap();
1043 assert_eq!(out["link"], json!(f.bin.join("tool")));
1044 assert!(out["message"].as_str().unwrap().contains("Installed"));
1045 let out = run(&t, UNINSTALL, &json!({})).unwrap().unwrap();
1046 assert!(out["message"].as_str().unwrap().starts_with("Removed"), "{out}");
1047 }
1048
1049 #[test]
1050 fn commands_are_disabled_without_a_tool_or_helper() {
1051 let f = fixture();
1052 let reg = Registry::new(commands::<App>(&["App"]));
1053 let mut app = App { journal: Journal::default(), tool: None };
1054 assert!(reg.execute(&mut app, INSTALL, json!({})).is_err());
1055 let info = reg.info(reg.find(INSTALL).unwrap(), &app);
1056 assert!(!info.enabled);
1057 std::fs::remove_file(&f.helper).unwrap();
1058 app.tool = Some(tool_at(&f, Elevation::Never));
1059 let info = reg.info(reg.find(INSTALL).unwrap(), &app);
1060 assert!(!info.enabled && info.disabled_reason.unwrap().contains("missing"));
1061 assert!(reg.info(reg.find(UNINSTALL).unwrap(), &app).enabled);
1062 }
1063}