Skip to main content

lovely/
butler.rs

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}