use anyhow::{anyhow, Context, Result};
use colored::Colorize;
use indicatif::{ProgressBar, ProgressStyle};
use std::collections::VecDeque;
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
use crate::{
archive::{download, extract},
commands::bootstrap,
config::{Config, ConfigMut},
fs as gvsn_fs,
http::HttpClient,
lock,
remote::index,
tempdir::TempDir,
toolchain,
user_version::VersionSpec,
};
pub fn run(
config: &Config,
client: &HttpClient,
version_str: &str,
force: bool,
no_cgo: bool,
bootstrap_spec: Option<&str>,
env_vars: &[String],
) -> Result<()> {
config.ensure_dirs()?;
let spec = VersionSpec::parse(version_str)?;
println!("{} Fetching available Go versions...", "->".cyan());
let releases = index::fetch_releases(client, &config.root)?;
let release = index::resolve(&spec, &releases)?;
let version = release
.go_version()
.ok_or_else(|| anyhow!("Could not parse version tag '{}'", release.version))?;
if toolchain::is_installed(config, &version) {
if force {
println!(
"{} Removing existing Go {} installation...",
"->".cyan(),
version.tag().bold()
);
let lock_path = config.root.join(".lock");
lock::with_lock(&lock_path, || {
std::fs::remove_dir_all(config.version_dir(&version.tag()))
.context("Failed to remove existing installation")
})?;
} else {
println!(
"{} Go {} is already installed.",
"✓".green(),
version.tag().bold()
);
println!(" Use {} to rebuild from source.", "--force".cyan());
return Ok(());
}
}
let src_file = release.source_file().ok_or_else(|| {
anyhow!(
"No source tarball found for {}. \
Source tarballs are only available for stable releases.",
version.tag()
)
})?;
println!();
println!(
" {} Building {} from source.",
"->".cyan(),
version.tag().bold()
);
println!(
" {} This will take 5-15 minutes and requires ~3 GB of disk space.",
"!".yellow()
);
println!();
let bootstrap =
bootstrap::resolve_bootstrap(config, client, &version, bootstrap_spec, &releases)?;
println!("{} Bootstrap: {}", "->".cyan(), bootstrap.label.bold());
let lock_path = config.root.join(".lock");
let src_archive = config.tmp_dir().join(&src_file.filename);
let staging_dir = {
let _lock = lock::FileLock::lock(&lock_path)?;
println!(
"{} Downloading {}...",
"->".cyan(),
src_file.filename.bold()
);
if let Err(e) = download::fetch(
client,
&index::download_url(&src_file.filename),
&src_archive,
) {
let _ = std::fs::remove_file(&src_archive);
bootstrap::cleanup_bootstrap(&bootstrap);
return Err(e).context("Failed to download source tarball");
}
if !src_file.sha256.is_empty() {
println!("{} Verifying checksum...", "->".cyan());
if let Err(e) = download::verify_sha256(&src_archive, &src_file.sha256) {
let _ = std::fs::remove_file(&src_archive);
bootstrap::cleanup_bootstrap(&bootstrap);
return Err(e);
}
}
let staging_dir = TempDir::new_in(config.tmp_dir(), format!("src-{}", version.tag()))?;
if let Err(e) = extract::unpack(&src_archive, staging_dir.path()) {
let _ = std::fs::remove_file(&src_archive);
bootstrap::cleanup_bootstrap(&bootstrap);
return Err(e).context("Failed to extract source tarball");
}
let _ = std::fs::remove_file(&src_archive);
staging_dir
};
let source_root = staging_dir.path().join("go");
if !source_root.exists() {
bootstrap::cleanup_bootstrap(&bootstrap);
anyhow::bail!(
"Unexpected archive layout: expected 'go/' inside {}",
staging_dir.path().display()
);
}
staging_dir.keep();
println!("{} Compiling Go {}...", "->".cyan(), version.tag().bold());
if !client.is_verbose() {
println!(
" {} This will take 5-15 minutes. Run with {} to see build output.",
"i".yellow(),
"-v".cyan()
);
}
println!();
let compiled = compile(
client,
&source_root,
&bootstrap.path,
no_cgo,
env_vars,
&config.tmp_dir(),
);
bootstrap::cleanup_bootstrap(&bootstrap);
compiled?;
let dest = config.version_dir(&version.tag());
let lock_path = config.root.join(".lock");
if let Err(e) = lock::with_lock(&lock_path, || gvsn_fs::move_dir(&source_root, &dest)) {
return Err(e).context("Failed to move compiled Go to versions directory");
}
println!();
println!(
"{} Go {} built and installed successfully.",
"✓".green(),
version.tag().bold()
);
println!(
" Run {} to activate.",
format!("gvsn use {}", version).cyan()
);
Ok(())
}
fn compile(
client: &HttpClient,
source_root: &Path,
bootstrap_path: &Path,
no_cgo: bool,
env_vars: &[String],
gvsn_tmp: &Path,
) -> Result<()> {
let src_dir = source_root.join("src");
let build_tmp = gvsn_tmp.join(format!("go-build-{}", std::process::id()));
std::fs::create_dir_all(&build_tmp)
.context("Failed to create build scratch directory inside .gvsn/tmp")?;
#[cfg(windows)]
let (script_name, mut cmd) = {
let script = src_dir.join("make.bat");
if !script.exists() {
anyhow::bail!(
"Build script not found at {}. The source archive may be corrupt.",
script.display()
);
}
let mut c = std::process::Command::new("cmd.exe");
c.args(["/c", script.to_str().unwrap_or("make.bat")]);
("make.bat", c)
};
#[cfg(not(windows))]
let (script_name, mut cmd) = {
let script = src_dir.join("make.bash");
if !script.exists() {
anyhow::bail!(
"Build script not found at {}. The source archive may be corrupt.",
script.display()
);
}
let mut c = std::process::Command::new("bash");
c.arg(&script);
("make.bash", c)
};
cmd.current_dir(&src_dir);
cmd.env("GOROOT_BOOTSTRAP", bootstrap_path);
#[cfg(windows)]
{
cmd.env("TEMP", &build_tmp);
cmd.env("TMP", &build_tmp);
}
#[cfg(not(windows))]
{
cmd.env("TMPDIR", &build_tmp);
}
if no_cgo {
cmd.env("CGO_ENABLED", "0");
}
for (key, val) in parse_env_vars(env_vars) {
cmd.env(key, val);
}
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd
.spawn()
.with_context(|| format!("Failed to start {script_name}"))?;
let stdout_pipe = child.stdout.take().expect("stdout was piped");
let stderr_pipe = child.stderr.take().expect("stderr was piped");
let (tx, rx) = mpsc::channel::<String>();
let tx2 = tx.clone();
let stdout_thread = thread::spawn(move || {
BufReader::new(stdout_pipe)
.lines()
.map_while(Result::ok)
.for_each(|l| {
tx.send(l).ok();
});
});
let stderr_thread = thread::spawn(move || {
BufReader::new(stderr_pipe)
.lines()
.map_while(Result::ok)
.for_each(|l| {
tx2.send(l).ok();
});
});
let pb = ProgressBar::new_spinner();
pb.set_style(
ProgressStyle::default_spinner()
.template(" {spinner:.cyan} {msg} {elapsed_precise}")
.unwrap(),
);
pb.set_message("Starting build...");
pb.enable_steady_tick(Duration::from_millis(120));
let verbose = client.is_verbose();
let mut tail: VecDeque<String> = VecDeque::with_capacity(100);
for line in rx {
if let Some(msg) = phase_message(&line) {
pb.set_message(msg);
}
if verbose {
pb.println(format!(" {} {}", "│".dimmed(), line));
}
if tail.len() >= 100 {
tail.pop_front();
}
tail.push_back(line);
}
stdout_thread.join().ok();
stderr_thread.join().ok();
let status = child.wait().context("Build process was interrupted")?;
pb.finish_and_clear();
let _ = std::fs::remove_dir_all(&build_tmp);
if !status.success() {
if !verbose && !tail.is_empty() {
eprintln!();
eprintln!(
" {} Build output (last {} lines):",
"!".yellow(),
tail.len()
);
for line in &tail {
eprintln!(" {} {}", "│".dimmed(), line);
}
}
#[cfg(windows)]
{
let gvsn_root = gvsn_tmp.parent().unwrap_or(gvsn_tmp);
println!();
println!(
" {} Build failed. If the output above contains 'Access denied' or",
"!".yellow()
);
println!(" 'Acceso denegado', your antivirus is blocking intermediate");
println!(" executables compiled during the build.");
println!();
println!(
" {} All gvsn build artifacts are written exclusively inside:",
"i".cyan()
);
println!(" {}", gvsn_root.display());
println!();
println!(
" {} Add that directory as an exclusion in your antivirus:",
"->".cyan()
);
println!(" Windows Security -> Virus & threat protection ->");
println!(" Manage settings -> Exclusions -> Add an exclusion -> Folder");
println!(" Then retry: gvsn build <version>");
}
anyhow::bail!(
"Build failed with exit code {}.",
status.code().unwrap_or(-1)
);
}
Ok(())
}
fn phase_message(line: &str) -> Option<&'static str> {
if line.contains("Building C bootstrap") {
Some("Building C bootstrap tool...")
} else if line.contains("Building compilers") || line.contains("Building Go bootstrap") {
Some("Building Go compiler...")
} else if line.contains("Building packages") || line.contains("Building commands") {
Some("Building standard library...")
} else if line.contains("Installed Go for") || line.contains("Installed commands") {
Some("Finalizing...")
} else {
None
}
}
fn parse_env_vars(env_vars: &[String]) -> Vec<(&str, &str)> {
env_vars
.iter()
.filter_map(|kv| kv.split_once('='))
.collect()
}
#[cfg(test)]
mod tests {
use super::{parse_env_vars, phase_message};
#[test]
fn phase_message_detects_c_bootstrap() {
assert_eq!(
phase_message("##### Building C bootstrap tool."),
Some("Building C bootstrap tool...")
);
}
#[test]
fn phase_message_detects_compiler_phase() {
assert_eq!(
phase_message("##### Building compilers and Go bootstrap tool for host, linux/amd64."),
Some("Building Go compiler...")
);
assert_eq!(
phase_message("##### Building Go bootstrap cmd/go (go_bootstrap) using Go."),
Some("Building Go compiler...")
);
}
#[test]
fn phase_message_detects_stdlib_phase() {
assert_eq!(
phase_message("##### Building packages and commands for linux/amd64."),
Some("Building standard library...")
);
assert_eq!(
phase_message("##### Building commands only."),
Some("Building standard library...")
);
}
#[test]
fn phase_message_detects_finalizing_phase() {
assert_eq!(
phase_message("Installed Go for linux/amd64 in /tmp/go"),
Some("Finalizing...")
);
assert_eq!(
phase_message("Installed commands in /tmp/go/bin"),
Some("Finalizing...")
);
}
#[test]
fn phase_message_returns_none_for_unrecognized_line() {
assert_eq!(phase_message("some unrelated compiler output"), None);
assert_eq!(phase_message(""), None);
}
#[test]
fn parse_env_vars_splits_key_value_pairs() {
let vars = vec!["FOO=bar".to_string(), "BAZ=qux".to_string()];
assert_eq!(parse_env_vars(&vars), vec![("FOO", "bar"), ("BAZ", "qux")]);
}
#[test]
fn parse_env_vars_keeps_value_with_embedded_equals() {
let vars = vec!["FOO=a=b=c".to_string()];
assert_eq!(parse_env_vars(&vars), vec![("FOO", "a=b=c")]);
}
#[test]
fn parse_env_vars_drops_malformed_entries() {
let vars = vec!["NOVALUE".to_string(), "FOO=bar".to_string()];
assert_eq!(parse_env_vars(&vars), vec![("FOO", "bar")]);
}
#[test]
fn parse_env_vars_handles_empty_input() {
let vars: Vec<String> = vec![];
assert!(parse_env_vars(&vars).is_empty());
}
}