diff --git a/Cargo.lock b/Cargo.lock index a34c6a5..91c4be6 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 cf03201..9c200b1 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 06a51c5..371ab19 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)`master - 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)`1.16.0 - Check whether a diff --git a/src/function.rs b/src/function.rs index 2c7dc0c..f8fc996 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 { "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 { Ok(capitalized) } +fn choose(_evaluator: &Evaluator, n: &str, alphabet: &str) -> Result { + if alphabet.is_empty() { + return Err("empty alphabet".into()); + } + + let mut chars = HashSet::::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::>(); + + let n = n + .parse::() + .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 { Ok(Path::new(path).lexiclean().to_str().unwrap().to_owned()) } diff --git a/tests/functions.rs b/tests/functions.rs index 95bddfd..e134110 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()