Repository: casey/just
Pull request #1822 (2024-01-06 09:13:49)

Add functions to return standard directories
Add the following builtin functions:

- `cache_directory()`
- `config_directory()`
- `config_local_directory()`
- `data_directory()`
- `data_local_directory()`
- `executable_directory()`
- `home_directory()`

These use the `dirs` crate to return XDG-compliant paths, or the system defaults.

Fixes: https://github.com/casey/just/issues/1820

tgross35: > Would it be better to use [`dirs::preference_dir`](https://docs.rs/dirs/latest/dirs/fn.preference_dir.html) for `config_directory()`?

I think it would be better to keep names consistent with what they are on `dirs`, which could mean adding `preference_directory`. But the only difference between the two is on mac - `/Users/Alice/Library/Application Support`(`config_dir`) contains a directory with the application name and then whatever contents is needed, pretty in line with other platforms.

`/Users/Alice/Library/Preferences` (`preference_dir`) only contains `com.*.*.plist` files, which are a something like a binary JSON. I think less likely to be used than the config directory where you can just place files.
tgross35: I see you changed this to return an error if the directory is not found (thanks for the updates!). Would it be better to make these accept either 0 or 1 argument, so a fallback can be provided if not available?
tgross35: Or looks like it's ready to merge. If the above sounds reasonable, I can submit a followup PR :)
casey: Nice! I tweaked the readme a little, and changed the functions to return an error if the directory isn't found. Also, for some reason, on my mac the executable directory wasn't found, so I modified the test to handle this error.

Patch:
diff --git a/README.md b/README.md
index aae963c7..87eb44c4 100644
--- a/README.md
+++ b/README.md
@@ -1415,6 +1415,23 @@ which will halt execution.
   `requirement`, e.g., `">=0.1.0"`, returning `"true"` if so and `"false"`
   otherwise.
 
+##### XDG Directories
+
+These functions return paths to user-specific directories for things like
+configuration, data, caches, executables, and the user's home directory. These
+functions follow the
+[XDG Base Directory Specification](https://specifications.freedesktop.org/basedir-spec/basedir-spec-latest.html),
+and are implemented with the
+[`dirs`](https://docs.rs/dirs/latest/dirs/index.html) crate.
+
+- `cache_directory()` - The user-specific cache directory.
+- `config_directory()` - The user-specific configuration directory.
+- `config_local_directory()` - The local user-specific configuration directory.
+- `data_directory()` - The user-specific data directory.
+- `data_local_directory()` - The local user-specific data directory.
+- `executable_directory()` - The user-specific executable directory.
+- `home_directory()` - The user's home directory.
+
 ### Recipe Attributes
 
 Recipes may be annotated with attributes that change their behavior.
diff --git a/src/function.rs b/src/function.rs
index 086ede1b..c4923e59 100644
--- a/src/function.rs
+++ b/src/function.rs
@@ -21,15 +21,22 @@ pub(crate) fn get(name: &str) -> Option<Function> {
   let function = match name {
     "absolute_path" => Unary(absolute_path),
     "arch" => Nullary(arch),
+    "cache_directory" => Nullary(|_| dir("cache", dirs::cache_dir)),
     "capitalize" => Unary(capitalize),
     "clean" => Unary(clean),
+    "config_directory" => Nullary(|_| dir("config", dirs::config_dir)),
+    "config_local_directory" => Nullary(|_| dir("local config", dirs::config_local_dir)),
+    "data_directory" => Nullary(|_| dir("data", dirs::data_dir)),
+    "data_local_directory" => Nullary(|_| dir("local data", dirs::data_local_dir)),
     "env" => UnaryOpt(env),
     "env_var" => Unary(env_var),
     "env_var_or_default" => Binary(env_var_or_default),
     "error" => Unary(error),
+    "executable_directory" => Nullary(|_| dir("executable", dirs::executable_dir)),
     "extension" => Unary(extension),
     "file_name" => Unary(file_name),
     "file_stem" => Unary(file_stem),
+    "home_directory" => Nullary(|_| dir("home", dirs::home_dir)),
     "invocation_directory" => Nullary(invocation_directory),
     "invocation_directory_native" => Nullary(invocation_directory_native),
     "join" => BinaryPlus(join),
@@ -114,6 +121,22 @@ fn clean(_context: &FunctionContext, path: &str) -> Result<String, String> {
   Ok(Path::new(path).lexiclean().to_str().unwrap().to_owned())
 }
 
+fn dir(name: &'static str, f: fn() -> Option<PathBuf>) -> Result<String, String> {
+  match f() {
+    Some(path) => path
+      .as_os_str()
+      .to_str()
+      .map(str::to_string)
+      .ok_or_else(|| {
+        format!(
+          "unable to convert {name} directory path to string: {}",
+          path.display(),
+        )
+      }),
+    None => Err(format!("{name} directory not found")),
+  }
+}
+
 fn env_var(context: &FunctionContext, key: &str) -> Result<String, String> {
   use std::env::VarError::*;
 
@@ -433,3 +456,23 @@ fn semver_matches(
       .to_string(),
   )
 }
+
+#[cfg(test)]
+mod tests {
+  use super::*;
+
+  #[test]
+  fn dir_not_found() {
+    assert_eq!(dir("foo", || None).unwrap_err(), "foo directory not found");
+  }
+
+  #[cfg(unix)]
+  #[test]
+  fn dir_not_unicode() {
+    use std::os::unix::ffi::OsStrExt;
+    assert_eq!(
+      dir("foo", || Some(OsStr::from_bytes(b"\xe0\x80\x80").into())).unwrap_err(),
+      "unable to convert foo directory path to string: ���",
+    );
+  }
+}


Test patch:
diff --git a/tests/directories.rs b/tests/directories.rs
new file mode 100644
index 00000000..41a05cd6
--- /dev/null
+++ b/tests/directories.rs
@@ -0,0 +1,85 @@
+use super::*;
+
+#[test]
+fn cache_directory() {
+  Test::new()
+    .justfile("x := cache_directory()")
+    .args(["--evaluate", "x"])
+    .stdout(dirs::cache_dir().unwrap_or_default().to_string_lossy())
+    .run();
+}
+
+#[test]
+fn config_directory() {
+  Test::new()
+    .justfile("x := config_directory()")
+    .args(["--evaluate", "x"])
+    .stdout(dirs::config_dir().unwrap_or_default().to_string_lossy())
+    .run();
+}
+
+#[test]
+fn config_local_directory() {
+  Test::new()
+    .justfile("x := config_local_directory()")
+    .args(["--evaluate", "x"])
+    .stdout(
+      dirs::config_local_dir()
+        .unwrap_or_default()
+        .to_string_lossy(),
+    )
+    .run();
+}
+
+#[test]
+fn data_directory() {
+  Test::new()
+    .justfile("x := data_directory()")
+    .args(["--evaluate", "x"])
+    .stdout(dirs::data_dir().unwrap_or_default().to_string_lossy())
+    .run();
+}
+
+#[test]
+fn data_local_directory() {
+  Test::new()
+    .justfile("x := data_local_directory()")
+    .args(["--evaluate", "x"])
+    .stdout(dirs::data_local_dir().unwrap_or_default().to_string_lossy())
+    .run();
+}
+
+#[test]
+fn executable_directory() {
+  if let Some(executable_dir) = dirs::executable_dir() {
+    Test::new()
+      .justfile("x := executable_directory()")
+      .args(["--evaluate", "x"])
+      .stdout(executable_dir.to_string_lossy())
+      .run();
+  } else {
+    Test::new()
+      .justfile("x := executable_directory()")
+      .args(["--evaluate", "x"])
+      .stderr(
+        "
+          error: Call to function `executable_directory` failed: executable directory not found
+           ——▶ justfile:1:6
+            │
+          1 │ x := executable_directory()
+            │      ^^^^^^^^^^^^^^^^^^^^
+        ",
+      )
+      .status(EXIT_FAILURE)
+      .run();
+  }
+}
+
+#[test]
+fn home_directory() {
+  Test::new()
+    .justfile("x := home_directory()")
+    .args(["--evaluate", "x"])
+    .stdout(dirs::home_dir().unwrap_or_default().to_string_lossy())
+    .run();
+}
diff --git a/tests/lib.rs b/tests/lib.rs
index 24d3ffa2..a9f3c344 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -44,6 +44,7 @@ mod completions;
 mod conditional;
 mod confirm;
 mod delimiters;
+mod directories;
 mod dotenv;
 mod edit;
 mod equals;