Repository: casey/just
Pull request #2049 (2024-05-18 01:43:42)

Add function for generating random values
Adds `pick()` function as discussed in https://github.com/casey/just/issues/1991#issuecomment-2118496078

I'm still relatively new to Rust, so code review would be much appreciated :slightly_smiling_face: 

cc @t3hmrman 

t3hmrman: Hey @laniakea64 thanks for the quick turnaround on this! One more thing that would be awesome are some tests! There's not much you can test (especially as this is real random and not pseudo random), but at the very least the string formation/length might be worth testing!

I wonder if you can easily test entropy (I assume GH runners have enough and this won't be a problem)

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index a34c6a5d..91c4be60 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -537,6 +537,7 @@ dependencies = [
  "log",
  "num_cpus",
  "pretty_assertions",
+ "rand",
  "regex",
  "semver",
  "serde",
@@ -637,6 +638,12 @@ version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
 
+[[package]]
+name = "ppv-lite86"
+version = "0.2.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de"
+
 [[package]]
 name = "pretty_assertions"
 version = "1.4.0"
@@ -710,6 +717,36 @@ dependencies = [
  "proc-macro2",
 ]
 
+[[package]]
+name = "rand"
+version = "0.8.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
+dependencies = [
+ "libc",
+ "rand_chacha",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_chacha"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
+dependencies = [
+ "ppv-lite86",
+ "rand_core",
+]
+
+[[package]]
+name = "rand_core"
+version = "0.6.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
+dependencies = [
+ "getrandom",
+]
+
 [[package]]
 name = "rayon"
 version = "1.10.0"
diff --git a/Cargo.toml b/Cargo.toml
index cf032016..9c200b18 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -36,6 +36,7 @@ lexiclean = "0.0.1"
 libc = "0.2.0"
 log = "0.4.4"
 num_cpus = "1.15.0"
+rand = "0.8.5"
 regex = "1.10.4"
 semver = "1.0.20"
 serde = { version = "1.0.130", features = ["derive", "rc"] }
diff --git a/README.md b/README.md
index 06a51c59..371ab198 100644
--- a/README.md
+++ b/README.md
@@ -1517,6 +1517,13 @@ which will halt execution.
 
 [BLAKE3]: https://github.com/BLAKE3-team/BLAKE3/
 
+#### Random
+
+- `choose(n, alphabet)`<sup>master</sup> - Generate a string of `n` randomly
+  selected characters from `alphabet`, which may not contain repeated
+  characters. For example, `choose('64', HEX)` will generate a random
+  64-character lowercase hex string.
+
 #### Semantic Versions
 
 - `semver_matches(version, requirement)`<sup>1.16.0</sup> - Check whether a
diff --git a/src/function.rs b/src/function.rs
index 2c7dc0c2..f8fc9963 100644
--- a/src/function.rs
+++ b/src/function.rs
@@ -4,7 +4,9 @@ use {
     ToKebabCase, ToLowerCamelCase, ToShoutyKebabCase, ToShoutySnakeCase, ToSnakeCase, ToTitleCase,
     ToUpperCamelCase,
   },
+  rand::{seq::SliceRandom, thread_rng},
   semver::{Version, VersionReq},
+  std::collections::HashSet,
   Function::*,
 };
 
@@ -27,6 +29,7 @@ pub(crate) fn get(name: &str) -> Option<Function> {
     "cache_directory" => Nullary(|_| dir("cache", dirs::cache_dir)),
     "canonicalize" => Unary(canonicalize),
     "capitalize" => Unary(capitalize),
+    "choose" => Binary(choose),
     "clean" => Unary(clean),
     "config_directory" => Nullary(|_| dir("config", dirs::config_dir)),
     "config_local_directory" => Nullary(|_| dir("local config", dirs::config_local_dir)),
@@ -157,6 +160,30 @@ fn capitalize(_evaluator: &Evaluator, s: &str) -> Result<String, String> {
   Ok(capitalized)
 }
 
+fn choose(_evaluator: &Evaluator, n: &str, alphabet: &str) -> Result<String, String> {
+  if alphabet.is_empty() {
+    return Err("empty alphabet".into());
+  }
+
+  let mut chars = HashSet::<char>::with_capacity(alphabet.len());
+
+  for c in alphabet.chars() {
+    if !chars.insert(c) {
+      return Err(format!("alphabet contains repeated character `{c}`"));
+    }
+  }
+
+  let alphabet = alphabet.chars().collect::<Vec<char>>();
+
+  let n = n
+    .parse::<usize>()
+    .map_err(|err| format!("failed to parse `{n}` as positive integer: {err}"))?;
+
+  let mut rng = thread_rng();
+
+  Ok((0..n).map(|_| alphabet.choose(&mut rng).unwrap()).collect())
+}
+
 fn clean(_evaluator: &Evaluator, path: &str) -> Result<String, String> {
   Ok(Path::new(path).lexiclean().to_str().unwrap().to_owned())
 }


Test patch:
diff --git a/tests/functions.rs b/tests/functions.rs
index 95bddfd8..e1341101 100644
--- a/tests/functions.rs
+++ b/tests/functions.rs
@@ -661,6 +661,69 @@ fn uuid() {
     .run();
 }
 
+#[test]
+fn choose() {
+  Test::new()
+    .justfile(r#"x := choose('10', 'xXyYzZ')"#)
+    .args(["--evaluate", "x"])
+    .stdout_regex("^[X-Zx-z]{10}$")
+    .run();
+}
+
+#[test]
+fn choose_bad_alphabet_empty() {
+  Test::new()
+    .justfile("x := choose('10', '')")
+    .args(["--evaluate"])
+    .status(1)
+    .stderr(
+      "
+      error: Call to function `choose` failed: empty alphabet
+       ——▶ justfile:1:6
+        │
+      1 │ x := choose('10', '')
+        │      ^^^^^^
+    ",
+    )
+    .run();
+}
+
+#[test]
+fn choose_bad_alphabet_repeated() {
+  Test::new()
+    .justfile("x := choose('10', 'aa')")
+    .args(["--evaluate"])
+    .status(1)
+    .stderr(
+      "
+      error: Call to function `choose` failed: alphabet contains repeated character `a`
+       ——▶ justfile:1:6
+        │
+      1 │ x := choose('10', 'aa')
+        │      ^^^^^^
+    ",
+    )
+    .run();
+}
+
+#[test]
+fn choose_bad_length() {
+  Test::new()
+    .justfile("x := choose('foo', HEX)")
+    .args(["--evaluate"])
+    .status(1)
+    .stderr(
+      "
+      error: Call to function `choose` failed: failed to parse `foo` as positive integer: invalid digit found in string
+       ——▶ justfile:1:6
+        │
+      1 │ x := choose('foo', HEX)
+        │      ^^^^^^
+    ",
+    )
+    .run();
+}
+
 #[test]
 fn sha256() {
   Test::new()