use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use crate::versions::{Version, VersionReq};
use crate::{config, store, tools};
const LANGUAGE: &str = super::LANGUAGE;
fn parse_spec(spec: &str) -> Result<(String, Option<VersionReq>)> {
match spec.split_once('@') {
Some((name, req)) => {
let req: VersionReq = req
.parse()
.with_context(|| format!("invalid version requirement in '{spec}'"))?;
Ok((name.to_string(), Some(req)))
}
None => Ok((spec.to_string(), None)),
}
}
fn pip_spec(name: &str, req: &Option<VersionReq>) -> String {
match req {
None => name.to_string(),
Some(VersionReq::Major(m)) => format!("{name}=={m}.*"),
Some(VersionReq::MajorMinor(m, n)) => format!("{name}=={m}.{n}.*"),
Some(VersionReq::Exact(v)) => format!("{name}=={v}"),
}
}
fn venv_bin(venv: &Path) -> PathBuf {
venv.join(if cfg!(windows) { "Scripts" } else { "bin" })
}
fn dir_files(dir: &Path) -> HashSet<String> {
std::fs::read_dir(dir)
.into_iter()
.flatten()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file())
.filter_map(|e| e.file_name().into_string().ok())
.collect()
}
fn run_checked(cmd: &mut Command, what: &str) -> Result<()> {
let status = cmd
.status()
.with_context(|| format!("failed to run {what}"))?;
if !status.success() {
bail!("{what} failed");
}
Ok(())
}
fn tool_python() -> Result<PathBuf> {
let version = store::installed_versions(LANGUAGE)?
.last()
.copied()
.context("no python toolchains installed (run `linguo python install` first)")?;
Ok(super::dist::bin_dir(&super::toolchain_path(&version)?).join(super::dist::python_exe()))
}
fn install_tool(name: &str, req: &Option<VersionReq>) -> Result<Version> {
let python = tool_python()?;
let base = tools::tool_dir(LANGUAGE, name)?;
std::fs::create_dir_all(&base)
.with_context(|| format!("failed to create {}", base.display()))?;
let staging = base.join(".staging");
if staging.exists() {
std::fs::remove_dir_all(&staging)?;
}
run_checked(
Command::new(&python).args(["-m", "venv"]).arg(&staging),
"python -m venv",
)?;
let bin = venv_bin(&staging);
let before = dir_files(&bin);
let staged_python = bin.join(super::dist::python_exe());
run_checked(
Command::new(&staged_python)
.args(["-m", "pip", "install", "--disable-pip-version-check"])
.arg(pip_spec(name, req)),
"pip install",
)?;
let resolved = installed_version(&staged_python, name)?;
let dest = tools::version_dir(LANGUAGE, name, &resolved)?;
if dest.exists() {
std::fs::remove_dir_all(&staging)?;
eprintln!("python tool {name} {resolved} is already installed");
return Ok(resolved);
}
std::fs::rename(&staging, &dest)
.with_context(|| format!("failed to move tool venv into {}", dest.display()))?;
expose_scripts(
&venv_bin(&dest),
&before,
&tools::exposed_dir(LANGUAGE, name, &resolved)?,
)?;
eprintln!("installed python tool {name} {resolved}");
Ok(resolved)
}
fn installed_version(python: &Path, name: &str) -> Result<Version> {
let output = Command::new(python)
.args(["-m", "pip", "show", name])
.output()
.context("failed to run pip show")?;
if !output.status.success() {
bail!("pip show {name} failed");
}
let text = String::from_utf8_lossy(&output.stdout);
let raw = text
.lines()
.find_map(|l| l.strip_prefix("Version:"))
.map(str::trim)
.with_context(|| format!("could not determine installed version of {name}"))?;
raw.parse()
.with_context(|| format!("unexpected version '{raw}' for {name}"))
}
fn expose_scripts(bin: &Path, before: &HashSet<String>, exposed: &Path) -> Result<()> {
std::fs::create_dir_all(exposed)
.with_context(|| format!("failed to create {}", exposed.display()))?;
let mut count = 0;
for entry in std::fs::read_dir(bin)?.filter_map(|e| e.ok()) {
if !entry.path().is_file() {
continue;
}
let name = entry.file_name();
let Some(name_str) = name.to_str() else {
continue;
};
if before.contains(name_str) {
continue;
}
std::fs::copy(entry.path(), exposed.join(&name))
.with_context(|| format!("failed to expose {name_str}"))?;
count += 1;
}
if count == 0 {
bail!("the package exposed no executables");
}
Ok(())
}
pub fn install(spec: &str, project: bool) -> Result<()> {
let (name, req) = parse_spec(spec)?;
let resolved = install_tool(&name, &req)?;
let pin_raw = match &req {
Some(req) => req.to_string(),
None => format!("{}.{}", resolved.major, resolved.minor),
};
let path = if project {
std::env::current_dir()?.join(config::PIN_FILE)
} else {
config::linguo_root()?.join(config::GLOBAL_CONFIG)
};
config::write_tool_pin(&path, LANGUAGE, &name, &pin_raw)?;
println!(
"pinned python tool {name} to {pin_raw} in {}",
path.display()
);
Ok(())
}
pub fn upgrade(name: Option<String>, latest: bool) -> Result<()> {
let cwd = std::env::current_dir()?;
let pins = config::tool_pins(LANGUAGE, &cwd)?;
let targets: Vec<config::ToolPin> = match &name {
Some(name) => vec![
pins.into_iter()
.find(|p| &p.tool == name)
.with_context(|| format!("python tool '{name}' is not pinned"))?,
],
None => pins,
};
if targets.is_empty() {
println!("no python tools pinned here (run `linguo python tool install <name>`)");
return Ok(());
}
for pin in targets {
let req: Option<VersionReq> = if latest {
None
} else {
Some(pin.raw.parse().with_context(|| {
format!("invalid version '{}' pinned for {}", pin.raw, pin.tool)
})?)
};
let resolved = install_tool(&pin.tool, &req)?;
if latest {
let pin_raw = format!("{}.{}", resolved.major, resolved.minor);
if pin_raw != pin.raw {
let path = pin_source_path(&pin)?;
config::write_tool_pin(&path, LANGUAGE, &pin.tool, &pin_raw)?;
println!("bumped {} pin {} -> {pin_raw}", pin.tool, pin.raw);
}
}
}
Ok(())
}
fn pin_source_path(pin: &config::ToolPin) -> Result<PathBuf> {
match &pin.source {
config::PinSource::Project(path) => Ok(path.clone()),
config::PinSource::Global => Ok(config::linguo_root()?.join(config::GLOBAL_CONFIG)),
}
}
pub fn sync_in(dir: &Path) -> Result<()> {
let mut installed = 0;
for pin in config::tool_pins(LANGUAGE, dir)? {
let req: VersionReq = pin
.raw
.parse()
.with_context(|| format!("invalid version '{}' pinned for {}", pin.raw, pin.tool))?;
if tools::find_installed(LANGUAGE, &pin.tool, &req)?.is_none() {
install_tool(&pin.tool, &Some(req))?;
installed += 1;
}
}
if installed > 0 {
println!("installed {installed} declared tool(s)");
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_specs() {
let (name, req) = parse_spec("ruff").unwrap();
assert_eq!(name, "ruff");
assert!(req.is_none());
let (name, req) = parse_spec("ruff@0.6").unwrap();
assert_eq!(name, "ruff");
assert_eq!(req.unwrap().to_string(), "0.6");
}
#[test]
fn pip_specs_translate_reqs() {
assert_eq!(pip_spec("ruff", &None), "ruff");
assert_eq!(
pip_spec("ruff", &Some("0.6".parse().unwrap())),
"ruff==0.6.*"
);
assert_eq!(
pip_spec("ruff", &Some("0.6.9".parse().unwrap())),
"ruff==0.6.9"
);
assert_eq!(
pip_spec("black", &Some("25".parse().unwrap())),
"black==25.*"
);
}
}