Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions crates/vfox/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ name = "vfox-cli"
path = "src/bin.rs"

[dependencies]
glob = "0.3"
homedir = "0.3"
indexmap = { version = "2", features = ["serde"] }
itertools = "0.15"
Expand Down
149 changes: 141 additions & 8 deletions crates/vfox/src/lua_mod/archiver.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::error::Result;
use mlua::{ExternalResult, Lua, MultiValue, Table};
use std::path::PathBuf;
use mlua::{ExternalResult, Lua, MultiValue, Table, Value};
use std::path::{Path, PathBuf};

pub fn mod_archiver(lua: &Lua) -> Result<()> {
let package: Table = lua.globals().get("package")?;
Expand All @@ -18,19 +18,95 @@ pub fn mod_archiver(lua: &Lua) -> Result<()> {

fn decompress(_lua: &Lua, input: MultiValue) -> mlua::Result<()> {
let paths: Vec<mlua::Value> = input.into_iter().collect();
if paths.len() < 2 {
return Err(mlua::Error::runtime(
"archiver.decompress requires an archive and destination",
));
}
let archive: PathBuf = PathBuf::from(paths[0].to_string()?);
let destination: PathBuf = PathBuf::from(paths[1].to_string()?);
let filename = archive.file_name().unwrap().to_str().unwrap();
let strip_components = match paths.get(2) {
None | Some(Value::Nil) => 0,
Some(Value::Table(options)) => options
.get::<Option<usize>>("strip_components")?
.unwrap_or(0),
Some(value) => {
return Err(mlua::Error::runtime(format!(
"archiver.decompress options must be a table, got {}",
value.type_name()
)));
}
};
if strip_components > 1 {
return Err(mlua::Error::runtime(
"archiver.decompress only supports strip_components values of 0 or 1",
));
}

if strip_components == 0 {
return decompress_archive(&archive, &destination);
}

let parent = destination
.parent()
.filter(|path| !path.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
xx::file::mkdirp(parent).into_lua_err()?;
let temp_dir = tempfile::Builder::new()
.prefix(".mise-extract-")
.tempdir_in(parent)
.into_lua_err()?;
decompress_archive(&archive, temp_dir.path())?;
xx::file::mkdirp(&destination).into_lua_err()?;
strip_archive_path_components(temp_dir.path(), &destination)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

/// Match mise's built-in archive extraction behavior: promote the contents of
/// top-level directories while retaining files that are already at the root.
fn strip_archive_path_components(extracted: &Path, destination: &Path) -> mlua::Result<()> {
for entry in xx::file::ls(extracted).into_lua_err()? {
if entry
.symlink_metadata()
.into_lua_err()?
.file_type()
.is_dir()
{
for child in xx::file::ls(&entry).into_lua_err()? {
let file_name = child.file_name().ok_or_else(|| {
mlua::Error::runtime(format!("invalid archive entry: {}", child.display()))
})?;
xx::file::mv(&child, destination.join(file_name)).into_lua_err()?;
}
} else {
let file_name = entry.file_name().ok_or_else(|| {
mlua::Error::runtime(format!("invalid archive entry: {}", entry.display()))
})?;
xx::file::mv(&entry, destination.join(file_name)).into_lua_err()?;
Comment thread
jdx marked this conversation as resolved.
Resolved
}
}
Ok(())
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}

fn decompress_archive(archive: &Path, destination: &Path) -> mlua::Result<()> {
let filename = archive
.file_name()
.and_then(|name| name.to_str())
.ok_or_else(|| {
mlua::Error::runtime(format!("invalid archive path: {}", archive.display()))
})?;
if filename.ends_with(".zip") {
xx::archive::unzip(&archive, &destination).into_lua_err()?;
xx::archive::unzip(archive, destination).into_lua_err()?;
} else if filename.ends_with(".tar.gz") {
xx::archive::untar_gz(&archive, &destination).into_lua_err()?;
xx::archive::untar_gz(archive, destination).into_lua_err()?;
} else if filename.ends_with(".tar.xz") {
xx::archive::untar_xz(&archive, &destination).into_lua_err()?;
xx::archive::untar_xz(archive, destination).into_lua_err()?;
} else if filename.ends_with(".tar.bz2") {
xx::archive::untar_bz2(&archive, &destination).into_lua_err()?;
xx::archive::untar_bz2(archive, destination).into_lua_err()?;
} else {
unimplemented!("Unsupported archive format {:?}", archive);
return Err(mlua::Error::runtime(format!(
"unsupported archive format: {}",
archive.display()
)));
}
Ok(())
}
Expand Down Expand Up @@ -58,4 +134,61 @@ mod tests {
);
// TempDir automatically cleans up when dropped
}

#[test]
fn test_zip_strip_components() {
let temp_dir = tempfile::TempDir::new().unwrap();
let dst_path = temp_dir.path().join("unzip");
let dst_path_str = dst_path.to_string_lossy().to_string();
let lua = Lua::new();
mod_archiver(&lua).unwrap();
lua.load(mlua::chunk! {
local archiver = require("archiver")
archiver.decompress("test/data/foo.zip", $dst_path_str, {strip_components = 1})
})
.exec()
.unwrap();
assert_eq!(
std::fs::read_to_string(dst_path.join("test.txt")).unwrap(),
"yep\n"
);
assert!(!dst_path.join("foo").exists());
}

#[test]
fn test_strip_components_preserves_root_files() {
let temp_dir = tempfile::TempDir::new().unwrap();
let extracted = temp_dir.path().join("extracted");
let destination = temp_dir.path().join("destination");
std::fs::create_dir_all(extracted.join("pkg")).unwrap();
std::fs::create_dir_all(&destination).unwrap();
std::fs::write(extracted.join("README"), "readme").unwrap();
std::fs::write(extracted.join("pkg/tool"), "tool").unwrap();

strip_archive_path_components(&extracted, &destination).unwrap();

assert_eq!(
std::fs::read_to_string(destination.join("README")).unwrap(),
"readme"
);
assert_eq!(
std::fs::read_to_string(destination.join("tool")).unwrap(),
"tool"
);
assert!(!destination.join("pkg").exists());
}

#[test]
fn test_unsupported_archive_returns_lua_error() {
let lua = Lua::new();
mod_archiver(&lua).unwrap();
let err = lua
.load(mlua::chunk! {
local archiver = require("archiver")
archiver.decompress("archive.rar", "destination")
})
.exec()
.unwrap_err();
assert!(err.to_string().contains("unsupported archive format"));
}
}
70 changes: 69 additions & 1 deletion crates/vfox/src/lua_mod/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use std::os::unix::fs::{PermissionsExt, symlink as _symlink};
use std::os::windows::fs::symlink_dir;
#[cfg(windows)]
use std::os::windows::fs::symlink_file;
use std::path::Path;
use std::path::{Path, PathBuf};

fn join_path(_lua: &Lua, args: MultiValue) -> mlua::Result<String> {
let sep = std::path::MAIN_SEPARATOR;
Expand Down Expand Up @@ -51,10 +51,38 @@ pub fn mod_file(lua: &Lua) -> Result<()> {
stat(&_lua, path).await
})?,
),
("list", lua.create_function(list)?),
("glob", lua.create_function(glob)?),
("move", lua.create_function(move_path)?),
])?,
)?)
}

fn paths_to_strings(paths: Vec<PathBuf>) -> Vec<String> {
let mut paths = paths
.into_iter()
.map(|path| path.to_string_lossy().into_owned())
.collect::<Vec<_>>();
paths.sort();
paths
}

fn list(_lua: &Lua, path: String) -> mlua::Result<Vec<String>> {
xx::file::ls(path).map(paths_to_strings).into_lua_err()
}

fn glob(_lua: &Lua, pattern: String) -> mlua::Result<Vec<String>> {
let paths = glob::glob(&pattern)
.map_err(mlua::Error::external)?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(mlua::Error::external)?;
Ok(paths_to_strings(paths))
}

fn move_path(_lua: &Lua, (source, destination): (String, String)) -> mlua::Result<()> {
xx::file::mv(source, destination).into_lua_err()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

async fn read(_lua: &Lua, input: MultiValue) -> mlua::Result<String> {
let args: Vec<String> = input
.into_iter()
Expand Down Expand Up @@ -273,4 +301,44 @@ mod tests {
.unwrap();
}
}

#[test]
fn test_list_glob_and_move() {
let temp_dir = tempfile::TempDir::new().unwrap();
let source_dir = temp_dir.path().join("source");
let nested_dir = source_dir.join("nested");
let destination_dir = temp_dir.path().join("destination");
fs::create_dir_all(&nested_dir).unwrap();
fs::write(source_dir.join("a.txt"), "a").unwrap();
fs::write(source_dir.join("b.log"), "b").unwrap();
fs::write(nested_dir.join("c.txt"), "c").unwrap();

let source_dir_str = source_dir.to_string_lossy().to_string();
let glob_pattern = source_dir.join("*.txt").to_string_lossy().to_string();
let nested_dir_str = nested_dir.to_string_lossy().to_string();
let destination_dir_str = destination_dir.to_string_lossy().to_string();
let lua = Lua::new();
mod_file(&lua).unwrap();

lua.load(mlua::chunk! {
local file = require("file")
local entries = file.list($source_dir_str)
assert(#entries == 3, "list should return immediate directory entries")
assert(entries[1] < entries[2] and entries[2] < entries[3], "list should be sorted")

local matches = file.glob($glob_pattern)
assert(#matches == 1, "glob should return matching files")
assert(matches[1]:match("a%.txt$"), "glob should return the matching path")

file.move($nested_dir_str, $destination_dir_str)
})
.exec()
.unwrap();

assert_eq!(
fs::read_to_string(destination_dir.join("c.txt")).unwrap(),
"c"
);
assert!(!nested_dir.exists());
}
}
8 changes: 7 additions & 1 deletion crates/vfox/types/mise-plugin.lua
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ local json = {}
---@field symlink fun(src: string, dst: string) Create a symbolic link
---@field join_path fun(...: string): string Join path components
---@field stat fun(path: string): FileStat|nil Get file metadata or nil if not found
---@field list fun(path: string): string[] List immediate directory entries in sorted order
---@field glob fun(pattern: string): string[] List paths matching a glob pattern in sorted order
---@field move fun(src: string, dst: string) Move a file or directory
local file = {}

-- cmd module ---------------------------------------------------------
Expand All @@ -204,8 +207,11 @@ local env = {}

-- archiver module ----------------------------------------------------

---@class ArchiverDecompressOpts
---@field strip_components? integer Flatten top-level directories while retaining root files (0 or 1)

---@class archiver
---@field decompress fun(archive: string, dest: string) Decompress an archive (.zip, .tar.gz, .tar.xz, .tar.bz2)
---@field decompress fun(archive: string, dest: string, opts?: ArchiverDecompressOpts) Decompress an archive (.zip, .tar.gz, .tar.xz, .tar.bz2)
local archiver = {}

-- semver module ------------------------------------------------------
Expand Down
35 changes: 35 additions & 0 deletions docs/plugin-lua-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,16 @@ if err ~= nil then
end
```

To flatten versioned directories at the root of an archive, pass
`strip_components = 1`. Files already at the archive root are retained, matching
mise's built-in archive backends.

```lua
archiver.decompress("node-v24.18.1-linux-x64.tar.gz", "destination/", {
strip_components = 1,
})
```

### Real-World Example: Plugin Installation

```lua
Expand Down Expand Up @@ -514,6 +524,31 @@ else
end
```

### List and match files

```lua
local file = require("file")

-- Immediate entries, returned in sorted order
local entries = file.list("/path/to/directory")

-- Paths matching a glob, returned in sorted order
local executables = file.glob(file.join_path("/path/to/bin", "mytool-*"))
```

### Move files and directories

`file.move` moves either a file or an entire directory. Parent directories for
the destination are created automatically.

```lua
local file = require("file")
file.move(
file.join_path("/path/to/bin", "mytool-linux-amd64"),
file.join_path("/path/to/bin", "mytool")
)
```

## Environment Module

The env module provides environment variable operations.
Expand Down
Loading