From bb66ff48db3ea88a872d9a51ec60216a36b12875 Mon Sep 17 00:00:00 2001 From: hunter-hongg <2843249813@qq.com> Date: Tue, 24 Mar 2026 20:52:45 +0800 Subject: [PATCH] Add `relative_path` built-in function (#3104) Adds a new built-in function `relative_path` that computes a relative path from one path to another using `Path::strip_prefix`. This provides an OS-agnostic alternative to the `relpath` bash utility, which breaks on macOS where bash runs as zsh. The function handles both absolute and relative paths across all supported platforms. --- .gitignore | 1 + Cargo.lock | 7 ++++++ Cargo.toml | 1 + src/function.rs | 17 +++++++++++++++ tests/functions.rs | 54 ++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 80 insertions(+) diff --git a/.gitignore b/.gitignore index 443c5f02b3..57b7b3a4ff 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,4 @@ /test-utilities/Cargo.lock /test-utilities/target /tmp +/3104issue.json diff --git a/Cargo.lock b/Cargo.lock index 40179f8c0c..bd7b4aebef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -566,6 +566,7 @@ dependencies = [ "libc", "nix", "num_cpus", + "pathdiff", "percent-encoding", "pretty_assertions", "rand", @@ -701,6 +702,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + [[package]] name = "percent-encoding" version = "2.3.2" diff --git a/Cargo.toml b/Cargo.toml index 545011120d..a0dd203a38 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ is_executable = "1.0.4" lexiclean = "0.0.1" libc = "0.2.0" num_cpus = "1.15.0" +pathdiff = "0.2.3" percent-encoding = "2.3.1" rand = "0.9.0" regex = "1.10.4" diff --git a/src/function.rs b/src/function.rs index 4d154874f2..c60fb87ad7 100644 --- a/src/function.rs +++ b/src/function.rs @@ -84,6 +84,7 @@ pub(crate) fn get(name: &str) -> Option { "prepend" => Binary(prepend), "quote" => Unary(quote), "read" => Unary(read), + "relative_path" => Binary(relpath), "replace" => Ternary(replace), "replace_regex" => Ternary(replace_regex), "require" => Unary(require), @@ -502,6 +503,22 @@ fn read(context: Context, filename: &str) -> FunctionResult { .map_err(|err| format!("I/O error reading `{filename}`: {err}")) } +fn relpath(_context: Context, target: &str, base: &str) -> FunctionResult { + let base = match Utf8Path::new(base).canonicalize(){ + Ok(b) => b, + Err(e) => return Err(format!("Canonicalize path {base} failed: {e}")), + }; + let target = match Utf8Path::new(target).canonicalize(){ + Ok(t) => t, + Err(e) => return Err(format!("Canonicalize path {target} failed: {e}")), + }; + if let Some(rel) = pathdiff::diff_paths(&target, &base) { + Ok(rel.display().to_string()) + } else { + Ok(target.display().to_string()) // fallback to absolute path + } +} + fn replace(_context: Context, s: &str, from: &str, to: &str) -> FunctionResult { Ok(s.replace(from, to)) } diff --git a/tests/functions.rs b/tests/functions.rs index c7d0bf751c..1c6abe9e63 100644 --- a/tests/functions.rs +++ b/tests/functions.rs @@ -1501,3 +1501,57 @@ fn read_file_not_found() { .stderr_regex(r"error: Call to function `read` failed: I/O error reading `bar`: .*") .failure(); } + +#[test] +fn relative_path() { + Test::new() + .justfile( + r" +foo := relative_path('/usr/bin', '/usr/lib') +" + ) + .args(["--evaluate", "foo"]) + .stdout("../bin") + .success(); +} + +#[test] +fn relative_path_dont_exists() { + Test::new() + .justfile( + r" +foo := relative_path('/foo/bar', '/qux/baz') +" + ) // make sure these two dir doesn't exists on your system + .args(["--evaluate", "foo"]) + .stderr_regex(r"error: Call to function `relative_path` failed: Canonicalize path .* failed: .*") + .failure(); +} + +#[cfg(target_os = "windows")] +#[test] +fn relative_path_windows() { + Test::new() + .justfile( + r" +foo := relative_path('C:\usr\bin', 'C:\usr\lib') +" + ) + .args(["--evaluate", "foo"]) + .stdout("..\\bin") + .success(); +} + +#[cfg(target_os = "windows")] +#[test] +fn relative_path_windows_none() { + Test::new() + .justfile( + r" +foo := relative_path('C:\usr\bin', 'D:\usr\lib') +" + ) + .args(["--evaluate", "foo"]) + .stdout("D:\\usr\\lib") + .success(); +} \ No newline at end of file