use anyhow::Result;
use colored::Colorize;
use crate::{commands::setup, config::Config, shell, toolchain, version::GoVersion};
pub fn run(config: &Config, shell_str: Option<&str>, fix: bool) -> Result<()> {
println!("Checking gvsn environment...\n");
let mut issues = 0u32;
let go_name = if cfg!(windows) { "go.exe" } else { "go" };
let mut path_issue = false;
if shell::gvsn_in_path() {
ok("gvsn binary is in PATH");
} else {
let dir = std::env::current_exe()
.ok()
.and_then(|p| p.parent().map(|d| d.display().to_string()))
.unwrap_or_else(|| "<unknown>".to_string());
fail(&format!("gvsn is NOT in PATH (add '{dir}' to your PATH)"));
issues += 1;
path_issue = true;
}
let mut installed_global: Option<GoVersion> = None;
match toolchain::global_version(config) {
Err(_) => {
fail("No global Go version set (run 'gvsn use <version>')");
issues += 1;
}
Ok(v) => {
ok(&format!("Global version: {}", v.tag().bold()));
if toolchain::is_installed(config, &v) {
ok(&format!("{} is installed", v.tag()));
let root = config.version_dir(&v.tag());
ok(&format!("GOROOT -> {}", root.display()));
installed_global = Some(v);
} else {
fail(&format!(
"{} is NOT installed (run 'gvsn install {}')",
v.tag(),
v.tag()
));
issues += 1;
}
}
}
let current_dir = config.current_dir();
let current_go = current_dir.join("bin").join(go_name);
let current_link_missing = !current_go.exists();
if !current_link_missing {
ok(&format!(
"~/.gvsn/current junction is configured ({})",
current_dir.display()
));
} else {
fail("~/.gvsn/current not set up (run 'gvsn use <version>' to enable universal shell support)");
issues += 1;
}
if let Ok(cwd) = std::env::current_dir() {
if let Some((raw, source)) = read_local_pin(&cwd) {
match crate::version::GoVersion::parse(&raw) {
Ok(v) if toolchain::is_installed(config, &v) => {
ok(&format!("{source} = {} (installed)", v.tag()));
}
Ok(v) => {
warn(&format!(
"{source} = {} but NOT installed (run 'gvsn install {}')",
v.tag(),
v.tag()
));
issues += 1;
}
Err(_) => {
warn(&format!("{source} contains invalid version: '{raw}'"));
issues += 1;
}
}
}
}
let sh = match shell_str {
Some(s) => shell::from_str(s).ok(),
None => shell::detect(),
};
let mut profile_issue = false;
if let Some(sh) = &sh {
match sh.profile_path() {
None => warn(&format!("Cannot determine profile path for {}", sh.name())),
Some(profile) => {
if profile.exists() {
let content = std::fs::read_to_string(&profile).unwrap_or_default();
if content.contains("# gvsn init") {
ok(&format!("Shell profile configured ({})", profile.display()));
} else {
fail(&format!(
"gvsn init missing from {} (run 'gvsn setup')",
profile.display()
));
issues += 1;
profile_issue = true;
}
} else {
fail(&format!(
"Profile not found: {} (run 'gvsn setup')",
profile.display()
));
issues += 1;
profile_issue = true;
}
}
}
}
let path_sep = if cfg!(windows) { ';' } else { ':' };
let path_var = std::env::var("PATH").unwrap_or_default();
let versions_dir = config.versions_dir();
let go_paths = find_go_binaries(&path_var, path_sep, go_name);
match classify_go_paths(&go_paths, &versions_dir, ¤t_dir) {
GoPathStatus::None => {
}
GoPathStatus::SingleManaged(go) => {
ok(&format!("Active Go is gvsn-managed ({})", go.display()));
}
GoPathStatus::SingleUnmanaged(go) => {
fail(&format!(
"Active Go is NOT managed by gvsn: {}",
go.display()
));
println!(
" Hint: remove the system Go package or run 'gvsn setup' \
so gvsn's PATH entry comes first."
);
issues += 1;
}
GoPathStatus::MultipleAllManaged(active) => {
ok(&format!("Active Go is gvsn-managed ({})", active.display()));
}
GoPathStatus::MultipleShadowedNonGvsn { shadowed, .. } => {
warn(&format!(
"{} Go binaries found in PATH - {} non-gvsn installation(s) are shadowed \
(consider removing them to avoid confusion):",
shadowed.len() + 1,
shadowed.len()
));
for path in &shadowed {
println!(" {} (non-gvsn, shadowed)", path.display());
}
}
GoPathStatus::MultipleActiveNotManaged { all } => {
fail(&format!(
"{} Go binaries in PATH - gvsn's version is being shadowed:",
all.len()
));
for (i, path) in all.iter().enumerate() {
let label = if i == 0 {
" (active - NOT gvsn-managed)"
} else if path.starts_with(&versions_dir) || path.starts_with(¤t_dir) {
" (gvsn-managed - shadowed)"
} else {
" (shadowed)"
};
println!(" {}{label}", path.display());
}
println!(
" Hint: remove the system Go package or run 'gvsn setup' \
to ensure gvsn's PATH entry is first."
);
issues += 1;
}
}
if fix {
let mut attempted_any = false;
let mut fixed_any = false;
println!();
if path_issue || profile_issue {
attempted_any = true;
match &sh {
Some(sh) => match setup::run(Some(sh.name()), false) {
Ok(()) => {
println!(" {} Ran 'gvsn setup' for {}.", "->".cyan(), sh.name());
if profile_issue {
println!(" Shell profile hook fixed.");
issues = issues.saturating_sub(1);
}
if path_issue {
println!(
" PATH updated - open a new shell session for it to take effect."
);
}
fixed_any = true;
}
Err(e) => {
println!(" {} Could not run 'gvsn setup': {e:#}", "x".red());
}
},
None => {
println!(
" {} Cannot auto-fix PATH/shell hook: no shell detected.",
"!".yellow()
);
}
}
}
if current_link_missing {
attempted_any = true;
match &installed_global {
Some(v) => match toolchain::set_active_version(config, v) {
Ok(()) => {
println!(" {} Recreated ~/.gvsn/current -> {}", "->".cyan(), v.tag());
issues = issues.saturating_sub(1);
fixed_any = true;
}
Err(e) => {
println!(" {} Could not recreate ~/.gvsn/current: {e:#}", "x".red());
}
},
None => {
println!(
" {} Cannot auto-fix ~/.gvsn/current: no installed global version set.",
"!".yellow()
);
}
}
}
if let Some(msg) = fix_outcome_message(attempted_any, fixed_any) {
println!(" {msg}");
}
}
println!();
if issues == 0 {
println!("{} Everything looks good!", "✓".green().bold());
} else {
println!("{} {} issue(s) found.", "!".yellow().bold(), issues);
std::process::exit(1);
}
Ok(())
}
fn read_local_pin(dir: &std::path::Path) -> Option<(String, &'static str)> {
let go_version_path = dir.join(crate::version::GO_VERSION_FILE);
if go_version_path.exists() {
return std::fs::read_to_string(&go_version_path)
.ok()
.map(|raw| (raw.trim().to_string(), ".go-version"));
}
let tool_versions_path = dir.join(crate::version::TOOL_VERSIONS_FILE);
std::fs::read_to_string(&tool_versions_path)
.ok()
.and_then(|content| {
crate::version::parse_tool_versions_golang_line(&content)
.map(|raw| (raw.to_string(), ".tool-versions"))
})
}
fn fix_outcome_message(attempted_any: bool, fixed_any: bool) -> Option<String> {
if !attempted_any {
Some(format!("{} Nothing to auto-fix.", "i".cyan()))
} else if !fixed_any {
Some(format!(
"{} Found fixable issue(s), but could not repair any of them - see above.",
"!".yellow()
))
} else {
None
}
}
fn ok(msg: &str) {
println!(" {} {msg}", "✓".green());
}
fn fail(msg: &str) {
println!(" {} {msg}", "x".red());
}
fn warn(msg: &str) {
println!(" {} {msg}", "!".yellow());
}
fn find_go_binaries(path_var: &str, path_sep: char, go_name: &str) -> Vec<std::path::PathBuf> {
path_var
.split(path_sep)
.map(std::path::Path::new)
.filter_map(|dir| {
let candidate = dir.join(go_name);
if candidate.is_file() {
Some(candidate)
} else {
None
}
})
.collect()
}
#[derive(Debug, PartialEq, Eq)]
enum GoPathStatus {
None,
SingleManaged(std::path::PathBuf),
SingleUnmanaged(std::path::PathBuf),
MultipleAllManaged(std::path::PathBuf),
MultipleShadowedNonGvsn {
active: std::path::PathBuf,
shadowed: Vec<std::path::PathBuf>,
},
MultipleActiveNotManaged { all: Vec<std::path::PathBuf> },
}
fn classify_go_paths(
go_paths: &[std::path::PathBuf],
versions_dir: &std::path::Path,
current_dir: &std::path::Path,
) -> GoPathStatus {
let is_managed =
|p: &std::path::Path| p.starts_with(versions_dir) || p.starts_with(current_dir);
match go_paths.len() {
0 => GoPathStatus::None,
1 => {
let go = go_paths[0].clone();
if is_managed(&go) {
GoPathStatus::SingleManaged(go)
} else {
GoPathStatus::SingleUnmanaged(go)
}
}
_ => {
if is_managed(&go_paths[0]) {
let shadowed: Vec<_> = go_paths
.iter()
.skip(1)
.filter(|p| !is_managed(p))
.cloned()
.collect();
if shadowed.is_empty() {
GoPathStatus::MultipleAllManaged(go_paths[0].clone())
} else {
GoPathStatus::MultipleShadowedNonGvsn {
active: go_paths[0].clone(),
shadowed,
}
}
} else {
GoPathStatus::MultipleActiveNotManaged {
all: go_paths.to_vec(),
}
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn make_go(dir: &std::path::Path, go_name: &str) -> std::path::PathBuf {
std::fs::create_dir_all(dir).unwrap();
let go = dir.join(go_name);
std::fs::write(&go, b"").unwrap();
go
}
#[test]
fn read_local_pin_returns_none_when_neither_file_present() {
let dir = tempdir().unwrap();
assert_eq!(read_local_pin(dir.path()), None);
}
#[test]
fn read_local_pin_reads_go_version_file() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join(".go-version"), "go1.22.4").unwrap();
assert_eq!(
read_local_pin(dir.path()),
Some(("go1.22.4".to_string(), ".go-version"))
);
}
#[test]
fn read_local_pin_falls_back_to_tool_versions_golang_line() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join(".tool-versions"), "golang 1.22.4\n").unwrap();
assert_eq!(
read_local_pin(dir.path()),
Some(("1.22.4".to_string(), ".tool-versions"))
);
}
#[test]
fn read_local_pin_prefers_go_version_over_tool_versions() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join(".go-version"), "go1.21.13").unwrap();
std::fs::write(dir.path().join(".tool-versions"), "golang 1.22.4\n").unwrap();
assert_eq!(
read_local_pin(dir.path()),
Some(("go1.21.13".to_string(), ".go-version"))
);
}
#[test]
fn read_local_pin_ignores_tool_versions_without_a_golang_line() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join(".tool-versions"), "nodejs 20.11.0\n").unwrap();
assert_eq!(read_local_pin(dir.path()), None);
}
#[test]
fn fix_outcome_message_is_none_when_a_fix_succeeded() {
assert_eq!(fix_outcome_message(true, true), None);
}
#[test]
fn fix_outcome_message_reports_nothing_to_fix_when_nothing_was_attempted() {
let msg = fix_outcome_message(false, false).unwrap();
assert!(msg.contains("Nothing to auto-fix"));
}
#[test]
fn fix_outcome_message_never_says_nothing_to_fix_when_an_attempt_was_made() {
let msg = fix_outcome_message(true, false).unwrap();
assert!(!msg.contains("Nothing to auto-fix"));
assert!(msg.contains("could not repair"));
}
#[test]
fn find_go_binaries_returns_empty_for_empty_path() {
assert!(find_go_binaries("", ':', "go").is_empty());
}
#[test]
fn find_go_binaries_finds_executables_in_order() {
let sep = '|';
let root = tempdir().unwrap();
let dir_a = root.path().join("a");
let dir_b = root.path().join("b");
let dir_c = root.path().join("c"); let go_a = make_go(&dir_a, "go");
let go_b = make_go(&dir_b, "go");
std::fs::create_dir_all(&dir_c).unwrap();
let path_var = format!(
"{}{sep}{}{sep}{}",
dir_a.display(),
dir_c.display(),
dir_b.display()
);
let found = find_go_binaries(&path_var, sep, "go");
assert_eq!(found, vec![go_a, go_b]);
}
#[test]
fn find_go_binaries_ignores_directories_named_like_the_binary() {
let root = tempdir().unwrap();
let dir = root.path().join("bin");
std::fs::create_dir_all(dir.join("go")).unwrap();
let found = find_go_binaries(&dir.display().to_string(), '|', "go");
assert!(found.is_empty());
}
#[test]
fn classify_empty_is_none() {
let versions_dir = std::path::Path::new("/gvsn/versions");
let current_dir = std::path::Path::new("/gvsn/current");
assert_eq!(
classify_go_paths(&[], versions_dir, current_dir),
GoPathStatus::None
);
}
#[test]
fn classify_single_managed_via_versions_dir() {
let versions_dir = std::path::Path::new("/gvsn/versions");
let current_dir = std::path::Path::new("/gvsn/current");
let go = versions_dir.join("go1.22.4/bin/go");
assert_eq!(
classify_go_paths(std::slice::from_ref(&go), versions_dir, current_dir),
GoPathStatus::SingleManaged(go)
);
}
#[test]
fn classify_single_managed_via_current_dir() {
let versions_dir = std::path::Path::new("/gvsn/versions");
let current_dir = std::path::Path::new("/gvsn/current");
let go = current_dir.join("bin/go");
assert_eq!(
classify_go_paths(std::slice::from_ref(&go), versions_dir, current_dir),
GoPathStatus::SingleManaged(go)
);
}
#[test]
fn classify_single_unmanaged() {
let versions_dir = std::path::Path::new("/gvsn/versions");
let current_dir = std::path::Path::new("/gvsn/current");
let go = std::path::PathBuf::from("/usr/local/go/bin/go");
assert_eq!(
classify_go_paths(std::slice::from_ref(&go), versions_dir, current_dir),
GoPathStatus::SingleUnmanaged(go)
);
}
#[test]
fn classify_multiple_all_managed_is_harmless() {
let versions_dir = std::path::Path::new("/gvsn/versions");
let current_dir = std::path::Path::new("/gvsn/current");
let go1 = current_dir.join("bin/go");
let go2 = versions_dir.join("go1.22.4/bin/go");
assert_eq!(
classify_go_paths(&[go1.clone(), go2], versions_dir, current_dir),
GoPathStatus::MultipleAllManaged(go1)
);
}
#[test]
fn classify_multiple_shadowed_non_gvsn() {
let versions_dir = std::path::Path::new("/gvsn/versions");
let current_dir = std::path::Path::new("/gvsn/current");
let active = current_dir.join("bin/go");
let shadowed = std::path::PathBuf::from("/usr/local/go/bin/go");
assert_eq!(
classify_go_paths(
&[active.clone(), shadowed.clone()],
versions_dir,
current_dir
),
GoPathStatus::MultipleShadowedNonGvsn {
active,
shadowed: vec![shadowed],
}
);
}
#[test]
fn classify_multiple_active_not_managed() {
let versions_dir = std::path::Path::new("/gvsn/versions");
let current_dir = std::path::Path::new("/gvsn/current");
let system_go = std::path::PathBuf::from("/usr/local/go/bin/go");
let managed_go = versions_dir.join("go1.22.4/bin/go");
let all = vec![system_go, managed_go];
assert_eq!(
classify_go_paths(&all, versions_dir, current_dir),
GoPathStatus::MultipleActiveNotManaged { all }
);
}
}