use anyhow::{Context, Result, bail}; use cache::relative_to_absolute; use clap::{Parser, Subcommand}; use std::collections::HashMap; // use std::env; use std::env; use std::fs::{File, create_dir_all, read_dir, remove_dir_all, set_permissions}; use std::io::{BufRead, BufReader}; use std::path::PathBuf; use std::process::Command; use std::time::Duration; mod cache; #[derive(Parser)] #[command( about = "Go Updater", long_about = "Go Updater This checks https://go.dev/dl for newer versions of go. It depends upon go being in the path, and optionally GOPATH being set. This can't update a package manager installed version of go (permissions)." )] struct Cli { /* /// Cache directory path #[arg(short, long, default_value = "cache")] cache: PathBuf, */ #[command(subcommand)] command: Option, } #[derive(Subcommand)] enum Commands { /// Update go Update {}, /// Display information Info {}, } /// Query `go env` /// /// This is a good example of parsing output line by line. #[must_use] fn get_go_env() -> Result> { let output = Command::new("go").arg("env").output()?; if output.status.success() { let mut result = HashMap::::new(); let output_str = std::str::from_utf8(output.stdout.as_slice())?; for mut line in output_str.split("\n") { line = line.trim(); if let Some(parts) = line.split_once("=") { let value = parts.1.trim_matches('\''); result.insert(parts.0.to_string(), value.to_string()); } } /* for mut line in String::from_utf8_lossy(output.stdout.as_slice()).split("\n") { } */ Ok(result) } else { bail!("Failed to query `go env`"); } } fn extract_tarball(tarball: &str, target: &str) -> Result<()> { println!("Extract {} to {}", tarball, target); let output = Command::new("tar") // Extract, gzipped, from file .arg("-xzf") .arg(tarball) // archive contains go directory. Strip that out. .arg("--strip-components=1") // Set target to extract to. .arg("-C") .arg(target) .output()?; if output.status.success() { return Ok(()); } bail!("Extract {} failed.", tarball); } /* /// Query `go version` #[must_use] #[allow(dead_code)] #[deprecated = "Use get_go_env"] fn find_go_version() -> Result { let output = Command::new("go").arg("version").output()?; if output.status.success() { // Ok! We have something! return Ok(String::from_utf8_lossy(output.stdout.as_slice()).to_string()); } bail!("Failed to query go version."); } */ /* /// Locate the go binary /// /// This is redundant, it should be located via GO_PATH. #[allow(dead_code)] #[must_use] fn find_go() -> Result { let output = Command::new("which").arg("go").output()?; if output.status.success() { return Ok(String::from_utf8_lossy(output.stdout.as_slice()).to_string()); } bail!("Failed to locate go binary."); } */ const GO_URL: &str = "https://go.dev/dl/"; // static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); // I've changed the parser, so scrapy isn't needed. // I'll revisit this when I get further with html tokeparser. /* CSS Selector elements of interest: (a href="" part). href are relative... div[class="downloadWrapper"] > a[class="download downloadBox"] table[class="downloadtable"] > tr[class=" "] > td[class="filename"] > a[class="download"] Or possibly, table[class="downloadtable"] > tr[class=" "] or tr[class="highlight "] ? and grab the section of td's. class=filename has a href, last has SHA256. go1.24.0.src.tar.gz go1.24.0.linux-amd64.tar.gz go1.24.0.linux-arm64.tar.gz go1.24.0.windows-amd64.zip go1.24.0.windows-amd64.msi */ // URL: https://go.dev/dl/go1.24.1.linux-amd64.tar.gz /// Get go version from download link. /// /// Given https://go.dev/dl/go1.24.1.linux-amd64.tar.gz /// return "1.24.1" version part. #[must_use] fn version_from_url(url: &str, arch: &str) -> Option { if let Some(parts) = url.split_once(arch) { if let Some(part) = parts.0.rsplit_once("/go") { let part = part.1.trim_matches('.'); return Some(part.to_string()); } } None } /// Return just the href="". #[must_use] fn just_href(link: &str) -> Result { let parts = link.split_once("href=\"").unwrap_or(("", "")).1; let href = parts.split_once("\"").unwrap_or(("", "")).0; if !href.is_empty() { return Ok(href.to_string()); } bail!("Unable to locate href"); } /// Find a href link for given os and arch (architecture) in a file. /// /// Look for a line with Result { // , Box> { let fp = File::open(GO_FILE)?; let reader = BufReader::new(fp); for line in reader.lines() { if let Ok(line) = line { if line.contains("a class=\"download\"") { if line.contains(arch) { // Return just the href part. return just_href(&line); } } } } bail!("Unable to locate architecture download link"); } */ /// Get value from given HashMap /// /// This gives us a String, not a &String, and not an Option. /// Returns empty String if key not found. fn hashget(hash: &HashMap, key: &str) -> String { if let Some(value) = hash.get(key) { return value.clone(); } String::new() } fn recursive_remove_readonly(path: &str) -> Result { let mut result: bool = false; for entry in read_dir(path)? { let fsentry = entry?; if let Ok(meta) = fsentry.metadata() { if meta.is_dir() { if let Ok(r) = recursive_remove_readonly(&fsentry.path().to_string_lossy().to_string()) { if r { result = true; } } } if meta.permissions().readonly() { // println!("Fixing: {}", fsentry.path().display()); let mut read = meta.permissions(); read.set_readonly(false); // Ok, this is marked readonly, change it. set_permissions(fsentry.path(), read)?; result = true; } } } Ok(result) } fn main() -> Result<()> { let cli = Cli::parse(); // Construct cache_dir from HOME/~ let home = env::var("HOME")?; let cache_dir = format!("{home}/.cache/go-up"); /* GOROOT, defaults to $HOME/go This seems to be managed by go now. So running ```go env``` would have/show GOROOT being set by that instance of go. GOPATH is another story. It's where go stores things it downloads from the web, and most certainly shouldn't be $HOME/go != GOROOT. I want to be a bit looser on requiring GOROOT later on. */ /* // Get go environment // The old way of doing this. Using `go env` now. let go_path = env::var("GOPATH").unwrap_or(String::new()); let go_root = env::var("GOROOT").unwrap_or(String::new()); */ let go_env = get_go_env().context("Calling `go env` failed.")?; let mut go_version = hashget(&go_env, "GOVERSION"); if go_version.starts_with("go") { go_version.remove(0); go_version.remove(0); } let go_arch = hashget(&go_env, "GOARCH"); let go_os = hashget(&go_env, "GOOS"); let go_path = hashget(&go_env, "GOPATH"); let go_root = hashget(&go_env, "GOROOT"); // Construct OS-ARCH value. let go_os_arch = format!("{go_os}-{go_arch}"); // Initialize the cache. let cache = cache::Cache::new(PathBuf::from(cache_dir), None)?; let ex = cache.expire(Duration::from_secs(7 * 60 * 60 * 24))?; if ex { println!("Expired files from cache."); } match &cli.command { Some(Commands::Update {}) => { let status = cache.fetch(GO_URL)?; // Check to see if file already exists AND check version against go's version. // Since the go.dev site doesn't allow caching or knowing when it changes, // we always end up in Status::Fetched arm. let filename = status.download_path(); // Make this function the same whether or not we have a cache hit. let fp = File::open(filename)?; let link = find_arch_link(&go_os_arch, &fp); if let Ok(relative) = link { // Download link for arch located. Make absolute URL. let latest_version = version_from_url(&relative, &go_os_arch); if let Some(latest) = latest_version { println!("Version: {} [have {}]", latest, go_version); if go_version != latest { println!("Downloading newer version..."); let abs = relative_to_absolute(GO_URL, &relative).unwrap(); let latest_status = cache.fetch(&abs)?; let update = latest_status.download_path(); println!("Clearing GOROOT {}", go_root); // GOROOT might contain GOPATH files // Fix permissions before attempting to remove. recursive_remove_readonly(&go_path)?; remove_dir_all(&go_root)?; create_dir_all(&go_root)?; if !go_path.is_empty() { if go_path != go_root { println!("Clearing GOPATH {}", go_path); // This fails with permissions error recursive_remove_readonly(&go_path)?; remove_dir_all(&go_path)?; create_dir_all(&go_path)?; } } println!("Extracting..."); extract_tarball(&update.to_string_lossy().to_string(), &go_root)?; // Extract go.tar.gz into go_root // Ok, we have the update! Now what? // Verify new GO version! let new_go_env = get_go_env().context("Calling `go env` with new go.")?; let mut new_go_version = hashget(&new_go_env, "GOVERSION"); if new_go_version.starts_with("go") { new_go_version.remove(0); new_go_version.remove(0); } println!("Updated {} to {}", go_version, new_go_version); /* Clear GOROOT. mkdir_all(GOROOT). If GOPATH exists IF GOPATH != GOROOT, clear it. mkdir_all(GOPATH) Untarball go into GOROOT. Check/Verify go version? */ // println!("Latest: {:?}", latest_status); } else { println!("You're already good to GO."); } } else { println!("Finding version failed: [{}]", relative); } } else { bail!("Unable to locate download link"); } /* match status { cache::Status::Fetched(filename) | cache::Status::Cached(filename) => { // Make this function the same whether or not we have a cache hit. let fp = File::open(filename)?; let link = find_arch_link(&go_os_arch, &fp); if let Ok(relative) = link { // Download link for arch located. Make absolute URL. let abs = relative_to_absolute(GO_URL, &relative).unwrap(); println!("URL: {}", abs); let latest_version = version_from_url(&abs, &go_os_arch); if let Some(latest) = latest_version { println!("Version: {} [have {}]", latest, go_version); if go_version != latest { println!("Downloading newer version..."); let latest_status = cache.fetch(&abs); println!("Latest: {:?}", latest_status); } else { println!("You're already good to GO."); } } else { println!("Finding version failed: [{}]", abs); } } else { bail!("Unable to locate download link"); } } _ => { println!("Status = {:?}", status); } } */ } Some(Commands::Info {}) => { println!("GOPATH {}", go_path); println!("GOROOT {}", go_root); println!("go ver {}", go_version); println!("go_arch {}", go_arch); println!("go os {}", go_os); println!("go os arch: {}", go_os_arch); } None => { // Display help. let _show_help: Cli = Cli::parse_from(["", "--help"]); } } Ok(()) }