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 {
523 let home = std::env::var_os("HOME").map(PathBuf::from);
524 let shown = match home.as_deref().and_then(|h| dir.strip_prefix(h).ok()) {
525 Some(rest) => format!("$HOME/{}", rest.display()),
526 None => dir.display().to_string(),
527 };
528 format!("export PATH=\"{}:$PATH\"", shown.replace(['"', '\\', '`'], ""))
529}
530
531pub fn sh_quote(p: &Path) -> Result<String> {
533 let s = p.to_str().ok_or_else(|| CliToolError::Invalid(format!("{} is not valid UTF-8", p.display())))?;
534 if s.is_empty() || s.chars().any(char::is_control) {
535 return Err(CliToolError::Invalid(format!("{} can't be passed to the shell", p.display())));
536 }
537 Ok(format!("'{}'", s.replace('\'', r"'\''")))
538}
539
540#[cfg(unix)]
543fn link_atomically(target: &Path, link: &Path) -> io::Result<()> {
544 let dir = link.parent().ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "the link has no folder"))?;
545 std::fs::create_dir_all(dir)?;
546 let name = link.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
547 let tmp = dir.join(format!(".{name}.{}.tmp", std::process::id()));
548 let _ = std::fs::remove_file(&tmp);
549 std::os::unix::fs::symlink(target, &tmp)?;
550 std::fs::rename(&tmp, link).inspect_err(|_| {
551 let _ = std::fs::remove_file(&tmp);
552 })
553}
554
555#[cfg(not(unix))]
556fn link_atomically(_: &Path, _: &Path) -> io::Result<()> {
557 Err(io::Error::new(io::ErrorKind::Unsupported, "command-line tool links need a Unix system"))
558}
559
560#[cfg(target_os = "macos")]
563fn admin_prompt(script: &str, prompt: &str) -> Elevated {
564 let out = std::process::Command::new("/usr/bin/osascript")
565 .args([
566 "-e",
567 "on run argv",
568 "-e",
569 "do shell script (item 1 of argv) with prompt (item 2 of argv) with administrator privileges",
570 "-e",
571 "end run",
572 script,
573 prompt,
574 ])
575 .output();
576 match out {
577 Ok(o) if o.status.success() => Elevated::Done,
578 Ok(o) => {
579 let err = String::from_utf8_lossy(&o.stderr);
580 if err.contains("-128") { Elevated::Declined } else { Elevated::Failed(crate::os_text(err)) }
581 }
582 Err(e) => Elevated::Failed(crate::os_text(e)),
583 }
584}
585
586#[cfg(not(target_os = "macos"))]
587fn admin_prompt(_: &str, _: &str) -> Elevated {
588 Elevated::Declined
589}
590
591pub trait CliToolHost: Host<Error: From<CommandError>> {
598 fn cli_tool(&self) -> Option<&CliTool>;
599}
600
601const INSTALL_PARAMS: &[Param] = &[Param::bool("replace").about("replace a file or link that isn't this app's tool")];
602
603pub fn commands<S: CliToolHost>(menu: &'static [&'static str]) -> Vec<CommandSpec<S>> {
607 vec![
608 CommandSpec::new(INSTALL, "Install Command Line Tool…", run_install::<S>)
609 .menu(menu)
610 .params(Params::Static(INSTALL_PARAMS))
611 .enabled(can_install::<S>)
612 .query(),
613 CommandSpec::new(UNINSTALL, "Uninstall Command Line Tool", run_uninstall::<S>).menu(menu).enabled(has_tool::<S>).query(),
614 CommandSpec::new(STATUS, "Command Line Tool Status", run_status::<S>).enabled(has_tool::<S>).query(),
615 ]
616}
617
618fn tool<S: CliToolHost>(s: &S) -> std::result::Result<&CliTool, S::Error> {
619 s.cli_tool().ok_or_else(|| CommandError::failed("", "this build of the app has no command-line tool").into())
620}
621
622fn has_tool<S: CliToolHost>(s: &S) -> std::result::Result<(), String> {
623 match s.cli_tool() {
624 Some(_) => Ok(()),
625 None => Err("this build of the app has no command-line tool".into()),
626 }
627}
628
629fn can_install<S: CliToolHost>(s: &S) -> std::result::Result<(), String> {
630 has_tool(s)?;
631 match s.cli_tool() {
632 Some(t) if t.helper_present() => Ok(()),
633 _ => Err("the command-line tool is missing from this copy of the app".into()),
634 }
635}
636
637fn to_value<T: Serialize>(v: &T) -> Value {
638 serde_json::to_value(v).unwrap_or(Value::Null)
639}
640
641fn with_message(mut v: Value, message: String) -> Value {
642 if let Some(m) = v.as_object_mut() {
643 m.insert("message".into(), Value::String(message));
644 }
645 v
646}
647
648pub fn run(tool: &CliTool, id: &str, params: &Value) -> Option<Result<Value>> {
652 Some(match id {
653 INSTALL => {
654 let replace = params.get("replace").and_then(Value::as_bool).unwrap_or(false);
655 tool.install(replace).map(|out| with_message(to_value(&out), out.message(tool.name())))
656 }
657 UNINSTALL => tool.uninstall().map(|out| {
658 let kept: Vec<Value> = out.kept.iter().map(|(p, why)| json!({"path": p, "reason": why})).collect();
659 json!({"removed": out.removed, "kept": kept, "message": out.message(tool.name())})
660 }),
661 STATUS => {
662 let st = tool.status();
663 Ok(with_message(to_value(&st), st.message(tool.name())))
664 }
665 _ => return None,
666 })
667}
668
669fn run_command<S: CliToolHost>(s: &mut S, id: &str, p: &Value) -> std::result::Result<Value, S::Error> {
670 let t = tool(s)?;
671 match run(t, id, p) {
672 Some(r) => r.map_err(|e| S::Error::from(CommandError::from(e))),
673 None => Err(CommandError::failed(id, "not a command-line tool command").into()),
674 }
675}
676
677fn run_install<S: CliToolHost>(s: &mut S, p: &Value) -> std::result::Result<Value, S::Error> {
678 run_command(s, INSTALL, p)
679}
680
681fn run_uninstall<S: CliToolHost>(s: &mut S, p: &Value) -> std::result::Result<Value, S::Error> {
682 run_command(s, UNINSTALL, p)
683}
684
685fn run_status<S: CliToolHost>(s: &mut S, p: &Value) -> std::result::Result<Value, S::Error> {
686 run_command(s, STATUS, p)
687}
688
689#[cfg(test)]
690#[cfg(unix)]
691#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
692mod tests {
693 use std::os::unix::fs::{PermissionsExt, symlink};
694 use std::sync::Mutex;
695
696 use ashlar_command::{Journal, Registry};
697
698 use super::*;
699
700 struct Fixture {
701 _root: tempfile::TempDir,
702 app: PathBuf,
703 helper: PathBuf,
704 bin: PathBuf,
705 fallback: PathBuf,
706 }
707
708 fn fixture() -> Fixture {
709 let root = tempfile::tempdir().unwrap();
710 let app = root.path().join("Some App.app");
711 let helper = app.join("Contents/Helpers/tool");
712 std::fs::create_dir_all(helper.parent().unwrap()).unwrap();
713 std::fs::write(&helper, "#!/bin/sh\necho tool 1.0\n").unwrap();
714 std::fs::set_permissions(&helper, std::fs::Permissions::from_mode(0o755)).unwrap();
715 let bin = root.path().join("usr local/bin");
716 let fallback = root.path().join("home/.local/bin");
717 Fixture { app, helper, bin, fallback, _root: root }
718 }
719
720 fn tool_at(f: &Fixture, elevation: Elevation) -> CliTool {
721 CliTool::new("tool", &f.helper).unwrap().bin_dir(&f.bin).fallback_dir(Some(f.fallback.clone())).elevation(elevation)
722 }
723
724 fn read_only(dir: &Path) {
725 std::fs::create_dir_all(dir).unwrap();
726 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o555)).unwrap();
727 }
728
729 fn writable(dir: &Path) {
730 std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o755)).unwrap();
731 }
732
733 fn running_as_root() -> bool {
735 let d = tempfile::tempdir().unwrap();
736 read_only(d.path());
737 let can = std::fs::write(d.path().join("probe"), "").is_ok();
738 writable(d.path());
739 can
740 }
741
742 #[test]
743 fn names_are_checked() {
744 for ok in ["tool", "my-tool", "a.b_c2"] {
745 assert!(check_name(ok).is_ok(), "{ok}");
746 }
747 for bad in ["", "-x", ".x", "a/b", "A", "a b", "a;b", &"x".repeat(65)] {
748 assert!(check_name(bad).is_err(), "{bad}");
749 }
750 }
751
752 #[test]
753 fn quoting_survives_quotes_and_spaces_and_refuses_control_characters() {
754 let p = Path::new("/tmp/it's a \"dir\"/$(x)`y`");
755 let q = sh_quote(p).unwrap();
756 let out = std::process::Command::new("/bin/sh").arg("-c").arg(format!("printf %s {q}")).output().unwrap();
757 assert_eq!(String::from_utf8(out.stdout).unwrap(), p.to_str().unwrap());
758 assert!(sh_quote(Path::new("/tmp/a\nb")).is_err());
759 assert!(sh_quote(Path::new("")).is_err());
760 }
761
762 #[test]
763 fn installs_into_a_missing_folder_then_reports_installed_and_is_idempotent() {
764 let f = fixture();
765 let t = tool_at(&f, Elevation::Never);
766 assert_eq!(t.status().state, LinkState::Missing);
767 let out = t.install(false).unwrap();
768 assert_eq!(out.link, f.bin.join("tool"));
769 assert!(!out.fallback && !out.unchanged && !out.elevated && out.path_hint.is_none());
770 assert_eq!(std::fs::read_link(f.bin.join("tool")).unwrap(), f.helper);
771 assert!(t.status().is_installed());
772 let again = t.install(false).unwrap();
773 assert!(again.unchanged);
774 let leftovers: Vec<_> = std::fs::read_dir(&f.bin).unwrap().map(|e| e.unwrap().file_name()).collect();
775 assert_eq!(leftovers, vec![std::ffi::OsString::from("tool")], "no temporary links left behind");
776 }
777
778 #[test]
779 fn a_missing_helper_is_an_error_and_nothing_is_created() {
780 let f = fixture();
781 std::fs::remove_file(&f.helper).unwrap();
782 let t = tool_at(&f, Elevation::Never);
783 assert!(matches!(t.install(false), Err(CliToolError::HelperMissing(_))));
784 assert!(!f.bin.exists());
785 }
786
787 #[test]
788 fn never_replaces_what_isnt_ours_without_replace() {
789 let f = fixture();
790 std::fs::create_dir_all(&f.bin).unwrap();
791 let link = f.bin.join("tool");
792 std::fs::write(&link, "someone else's").unwrap();
793 let t = tool_at(&f, Elevation::Never);
794 assert_eq!(t.status().state, LinkState::File);
795 let err = t.install(false).unwrap_err();
796 assert!(err.is_conflict(), "{err}");
797 assert!(err.to_string().contains("replace"), "{err}");
798 assert_eq!(std::fs::read_to_string(&link).unwrap(), "someone else's");
799 let out = t.install(true).unwrap();
800 assert_eq!(out.replaced.as_deref(), Some("a file"));
801 assert!(out.message("tool").contains("replaced a file"));
802 assert!(t.status().is_installed());
803 }
804
805 #[test]
806 fn a_link_to_something_else_is_foreign_and_is_kept_on_uninstall() {
807 let f = fixture();
808 std::fs::create_dir_all(&f.bin).unwrap();
809 let link = f.bin.join("tool");
810 symlink("/bin/echo", &link).unwrap();
811 let t = tool_at(&f, Elevation::Never);
812 assert_eq!(t.status().state, LinkState::ForeignLink { target: "/bin/echo".into() });
813 assert!(t.install(false).unwrap_err().is_conflict());
814 let r = t.uninstall().unwrap();
815 assert!(r.removed.is_empty());
816 assert_eq!(r.kept.len(), 1);
817 assert_eq!(std::fs::read_link(&link).unwrap(), Path::new("/bin/echo"));
818 }
819
820 #[test]
821 fn a_folder_is_never_replaced() {
822 let f = fixture();
823 std::fs::create_dir_all(f.bin.join("tool")).unwrap();
824 let t = tool_at(&f, Elevation::Never);
825 let err = t.install(true).unwrap_err();
826 assert!(matches!(err, CliToolError::Conflict { replaceable: false, .. }), "{err}");
827 }
828
829 #[test]
830 fn a_link_left_by_a_moved_app_is_broken_and_repaired_without_asking() {
831 let f = fixture();
832 std::fs::create_dir_all(&f.bin).unwrap();
833 let old = f.app.parent().unwrap().join("Old Place/Some App.app/Contents/Helpers/tool");
834 symlink(&old, f.bin.join("tool")).unwrap();
835 let t = tool_at(&f, Elevation::Never);
836 let st = t.status();
837 assert!(st.needs_repair(), "{st:?}");
838 assert!(st.message("tool").contains("repair"));
839 let out = t.repair().unwrap().unwrap();
840 assert!(out.repaired && out.replaced.is_none());
841 assert!(t.status().is_installed());
842 assert!(t.repair().unwrap().is_none(), "nothing left to repair");
843 }
844
845 #[test]
846 fn a_link_to_another_copy_needs_replace() {
847 let f = fixture();
848 let other = f.app.parent().unwrap().join("Copy.app/Contents/Helpers/tool");
849 std::fs::create_dir_all(other.parent().unwrap()).unwrap();
850 std::fs::write(&other, "").unwrap();
851 std::fs::create_dir_all(&f.bin).unwrap();
852 symlink(&other, f.bin.join("tool")).unwrap();
853 let t = tool_at(&f, Elevation::Never);
854 assert_eq!(t.status().state, LinkState::OtherCopy { target: other.clone() });
855 assert!(t.install(false).unwrap_err().is_conflict());
856 let r = t.uninstall().unwrap();
857 assert!(r.removed.is_empty() && r.kept.len() == 1);
858 assert!(t.install(true).unwrap().replaced.unwrap().contains("Copy.app"));
859 }
860
861 #[test]
862 fn a_read_only_folder_asks_for_admin_and_uses_the_answer() {
863 if running_as_root() {
864 return;
865 }
866 let f = fixture();
867 read_only(&f.bin);
868 let scripts: Arc<Mutex<Vec<String>>> = Arc::default();
869 let seen = scripts.clone();
870 let bin = f.bin.clone();
871 let accept: Elevator = Arc::new(move |script: &str| {
873 seen.lock().unwrap().push(script.to_string());
874 writable(&bin);
875 let ok = std::process::Command::new("/bin/sh").arg("-c").arg(script).status().unwrap().success();
876 if ok { Elevated::Done } else { Elevated::Failed("script failed".into()) }
877 });
878 let t = tool_at(&f, Elevation::Custom(accept));
879 let out = t.install(false).unwrap();
880 assert!(out.elevated && !out.fallback);
881 assert!(t.status().is_installed());
882 let script = scripts.lock().unwrap().first().cloned().unwrap();
883 assert!(script.starts_with("/bin/mkdir -p '") && script.contains("/bin/ln -sfn '"), "{script}");
884 assert!(script.contains("'") && script.contains("usr local/bin"), "paths are quoted: {script}");
885 read_only(&f.bin);
887 let r = t.uninstall().unwrap();
888 assert_eq!(r.removed, vec![f.bin.join("tool")]);
889 assert!(scripts.lock().unwrap().last().unwrap().starts_with("/bin/rm -f '"));
890 writable(&f.bin);
891 }
892
893 #[test]
894 fn a_declined_prompt_falls_back_to_the_user_folder_with_a_path_hint() {
895 if running_as_root() {
896 return;
897 }
898 let f = fixture();
899 read_only(&f.bin);
900 let asked = Arc::new(Mutex::new(0));
901 let n = asked.clone();
902 let decline: Elevator = Arc::new(move |_: &str| {
903 *n.lock().unwrap() += 1;
904 Elevated::Declined
905 });
906 let t = tool_at(&f, Elevation::Custom(decline));
907 let out = t.install(false).unwrap();
908 assert_eq!(*asked.lock().unwrap(), 1);
909 assert!(out.fallback && !out.elevated);
910 assert_eq!(out.link, f.fallback.join("tool"));
911 let hint = out.path_hint.clone().unwrap();
912 assert!(hint.starts_with("export PATH=\"") && hint.ends_with(":$PATH\""), "{hint}");
913 assert!(out.message("tool").contains("~/.zshrc"));
914 let st = t.status();
915 assert!(st.fallback && st.is_installed(), "{st:?}");
916 let r = t.uninstall().unwrap();
918 assert_eq!(r.removed, vec![f.fallback.join("tool")]);
919 assert_eq!(*asked.lock().unwrap(), 1);
920 writable(&f.bin);
921 }
922
923 #[test]
924 fn without_a_fallback_a_declined_prompt_is_an_error() {
925 if running_as_root() {
926 return;
927 }
928 let f = fixture();
929 read_only(&f.bin);
930 let t = tool_at(&f, Elevation::Never).fallback_dir(None);
931 assert!(matches!(t.install(false), Err(CliToolError::Os(_))));
932 writable(&f.bin);
933 }
934
935 #[test]
936 fn a_failed_admin_script_is_reported_not_hidden() {
937 if running_as_root() {
938 return;
939 }
940 let f = fixture();
941 read_only(&f.bin);
942 let fail: Elevator = Arc::new(|_: &str| Elevated::Failed("the disk said no".into()));
943 let t = tool_at(&f, Elevation::Custom(fail));
944 assert_eq!(t.install(false).unwrap_err(), CliToolError::Os("the disk said no".into()));
945 assert!(!f.fallback.join("tool").exists());
946 writable(&f.bin);
947 }
948
949 #[test]
950 fn without_env_overrides_the_system_folder_and_the_prompt_are_used() {
951 let f = fixture();
952 let t = CliTool::new("tool", &f.helper).unwrap();
953 if std::env::var_os(ENV_BIN_DIR).is_none() && std::env::var_os(ENV_FALLBACK_DIR).is_none() {
954 assert_eq!(t.link(), Path::new(SYSTEM_BIN_DIR).join("tool"));
955 assert!(matches!(t.elevation, Elevation::Prompt));
956 } else {
957 assert!(matches!(t.elevation, Elevation::Never));
958 }
959 }
960
961 #[test]
962 fn bundled_helper_is_next_to_the_executables_folder() {
963 let p = bundled_helper("tool").unwrap();
964 assert!(p.ends_with("Helpers/tool"), "{}", p.display());
965 assert!(bundled_helper("../x").is_err());
966 }
967
968 struct App {
971 journal: Journal,
972 tool: Option<CliTool>,
973 }
974
975 impl Host for App {
976 type Error = CommandError;
977 fn journal(&mut self) -> &mut Journal {
978 &mut self.journal
979 }
980 }
981
982 impl CliToolHost for App {
983 fn cli_tool(&self) -> Option<&CliTool> {
984 self.tool.as_ref()
985 }
986 }
987
988 #[test]
989 fn commands_install_report_and_uninstall() {
990 let f = fixture();
991 let reg = Registry::new(commands::<App>(&["App"]));
992 let mut app = App { journal: Journal::default(), tool: Some(tool_at(&f, Elevation::Never)) };
993 let ids: Vec<_> = reg.specs().iter().map(|s| s.id).collect();
994 assert_eq!(ids, [INSTALL, UNINSTALL, STATUS]);
995 assert_eq!(reg.find(INSTALL).unwrap().menu, &["App"]);
996 assert!(reg.find(STATUS).unwrap().menu.is_empty());
997
998 let st = reg.execute(&mut app, STATUS, json!({})).unwrap();
999 assert_eq!(st["state"], "missing");
1000 let out = reg.execute(&mut app, INSTALL, json!({})).unwrap();
1001 assert!(out["message"].as_str().unwrap().starts_with("Installed `tool`"), "{out}");
1002 assert_eq!(reg.execute(&mut app, STATUS, json!({})).unwrap()["state"], "ours");
1003 let out = reg.execute(&mut app, UNINSTALL, json!({})).unwrap();
1004 assert_eq!(out["removed"].as_array().unwrap().len(), 1, "{out}");
1005 assert!(!f.bin.join("tool").exists());
1006 }
1007
1008 #[test]
1009 fn commands_refuse_a_conflict_with_a_reason_and_accept_replace() {
1010 let f = fixture();
1011 std::fs::create_dir_all(&f.bin).unwrap();
1012 std::fs::write(f.bin.join("tool"), "x").unwrap();
1013 let reg = Registry::new(commands::<App>(&["App"]));
1014 let mut app = App { journal: Journal::default(), tool: Some(tool_at(&f, Elevation::Never)) };
1015 let err = reg.execute(&mut app, INSTALL, json!({})).unwrap_err();
1016 assert!(err.to_string().contains("nothing was changed"), "{err}");
1017 let out = reg.execute(&mut app, INSTALL, json!({"replace": true})).unwrap();
1018 assert_eq!(out["replaced"], "a file");
1019 }
1020
1021 #[test]
1022 fn run_routes_the_three_ids_and_nothing_else() {
1023 let f = fixture();
1024 let t = tool_at(&f, Elevation::Never);
1025 assert!(run(&t, "app.quit", &json!({})).is_none());
1026 assert_eq!(run(&t, STATUS, &json!({})).unwrap().unwrap()["state"], "missing");
1027 let out = run(&t, INSTALL, &json!({})).unwrap().unwrap();
1028 assert_eq!(out["link"], json!(f.bin.join("tool")));
1029 assert!(out["message"].as_str().unwrap().contains("Installed"));
1030 let out = run(&t, UNINSTALL, &json!({})).unwrap().unwrap();
1031 assert!(out["message"].as_str().unwrap().starts_with("Removed"), "{out}");
1032 }
1033
1034 #[test]
1035 fn commands_are_disabled_without_a_tool_or_helper() {
1036 let f = fixture();
1037 let reg = Registry::new(commands::<App>(&["App"]));
1038 let mut app = App { journal: Journal::default(), tool: None };
1039 assert!(reg.execute(&mut app, INSTALL, json!({})).is_err());
1040 let info = reg.info(reg.find(INSTALL).unwrap(), &app);
1041 assert!(!info.enabled);
1042 std::fs::remove_file(&f.helper).unwrap();
1043 app.tool = Some(tool_at(&f, Elevation::Never));
1044 let info = reg.info(reg.find(INSTALL).unwrap(), &app);
1045 assert!(!info.enabled && info.disabled_reason.unwrap().contains("missing"));
1046 assert!(reg.info(reg.find(UNINSTALL).unwrap(), &app).enabled);
1047 }
1048}