use std::{
collections::{HashMap, HashSet},
path::{Path, PathBuf},
};
use anyhow::Context as _;
use clap::{Parser, Subcommand};
use mlua_pkg::{
fetcher::{Fetcher, GitFetcher},
lockfile::{LockedPkg, Lockfile},
manifest::{Dep, Manifest, Package},
resolve_entry, PkgError,
};
#[derive(Parser)]
#[command(
name = "mlua-pkg",
about = "Lua package manager for mlua",
version,
author
)]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Install,
Add {
name: String,
git: String,
#[arg(long)]
tag: Option<String>,
#[arg(long)]
rev: Option<String>,
#[arg(long)]
branch: Option<String>,
#[arg(long)]
entry: Option<PathBuf>,
},
Update {
name: Option<String>,
},
Clean {
#[arg(long)]
all: bool,
},
}
fn strip_cargo_subcommand<I>(args: I) -> Vec<String>
where
I: IntoIterator<Item = String>,
{
let mut args: Vec<String> = args.into_iter().collect();
if args.get(1).map(String::as_str) == Some("mlua-pkg") {
args.remove(1);
}
args
}
fn main() -> anyhow::Result<()> {
let cli = Cli::parse_from(strip_cargo_subcommand(std::env::args()));
match cli.cmd {
Cmd::Install => run_install(
Path::new("mlua-pkg.toml"),
Path::new(".mlua-pkgs/cache"),
Path::new(".mlua-pkgs/vendored"),
Path::new("mlua-pkg.lock"),
),
Cmd::Add {
name,
git,
tag,
rev,
branch,
entry,
} => run_add(
Path::new("mlua-pkg.toml"),
name,
git,
tag,
rev,
branch,
entry,
),
Cmd::Update { name } => run_update(
name,
Path::new("mlua-pkg.toml"),
Path::new(".mlua-pkgs/cache"),
Path::new(".mlua-pkgs/vendored"),
Path::new("mlua-pkg.lock"),
),
Cmd::Clean { all } => run_clean(
all,
Path::new(".mlua-pkgs/cache"),
Path::new("mlua-pkg.lock"),
),
}
}
fn run_install(
manifest_path: &Path,
cache_dir: &Path,
vendored_dir: &Path,
lock_path: &Path,
) -> anyhow::Result<()> {
let manifest = Manifest::from_path(manifest_path)
.with_context(|| format!("reading {}", manifest_path.display()))?;
let fetcher = GitFetcher::new(cache_dir.to_path_buf());
std::fs::create_dir_all(vendored_dir)?;
let mut locked_pkgs: Vec<LockedPkg> = Vec::with_capacity(manifest.deps.len());
let mut seen_names: HashSet<String> = HashSet::new();
for (name, dep) in &manifest.deps {
if !seen_names.insert(name.clone()) {
return Err(PkgError::SameNameConflict { name: name.clone() }.into());
}
let fetched = fetcher
.fetch(dep)
.with_context(|| format!("fetching '{name}'"))?;
if let Some(author) = &fetched.manifest {
if let Some(req_tag) = &dep.tag {
let av = &author.package.version;
let normalized = req_tag.strip_prefix('v').unwrap_or(req_tag.as_str());
if av != req_tag && av != normalized {
eprintln!(
"warning: {name}: requested tag '{req_tag}' vs \
author manifest version '{av}'"
);
}
}
}
let author_entry: Option<PathBuf> = fetched
.manifest
.as_ref()
.and_then(|m| m.package.entry.clone());
let override_entry: Option<&Path> = dep.entry.as_deref().or(author_entry.as_deref());
let entry_abs = resolve_entry(&fetched.cache_path, override_entry)
.with_context(|| format!("resolving entry for '{name}'"))?;
let symlink_path = vendored_dir.join(name);
if symlink_path.symlink_metadata().is_ok() {
remove_symlink(&symlink_path)?;
}
let rel_target = relative_path(vendored_dir, &entry_abs)?;
create_symlink(&rel_target, &symlink_path)?;
let entry = entry_rel_to_pkg(&fetched.cache_path, &entry_abs);
locked_pkgs.push(LockedPkg {
name: name.clone(),
source: format!("git+{}", dep.git),
tag: dep.tag.clone(),
rev: dep.rev.clone(),
branch: dep.branch.clone(),
sha: fetched.sha,
entry,
});
}
let lockfile = Lockfile {
version: 1,
pkg: locked_pkgs,
};
lockfile.write(lock_path)?;
println!("installed {} package(s)", manifest.deps.len());
Ok(())
}
fn entry_rel_to_pkg(cache_path: &Path, entry_abs: &Path) -> PathBuf {
match entry_abs.strip_prefix(cache_path) {
Ok(rel) if rel.as_os_str().is_empty() => PathBuf::from("."),
Ok(rel) => rel.to_path_buf(),
Err(_) => PathBuf::from("."),
}
}
fn run_add(
manifest_path: &Path,
name: String,
git: String,
tag: Option<String>,
rev: Option<String>,
branch: Option<String>,
entry: Option<PathBuf>,
) -> anyhow::Result<()> {
let ref_count = [tag.is_some(), rev.is_some(), branch.is_some()]
.into_iter()
.filter(|&b| b)
.count();
if ref_count > 1 {
return Err(anyhow::anyhow!(
"at most one of --tag, --rev, --branch may be specified"
));
}
let mut manifest = if manifest_path.exists() {
Manifest::from_path(manifest_path)
.with_context(|| format!("reading {}", manifest_path.display()))?
} else {
let pkg_name = std::env::current_dir()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().into_owned()))
.unwrap_or_else(|| "my-project".to_string());
Manifest {
package: Package {
name: pkg_name,
version: "0.1.0".to_string(),
entry: None,
},
deps: HashMap::new(),
}
};
let dep = Dep {
git,
tag,
rev,
branch,
entry,
};
let existed = manifest.deps.insert(name.clone(), dep).is_some();
let toml_str = toml::to_string(&manifest)?;
std::fs::write(manifest_path, toml_str)?;
if existed {
println!(
"updated '{}' in {}; run 'mlua-pkg install' to fetch",
name,
manifest_path.display()
);
} else {
println!(
"added '{}' to {}; run 'mlua-pkg install' to fetch",
name,
manifest_path.display()
);
}
Ok(())
}
fn run_update(
name: Option<String>,
manifest_path: &Path,
cache_dir: &Path,
vendored_dir: &Path,
lock_path: &Path,
) -> anyhow::Result<()> {
if let Some(ref n) = name {
let manifest = Manifest::from_path(manifest_path)
.with_context(|| format!("reading {}", manifest_path.display()))?;
if !manifest.deps.contains_key(n) {
return Err(anyhow::anyhow!(
"unknown package '{}' in {}",
n,
manifest_path.display()
));
}
}
run_install(manifest_path, cache_dir, vendored_dir, lock_path)
}
fn run_clean(all: bool, cache_dir: &Path, lock_path: &Path) -> anyhow::Result<()> {
if all {
if cache_dir.exists() {
std::fs::remove_dir_all(cache_dir)?;
println!("removed all cached packages");
} else {
println!("nothing to clean");
}
return Ok(());
}
let lockfile = match Lockfile::read(lock_path) {
Ok(lf) => lf,
Err(PkgError::MissingLockfile { .. }) => {
println!("no lockfile found; nothing to clean");
return Ok(());
}
Err(e) => return Err(e.into()),
};
let in_use: HashSet<String> = lockfile.pkg.iter().map(|p| p.sha.clone()).collect();
let git_dir = cache_dir.join("git");
if !git_dir.exists() {
println!("nothing to clean");
return Ok(());
}
let mut removed: usize = 0;
remove_stale_sha_dirs(&git_dir, &in_use, &mut removed)?;
println!(
"removed {removed} stale cache entr{}",
if removed == 1 { "y" } else { "ies" }
);
Ok(())
}
fn remove_stale_sha_dirs(
dir: &Path,
in_use: &HashSet<String>,
removed: &mut usize,
) -> std::io::Result<()> {
for entry in std::fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let file_name = entry.file_name();
let name = file_name.to_string_lossy();
if name.len() == 40 && name.chars().all(|c| c.is_ascii_hexdigit()) {
if !in_use.contains(name.as_ref()) {
std::fs::remove_dir_all(&path)?;
*removed += 1;
}
} else {
remove_stale_sha_dirs(&path, in_use, removed)?;
}
}
Ok(())
}
#[cfg(unix)]
fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::unix::fs::symlink(target, link)
}
#[cfg(windows)]
fn create_symlink(target: &Path, link: &Path) -> std::io::Result<()> {
std::os::windows::fs::symlink_dir(target, link)
}
#[cfg(not(any(unix, windows)))]
fn create_symlink(_target: &Path, _link: &Path) -> std::io::Result<()> {
Err(std::io::Error::new(
std::io::ErrorKind::Unsupported,
"symlinks not supported on this platform",
))
}
#[cfg(unix)]
fn remove_symlink(path: &Path) -> std::io::Result<()> {
std::fs::remove_file(path)
}
#[cfg(windows)]
fn remove_symlink(path: &Path) -> std::io::Result<()> {
if path.is_dir() {
std::fs::remove_dir(path)
} else {
std::fs::remove_file(path)
}
}
#[cfg(not(any(unix, windows)))]
fn remove_symlink(path: &Path) -> std::io::Result<()> {
std::fs::remove_file(path)
}
fn relative_path(from_dir: &Path, to: &Path) -> std::io::Result<PathBuf> {
let from_abs = std::fs::canonicalize(from_dir)?;
let to_abs = std::fs::canonicalize(to)?;
let from_parts: Vec<_> = from_abs.components().collect();
let to_parts: Vec<_> = to_abs.components().collect();
let common = from_parts
.iter()
.zip(to_parts.iter())
.take_while(|(a, b)| a == b)
.count();
let mut rel = PathBuf::new();
for _ in &from_parts[common..] {
rel.push("..");
}
for c in &to_parts[common..] {
rel.push(c);
}
Ok(rel)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn init_repo_with_commit(dir: &Path) -> String {
use git2::{Repository, Signature};
let repo = Repository::init(dir).unwrap();
{
let mut cfg = repo.config().unwrap();
cfg.set_str("user.name", "Test").unwrap();
cfg.set_str("user.email", "test@example.com").unwrap();
}
std::fs::write(dir.join("main.lua"), "return {}\n").unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new("main.lua")).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = Signature::now("Test", "test@example.com").unwrap();
let oid = repo
.commit(Some("HEAD"), &sig, &sig, "init", &tree, &[])
.unwrap();
oid.to_string()
}
fn write_file(path: &Path, content: &str) {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
#[test]
fn install_creates_lockfile_and_symlink() {
let remote = TempDir::new().unwrap();
let sha = init_repo_with_commit(remote.path());
let project = TempDir::new().unwrap();
let manifest_path = project.path().join("mlua-pkg.toml");
let cache_dir = project.path().join(".mlua-pkgs/cache");
let vendored_dir = project.path().join(".mlua-pkgs/vendored");
let lock_path = project.path().join("mlua-pkg.lock");
let url = format!("file://{}", remote.path().display());
write_file(
&manifest_path,
&format!(
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n\
[deps]\nmylib = {{ git = \"{url}\", rev = \"{sha}\" }}\n"
),
);
run_install(&manifest_path, &cache_dir, &vendored_dir, &lock_path).unwrap();
assert!(lock_path.exists(), "lockfile must be written");
let lf = Lockfile::read(&lock_path).unwrap();
assert_eq!(lf.pkg.len(), 1, "one locked package");
assert_eq!(lf.pkg[0].name, "mylib");
assert_eq!(lf.pkg[0].sha, sha);
assert_eq!(lf.pkg[0].source, format!("git+{url}"));
let symlink = vendored_dir.join("mylib");
assert!(
symlink.symlink_metadata().is_ok(),
"symlink .mlua-pkgs/vendored/mylib must exist"
);
let target = std::fs::read_link(&symlink).unwrap();
assert!(
target.is_relative(),
"symlink target must be a relative path, got: {}",
target.display()
);
}
#[test]
fn install_missing_manifest_returns_error() {
let project = TempDir::new().unwrap();
let result = run_install(
&project.path().join("mlua-pkg.toml"),
&project.path().join(".mlua-pkgs/cache"),
&project.path().join(".mlua-pkgs/vendored"),
&project.path().join("mlua-pkg.lock"),
);
assert!(result.is_err(), "must fail when mlua-pkg.toml is absent");
}
#[test]
fn install_is_idempotent() {
let remote = TempDir::new().unwrap();
let sha = init_repo_with_commit(remote.path());
let project = TempDir::new().unwrap();
let manifest_path = project.path().join("mlua-pkg.toml");
let cache_dir = project.path().join(".mlua-pkgs/cache");
let vendored_dir = project.path().join(".mlua-pkgs/vendored");
let lock_path = project.path().join("mlua-pkg.lock");
let url = format!("file://{}", remote.path().display());
write_file(
&manifest_path,
&format!(
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n\
[deps]\nlib = {{ git = \"{url}\", rev = \"{sha}\" }}\n"
),
);
run_install(&manifest_path, &cache_dir, &vendored_dir, &lock_path).unwrap();
run_install(&manifest_path, &cache_dir, &vendored_dir, &lock_path).unwrap();
let lf = Lockfile::read(&lock_path).unwrap();
assert_eq!(lf.pkg.len(), 1);
}
#[test]
fn add_creates_manifest_with_dep() {
let project = TempDir::new().unwrap();
let manifest_path = project.path().join("mlua-pkg.toml");
run_add(
&manifest_path,
"mylib".to_string(),
"https://github.com/x/mylib".to_string(),
Some("v1.0.0".to_string()),
None,
None,
None,
)
.unwrap();
let manifest = Manifest::from_path(&manifest_path).unwrap();
assert!(manifest.deps.contains_key("mylib"), "dep must be present");
let dep = &manifest.deps["mylib"];
assert_eq!(dep.git, "https://github.com/x/mylib");
assert_eq!(dep.tag.as_deref(), Some("v1.0.0"));
assert!(dep.rev.is_none());
assert!(dep.branch.is_none());
}
#[test]
fn add_to_existing_manifest_preserves_other_deps() {
let project = TempDir::new().unwrap();
let manifest_path = project.path().join("mlua-pkg.toml");
write_file(
&manifest_path,
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n\
[deps]\nexisting = { git = \"https://github.com/a/b\", branch = \"main\" }\n",
);
run_add(
&manifest_path,
"newdep".to_string(),
"https://github.com/x/newdep".to_string(),
None,
Some("abc1234567890123456789012345678901234567890".to_string()),
None,
None,
)
.unwrap();
let manifest = Manifest::from_path(&manifest_path).unwrap();
assert_eq!(manifest.deps.len(), 2, "both deps must be present");
assert!(manifest.deps.contains_key("existing"));
assert!(manifest.deps.contains_key("newdep"));
}
#[test]
fn add_rejects_multiple_ref_fields() {
let project = TempDir::new().unwrap();
let manifest_path = project.path().join("mlua-pkg.toml");
let result = run_add(
&manifest_path,
"lib".to_string(),
"https://github.com/x/lib".to_string(),
Some("v1.0.0".to_string()),
Some("abc123".to_string()),
None,
None,
);
assert!(result.is_err(), "tag + rev together must be rejected");
}
#[test]
fn update_unknown_name_returns_error() {
let project = TempDir::new().unwrap();
let manifest_path = project.path().join("mlua-pkg.toml");
write_file(
&manifest_path,
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n",
);
let result = run_update(
Some("nonexistent".to_string()),
&manifest_path,
&project.path().join("cache"),
&project.path().join("vendored"),
&project.path().join("mlua-pkg.lock"),
);
assert!(result.is_err(), "unknown dep name must return error");
}
#[test]
fn clean_all_removes_cache() {
let project = TempDir::new().unwrap();
let cache_dir = project.path().join(".mlua-pkgs/cache");
let git_dir = cache_dir.join("git/example.com/org/repo");
std::fs::create_dir_all(&git_dir).unwrap();
std::fs::write(git_dir.join("sentinel"), "data").unwrap();
run_clean(true, &cache_dir, &project.path().join("mlua-pkg.lock")).unwrap();
assert!(!cache_dir.exists(), "cache directory must be removed");
}
#[test]
fn clean_all_on_empty_dir_is_noop() {
let project = TempDir::new().unwrap();
let cache_dir = project.path().join(".mlua-pkgs/cache");
run_clean(true, &cache_dir, &project.path().join("mlua-pkg.lock")).unwrap();
}
#[test]
fn clean_without_lockfile_is_noop() {
let project = TempDir::new().unwrap();
run_clean(
false,
&project.path().join(".mlua-pkgs/cache"),
&project.path().join("mlua-pkg.lock"),
)
.unwrap();
}
#[test]
fn clean_removes_stale_sha_dirs_only() {
let project = TempDir::new().unwrap();
let cache_dir = project.path().join(".mlua-pkgs/cache");
let git_base = cache_dir.join("git/gh.com/org/repo");
let sha_in_use = "a".repeat(40);
let sha_stale = "b".repeat(40);
std::fs::create_dir_all(git_base.join(&sha_in_use)).unwrap();
std::fs::create_dir_all(git_base.join(&sha_stale)).unwrap();
let lock_path = project.path().join("mlua-pkg.lock");
let lf = Lockfile {
version: 1,
pkg: vec![LockedPkg {
name: "lib".to_string(),
source: "git+https://gh.com/org/repo".to_string(),
tag: None,
rev: None,
branch: None,
sha: sha_in_use.clone(),
entry: PathBuf::from("."),
}],
};
lf.write(&lock_path).unwrap();
run_clean(false, &cache_dir, &lock_path).unwrap();
assert!(
git_base.join(&sha_in_use).exists(),
"in-use SHA dir must be retained"
);
assert!(
!git_base.join(&sha_stale).exists(),
"stale SHA dir must be removed"
);
}
#[test]
fn relative_path_sibling_dirs() {
let tmp = TempDir::new().unwrap();
let from_dir = tmp.path().join("a/b");
let to_dir = tmp.path().join("a/c/d");
std::fs::create_dir_all(&from_dir).unwrap();
std::fs::create_dir_all(&to_dir).unwrap();
let rel = relative_path(&from_dir, &to_dir).unwrap();
assert_eq!(rel, PathBuf::from("../c/d"));
}
#[test]
fn relative_path_vendored_to_cache() {
let tmp = TempDir::new().unwrap();
let vendored = tmp.path().join(".mlua-pkgs/vendored");
let entry = tmp
.path()
.join(".mlua-pkgs/cache/git/gh.com/org/repo/aaaa1234/src");
std::fs::create_dir_all(&vendored).unwrap();
std::fs::create_dir_all(&entry).unwrap();
let rel = relative_path(&vendored, &entry).unwrap();
assert!(
rel.starts_with(".."),
"must navigate up from vendored first"
);
assert!(
rel.to_string_lossy().contains("cache"),
"must contain 'cache' segment"
);
}
#[test]
fn entry_rel_to_pkg_subdir() {
let cache = PathBuf::from("/tmp/repo");
let entry = PathBuf::from("/tmp/repo/src");
assert_eq!(entry_rel_to_pkg(&cache, &entry), PathBuf::from("src"));
}
#[test]
fn entry_rel_to_pkg_root() {
let cache = PathBuf::from("/tmp/repo");
let entry = PathBuf::from("/tmp/repo");
assert_eq!(entry_rel_to_pkg(&cache, &entry), PathBuf::from("."));
}
#[test]
fn cli_debug_assert() {
use clap::CommandFactory;
Cli::command().debug_assert();
}
#[test]
fn strip_cargo_subcommand_drops_redundant_arg() {
let input = vec![
"cargo-mlua-pkg".to_string(),
"mlua-pkg".to_string(),
"install".to_string(),
];
let out = strip_cargo_subcommand(input);
assert_eq!(
out,
vec!["cargo-mlua-pkg".to_string(), "install".to_string()]
);
}
#[test]
fn strip_cargo_subcommand_leaves_standalone_invocation_alone() {
let input = vec!["mlua-pkg".to_string(), "install".to_string()];
let out = strip_cargo_subcommand(input);
assert_eq!(out, vec!["mlua-pkg".to_string(), "install".to_string()]);
}
#[test]
fn strip_cargo_subcommand_handles_empty() {
let out = strip_cargo_subcommand(Vec::<String>::new());
assert!(out.is_empty());
}
}