Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
218 changes: 210 additions & 8 deletions crates/vfox/src/lua_mod/archiver.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
use crate::error::Result;
use mlua::{ExternalResult, Lua, MultiValue, Table};
use std::path::PathBuf;
use mlua::{ExternalResult, Lua, MultiValue, Table, Value};
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};

pub fn mod_archiver(lua: &Lua) -> Result<()> {
let package: Table = lua.globals().get("package")?;
Expand All @@ -18,19 +19,131 @@ 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<()> {
let mut moves = vec![];
let mut targets = BTreeSet::new();
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()))
})?;
plan_move(
child.clone(),
destination.join(file_name),
&mut moves,
&mut targets,
)?;
}
} else {
let file_name = entry.file_name().ok_or_else(|| {
mlua::Error::runtime(format!("invalid archive entry: {}", entry.display()))
})?;
plan_move(
entry.clone(),
destination.join(file_name),
&mut moves,
&mut targets,
)?;
}
}
for (source, target) in moves {
xx::file::mv(source, target).into_lua_err()?;
}
Ok(())
}

fn plan_move(
source: PathBuf,
target: PathBuf,
moves: &mut Vec<(PathBuf, PathBuf)>,
targets: &mut BTreeSet<PathBuf>,
) -> mlua::Result<()> {
let target_exists = match target.symlink_metadata() {
Ok(_) => true,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => false,
Err(err) => return Err(mlua::Error::external(err)),
};
if target_exists || !targets.insert(target.clone()) {
return Err(mlua::Error::runtime(format!(
"archive strip destination already exists: {}",
target.display()
)));
}
moves.push((source, target));
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 +171,93 @@ 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_strip_components_rejects_collisions_before_moving() {
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("tool"), "root").unwrap();
std::fs::write(extracted.join("pkg/tool"), "nested").unwrap();

let err = strip_archive_path_components(&extracted, &destination).unwrap_err();

assert!(err.to_string().contains("destination already exists"));
assert!(!destination.join("tool").exists());
assert!(extracted.join("tool").exists());
assert!(extracted.join("pkg/tool").exists());
}

#[test]
fn test_decompress_error_can_be_caught_with_pcall() {
let lua = Lua::new();
mod_archiver(&lua).unwrap();
lua.load(mlua::chunk! {
local archiver = require("archiver")
local ok, err = pcall(archiver.decompress, "archive.rar", "destination")
assert(not ok, "pcall should catch decompression errors")
assert(tostring(err):match("unsupported archive format"))
})
.exec()
.unwrap();
}

#[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
Loading