Skip to main content

upstream_rs/services/builder/
scripts.rs

1use std::path::{Path, PathBuf};
2use std::process::Command;
3
4use crate::{output, output::pager};
5use anyhow::{Context, Result, bail};
6
7use super::profiles::run_command_with_line_callback;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum BuildScriptAction {
11    Install,
12    Upgrade,
13}
14
15impl BuildScriptAction {
16    fn primary_names(&self) -> &'static [&'static str] {
17        match self {
18            BuildScriptAction::Install => &["install.sh", "install.bash", "install.ps1"],
19            BuildScriptAction::Upgrade => &["upgrade.sh", "upgrade.bash", "upgrade.ps1"],
20        }
21    }
22
23    fn label(&self) -> &'static str {
24        match self {
25            BuildScriptAction::Install => "install",
26            BuildScriptAction::Upgrade => "upgrade",
27        }
28    }
29}
30
31fn fallback_names(action: BuildScriptAction) -> &'static [&'static str] {
32    match action {
33        BuildScriptAction::Install => &[],
34        BuildScriptAction::Upgrade => &["install.sh", "install.bash", "install.ps1"],
35    }
36}
37
38fn resolve_script_from_names(workspace_root: &Path, names: &[&str]) -> Option<PathBuf> {
39    for dir in [workspace_root.to_path_buf(), workspace_root.join("scripts")] {
40        for name in names {
41            let path = dir.join(name);
42            if path.is_file() {
43                return Some(path);
44            }
45        }
46    }
47
48    None
49}
50
51pub fn script_for(action: BuildScriptAction, workspace_root: &Path) -> Option<PathBuf> {
52    resolve_script_from_names(workspace_root, action.primary_names())
53        .or_else(|| resolve_script_from_names(workspace_root, fallback_names(action)))
54}
55
56fn is_ps1(path: &Path) -> bool {
57    path.extension()
58        .and_then(|extension| extension.to_str())
59        .is_some_and(|extension| extension.eq_ignore_ascii_case("ps1"))
60}
61
62fn validate_script(path: &Path) -> Result<()> {
63    if is_ps1(path) {
64        return Ok(());
65    }
66
67    let content = std::fs::read(path)
68        .with_context(|| format!("Failed to read build script '{}'", path.display()))?;
69    if content.starts_with(b"#!") {
70        return Ok(());
71    }
72
73    bail!(
74        "Build script '{}' is missing a shebang. Add '#!' so the OS can resolve the interpreter.",
75        path.display()
76    );
77}
78
79fn command_preview(path: &Path) -> String {
80    if is_ps1(path) {
81        return format!("pwsh -File {}", path.display());
82    }
83
84    path.display().to_string()
85}
86
87fn command_for(path: &Path) -> Result<Command> {
88    if is_ps1(path) {
89        let mut command = Command::new("pwsh");
90        command.arg("-File").arg(path);
91        return Ok(command);
92    }
93
94    Ok(Command::new(path))
95}
96
97fn review_script(path: &Path) -> Result<()> {
98    let content = std::fs::read_to_string(path)
99        .with_context(|| format!("Failed to read build script '{}'", path.display()))?;
100    let mut preview = String::new();
101    for line in content.lines() {
102        preview.push_str("  ");
103        preview.push_str(line);
104        preview.push('\n');
105    }
106    preview.push('\n');
107    preview.push_str(&format!("  Command: {}\n", command_preview(path)));
108
109    pager::page_text(
110        Some(&format!("Reviewing script: {}", path.display())),
111        &preview,
112    )?;
113    Ok(())
114}
115
116pub fn run_build_script(
117    action: BuildScriptAction,
118    workspace_root: &Path,
119    line_callback: Option<&mut dyn FnMut(&str)>,
120) -> Result<()> {
121    let Some(path) = script_for(action, workspace_root) else {
122        return Ok(());
123    };
124
125    validate_script(&path)?;
126    review_script(&path)?;
127    output::confirm_or_cancel(
128        format!(
129            "Run {} script '{}' from '{}' ?",
130            action.label(),
131            path.file_name()
132                .and_then(|value| value.to_str())
133                .unwrap_or("script"),
134            path.parent()
135                .and_then(|value| value.file_name())
136                .and_then(|value| value.to_str())
137                .unwrap_or("scripts"),
138        ),
139        true,
140    )?;
141
142    let mut status_callback = line_callback;
143
144    let mut command = command_for(&path)?;
145    command.current_dir(workspace_root);
146
147    let context = format!(
148        "Failed to run build script '{}'. Check the script shebang, executable bit, and interpreter availability.",
149        path.display()
150    );
151    let status =
152        run_command_with_line_callback(&mut command, context.as_str(), &mut status_callback)
153            .with_context(|| format!("Build script execution failed: '{}'", path.display()))?;
154
155    if !status.success() {
156        bail!(
157            "Script '{}' exited with non-zero status ({})",
158            path.display(),
159            status.code().unwrap_or(-1)
160        );
161    }
162
163    Ok(())
164}
165
166#[cfg(test)]
167mod tests {
168    use super::{
169        BuildScriptAction, command_for, command_preview, is_ps1, script_for, validate_script,
170    };
171    use std::time::{SystemTime, UNIX_EPOCH};
172    use std::{fs, path::PathBuf};
173
174    fn temp_root(name: &str) -> PathBuf {
175        let nanos = SystemTime::now()
176            .duration_since(UNIX_EPOCH)
177            .map(|d| d.as_nanos())
178            .unwrap_or(0);
179        std::env::temp_dir().join(format!("upstream-builder-script-test-{name}-{nanos}"))
180    }
181
182    #[test]
183    fn install_prefers_root_bash_over_scripts_sh() {
184        let root = temp_root("install-prefers-sh");
185        fs::create_dir_all(root.join("scripts")).expect("create scripts dir");
186        fs::write(
187            root.join("install.bash"),
188            "#!/usr/bin/env bash\necho bash\n",
189        )
190        .expect("write root install.bash");
191        fs::write(
192            root.join("scripts").join("install.sh"),
193            "#!/bin/sh\necho sh\n",
194        )
195        .expect("write scripts install.sh");
196        let path = script_for(BuildScriptAction::Install, &root).expect("detect script");
197        assert_eq!(path, root.join("install.bash"));
198        let _ = fs::remove_dir_all(&root);
199    }
200
201    #[test]
202    fn upgrade_prefers_upgrade_script_over_install() {
203        let root = temp_root("upgrade-priority");
204        fs::create_dir_all(&root).expect("create root");
205        fs::write(root.join("install.sh"), "#!/bin/sh\necho install\n").expect("write install");
206        fs::write(root.join("upgrade.bash"), "#!/bin/bash\necho upgrade\n").expect("write upgrade");
207        let path = script_for(BuildScriptAction::Upgrade, &root).expect("detect script");
208        assert_eq!(path, root.join("upgrade.bash"));
209        let _ = fs::remove_dir_all(&root);
210    }
211
212    #[test]
213    fn supports_scripts_directory_fallback() {
214        let root = temp_root("scripts-fallback");
215        fs::create_dir_all(root.join("scripts")).expect("create scripts dir");
216        fs::write(
217            root.join("scripts").join("install.bash"),
218            "#!/bin/bash\necho scripts\n",
219        )
220        .expect("write scripts install.bash");
221        let path = script_for(BuildScriptAction::Install, &root).expect("detect script");
222        assert_eq!(path, root.join("scripts").join("install.bash"));
223        let _ = fs::remove_dir_all(&root);
224    }
225
226    #[test]
227    fn detects_ps1_candidates() {
228        let root = temp_root("ps1-candidate");
229        fs::create_dir_all(&root).expect("create root");
230        fs::write(root.join("install.ps1"), "Write-Output install\n").expect("write ps1");
231        let path = script_for(BuildScriptAction::Install, &root).expect("detect script");
232        assert_eq!(path, root.join("install.ps1"));
233        assert!(is_ps1(&path));
234        let _ = fs::remove_dir_all(&root);
235    }
236
237    #[test]
238    fn ps1_script_uses_pwsh_on_all_platforms() {
239        let root = temp_root("ps1-command");
240        fs::create_dir_all(&root).expect("create root");
241        let script = root.join("install.ps1");
242        fs::write(&script, "Write-Output install\n").expect("write ps1");
243
244        validate_script(&script).expect("ps1 script is valid");
245        assert_eq!(
246            command_preview(&script),
247            format!("pwsh -File {}", script.display())
248        );
249
250        let command = command_for(&script).expect("build command");
251        assert_eq!(command.get_program().to_string_lossy(), "pwsh");
252        assert_eq!(
253            command
254                .get_args()
255                .map(|arg| arg.to_string_lossy().to_string())
256                .collect::<Vec<_>>(),
257            vec!["-File".to_string(), script.to_string_lossy().to_string()]
258        );
259
260        let _ = fs::remove_dir_all(&root);
261    }
262
263    #[test]
264    fn non_ps1_script_requires_shebang() {
265        let root = temp_root("requires-shebang");
266        fs::create_dir_all(&root).expect("create root");
267        let script = root.join("install.sh");
268        fs::write(&script, "echo no shebang\n").expect("write script");
269        let err = validate_script(&script).expect_err("must reject missing shebang");
270        assert!(err.to_string().contains("missing a shebang"));
271        let _ = fs::remove_dir_all(&root);
272    }
273
274    #[test]
275    fn non_ps1_script_with_shebang_is_valid() {
276        let root = temp_root("with-shebang");
277        fs::create_dir_all(&root).expect("create root");
278        let script = root.join("install.sh");
279        fs::write(&script, "#!/bin/sh\necho ok\n").expect("write script");
280        validate_script(&script).expect("valid shebang script");
281        let _ = fs::remove_dir_all(&root);
282    }
283}