1use crate::runtime::cache_dir;
2use crate::{LovelyError, Result};
3use std::env;
4use std::ffi::OsString;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9const BUTLER_BASE_URL: &str = "https://broth.itch.zone/butler";
10
11#[derive(Debug, Clone)]
12pub struct Butler {
13 path: PathBuf,
14}
15
16impl Butler {
17 pub fn resolve() -> Result<Self> {
18 if let Some(path) = env::var_os("LOVELY_BUTLER_PATH") {
19 let path = PathBuf::from(path);
20 if path.is_file() {
21 return Ok(Self { path });
22 }
23 return Err(LovelyError::Command(format!(
24 "LOVELY_BUTLER_PATH does not point to a file: {}",
25 path.display()
26 )));
27 }
28
29 if let Some(path) = find_on_path("butler") {
30 return Ok(Self { path });
31 }
32
33 let path = cached_butler_path();
34 if path.is_file() {
35 return Ok(Self { path });
36 }
37
38 fetch_butler(&path)?;
39 Ok(Self { path })
40 }
41
42 pub fn push(&self, artifact: &Path, destination: &str) -> Result<()> {
43 let status = Command::new(&self.path)
44 .arg("push")
45 .arg(artifact)
46 .arg(destination)
47 .status()
48 .map_err(|err| LovelyError::io(&self.path, err))?;
49
50 if !status.success() {
51 return Err(LovelyError::Command(format!(
52 "butler push failed with status {status}"
53 )));
54 }
55
56 Ok(())
57 }
58
59 pub fn path(&self) -> &Path {
60 &self.path
61 }
62}
63
64fn fetch_butler(destination: &Path) -> Result<()> {
65 let Some(platform) = butler_platform() else {
66 return Err(LovelyError::Command(
67 "automatic Butler install is not supported on this platform; install Butler or set LOVELY_BUTLER_PATH".to_string(),
68 ));
69 };
70
71 let Some(parent) = destination.parent() else {
72 return Err(LovelyError::Command(
73 "invalid Butler cache destination".to_string(),
74 ));
75 };
76 fs::create_dir_all(parent).map_err(|err| LovelyError::io(parent, err))?;
77
78 let archive = parent.join("butler.zip");
79 let url = env::var("LOVELY_BUTLER_URL")
80 .unwrap_or_else(|_| format!("{BUTLER_BASE_URL}/{platform}/LATEST/archive/default"));
81
82 run_tool(
83 "curl",
84 &[
85 OsString::from("-fsSL"),
86 OsString::from("-o"),
87 archive.as_os_str().to_os_string(),
88 OsString::from(url),
89 ],
90 "download Butler",
91 )?;
92
93 #[cfg(windows)]
94 {
95 run_tool(
96 "powershell",
97 &[
98 OsString::from("-NoProfile"),
99 OsString::from("-Command"),
100 OsString::from(format!(
101 "Expand-Archive -Force -LiteralPath '{}' -DestinationPath '{}'",
102 archive.display(),
103 parent.display()
104 )),
105 ],
106 "extract Butler",
107 )?;
108 }
109
110 #[cfg(not(windows))]
111 {
112 run_tool(
113 "unzip",
114 &[
115 OsString::from("-o"),
116 archive.as_os_str().to_os_string(),
117 OsString::from("-d"),
118 parent.as_os_str().to_os_string(),
119 ],
120 "extract Butler",
121 )?;
122 }
123
124 if !destination.is_file() {
125 return Err(LovelyError::Command(format!(
126 "downloaded Butler archive did not contain {}",
127 destination.display()
128 )));
129 }
130
131 #[cfg(unix)]
132 {
133 use std::os::unix::fs::PermissionsExt;
134 let mut permissions = fs::metadata(destination)
135 .map_err(|err| LovelyError::io(destination, err))?
136 .permissions();
137 permissions.set_mode(0o755);
138 fs::set_permissions(destination, permissions)
139 .map_err(|err| LovelyError::io(destination, err))?;
140 }
141
142 Ok(())
143}
144
145fn run_tool(tool: &str, args: &[OsString], action: &str) -> Result<()> {
146 let output = Command::new(tool).args(args).output().map_err(|err| {
147 LovelyError::Command(format!(
148 "could not {action}: {tool} is required for automatic Butler install ({err})"
149 ))
150 })?;
151
152 if !output.status.success() {
153 let stderr = String::from_utf8_lossy(&output.stderr);
154 return Err(LovelyError::Command(format!(
155 "could not {action}: {tool} exited with status {}; {}",
156 output.status,
157 stderr.trim()
158 )));
159 }
160
161 Ok(())
162}
163
164fn cached_butler_path() -> PathBuf {
165 cache_dir()
166 .join("tools")
167 .join("butler")
168 .join(butler_platform().unwrap_or("unknown"))
169 .join(butler_binary_name())
170}
171
172fn find_on_path(binary: &str) -> Option<PathBuf> {
173 let path = env::var_os("PATH")?;
174 for dir in env::split_paths(&path) {
175 for name in path_binary_names(binary) {
176 let candidate = dir.join(name);
177 if candidate.is_file() {
178 return Some(candidate);
179 }
180 }
181 }
182 None
183}
184
185fn path_binary_names(binary: &str) -> Vec<String> {
186 #[cfg(windows)]
187 {
188 if Path::new(binary).extension().is_some() {
189 return vec![binary.to_string()];
190 }
191 vec![
192 format!("{binary}.exe"),
193 format!("{binary}.cmd"),
194 format!("{binary}.bat"),
195 ]
196 }
197
198 #[cfg(not(windows))]
199 {
200 vec![binary.to_string()]
201 }
202}
203
204fn butler_binary_name() -> &'static str {
205 if cfg!(windows) {
206 "butler.exe"
207 } else {
208 "butler"
209 }
210}
211
212fn butler_platform() -> Option<&'static str> {
213 match (env::consts::OS, env::consts::ARCH) {
214 ("linux", "x86_64") => Some("linux-amd64"),
215 ("linux", "aarch64") => Some("linux-arm64"),
216 ("macos", "x86_64") => Some("darwin-amd64"),
217 ("macos", "aarch64") => Some("darwin-arm64"),
218 ("windows", "x86_64") => Some("windows-amd64"),
219 ("windows", "aarch64") => Some("windows-arm64"),
220 _ => None,
221 }
222}