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 {
#[command(verbatim_doc_comment)]
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>,
#[arg(long)]
target_dir: Option<PathBuf>,
},
#[command(verbatim_doc_comment)]
Update {
name: Option<String>,
#[arg(long)]
dry_run: bool,
#[arg(long)]
force: bool,
},
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,
target_dir,
} => run_add(
Path::new("mlua-pkg.toml"),
name,
git,
tag,
rev,
branch,
entry,
target_dir,
),
Cmd::Update {
name,
dry_run,
force,
} => run_update(
name,
Path::new("mlua-pkg.toml"),
Path::new(".mlua-pkgs/cache"),
Path::new(".mlua-pkgs/vendored"),
Path::new("mlua-pkg.lock"),
dry_run,
force,
),
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}'"))?;
if let Some(rel_target_dir) = &dep.target_dir {
let manifest_root = manifest_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let dest = manifest_root.join(rel_target_dir);
copy_entry_into(&entry_abs, &dest)
.with_context(|| format!("vendoring '{name}' into {}", dest.display()))?;
} else {
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);
let locked_tag = fetched.resolved_tag.clone().or_else(|| dep.tag.clone());
locked_pkgs.push(LockedPkg {
name: name.clone(),
source: format!("git+{}", dep.git),
tag: locked_tag,
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 copy_entry_into(src: &Path, dest: &Path) -> std::io::Result<()> {
if dest.exists() {
std::fs::remove_dir_all(dest)?;
}
std::fs::create_dir_all(dest)?;
copy_dir_contents(src, dest)
}
fn copy_dir_contents(src: &Path, dest: &Path) -> std::io::Result<()> {
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let from = entry.path();
let to = dest.join(entry.file_name());
let ft = entry.file_type()?;
if ft.is_dir() {
std::fs::create_dir_all(&to)?;
copy_dir_contents(&from, &to)?;
} else if ft.is_symlink() {
let resolved = std::fs::canonicalize(&from)?;
if resolved.is_dir() {
std::fs::create_dir_all(&to)?;
copy_dir_contents(&resolved, &to)?;
} else {
std::fs::copy(&resolved, &to)?;
}
} else {
std::fs::copy(&from, &to)?;
}
}
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("."),
}
}
#[allow(clippy::too_many_arguments)]
fn run_add(
manifest_path: &Path,
name: String,
git: String,
tag: Option<String>,
rev: Option<String>,
branch: Option<String>,
entry: Option<PathBuf>,
target_dir: 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,
target_dir,
};
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(())
}
use mlua_pkg::version::{classify_tag_pin, pick_latest_for_pin, pick_latest_overall, TagPin};
fn set_dep_tag(doc: &mut toml_edit::DocumentMut, name: &str, new_tag: &str) -> anyhow::Result<()> {
let deps = doc
.get_mut("deps")
.and_then(|i| i.as_table_like_mut())
.ok_or_else(|| anyhow::anyhow!("[deps] table missing"))?;
let entry = deps
.get_mut(name)
.ok_or_else(|| anyhow::anyhow!("dep '{name}' missing in [deps]"))?;
if let Some(table) = entry.as_table_like_mut() {
table.insert("tag", toml_edit::value(new_tag));
Ok(())
} else {
Err(anyhow::anyhow!("dep '{name}' is not a table-like entry"))
}
}
#[derive(Debug)]
enum UpdateOutcome {
TagBumped { old: String, new: String },
PrefixResolved { pin: String, resolved: String },
Refresh,
Skipped(String),
}
#[allow(clippy::too_many_arguments)]
fn run_update(
name: Option<String>,
manifest_path: &Path,
cache_dir: &Path,
vendored_dir: &Path,
lock_path: &Path,
dry_run: bool,
force: bool,
) -> anyhow::Result<()> {
let manifest = Manifest::from_path(manifest_path)
.with_context(|| format!("reading {}", manifest_path.display()))?;
if let Some(ref n) = name {
if !manifest.deps.contains_key(n) {
return Err(anyhow::anyhow!(
"unknown package '{}' in {}",
n,
manifest_path.display()
));
}
}
let fetcher = GitFetcher::new(cache_dir.to_path_buf());
let raw = std::fs::read_to_string(manifest_path)?;
let mut doc: toml_edit::DocumentMut = raw.parse()?;
let mut any_refresh = false;
let mut summary: Vec<String> = Vec::new();
for (dep_name, dep) in &manifest.deps {
if let Some(ref n) = name {
if dep_name != n {
continue;
}
}
let outcome = update_dep(dep_name, dep, &fetcher, force)?;
match &outcome {
UpdateOutcome::TagBumped { old, new } => {
summary.push(format!("{dep_name}: tag {old} → {new}"));
if !dry_run {
set_dep_tag(&mut doc, dep_name, new)?;
}
any_refresh = true;
}
UpdateOutcome::PrefixResolved { pin, resolved } => {
summary.push(format!(
"{dep_name}: refresh (prefix '{pin}' → {resolved}; manifest unchanged)"
));
any_refresh = true;
}
UpdateOutcome::Refresh => {
summary.push(format!("{dep_name}: refresh (branch / unpinned)"));
any_refresh = true;
}
UpdateOutcome::Skipped(reason) => {
summary.push(format!("{dep_name}: skip ({reason})"));
}
}
}
if summary.is_empty() {
println!("no packages selected");
return Ok(());
}
for line in &summary {
println!("{line}");
}
if dry_run {
println!("(dry-run; manifest not modified)");
return Ok(());
}
std::fs::write(manifest_path, doc.to_string())?;
if any_refresh {
run_install(manifest_path, cache_dir, vendored_dir, lock_path)?;
}
Ok(())
}
fn update_dep(
name: &str,
dep: &Dep,
fetcher: &GitFetcher,
force: bool,
) -> anyhow::Result<UpdateOutcome> {
if dep.rev.is_some() {
return Ok(UpdateOutcome::Skipped("rev pin".into()));
}
if dep.branch.is_some() {
return Ok(UpdateOutcome::Refresh);
}
let Some(current_tag) = &dep.tag else {
return Ok(UpdateOutcome::Refresh);
};
let pin = match classify_tag_pin(current_tag) {
Some(p) => p,
None => {
return Ok(UpdateOutcome::Skipped(format!(
"tag '{current_tag}' is not SemVer"
)))
}
};
if matches!(pin, TagPin::Exact) && !force {
return Ok(UpdateOutcome::Skipped(
"exact tag pin (pass --force to bump)".into(),
));
}
let tags = fetcher
.list_tags(&dep.git)
.with_context(|| format!("listing tags for '{name}'"))?;
match pin {
TagPin::Exact => {
let Some(new_tag) = pick_latest_overall(&tags) else {
return Ok(UpdateOutcome::Skipped(
"no matching SemVer release tag on remote".into(),
));
};
if &new_tag == current_tag {
Ok(UpdateOutcome::Skipped(format!("already at {new_tag}")))
} else {
Ok(UpdateOutcome::TagBumped {
old: current_tag.clone(),
new: new_tag,
})
}
}
TagPin::Prefix(p) => {
let Some(resolved) = pick_latest_for_pin(&tags, &p) else {
return Ok(UpdateOutcome::Skipped(
"no matching SemVer release tag on remote".into(),
));
};
Ok(UpdateOutcome::PrefixResolved {
pin: current_tag.clone(),
resolved,
})
}
}
}
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_with_target_dir_physically_copies() {
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}\", \
target_dir = \"lua/mylib\" }}\n"
),
);
run_install(&manifest_path, &cache_dir, &vendored_dir, &lock_path).unwrap();
let vendored_file = project.path().join("lua/mylib/main.lua");
assert!(
vendored_file.exists(),
"vendored file must exist at target_dir"
);
let meta = std::fs::symlink_metadata(&vendored_file).unwrap();
assert!(
!meta.file_type().is_symlink(),
"vendored output must be a regular file, not a symlink"
);
assert!(
vendored_dir.join("mylib").symlink_metadata().is_err(),
".mlua-pkgs/vendored/<name> must not be created when target_dir is set"
);
let lf = Lockfile::read(&lock_path).unwrap();
assert_eq!(lf.pkg.len(), 1);
assert_eq!(lf.pkg[0].sha, sha);
}
#[test]
fn install_with_target_dir_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]\nmylib = {{ git = \"{url}\", rev = \"{sha}\", \
target_dir = \"lua/mylib\" }}\n"
),
);
run_install(&manifest_path, &cache_dir, &vendored_dir, &lock_path).unwrap();
run_install(&manifest_path, &cache_dir, &vendored_dir, &lock_path).unwrap();
assert!(project.path().join("lua/mylib/main.lua").exists());
}
#[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,
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,
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,
None,
);
assert!(result.is_err(), "tag + rev together must be rejected");
}
#[test]
fn set_dep_tag_preserves_inline_layout() {
let toml = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n\n\
[deps]\nfoo = { git = \"https://example.com/foo\", tag = \"v1.0.0\" }\n";
let mut doc: toml_edit::DocumentMut = toml.parse().unwrap();
set_dep_tag(&mut doc, "foo", "v1.0.5").unwrap();
let out = doc.to_string();
assert!(
out.contains("tag = \"v1.0.5\""),
"new tag must be written:\n{out}"
);
assert!(
out.contains("git = \"https://example.com/foo\""),
"git URL preserved"
);
}
fn add_tag(dir: &Path, tag: &str) {
use git2::{Repository, Signature};
let repo = Repository::open(dir).unwrap();
let head = repo.head().unwrap().peel_to_commit().unwrap();
let sig = Signature::now("Test", "test@example.com").unwrap();
repo.tag(tag, head.as_object(), &sig, tag, false).unwrap();
}
#[test]
fn update_prefix_pin_refreshes_lock_without_mutating_manifest() {
let remote = TempDir::new().unwrap();
init_repo_with_commit(remote.path());
add_tag(remote.path(), "v1.0.0");
add_tag(remote.path(), "v1.0.1");
add_tag(remote.path(), "v1.0.5");
add_tag(remote.path(), "v1.1.0");
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());
let original = format!(
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n\
[deps]\nmylib = {{ git = \"{url}\", tag = \"v1.0\" }}\n"
);
write_file(&manifest_path, &original);
run_update(
None,
&manifest_path,
&cache_dir,
&vendored_dir,
&lock_path,
false,
false,
)
.unwrap();
let after = std::fs::read_to_string(&manifest_path).unwrap();
assert_eq!(
after, original,
"prefix pin manifest must not be rewritten:\n{after}"
);
let lf = Lockfile::read(&lock_path).unwrap();
assert_eq!(lf.pkg.len(), 1, "one locked package");
assert_eq!(
lf.pkg[0].tag.as_deref(),
Some("v1.0.5"),
"lock must record resolved concrete tag"
);
}
#[test]
fn install_with_prefix_pin_resolves_to_concrete_tag() {
let remote = TempDir::new().unwrap();
init_repo_with_commit(remote.path());
add_tag(remote.path(), "v1.0.0");
add_tag(remote.path(), "v1.0.5");
add_tag(remote.path(), "v2.0.0");
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}\", tag = \"v1.0\" }}\n"
),
);
run_install(&manifest_path, &cache_dir, &vendored_dir, &lock_path).unwrap();
let lf = Lockfile::read(&lock_path).unwrap();
assert_eq!(
lf.pkg[0].tag.as_deref(),
Some("v1.0.5"),
"install must resolve prefix v1.0 to concrete v1.0.5 (excluding v2.0.0):\n{lf:?}"
);
}
#[test]
fn update_dry_run_leaves_manifest_unmodified() {
let remote = TempDir::new().unwrap();
init_repo_with_commit(remote.path());
add_tag(remote.path(), "v1.0.0");
add_tag(remote.path(), "v1.0.5");
let project = TempDir::new().unwrap();
let manifest_path = project.path().join("mlua-pkg.toml");
let url = format!("file://{}", remote.path().display());
let original = format!(
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n\
[deps]\nmylib = {{ git = \"{url}\", tag = \"v1.0\" }}\n"
);
write_file(&manifest_path, &original);
run_update(
None,
&manifest_path,
&project.path().join(".mlua-pkgs/cache"),
&project.path().join(".mlua-pkgs/vendored"),
&project.path().join("mlua-pkg.lock"),
true,
false,
)
.unwrap();
let after = std::fs::read_to_string(&manifest_path).unwrap();
assert_eq!(after, original, "dry-run must not modify manifest");
}
#[test]
fn update_exact_pin_without_force_is_noop() {
let remote = TempDir::new().unwrap();
init_repo_with_commit(remote.path());
add_tag(remote.path(), "v1.0.0");
add_tag(remote.path(), "v1.0.5");
let project = TempDir::new().unwrap();
let manifest_path = project.path().join("mlua-pkg.toml");
let url = format!("file://{}", remote.path().display());
let original = format!(
"[package]\nname = \"test\"\nversion = \"0.1.0\"\n\n\
[deps]\nmylib = {{ git = \"{url}\", tag = \"v1.0.0\" }}\n"
);
write_file(&manifest_path, &original);
run_update(
None,
&manifest_path,
&project.path().join(".mlua-pkgs/cache"),
&project.path().join(".mlua-pkgs/vendored"),
&project.path().join("mlua-pkg.lock"),
false,
false,
)
.unwrap();
let after = std::fs::read_to_string(&manifest_path).unwrap();
assert!(
after.contains("tag = \"v1.0.0\""),
"exact pin must remain v1.0.0 without --force:\n{after}"
);
}
#[test]
fn update_exact_pin_with_force_bumps_to_latest() {
let remote = TempDir::new().unwrap();
init_repo_with_commit(remote.path());
add_tag(remote.path(), "v1.0.0");
add_tag(remote.path(), "v1.0.5");
add_tag(remote.path(), "v2.0.0");
let project = TempDir::new().unwrap();
let manifest_path = project.path().join("mlua-pkg.toml");
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}\", tag = \"v1.0.0\" }}\n"
),
);
run_update(
None,
&manifest_path,
&project.path().join(".mlua-pkgs/cache"),
&project.path().join(".mlua-pkgs/vendored"),
&project.path().join("mlua-pkg.lock"),
false,
true,
)
.unwrap();
let after = std::fs::read_to_string(&manifest_path).unwrap();
assert!(
after.contains("tag = \"v2.0.0\""),
"--force must bump exact pin to global max v2.0.0:\n{after}"
);
}
#[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"),
false,
false,
);
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());
}
}