use std::collections::HashMap;
use std::process::Command;
use super::{InstallSource, LockError, LockFile, LockMeta, LockedTool};
use crate::config::Tool;
use crate::tools::common::{Os, has};
use crate::tools::spec::get_tool_spec;
pub fn generate_lock(tools: &HashMap<String, Tool>) -> Result<LockFile, LockError> {
let mut lock = LockFile {
version: super::LOCK_VERSION.to_string(),
meta: LockMeta::default(),
tools: HashMap::new(),
platforms: HashMap::new(),
};
for name in tools.keys() {
if let Some(locked) = lock_tool(name) {
lock.tools.insert(name.clone(), locked);
}
}
Ok(lock)
}
fn lock_tool(name: &str) -> Option<LockedTool> {
if !has(name) {
return None;
}
let version = get_installed_version(name)?;
let source = detect_install_source(name);
let binary_path = get_binary_path(name);
let checksum = binary_path.as_ref().and_then(|p| compute_checksum(p).ok());
Some(LockedTool {
version,
source,
checksum,
binary_path,
})
}
pub fn get_installed_version(name: &str) -> Option<String> {
let version_args = ["--version", "-v", "version", "-V"];
for arg in version_args {
if let Some(version) = try_get_version(name, arg) {
return Some(version);
}
}
None
}
fn try_get_version(cmd: &str, arg: &str) -> Option<String> {
let output = Command::new(cmd).arg(arg).output().ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
extract_version(&stdout)
}
fn extract_version(output: &str) -> Option<String> {
let patterns = [
r"(\d+\.\d+\.\d+(?:-[a-zA-Z0-9.]+)?)", r"(\d+\.\d+)", r"v(\d+\.\d+\.\d+)", ];
for pattern in patterns {
if let Ok(re) = regex::Regex::new(pattern) {
if let Some(caps) = re.captures(output) {
if let Some(m) = caps.get(1) {
return Some(m.as_str().to_string());
}
}
}
}
None
}
fn detect_install_source(name: &str) -> InstallSource {
let os = crate::tools::current_os();
match os {
Os::Macos => detect_macos_source(name),
Os::Linux => detect_linux_source(name),
Os::Windows => detect_windows_source(name),
Os::Bsd => detect_bsd_source(name),
}
}
fn detect_macos_source(name: &str) -> InstallSource {
if let Some(path) = get_binary_path(name) {
if path.contains("/opt/homebrew/") || path.contains("/usr/local/Cellar/") {
if is_brew_cask(name) {
return InstallSource::BrewCask;
}
return InstallSource::Brew;
}
}
if let Some(spec) = get_tool_spec(name) {
if spec.custom_install.is_some() {
return InstallSource::Custom(name.to_string());
}
}
InstallSource::Unknown
}
fn is_brew_cask(name: &str) -> bool {
if let Some(spec) = get_tool_spec(name) {
if let Some(macos) = &spec.macos {
return macos.cask.is_some();
}
}
false
}
fn detect_linux_source(name: &str) -> InstallSource {
if command_succeeds("dpkg", &["-s", name]) {
return InstallSource::Apt;
}
if command_succeeds("rpm", &["-q", name]) {
return InstallSource::Dnf;
}
if command_succeeds("pacman", &["-Q", name]) {
return InstallSource::Pacman;
}
if command_succeeds("apk", &["info", "-e", name]) {
return InstallSource::Apk;
}
if let Some(spec) = get_tool_spec(name) {
if spec.custom_install.is_some() {
return InstallSource::Custom(name.to_string());
}
}
InstallSource::Unknown
}
fn detect_windows_source(name: &str) -> InstallSource {
if command_succeeds("winget", &["list", "--id", name]) {
return InstallSource::Winget;
}
if command_succeeds("choco", &["list", "--local-only", name]) {
return InstallSource::Choco;
}
if let Some(spec) = get_tool_spec(name) {
if spec.custom_install.is_some() {
return InstallSource::Custom(name.to_string());
}
}
InstallSource::Unknown
}
fn detect_bsd_source(name: &str) -> InstallSource {
if command_succeeds("pkg", &["info", name]) {
return InstallSource::Pkg;
}
if let Some(spec) = get_tool_spec(name) {
if spec.custom_install.is_some() {
return InstallSource::Custom(name.to_string());
}
}
InstallSource::Unknown
}
fn command_succeeds(cmd: &str, args: &[&str]) -> bool {
Command::new(cmd)
.args(args)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn get_binary_path(name: &str) -> Option<String> {
#[cfg(unix)]
{
let output = Command::new("which").arg(name).output().ok()?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if !path.is_empty() {
return Some(path);
}
}
}
#[cfg(windows)]
{
let output = Command::new("where").arg(name).output().ok()?;
if output.status.success() {
let path = String::from_utf8_lossy(&output.stdout)
.lines()
.next()
.map(|s| s.trim().to_string());
return path;
}
}
None
}
pub(crate) fn is_legacy_checksum(checksum: &str) -> bool {
checksum.len() == 16 && checksum.chars().all(|c| c.is_ascii_hexdigit())
}
#[allow(dead_code)] pub(crate) fn is_sha256_checksum(checksum: &str) -> bool {
checksum.len() == 64 && checksum.chars().all(|c| c.is_ascii_hexdigit())
}
fn compute_checksum(path: &str) -> Result<String, LockError> {
use sha2::{Digest, Sha256};
use std::fs::File;
use std::io::Read;
let mut file = File::open(path).map_err(|e| LockError::IoError {
path: path.to_string(),
error: e.to_string(),
})?;
let mut hasher = Sha256::new();
let mut buffer = [0u8; 8192];
loop {
let n = file.read(&mut buffer).map_err(|e| LockError::IoError {
path: path.to_string(),
error: e.to_string(),
})?;
if n == 0 {
break;
}
hasher.update(&buffer[..n]);
}
Ok(hex::encode(hasher.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_version_semver() {
assert_eq!(
extract_version("git version 2.45.0"),
Some("2.45.0".to_string())
);
}
#[test]
fn test_extract_version_with_v_prefix() {
assert_eq!(
extract_version("node v20.10.0"),
Some("20.10.0".to_string())
);
}
#[test]
fn test_extract_version_two_parts() {
assert_eq!(extract_version("python 3.12"), Some("3.12".to_string()));
}
#[test]
fn test_extract_version_with_suffix() {
assert_eq!(
extract_version("rustc 1.75.0-beta.1"),
Some("1.75.0-beta.1".to_string())
);
}
#[test]
fn test_extract_version_no_match() {
assert_eq!(extract_version("no version here"), None);
}
#[test]
fn compute_checksum_known_vector_empty_file() {
let tmp = tempfile::NamedTempFile::new().expect("create tempfile");
let path = tmp.path().to_str().unwrap();
let hash = compute_checksum(path).expect("hash empty file");
assert_eq!(
hash,
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert!(is_sha256_checksum(&hash));
}
#[test]
fn compute_checksum_known_vector_hello_world() {
let mut tmp = tempfile::NamedTempFile::new().expect("create tempfile");
std::io::Write::write_all(&mut tmp, b"hello world").expect("write");
let path = tmp.path().to_str().unwrap();
let hash = compute_checksum(path).expect("hash hello world");
assert_eq!(
hash,
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
);
}
#[test]
fn compute_checksum_streams_large_file_consistently() {
let mut tmp = tempfile::NamedTempFile::new().expect("create tempfile");
let chunk = vec![0xABu8; 8192];
for _ in 0..3 {
std::io::Write::write_all(&mut tmp, &chunk).expect("write");
}
std::io::Write::write_all(&mut tmp, b"trailing").expect("write trailing");
let path = tmp.path().to_str().unwrap();
let hash_a = compute_checksum(path).unwrap();
let hash_b = compute_checksum(path).unwrap();
assert_eq!(hash_a, hash_b, "checksum must be deterministic");
assert_eq!(hash_a.len(), 64);
}
#[test]
fn legacy_checksum_detection() {
assert!(is_legacy_checksum("0123456789abcdef"));
assert!(!is_legacy_checksum(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
));
assert!(!is_legacy_checksum(""));
assert!(!is_legacy_checksum("nothex0123456789"));
}
#[test]
fn sha256_checksum_detection() {
assert!(is_sha256_checksum(
"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
));
assert!(!is_sha256_checksum("0123456789abcdef"));
assert!(!is_sha256_checksum(&"x".repeat(64)));
}
}