use std::path::{Path, PathBuf};
use std::process::Command;
use crate::{output, output::pager};
use anyhow::{Context, Result, bail};
use super::profiles::run_command_with_line_callback;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BuildScriptAction {
Install,
Upgrade,
}
impl BuildScriptAction {
fn primary_names(&self) -> &'static [&'static str] {
match self {
BuildScriptAction::Install => &["install.sh", "install.bash", "install.ps1"],
BuildScriptAction::Upgrade => &["upgrade.sh", "upgrade.bash", "upgrade.ps1"],
}
}
fn label(&self) -> &'static str {
match self {
BuildScriptAction::Install => "install",
BuildScriptAction::Upgrade => "upgrade",
}
}
}
fn fallback_names(action: BuildScriptAction) -> &'static [&'static str] {
match action {
BuildScriptAction::Install => &[],
BuildScriptAction::Upgrade => &["install.sh", "install.bash", "install.ps1"],
}
}
fn resolve_script_from_names(workspace_root: &Path, names: &[&str]) -> Option<PathBuf> {
for dir in [workspace_root.to_path_buf(), workspace_root.join("scripts")] {
for name in names {
let path = dir.join(name);
if path.is_file() {
return Some(path);
}
}
}
None
}
pub fn script_for(action: BuildScriptAction, workspace_root: &Path) -> Option<PathBuf> {
resolve_script_from_names(workspace_root, action.primary_names())
.or_else(|| resolve_script_from_names(workspace_root, fallback_names(action)))
}
fn is_ps1(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| extension.eq_ignore_ascii_case("ps1"))
}
fn validate_script(path: &Path) -> Result<()> {
if is_ps1(path) {
return Ok(());
}
let content = std::fs::read(path)
.with_context(|| format!("Failed to read build script '{}'", path.display()))?;
if content.starts_with(b"#!") {
return Ok(());
}
bail!(
"Build script '{}' is missing a shebang. Add '#!' so the OS can resolve the interpreter.",
path.display()
);
}
fn command_preview(path: &Path) -> String {
if is_ps1(path) {
return format!("pwsh -File {}", path.display());
}
path.display().to_string()
}
fn command_for(path: &Path) -> Result<Command> {
if is_ps1(path) {
let mut command = Command::new("pwsh");
command.arg("-File").arg(path);
return Ok(command);
}
Ok(Command::new(path))
}
fn review_script(path: &Path) -> Result<()> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read build script '{}'", path.display()))?;
let mut preview = String::new();
for line in content.lines() {
preview.push_str(" ");
preview.push_str(line);
preview.push('\n');
}
preview.push('\n');
preview.push_str(&format!(" Command: {}\n", command_preview(path)));
pager::page_text(
Some(&format!("Reviewing script: {}", path.display())),
&preview,
)?;
Ok(())
}
pub fn run_build_script(
action: BuildScriptAction,
workspace_root: &Path,
line_callback: Option<&mut dyn FnMut(&str)>,
) -> Result<()> {
let Some(path) = script_for(action, workspace_root) else {
return Ok(());
};
validate_script(&path)?;
review_script(&path)?;
output::confirm_or_cancel(
format!(
"Run {} script '{}' from '{}' ?",
action.label(),
path.file_name()
.and_then(|value| value.to_str())
.unwrap_or("script"),
path.parent()
.and_then(|value| value.file_name())
.and_then(|value| value.to_str())
.unwrap_or("scripts"),
),
true,
)?;
let mut status_callback = line_callback;
let mut command = command_for(&path)?;
command.current_dir(workspace_root);
let context = format!(
"Failed to run build script '{}'. Check the script shebang, executable bit, and interpreter availability.",
path.display()
);
let status =
run_command_with_line_callback(&mut command, context.as_str(), &mut status_callback)
.with_context(|| format!("Build script execution failed: '{}'", path.display()))?;
if !status.success() {
bail!(
"Script '{}' exited with non-zero status ({})",
path.display(),
status.code().unwrap_or(-1)
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{
BuildScriptAction, command_for, command_preview, is_ps1, script_for, validate_script,
};
use std::time::{SystemTime, UNIX_EPOCH};
use std::{fs, path::PathBuf};
fn temp_root(name: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!("upstream-builder-script-test-{name}-{nanos}"))
}
#[test]
fn install_prefers_root_bash_over_scripts_sh() {
let root = temp_root("install-prefers-sh");
fs::create_dir_all(root.join("scripts")).expect("create scripts dir");
fs::write(
root.join("install.bash"),
"#!/usr/bin/env bash\necho bash\n",
)
.expect("write root install.bash");
fs::write(
root.join("scripts").join("install.sh"),
"#!/bin/sh\necho sh\n",
)
.expect("write scripts install.sh");
let path = script_for(BuildScriptAction::Install, &root).expect("detect script");
assert_eq!(path, root.join("install.bash"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn upgrade_prefers_upgrade_script_over_install() {
let root = temp_root("upgrade-priority");
fs::create_dir_all(&root).expect("create root");
fs::write(root.join("install.sh"), "#!/bin/sh\necho install\n").expect("write install");
fs::write(root.join("upgrade.bash"), "#!/bin/bash\necho upgrade\n").expect("write upgrade");
let path = script_for(BuildScriptAction::Upgrade, &root).expect("detect script");
assert_eq!(path, root.join("upgrade.bash"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn supports_scripts_directory_fallback() {
let root = temp_root("scripts-fallback");
fs::create_dir_all(root.join("scripts")).expect("create scripts dir");
fs::write(
root.join("scripts").join("install.bash"),
"#!/bin/bash\necho scripts\n",
)
.expect("write scripts install.bash");
let path = script_for(BuildScriptAction::Install, &root).expect("detect script");
assert_eq!(path, root.join("scripts").join("install.bash"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn detects_ps1_candidates() {
let root = temp_root("ps1-candidate");
fs::create_dir_all(&root).expect("create root");
fs::write(root.join("install.ps1"), "Write-Output install\n").expect("write ps1");
let path = script_for(BuildScriptAction::Install, &root).expect("detect script");
assert_eq!(path, root.join("install.ps1"));
assert!(is_ps1(&path));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn ps1_script_uses_pwsh_on_all_platforms() {
let root = temp_root("ps1-command");
fs::create_dir_all(&root).expect("create root");
let script = root.join("install.ps1");
fs::write(&script, "Write-Output install\n").expect("write ps1");
validate_script(&script).expect("ps1 script is valid");
assert_eq!(
command_preview(&script),
format!("pwsh -File {}", script.display())
);
let command = command_for(&script).expect("build command");
assert_eq!(command.get_program().to_string_lossy(), "pwsh");
assert_eq!(
command
.get_args()
.map(|arg| arg.to_string_lossy().to_string())
.collect::<Vec<_>>(),
vec!["-File".to_string(), script.to_string_lossy().to_string()]
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn non_ps1_script_requires_shebang() {
let root = temp_root("requires-shebang");
fs::create_dir_all(&root).expect("create root");
let script = root.join("install.sh");
fs::write(&script, "echo no shebang\n").expect("write script");
let err = validate_script(&script).expect_err("must reject missing shebang");
assert!(err.to_string().contains("missing a shebang"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn non_ps1_script_with_shebang_is_valid() {
let root = temp_root("with-shebang");
fs::create_dir_all(&root).expect("create root");
let script = root.join("install.sh");
fs::write(&script, "#!/bin/sh\necho ok\n").expect("write script");
validate_script(&script).expect("valid shebang script");
let _ = fs::remove_dir_all(&root);
}
}