use std::{collections::BTreeMap, env::consts::EXE_SUFFIX, fs};
use crate::{
error::Error,
net::github::GithubAPI,
project::manifest::{
Tool,
edit::{ManifestDoc, Scope},
},
sys::paths,
tools, ui,
};
use clap::Subcommand;
use semver::Version;
#[derive(Subcommand, Debug)]
pub enum ToolCommand {
Add {
name: String,
#[arg(short, long)]
version: Option<String>,
#[arg(short, long, alias = "g")]
global: bool,
},
Remove {
name: String,
#[arg(short, long, alias = "g")]
global: bool,
},
Update,
List,
Delete {
name: String,
#[arg(short, long)]
version: Option<String>,
},
}
pub fn run(command: ToolCommand) -> Result<(), Error> {
match command {
ToolCommand::Add {
name,
version,
global,
} => add(name, version, global),
ToolCommand::Remove { name, global } => remove(name, global),
ToolCommand::Update => update(),
ToolCommand::Delete { name, version } => delete(name, version),
ToolCommand::List => list(),
}
}
fn add(name: String, version: Option<String>, global: bool) -> Result<(), Error> {
let github = GithubAPI::new();
let name = tools::expand_shorthand(&name);
let (owner, repo) = Tool::split_repository(&name)?;
let release = match &version {
Some(version) => github.get_release(&name, version.trim_start_matches('v'))?,
None => github.get_latest_release(&name)?,
};
let mut manifest = ManifestDoc::open_or_create(Scope::from_global(global))?;
let version = release.tag_name.trim_start_matches('v');
manifest.table_or_create("tools")?[repo] =
toml_edit::value(format!("{owner}/{repo}@{version}"));
manifest.save()?;
if global {
ui::print_success(&format!("Added global tool {name}@{version}"));
} else {
ui::print_success(&format!("Added tool {name}@{version} to lpm.toml"));
}
println!("Run `lpm install` to install it");
Ok(())
}
fn remove(name: String, global: bool) -> Result<(), Error> {
let scope = Scope::from_global(global);
let mut manifest = match ManifestDoc::open(scope) {
Err(Error::ManifestMissing) if global => return Err(Error::ToolMissing(name)),
opened => opened?,
};
let table = manifest.require_table("tools")?;
let mut removed = table.remove(&name).is_some();
if !removed {
let repository = tools::shorthand_repository(&name).unwrap_or(&name);
let prefix = format!("{repository}@");
let key = table.iter().find_map(|(key, item)| {
item.as_str()
.and_then(|spec| spec.trim().get(..prefix.len()))
.is_some_and(|head| head.eq_ignore_ascii_case(&prefix))
.then(|| key.to_string())
});
if let Some(key) = key {
removed = table.remove(&key).is_some();
}
}
if !removed {
return Err(Error::ToolMissing(name));
}
manifest.drop_if_empty("tools");
manifest.save()?;
ui::print_success(&format!(
"Successfully removed tool {name} from {}",
scope.label()
));
Ok(())
}
fn is_outdated(current: &str, latest: &str) -> bool {
match (Version::parse(current), Version::parse(latest)) {
(Ok(current), Ok(latest)) => latest > current,
_ => current != latest,
}
}
fn update() -> Result<(), Error> {
let github = GithubAPI::new();
let mut manifest = ManifestDoc::open(Scope::Project)?;
let Some(table) = manifest.table("tools")? else {
println!("No tools to update");
return Ok(());
};
let entries = table
.iter()
.map(|(alias, item)| {
item.as_str()
.map(|spec| (alias.to_string(), spec.to_string()))
.ok_or_else(|| {
Error::ManifestInvalid(format!("[tools] entry '{alias}' is not a string"))
})
})
.collect::<Result<Vec<_>, Error>>()?;
if entries.is_empty() {
println!("No tools to update");
return Ok(());
}
let mut updated = 0;
for (alias, spec) in entries {
let tool = Tool::parse(&spec)?;
let release = github.get_latest_release(&tool.repository)?;
let latest = release.tag_name.trim_start_matches('v');
if !is_outdated(&tool.version, latest) {
continue;
}
table[alias.as_str()] = toml_edit::value(format!("{}@{latest}", tool.repository));
println!("{}: {} -> {latest}", tool.repository, tool.version);
updated += 1;
}
if updated == 0 {
println!("All tools are up to date");
return Ok(());
}
manifest.save()?;
ui::print_success(&format!(
"Updated {updated} tool{} in lpm.toml",
if updated == 1 { "" } else { "s" }
));
println!("Run `lpm install` to install the updated tools");
Ok(())
}
fn list() -> Result<(), Error> {
#[derive(Default)]
struct VersionState {
installed: bool,
project: bool,
global: bool,
}
type Seen = BTreeMap<String, (String, BTreeMap<String, VersionState>)>;
fn state_of<'a>(seen: &'a mut Seen, name: &str, version: String) -> &'a mut VersionState {
let entry = seen
.entry(name.to_ascii_lowercase())
.or_insert_with(|| (name.to_string(), BTreeMap::new()));
entry.1.entry(version).or_default()
}
let mut seen: Seen = BTreeMap::new();
let tools_dir = paths::tools_dir()?;
if tools_dir.is_dir() {
for entry in fs::read_dir(&tools_dir)? {
let entry = entry?;
if !entry.file_type()?.is_dir() {
continue;
}
let dir_name = entry.file_name().to_string_lossy().into_owned();
let Some((owner, repo)) = dir_name.split_once('_') else {
continue;
};
let name = format!("{owner}/{repo}");
for version in fs::read_dir(entry.path())? {
let version = version?;
if version.path().is_dir() {
let version = version.file_name().to_string_lossy().into_owned();
state_of(&mut seen, &name, version).installed = true;
}
}
}
}
let project = tools::shim::project_tools(&std::env::current_dir()?)?;
let global = tools::shim::global_tools()?;
for (tools, is_global) in [(&project, false), (&global, true)] {
for tool in tools.values() {
let state = state_of(&mut seen, &tool.repository, tool.version.clone());
if is_global {
state.global = true;
} else {
state.project = true;
}
}
}
if seen.is_empty() {
println!("No tools installed or pinned");
return Ok(());
}
for (name, versions) in seen.into_values() {
let mut versions: Vec<_> = versions.into_iter().collect();
versions.sort_by(
|(a, _), (b, _)| match (Version::parse(a), Version::parse(b)) {
(Ok(a), Ok(b)) => a.cmp(&b),
_ => a.cmp(b),
},
);
let versions = versions
.into_iter()
.map(|(version, state)| {
let mut notes = Vec::new();
if state.project {
notes.push("project");
}
if state.global {
notes.push("global");
}
if !state.installed {
notes.push("not installed");
}
if notes.is_empty() {
version
} else {
format!("{version} ({})", notes.join(", "))
}
})
.collect::<Vec<_>>()
.join(", ");
ui::print_success(&format!("{name}@{versions}"));
}
Ok(())
}
fn delete(name: String, version: Option<String>) -> Result<(), Error> {
let name = tools::expand_shorthand(&name);
let (owner, repo) = Tool::split_repository(&name)?;
let tool_dir = paths::tools_dir()?.join(format!("{owner}_{repo}"));
let version = version
.as_deref()
.map(|version| version.trim_start_matches('v'));
let target = match version {
Some(version) => tool_dir.join(version),
None => tool_dir.clone(),
};
if !target.exists() {
return Err(Error::ToolMissing(name.to_string()));
}
fs::remove_dir_all(&target)?;
let tool_gone = version.is_none()
|| fs::read_dir(&tool_dir)
.map(|mut dir| dir.next().is_none())
.unwrap_or(true);
if tool_gone {
let _ = fs::remove_dir(&tool_dir);
if let Ok(bin_dir) = paths::bin_dir() {
let _ = fs::remove_file(bin_dir.join(format!("{repo}{EXE_SUFFIX}")));
}
}
let message = match version {
Some(version) => format!("Deleted tool {name}@{version}"),
None => format!("Deleted tool {name}"),
};
ui::print_success(&message);
Ok(())
}