use std::fs;
use tempfile::TempDir;
mod config_tests {
use super::*;
use plzplz::config::{self, FailHook};
fn write_config(dir: &TempDir, content: &str) -> std::path::PathBuf {
let path = dir.path().join("plz.toml");
fs::write(&path, content).unwrap();
path
}
#[test]
fn parse_minimal_config() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.hello]
run = "echo hello"
"#,
);
let cfg = config::load(&path).unwrap();
assert!(cfg.tasks.contains_key("hello"));
}
#[test]
fn parse_full_config() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.build]
run = "echo build"
description = "Build the project"
[tasks.test]
run_serial = ["echo one", "echo two"]
[tasks.lint]
run_parallel = ["echo a", "echo b"]
"#,
);
let cfg = config::load(&path).unwrap();
assert_eq!(cfg.tasks.len(), 3);
}
#[test]
fn parse_task_with_run() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.hello]
run = "echo hello"
"#,
);
let cfg = config::load(&path).unwrap();
assert_eq!(cfg.tasks["hello"].run.as_deref(), Some("echo hello"));
}
#[test]
fn parse_task_with_run_serial() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.multi]
run_serial = ["echo one", "echo two"]
"#,
);
let cfg = config::load(&path).unwrap();
let serial = cfg.tasks["multi"].run_serial.as_ref().unwrap();
assert_eq!(serial, &["echo one", "echo two"]);
}
#[test]
fn parse_task_with_run_parallel() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.multi]
run_parallel = ["echo a", "echo b"]
"#,
);
let cfg = config::load(&path).unwrap();
let parallel = cfg.tasks["multi"].run_parallel.as_ref().unwrap();
assert_eq!(parallel, &["echo a", "echo b"]);
}
#[test]
fn parse_task_with_tool_env() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.a]
run = "test"
env = "uv"
[tasks.b]
run = "test"
env = "pnpm"
"#,
);
let cfg = config::load(&path).unwrap();
assert_eq!(cfg.tasks["a"].tool_env.as_deref(), Some("uv"));
assert_eq!(cfg.tasks["b"].tool_env.as_deref(), Some("pnpm"));
}
#[test]
fn parse_task_with_npm_env() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.a]
run = "test"
env = "npm"
"#,
);
let cfg = config::load(&path).unwrap();
assert_eq!(cfg.tasks["a"].tool_env.as_deref(), Some("npm"));
}
#[test]
fn parse_task_with_dir() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.sub]
run = "echo hi"
dir = "subdir"
"#,
);
let cfg = config::load(&path).unwrap();
assert_eq!(cfg.tasks["sub"].dir.as_deref(), Some("subdir"));
}
#[test]
fn parse_fail_hook_command() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.lint]
run = "cargo clippy"
fail_hook = "cargo fmt"
"#,
);
let cfg = config::load(&path).unwrap();
match &cfg.tasks["lint"].fail_hook {
Some(FailHook::Command(cmd)) => assert_eq!(cmd, "cargo fmt"),
other => panic!("Expected Command, got {:?}", other),
}
}
#[test]
fn parse_fail_hook_suggest() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.lint]
run = "cargo clippy"
fail_hook = { suggest_command = "plz fix" }
"#,
);
let cfg = config::load(&path).unwrap();
match &cfg.tasks["lint"].fail_hook {
Some(FailHook::Suggest { suggest_command }) => {
assert_eq!(suggest_command, "plz fix")
}
other => panic!("Expected Suggest, got {:?}", other),
}
}
#[test]
fn parse_fail_hook_message() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.lint]
run = "cargo clippy"
fail_hook = { message = "Run plz fix first" }
"#,
);
let cfg = config::load(&path).unwrap();
match &cfg.tasks["lint"].fail_hook {
Some(FailHook::Message(msg)) => assert_eq!(msg, "Run plz fix first"),
other => panic!("Expected Message, got {:?}", other),
}
}
#[test]
fn parse_explicit_description() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.build]
run = "cargo build"
description = "Build the project"
"#,
);
let cfg = config::load(&path).unwrap();
assert_eq!(
cfg.tasks["build"].description.as_deref(),
Some("Build the project")
);
}
#[test]
fn parse_comment_description() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
# Build the project
[tasks.build]
run = "cargo build"
"#,
);
let cfg = config::load(&path).unwrap();
assert_eq!(
cfg.tasks["build"].description.as_deref(),
Some("Build the project")
);
}
#[test]
fn extract_comment_single_line() {
assert_eq!(
config::extract_comment("# Hello world\n"),
Some("Hello world".to_string())
);
}
#[test]
fn extract_comment_multi_line() {
assert_eq!(
config::extract_comment("# Line one\n# Line two\n"),
Some("Line one Line two".to_string())
);
}
#[test]
fn extract_comment_empty() {
assert_eq!(config::extract_comment(""), None);
assert_eq!(config::extract_comment("\n\n"), None);
}
#[test]
fn extends_env_applies_to_all_tasks() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[extends]
env = "pnpm"
[tasks.build]
run = "vite build"
[tasks.dev]
run = "vite dev"
"#,
);
let cfg = config::load(&path).unwrap();
assert_eq!(cfg.tasks["build"].tool_env.as_deref(), Some("pnpm"));
assert_eq!(cfg.tasks["dev"].tool_env.as_deref(), Some("pnpm"));
}
#[test]
fn extends_dir_applies_to_all_tasks() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[extends]
dir = "packages/web"
[tasks.build]
run = "echo build"
[tasks.dev]
run = "echo dev"
"#,
);
let cfg = config::load(&path).unwrap();
assert_eq!(cfg.tasks["build"].dir.as_deref(), Some("packages/web"));
assert_eq!(cfg.tasks["dev"].dir.as_deref(), Some("packages/web"));
}
#[test]
fn per_task_overrides_extends() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[extends]
env = "pnpm"
dir = "packages/web"
[tasks.build]
run = "vite build"
[tasks.api]
run = "node server.js"
env = "npm"
dir = "packages/api"
"#,
);
let cfg = config::load(&path).unwrap();
assert_eq!(cfg.tasks["build"].tool_env.as_deref(), Some("pnpm"));
assert_eq!(cfg.tasks["build"].dir.as_deref(), Some("packages/web"));
assert_eq!(cfg.tasks["api"].tool_env.as_deref(), Some("npm"));
assert_eq!(cfg.tasks["api"].dir.as_deref(), Some("packages/api"));
}
#[test]
fn empty_string_opts_out_of_extends() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[extends]
env = "pnpm"
dir = "packages/web"
[tasks.build]
run = "vite build"
[tasks.plain]
run = "echo hi"
env = ""
dir = ""
"#,
);
let cfg = config::load(&path).unwrap();
assert_eq!(cfg.tasks["build"].tool_env.as_deref(), Some("pnpm"));
assert_eq!(cfg.tasks["build"].dir.as_deref(), Some("packages/web"));
assert_eq!(cfg.tasks["plain"].tool_env, None);
assert_eq!(cfg.tasks["plain"].dir, None);
}
#[test]
fn parse_taskgroup_basic() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.hello]
run = "echo hello"
[taskgroup.rust.test]
run = "cargo test"
[taskgroup.rust.build]
run = "cargo build"
"#,
);
let cfg = config::load(&path).unwrap();
assert!(cfg.get_group("rust").is_some());
assert!(cfg.get_group_task("rust", "test").is_some());
assert!(cfg.get_group_task("rust", "build").is_some());
assert_eq!(
cfg.get_group_task("rust", "test").unwrap().run.as_deref(),
Some("cargo test")
);
}
#[test]
fn taskgroup_extends_inherits_from_group() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.hello]
run = "echo hello"
[taskgroup.frontend.extends]
env = "pnpm"
dir = "packages/web"
[taskgroup.frontend.build]
run = "vite build"
[taskgroup.frontend.dev]
run = "vite dev"
"#,
);
let cfg = config::load(&path).unwrap();
let build = cfg.get_group_task("frontend", "build").unwrap();
assert_eq!(build.tool_env.as_deref(), Some("pnpm"));
assert_eq!(build.dir.as_deref(), Some("packages/web"));
let dev = cfg.get_group_task("frontend", "dev").unwrap();
assert_eq!(dev.tool_env.as_deref(), Some("pnpm"));
assert_eq!(dev.dir.as_deref(), Some("packages/web"));
}
#[test]
fn taskgroup_cascade_top_level_to_group() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[extends]
env = "pnpm"
[tasks.hello]
run = "echo hello"
[taskgroup.web.build]
run = "vite build"
"#,
);
let cfg = config::load(&path).unwrap();
let build = cfg.get_group_task("web", "build").unwrap();
assert_eq!(build.tool_env.as_deref(), Some("pnpm"));
}
#[test]
fn taskgroup_group_extends_overrides_top_level() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[extends]
env = "pnpm"
dir = "default"
[tasks.hello]
run = "echo hello"
[taskgroup.api.extends]
env = "npm"
dir = "backend"
[taskgroup.api.serve]
run = "node server.js"
"#,
);
let cfg = config::load(&path).unwrap();
let serve = cfg.get_group_task("api", "serve").unwrap();
assert_eq!(serve.tool_env.as_deref(), Some("npm"));
assert_eq!(serve.dir.as_deref(), Some("backend"));
}
#[test]
fn taskgroup_task_overrides_group_extends() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.hello]
run = "echo hello"
[taskgroup.rust.extends]
dir = "backend"
[taskgroup.rust.test]
run = "cargo test"
[taskgroup.rust.special]
run = "echo special"
dir = "other"
"#,
);
let cfg = config::load(&path).unwrap();
let test = cfg.get_group_task("rust", "test").unwrap();
assert_eq!(test.dir.as_deref(), Some("backend"));
let special = cfg.get_group_task("rust", "special").unwrap();
assert_eq!(special.dir.as_deref(), Some("other"));
}
#[test]
fn taskgroup_empty_string_opts_out() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[extends]
env = "pnpm"
[tasks.hello]
run = "echo hello"
[taskgroup.misc.extends]
dir = "subdir"
[taskgroup.misc.plain]
run = "echo plain"
env = ""
dir = ""
"#,
);
let cfg = config::load(&path).unwrap();
let plain = cfg.get_group_task("misc", "plain").unwrap();
assert_eq!(plain.tool_env, None);
assert_eq!(plain.dir, None);
}
#[test]
fn taskgroup_validates_git_hook() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.hello]
run = "echo hello"
[taskgroup.rust.lint]
run = "cargo clippy"
git_hook = "not-a-hook"
"#,
);
let err = config::load(&path).unwrap_err();
assert!(err.to_string().contains("invalid git_hook"), "got: {err}");
}
#[test]
fn taskgroup_comment_description() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.hello]
run = "echo hello"
# Run the tests
[taskgroup.rust.test]
run = "cargo test"
"#,
);
let cfg = config::load(&path).unwrap();
assert_eq!(
cfg.get_group_task("rust", "test")
.unwrap()
.description
.as_deref(),
Some("Run the tests")
);
}
#[test]
fn parse_taskgroups_only_no_tasks() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[taskgroup.rust.test]
run = "cargo test"
[taskgroup.rust.build]
run = "cargo build"
"#,
);
let cfg = config::load(&path).unwrap();
assert!(cfg.tasks.is_empty());
assert_eq!(cfg.get_group("rust").unwrap().tasks.len(), 2);
assert!(cfg.get_group_task("rust", "test").is_some());
assert!(cfg.get_group_task("rust", "build").is_some());
}
#[test]
fn get_group_returns_none_for_missing() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.hello]
run = "echo hello"
"#,
);
let cfg = config::load(&path).unwrap();
assert!(cfg.get_group("nonexistent").is_none());
assert!(cfg.get_group_task("nonexistent", "test").is_none());
}
#[test]
fn parse_invalid_toml_errors() {
let dir = TempDir::new().unwrap();
let path = write_config(&dir, "this is not valid toml [[[");
assert!(config::load(&path).is_err());
}
#[test]
fn parse_missing_file_errors() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("nonexistent.toml");
assert!(config::load(&path).is_err());
}
}
mod runner_tests {
use super::*;
use plzplz::config;
use plzplz::runner;
fn load_config(dir: &TempDir, content: &str) -> config::PlzConfig {
let path = dir.path().join("plz.toml");
fs::write(&path, content).unwrap();
config::load(&path).unwrap()
}
#[test]
fn run_simple_command() {
let dir = TempDir::new().unwrap();
let cfg = load_config(
&dir,
r#"
[tasks.hello]
run = "echo hello"
"#,
);
runner::run_task(&cfg, "hello", dir.path(), false).unwrap();
}
#[test]
fn run_failing_command() {
let dir = TempDir::new().unwrap();
let cfg = load_config(
&dir,
r#"
[tasks.fail]
run = "false"
"#,
);
assert!(runner::run_task(&cfg, "fail", dir.path(), false).is_err());
}
#[test]
fn run_serial_commands() {
let dir = TempDir::new().unwrap();
let marker = dir.path().join("marker.txt");
let cfg = load_config(
&dir,
&format!(
r#"
[tasks.serial]
run_serial = ["echo first", "touch {}"]
"#,
marker.display()
),
);
runner::run_task(&cfg, "serial", dir.path(), false).unwrap();
assert!(marker.exists());
}
#[test]
fn run_serial_stops_on_failure() {
let dir = TempDir::new().unwrap();
let marker = dir.path().join("should_not_exist.txt");
let cfg = load_config(
&dir,
&format!(
r#"
[tasks.serial]
run_serial = ["false", "touch {}"]
"#,
marker.display()
),
);
assert!(runner::run_task(&cfg, "serial", dir.path(), false).is_err());
assert!(!marker.exists());
}
#[test]
fn run_parallel_commands() {
let dir = TempDir::new().unwrap();
let m1 = dir.path().join("p1.txt");
let m2 = dir.path().join("p2.txt");
let cfg = load_config(
&dir,
&format!(
r#"
[tasks.par]
run_parallel = ["touch {}", "touch {}"]
"#,
m1.display(),
m2.display()
),
);
runner::run_task(&cfg, "par", dir.path(), false).unwrap();
assert!(m1.exists());
assert!(m2.exists());
}
#[test]
fn run_parallel_fails_on_error() {
let dir = TempDir::new().unwrap();
let cfg = load_config(
&dir,
r#"
[tasks.par]
run_parallel = ["true", "false"]
"#,
);
assert!(runner::run_task(&cfg, "par", dir.path(), false).is_err());
}
#[test]
fn run_with_working_dir() {
let dir = TempDir::new().unwrap();
let subdir = dir.path().join("mysubdir");
fs::create_dir(&subdir).unwrap();
let out = dir.path().join("pwd_out.txt");
let cfg = load_config(
&dir,
&format!(
r#"
[tasks.wd]
run = "pwd > {}"
dir = "mysubdir"
"#,
out.display()
),
);
runner::run_task(&cfg, "wd", dir.path(), false).unwrap();
let content = fs::read_to_string(&out).unwrap();
assert!(content.trim().ends_with("mysubdir"));
}
#[test]
fn run_with_tool_env_uv() {
let dir = TempDir::new().unwrap();
let cfg = load_config(
&dir,
r#"
[tasks.uv_test]
run = "echo hello"
env = "uv"
"#,
);
assert_eq!(cfg.tasks["uv_test"].tool_env.as_deref(), Some("uv"));
let _ = runner::run_task(&cfg, "uv_test", dir.path(), false);
}
#[test]
fn run_with_tool_env_pnpm() {
let dir = TempDir::new().unwrap();
let cfg = load_config(
&dir,
r#"
[tasks.pnpm_test]
run = "echo hello"
env = "pnpm"
"#,
);
assert_eq!(cfg.tasks["pnpm_test"].tool_env.as_deref(), Some("pnpm"));
let _ = runner::run_task(&cfg, "pnpm_test", dir.path(), false);
}
#[test]
fn run_with_tool_env_npm() {
let dir = TempDir::new().unwrap();
let cfg = load_config(
&dir,
r#"
[tasks.npm_test]
run = "echo hello"
env = "npm"
"#,
);
assert_eq!(cfg.tasks["npm_test"].tool_env.as_deref(), Some("npm"));
let _ = runner::run_task(&cfg, "npm_test", dir.path(), false);
}
#[test]
fn run_task_reference() {
let dir = TempDir::new().unwrap();
let marker = dir.path().join("ref_marker.txt");
let cfg = load_config(
&dir,
&format!(
r#"
[tasks.caller]
run = "plz:target"
[tasks.target]
run = "touch {}"
"#,
marker.display()
),
);
runner::run_task(&cfg, "caller", dir.path(), false).unwrap();
assert!(marker.exists());
}
#[test]
fn run_unknown_task_errors() {
let dir = TempDir::new().unwrap();
let cfg = load_config(
&dir,
r#"
[tasks.hello]
run = "echo hello"
"#,
);
let err = runner::run_task(&cfg, "nonexistent", dir.path(), false);
assert!(err.is_err());
assert!(err.unwrap_err().to_string().contains("isn't a task"));
}
#[test]
fn run_serial_with_task_ref() {
let dir = TempDir::new().unwrap();
let m1 = dir.path().join("s1.txt");
let m2 = dir.path().join("s2.txt");
let cfg = load_config(
&dir,
&format!(
r#"
[tasks.main]
run_serial = ["touch {}", "plz:helper"]
[tasks.helper]
run = "touch {}"
"#,
m1.display(),
m2.display()
),
);
runner::run_task(&cfg, "main", dir.path(), false).unwrap();
assert!(m1.exists());
assert!(m2.exists());
}
#[test]
fn run_group_task_simple() {
let dir = TempDir::new().unwrap();
let marker = dir.path().join("group_marker.txt");
let cfg = load_config(
&dir,
&format!(
r#"
[tasks.hello]
run = "echo hello"
[taskgroup.rust.test]
run = "touch {}"
"#,
marker.display()
),
);
runner::run_group_task(&cfg, "rust", "test", dir.path(), false).unwrap();
assert!(marker.exists());
}
#[test]
fn run_group_task_with_dir() {
let dir = TempDir::new().unwrap();
let subdir = dir.path().join("backend");
fs::create_dir(&subdir).unwrap();
let out = dir.path().join("grp_pwd.txt");
let cfg = load_config(
&dir,
&format!(
r#"
[tasks.hello]
run = "echo hello"
[taskgroup.rust.extends]
dir = "backend"
[taskgroup.rust.wd]
run = "pwd > {}"
"#,
out.display()
),
);
runner::run_group_task(&cfg, "rust", "wd", dir.path(), false).unwrap();
let content = fs::read_to_string(&out).unwrap();
assert!(content.trim().ends_with("backend"));
}
#[test]
fn run_group_task_unknown_errors() {
let dir = TempDir::new().unwrap();
let cfg = load_config(
&dir,
r#"
[tasks.hello]
run = "echo hello"
[taskgroup.rust.test]
run = "cargo test"
"#,
);
let err = runner::run_group_task(&cfg, "rust", "nonexistent", dir.path(), false);
assert!(err.is_err());
assert!(err.unwrap_err().to_string().contains("isn't a task"));
}
#[test]
fn run_group_task_with_extra_args() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("args_out.txt");
let cfg = load_config(
&dir,
&format!(
r#"
[tasks.hello]
run = "echo hello"
[taskgroup.rust.echo]
run = "echo > {}"
"#,
out.display()
),
);
let args = vec!["--nocapture".to_string()];
runner::run_group_task_with_args(&cfg, "rust", "echo", dir.path(), false, &args).unwrap();
assert!(out.exists());
}
#[test]
fn run_group_task_ref_in_serial() {
let dir = TempDir::new().unwrap();
let m1 = dir.path().join("gs1.txt");
let m2 = dir.path().join("gs2.txt");
let cfg = load_config(
&dir,
&format!(
r#"
[tasks.main]
run_serial = ["touch {}", "plz:grp:helper"]
[taskgroup.grp.helper]
run = "touch {}"
"#,
m1.display(),
m2.display()
),
);
runner::run_task(&cfg, "main", dir.path(), false).unwrap();
assert!(m1.exists());
assert!(m2.exists());
}
#[test]
fn run_group_task_ref_in_parallel() {
let dir = TempDir::new().unwrap();
let m1 = dir.path().join("gp1.txt");
let m2 = dir.path().join("gp2.txt");
let cfg = load_config(
&dir,
&format!(
r#"
[tasks.main]
run_parallel = ["touch {}", "plz:grp:helper"]
[taskgroup.grp.helper]
run = "touch {}"
"#,
m1.display(),
m2.display()
),
);
runner::run_task(&cfg, "main", dir.path(), false).unwrap();
assert!(m1.exists());
assert!(m2.exists());
}
#[test]
fn run_parallel_with_task_ref() {
let dir = TempDir::new().unwrap();
let m1 = dir.path().join("p1.txt");
let m2 = dir.path().join("p2.txt");
let cfg = load_config(
&dir,
&format!(
r#"
[tasks.main]
run_parallel = ["touch {}", "plz:helper"]
[tasks.helper]
run = "touch {}"
"#,
m1.display(),
m2.display()
),
);
runner::run_task(&cfg, "main", dir.path(), false).unwrap();
assert!(m1.exists());
assert!(m2.exists());
}
}
mod init_tests {
use super::*;
use plzplz::init;
use plzplz::templates;
#[test]
fn detect_environment_rust() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let envs = templates::load_environments();
let detected = templates::detect_environments(dir.path(), &envs);
assert!(detected.contains(&"rust".to_string()));
}
#[test]
fn detect_environment_pnpm() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
let envs = templates::load_environments();
let detected = templates::detect_environments(dir.path(), &envs);
assert!(detected.contains(&"pnpm".to_string()));
}
#[test]
fn detect_environment_npm() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("package-lock.json"), "{}").unwrap();
let envs = templates::load_environments();
let detected = templates::detect_environments(dir.path(), &envs);
assert!(detected.contains(&"npm".to_string()));
}
#[test]
fn detect_environment_npm_from_package_json() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("package.json"), "{}").unwrap();
let envs = templates::load_environments();
let detected = templates::detect_environments(dir.path(), &envs);
assert!(detected.contains(&"npm".to_string()));
}
#[test]
fn detect_environment_uv() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("uv.lock"), "").unwrap();
let envs = templates::load_environments();
let detected = templates::detect_environments(dir.path(), &envs);
assert!(detected.contains(&"uv".to_string()));
}
#[test]
fn detect_environment_multiple() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
let envs = templates::load_environments();
let detected = templates::detect_environments(dir.path(), &envs);
assert!(detected.len() >= 2);
assert!(detected.contains(&"rust".to_string()));
assert!(detected.contains(&"pnpm".to_string()));
}
#[test]
fn detect_environment_none() {
let dir = TempDir::new().unwrap();
let envs = templates::load_environments();
let detected = templates::detect_environments(dir.path(), &envs);
assert!(detected.is_empty());
}
#[test]
fn pnpm_is_alternative_to_npm() {
let envs = templates::load_environments();
let pnpm = &envs["pnpm"];
assert!(pnpm.alternative_to.contains(&"npm".to_string()));
}
#[test]
fn load_embedded_templates() {
let all = templates::load_templates(None);
let names: Vec<&str> = all.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"rust"));
assert!(names.contains(&"vite"));
assert!(names.contains(&"vite-npm"));
assert!(names.contains(&"uv"));
}
#[test]
fn template_has_metadata() {
let all = templates::load_templates(None);
let rust = all.iter().find(|t| t.name == "rust").unwrap();
assert_eq!(rust.env.as_deref(), Some("rust"));
assert!(!rust.description.is_empty());
}
#[test]
fn strip_template_section() {
let input = r#"[template]
description = "Test"
env = "rust"
# Build
[tasks.build]
run = "cargo build"
"#;
let result = templates::strip_template_section(input);
assert!(!result.contains("[template]"));
assert!(!result.contains("description = \"Test\""));
assert!(result.contains("[tasks.build]"));
assert!(result.contains("cargo build"));
}
#[test]
fn load_embedded_snippets() {
let all = templates::load_snippets();
let env_names: Vec<&str> = all.iter().map(|(n, _)| n.as_str()).collect();
assert!(env_names.contains(&"general"));
assert!(env_names.contains(&"rust"));
assert!(env_names.contains(&"pnpm"));
}
#[test]
fn add_suffix_renames_tasks() {
let toml = r#"
[tasks.dev]
run = "cargo run"
[tasks.build]
run = "cargo build"
"#;
let (_, tasks) = init::parse_default(toml).unwrap();
let result = init::add_suffix_to_toml(toml, "rust", &tasks);
assert!(result.contains("[tasks.dev-rust]"));
assert!(result.contains("[tasks.build-rust]"));
assert!(!result.contains("[tasks.dev]"));
assert!(!result.contains("[tasks.build]"));
}
#[test]
fn add_suffix_updates_plz_references() {
let toml = r#"
[tasks.lint]
run_serial = ["cargo clippy", "cargo fmt --check"]
fail_hook = { suggest_command = "plz fix" }
[tasks.fix]
run_serial = ["cargo fmt", "cargo clippy --fix --allow-dirty"]
"#;
let (_, tasks) = init::parse_default(toml).unwrap();
let result = init::add_suffix_to_toml(toml, "rust", &tasks);
assert!(result.contains("plz fix-rust"), "got:\n{result}");
}
#[test]
fn convert_to_taskgroup_basic() {
let toml = r#"
[tasks.dev]
run = "cargo run"
[tasks.build]
run = "cargo build"
"#;
let (_, tasks) = init::parse_default(toml).unwrap();
let result = init::convert_to_taskgroup(toml, "rust", &tasks, "rust");
assert!(result.contains("[taskgroup.rust.dev]"));
assert!(result.contains("[taskgroup.rust.build]"));
assert!(!result.contains("[tasks.dev]"));
assert!(!result.contains("[tasks.build]"));
}
#[test]
fn convert_to_taskgroup_extracts_env() {
let toml = r#"
[tasks.dev]
env = "pnpm"
run = "vite"
[tasks.build]
env = "pnpm"
run = "vite build"
"#;
let (_, tasks) = init::parse_default(toml).unwrap();
let result = init::convert_to_taskgroup(toml, "pnpm", &tasks, "pnpm");
assert!(
result.contains("[taskgroup.pnpm.extends]"),
"missing extends: {result}"
);
assert!(
result.contains("env = \"pnpm\""),
"missing env in extends: {result}"
);
let env_count = result.matches("env = \"pnpm\"").count();
assert_eq!(env_count, 1, "env should only appear in extends: {result}");
}
#[test]
fn convert_to_taskgroup_updates_plz_references() {
let toml = r#"
[tasks.lint]
run_serial = ["cargo clippy", "cargo fmt --check"]
fail_hook = { suggest_command = "plz fix" }
[tasks.fix]
run_serial = ["cargo fmt", "cargo clippy --fix --allow-dirty"]
"#;
let (_, tasks) = init::parse_default(toml).unwrap();
let result = init::convert_to_taskgroup(toml, "rust", &tasks, "rust");
assert!(
result.contains("plz rust fix"),
"should update plz references: {result}"
);
}
#[test]
fn embedded_templates_parse_correctly() {
let all = templates::load_templates(None);
for t in &all {
let stripped = templates::strip_template_section(&t.content);
let parsed = init::parse_default(&stripped);
assert!(parsed.is_some(), "Template {} failed to parse", t.name);
let (_, tasks) = parsed.unwrap();
assert!(!tasks.is_empty(), "Template {} has no tasks", t.name);
}
}
#[test]
fn user_template_listed_before_embedded_for_same_env() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("templates")).unwrap();
let user_template = dir.path().join("templates").join("user.plz.toml");
fs::write(
&user_template,
r#"[template]
description = "My custom rust"
env = "rust"
[tasks.mybuild]
run = "cargo build --release"
"#,
)
.unwrap();
let all = templates::load_templates(Some(dir.path()));
let names: Vec<&str> = all.iter().map(|t| t.name.as_str()).collect();
let user_pos = names.iter().position(|n| *n == "user").unwrap();
let rust_pos = names.iter().position(|n| *n == "rust").unwrap();
assert!(
user_pos < rust_pos,
"user template should appear before embedded rust: {names:?}"
);
assert!(all[user_pos].is_user);
assert!(!all[rust_pos].is_user);
}
#[test]
fn user_template_appended_when_env_not_in_embedded() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("templates")).unwrap();
let user_template = dir.path().join("templates").join("user.plz.toml");
fs::write(
&user_template,
r#"[template]
description = "My custom env"
env = "custom"
[tasks.hello]
run = "echo hello"
"#,
)
.unwrap();
let all = templates::load_templates(Some(dir.path()));
let last = all.last().unwrap();
assert_eq!(last.name, "user");
assert_eq!(last.env.as_deref(), Some("custom"));
assert!(last.is_user);
}
#[test]
fn embedded_templates_not_marked_as_user() {
let all = templates::load_templates(None);
for t in &all {
assert!(
!t.is_user,
"embedded template {} should not be user",
t.name
);
}
}
}
mod hooks_tests {
use super::*;
use plzplz::config;
use plzplz::hooks;
fn write_config(dir: &TempDir, content: &str) -> std::path::PathBuf {
let path = dir.path().join("plz.toml");
fs::write(&path, content).unwrap();
path
}
fn init_git_repo(dir: &TempDir) {
fs::create_dir_all(dir.path().join(".git/hooks")).unwrap();
}
#[test]
fn parse_git_hook_field() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.lint]
run = "cargo clippy"
git_hook = "pre-commit"
"#,
);
let cfg = config::load(&path).unwrap();
assert_eq!(cfg.tasks["lint"].git_hook.as_deref(), Some("pre-commit"));
}
#[test]
fn reject_invalid_git_hook() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.lint]
run = "cargo clippy"
git_hook = "not-a-real-hook"
"#,
);
let err = config::load(&path).unwrap_err();
assert!(err.to_string().contains("invalid git_hook"), "got: {err}");
}
#[test]
fn tasks_by_stage_groups_correctly() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.lint]
run = "cargo clippy"
git_hook = "pre-commit"
[tasks.fmt]
run = "cargo fmt --check"
git_hook = "pre-commit"
[tasks.test]
run = "cargo test"
git_hook = "pre-push"
[tasks.build]
run = "cargo build"
"#,
);
let cfg = config::load(&path).unwrap();
let stages = hooks::tasks_by_stage(&cfg);
assert_eq!(stages.len(), 2);
assert_eq!(stages["pre-commit"].len(), 2);
assert!(stages["pre-commit"].contains(&"lint".to_string()));
assert!(stages["pre-commit"].contains(&"fmt".to_string()));
assert_eq!(stages["pre-push"], vec!["test"]);
}
#[test]
fn install_creates_hook_scripts() {
let dir = TempDir::new().unwrap();
init_git_repo(&dir);
let path = write_config(
&dir,
r#"
[tasks.lint]
run = "cargo clippy"
git_hook = "pre-commit"
[tasks.test]
run = "cargo test"
git_hook = "pre-push"
"#,
);
let cfg = config::load(&path).unwrap();
hooks::install(&cfg, dir.path()).unwrap();
let pre_commit = dir.path().join(".git/hooks/pre-commit");
let pre_push = dir.path().join(".git/hooks/pre-push");
assert!(pre_commit.exists());
assert!(pre_push.exists());
let content = fs::read_to_string(&pre_commit).unwrap();
assert!(content.contains("plz:managed"));
assert!(content.contains("hooks run pre-commit"));
let content = fs::read_to_string(&pre_push).unwrap();
assert!(content.contains("hooks run pre-push"));
}
#[test]
fn install_skips_non_managed_hooks() {
let dir = TempDir::new().unwrap();
init_git_repo(&dir);
let existing_hook = dir.path().join(".git/hooks/pre-commit");
fs::write(&existing_hook, "#!/bin/sh\necho my custom hook\n").unwrap();
let path = write_config(
&dir,
r#"
[tasks.lint]
run = "cargo clippy"
git_hook = "pre-commit"
"#,
);
let cfg = config::load(&path).unwrap();
hooks::install(&cfg, dir.path()).unwrap();
let content = fs::read_to_string(&existing_hook).unwrap();
assert!(
content.contains("my custom hook"),
"Should not overwrite user hook"
);
assert!(!content.contains("plz:managed"));
}
#[test]
fn install_overwrites_managed_hooks() {
let dir = TempDir::new().unwrap();
init_git_repo(&dir);
let hook_path = dir.path().join(".git/hooks/pre-commit");
fs::write(
&hook_path,
"#!/bin/sh\n# plz:managed - do not edit\nplz hooks run pre-commit\n",
)
.unwrap();
let path = write_config(
&dir,
r#"
[tasks.lint]
run = "cargo clippy"
git_hook = "pre-commit"
"#,
);
let cfg = config::load(&path).unwrap();
hooks::install(&cfg, dir.path()).unwrap();
let content = fs::read_to_string(&hook_path).unwrap();
assert!(content.contains("plz:managed"));
}
#[test]
fn uninstall_removes_managed_hooks() {
let dir = TempDir::new().unwrap();
init_git_repo(&dir);
let hook_path = dir.path().join(".git/hooks/pre-commit");
fs::write(
&hook_path,
"#!/bin/sh\n# plz:managed - do not edit\nplz hooks run pre-commit\n",
)
.unwrap();
let path = write_config(
&dir,
r#"
[tasks.lint]
run = "cargo clippy"
git_hook = "pre-commit"
"#,
);
let cfg = config::load(&path).unwrap();
hooks::uninstall(&cfg, dir.path()).unwrap();
assert!(!hook_path.exists());
}
#[test]
fn uninstall_skips_non_managed_hooks() {
let dir = TempDir::new().unwrap();
init_git_repo(&dir);
let hook_path = dir.path().join(".git/hooks/pre-commit");
fs::write(&hook_path, "#!/bin/sh\necho custom\n").unwrap();
let path = write_config(
&dir,
r#"
[tasks.lint]
run = "cargo clippy"
git_hook = "pre-commit"
"#,
);
let cfg = config::load(&path).unwrap();
hooks::uninstall(&cfg, dir.path()).unwrap();
assert!(hook_path.exists(), "Should not remove non-managed hook");
}
#[test]
fn find_git_hooks_dir_walks_up() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join(".git/hooks")).unwrap();
let subdir = dir.path().join("a/b/c");
fs::create_dir_all(&subdir).unwrap();
let result = hooks::find_git_hooks_dir(&subdir).unwrap();
assert_eq!(result, dir.path().join(".git/hooks"));
}
#[test]
fn find_git_hooks_dir_fails_without_git() {
let dir = TempDir::new().unwrap();
assert!(hooks::find_git_hooks_dir(dir.path()).is_err());
}
#[test]
fn tasks_by_stage_includes_group_tasks() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.lint]
run = "cargo clippy"
git_hook = "pre-commit"
[taskgroup.rust.fmt]
run = "cargo fmt --check"
git_hook = "pre-commit"
[taskgroup.rust.test]
run = "cargo test"
git_hook = "pre-push"
"#,
);
let cfg = config::load(&path).unwrap();
let stages = hooks::tasks_by_stage(&cfg);
assert_eq!(stages.len(), 2);
let pre_commit = &stages["pre-commit"];
assert!(pre_commit.contains(&"lint".to_string()));
assert!(pre_commit.contains(&"rust:fmt".to_string()));
let pre_push = &stages["pre-push"];
assert!(pre_push.contains(&"rust:test".to_string()));
}
#[test]
fn run_stage_executes_group_tasks() {
let dir = TempDir::new().unwrap();
let marker = dir.path().join("group_hook_ran.txt");
let path = write_config(
&dir,
&format!(
r#"
[tasks.hello]
run = "echo hello"
[taskgroup.rust.check]
run = "touch {}"
git_hook = "pre-commit"
"#,
marker.display()
),
);
let cfg = config::load(&path).unwrap();
hooks::run_stage(&cfg, "pre-commit", dir.path(), false).unwrap();
assert!(marker.exists());
}
#[test]
fn run_stage_executes_tasks() {
let dir = TempDir::new().unwrap();
let marker = dir.path().join("hook_ran.txt");
let path = write_config(
&dir,
&format!(
r#"
[tasks.check]
run = "touch {}"
git_hook = "pre-commit"
"#,
marker.display()
),
);
let cfg = config::load(&path).unwrap();
hooks::run_stage(&cfg, "pre-commit", dir.path(), false).unwrap();
assert!(marker.exists());
}
#[test]
fn hook_script_contains_skip_and_fallback() {
let dir = TempDir::new().unwrap();
init_git_repo(&dir);
let path = write_config(
&dir,
r#"
[tasks.lint]
run = "cargo clippy"
git_hook = "pre-commit"
"#,
);
let cfg = config::load(&path).unwrap();
hooks::install(&cfg, dir.path()).unwrap();
let content = fs::read_to_string(dir.path().join(".git/hooks/pre-commit")).unwrap();
assert!(content.contains("PLZ_SKIP_HOOKS"), "missing skip env var");
assert!(
content.contains("command -v plz"),
"missing not-in-PATH fallback"
);
assert!(
!content.contains("\"$@\""),
"should not pass git args to tasks"
);
assert!(
content.contains("plz:hooks_version="),
"missing hooks version marker"
);
}
#[test]
fn install_upgrades_outdated_hooks() {
let dir = TempDir::new().unwrap();
init_git_repo(&dir);
let path = write_config(
&dir,
r#"
[tasks.lint]
run = "cargo clippy"
git_hook = "pre-commit"
"#,
);
let cfg = config::load(&path).unwrap();
let hook_path = dir.path().join(".git/hooks/pre-commit");
fs::create_dir_all(hook_path.parent().unwrap()).unwrap();
fs::write(
&hook_path,
"#!/bin/sh\n# plz:managed - do not edit\nplz hooks run pre-commit \"$@\"\n",
)
.unwrap();
hooks::install(&cfg, dir.path()).unwrap();
let content = fs::read_to_string(&hook_path).unwrap();
assert!(
content.contains("plz:hooks_version="),
"should have been upgraded"
);
assert!(!content.contains("\"$@\""), "should not have $@");
}
#[test]
fn run_stage_unconfigured_stage_succeeds() {
let dir = TempDir::new().unwrap();
let path = write_config(
&dir,
r#"
[tasks.check]
run = "echo hi"
git_hook = "pre-commit"
"#,
);
let cfg = config::load(&path).unwrap();
hooks::run_stage(&cfg, "pre-push", dir.path(), false).unwrap();
}
#[cfg(unix)]
#[test]
fn install_sets_executable_permission() {
use std::os::unix::fs::PermissionsExt;
let dir = TempDir::new().unwrap();
init_git_repo(&dir);
let path = write_config(
&dir,
r#"
[tasks.lint]
run = "cargo clippy"
git_hook = "pre-commit"
"#,
);
let cfg = config::load(&path).unwrap();
hooks::install(&cfg, dir.path()).unwrap();
let hook_path = dir.path().join(".git/hooks/pre-commit");
let perms = fs::metadata(&hook_path).unwrap().permissions();
assert!(perms.mode() & 0o111 != 0, "Hook should be executable");
}
}
mod cli_tests {
use assert_cmd::Command;
use predicates::prelude::*;
use std::fs;
use tempfile::TempDir;
#[allow(deprecated)]
fn plz() -> Command {
Command::cargo_bin("plz").unwrap()
}
#[test]
fn cli_no_args_no_config_shows_help() {
let dir = TempDir::new().unwrap();
plz()
.current_dir(dir.path())
.assert()
.success()
.stdout(predicate::str::contains("Run a task from plz.toml"));
}
#[test]
fn cli_help_lists_all_commands() {
let dir = TempDir::new().unwrap();
let output = plz()
.arg("--help")
.current_dir(dir.path())
.output()
.unwrap();
let stdout = String::from_utf8(output.stdout).unwrap();
for expected in [
"init",
"add",
"hooks",
"schema",
"cheatsheet",
"update",
"--no-interactive",
] {
assert!(
stdout.contains(expected),
"Help output missing '{expected}'"
);
}
}
#[test]
fn cli_run_task() {
let dir = TempDir::new().unwrap();
let marker = dir.path().join("ran.txt");
fs::write(
dir.path().join("plz.toml"),
format!(
r#"
[tasks.hello]
run = "touch {}"
"#,
marker.display()
),
)
.unwrap();
plz()
.arg("hello")
.current_dir(dir.path())
.assert()
.success();
assert!(marker.exists());
}
#[test]
fn cli_run_unknown_task_errors() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("plz.toml"),
r#"
[tasks.hello]
run = "echo hello"
"#,
)
.unwrap();
plz()
.arg("nonexistent")
.current_dir(dir.path())
.assert()
.failure()
.stderr(predicate::str::contains("isn't a task"));
}
#[test]
fn cli_non_interactive_when_no_tty() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("plz.toml"),
r#"
[tasks.hello]
run = "echo hello"
"#,
)
.unwrap();
plz()
.current_dir(dir.path())
.assert()
.failure()
.stderr(predicate::str::contains(
"No task specified (running in non-interactive mode)",
));
}
#[test]
fn cli_non_interactive_flag() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("plz.toml"),
r#"
[tasks.hello]
run = "echo hello"
"#,
)
.unwrap();
plz()
.arg("--no-interactive")
.current_dir(dir.path())
.env_remove("PLZ_COMMAND")
.env_remove("CI")
.assert()
.failure()
.stderr(predicate::str::contains(
"No task specified (running in non-interactive mode)",
));
}
#[test]
fn cli_run_group_task() {
let dir = TempDir::new().unwrap();
let marker = dir.path().join("grp_ran.txt");
fs::write(
dir.path().join("plz.toml"),
format!(
r#"
[tasks.hello]
run = "echo hello"
[taskgroup.rust.test]
run = "touch {}"
"#,
marker.display()
),
)
.unwrap();
plz()
.args(["rust", "test"])
.current_dir(dir.path())
.assert()
.success();
assert!(marker.exists());
}
#[test]
fn cli_run_group_task_with_args() {
let dir = TempDir::new().unwrap();
let out = dir.path().join("grp_args.txt");
fs::write(
dir.path().join("plz.toml"),
format!(
r#"
[tasks.hello]
run = "echo hello"
[taskgroup.rust.echo]
run = "echo > {}"
"#,
out.display()
),
)
.unwrap();
plz()
.args(["rust", "echo", "--", "--nocapture"])
.current_dir(dir.path())
.assert()
.success();
assert!(out.exists());
}
#[test]
fn cli_top_level_wins_over_group() {
let dir = TempDir::new().unwrap();
let top_marker = dir.path().join("top.txt");
fs::write(
dir.path().join("plz.toml"),
format!(
r#"
[tasks.rust]
run = "touch {}"
[taskgroup.rust.test]
run = "echo test"
"#,
top_marker.display()
),
)
.unwrap();
plz().arg("rust").current_dir(dir.path()).assert().success();
assert!(top_marker.exists());
}
#[test]
fn cli_group_no_task_non_interactive_errors() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("plz.toml"),
r#"
[tasks.hello]
run = "echo hello"
[taskgroup.rust.test]
run = "cargo test"
"#,
)
.unwrap();
plz()
.arg("rust")
.current_dir(dir.path())
.assert()
.failure()
.stderr(predicate::str::contains("No task specified for group"));
}
#[test]
fn cli_group_unknown_task_errors() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("plz.toml"),
r#"
[tasks.hello]
run = "echo hello"
[taskgroup.rust.test]
run = "cargo test"
"#,
)
.unwrap();
plz()
.args(["rust", "nonexistent"])
.current_dir(dir.path())
.assert()
.failure()
.stderr(predicate::str::contains("isn't a task"));
}
#[test]
fn cli_hooks_run_via_shorthand() {
let dir = TempDir::new().unwrap();
let marker = dir.path().join("hook_ran.txt");
fs::write(
dir.path().join("plz.toml"),
format!(
r#"
[tasks.check]
run = "touch {}"
git_hook = "pre-commit"
"#,
marker.display()
),
)
.unwrap();
plz()
.args(["hooks", "run", "pre-commit"])
.current_dir(dir.path())
.assert()
.success();
assert!(marker.exists(), "hook task should have been executed");
}
#[test]
fn cli_init_already_exists() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("plz.toml"), "[tasks]").unwrap();
plz()
.arg("init")
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("already exists"))
.stderr(predicate::str::contains("plz"));
}
#[test]
fn cli_init_no_project_creates_hello_task() {
let dir = TempDir::new().unwrap();
plz()
.arg("init")
.current_dir(dir.path())
.assert()
.success()
.stderr(predicate::str::contains("Created plz.toml"));
let content = fs::read_to_string(dir.path().join("plz.toml")).unwrap();
assert!(content.contains("[tasks.hello]"));
assert!(content.contains("echo 'hello world'"));
}
}