#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
const CUENV_BIN: &str = env!("CARGO_BIN_EXE_cuenv");
fn clean_environment_command(bin: impl AsRef<OsStr>) -> Command {
let mut cmd = Command::new(bin);
cmd.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default())
.env("USER", std::env::var("USER").unwrap_or_default());
cmd
}
fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../..")
.canonicalize()
.expect("repo root should resolve")
}
fn copy_dir_recursive(src: &Path, dst: &Path) {
fs::create_dir_all(dst).unwrap();
for entry in fs::read_dir(src).unwrap() {
let entry = entry.unwrap();
let path = entry.path();
let file_name = path.file_name().unwrap();
let dst_path = dst.join(file_name);
if path.is_dir() {
copy_dir_recursive(&path, &dst_path);
} else if path.extension().and_then(|s| s.to_str()) == Some("cue") {
fs::copy(&path, &dst_path).unwrap();
}
}
}
fn write_local_cuenv_module(root: &Path) {
let cue_mod_dir = root.join("cue.mod");
fs::create_dir_all(&cue_mod_dir).unwrap();
fs::write(
cue_mod_dir.join("module.cue"),
"module: \"github.com/cuenv/cuenv\"\nlanguage: {\n\tversion: \"v0.9.0\"\n}\n",
)
.unwrap();
let schema_src = repo_root().join("schema");
let schema_dst = root.join("schema");
copy_dir_recursive(&schema_src, &schema_dst);
}
fn init_git_repo(root: &Path) {
let output = Command::new("git")
.args(["init"])
.current_dir(root)
.output()
.expect("Failed to init git repo");
assert!(
output.status.success(),
"git init failed: {}",
String::from_utf8_lossy(&output.stderr)
);
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(root)
.output()
.expect("Failed to configure git email");
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(root)
.output()
.expect("Failed to configure git name");
}
fn create_repo() -> TempDir {
let temp_dir = tempfile::Builder::new()
.prefix("cuenv_test_")
.tempdir()
.expect("Failed to create temp directory");
let root = temp_dir.path();
write_local_cuenv_module(root);
init_git_repo(root);
temp_dir
}
fn project_env_cue(name: &str, pipeline: &str, task: &str, _owner: &str) -> String {
format!(
r#"package cuenv
import "github.com/cuenv/cuenv/schema"
schema.#Project & {{
// Alias to avoid scoping conflict with pipeline's tasks field
let _t = tasks
name: "{name}"
ci: {{
providers: ["github"]
pipelines: {{
"{pipeline}": {{
tasks: [_t.{task}]
}}
}}
}}
tasks: {{
{task}: {{
command: "echo"
args: ["{task}"]
inputs: ["env.cue"]
}}
}}
}}
"#
)
}
fn base_env_cue(_owner: &str, _include_ignore: bool) -> String {
r#"package cuenv
import "github.com/cuenv/cuenv/schema"
schema.#Base
"#
.to_string()
}
fn tools_project_env_cue(name: &str) -> String {
format!(
r#"package cuenv
import "github.com/cuenv/cuenv/schema"
schema.#Project & {{
name: "{name}"
runtime: schema.#ToolsRuntime & {{
platforms: ["darwin-arm64"]
tools: {{
rust: {{
version: "stable"
source: schema.#Rustup & {{
toolchain: "stable"
}}
}}
}}
}}
}}
"#
)
}
fn nix_runtime_project_env_cue(name: &str) -> String {
format!(
r#"package cuenv
import "github.com/cuenv/cuenv/schema"
schema.#Project & {{
name: "{name}"
runtime: schema.#NixRuntime
}}
"#
)
}
fn stale_lockfile() -> &'static str {
r#"version = 3
[tools.jq]
version = "1.7.1"
[tools.jq.platforms.darwin-arm64]
provider = "github"
digest = "sha256:abc"
[tools.jq.platforms.darwin-arm64.source]
type = "github"
repo = "jqlang/jq"
tag = "jq-1.7.1"
asset = "jq"
"#
}
fn minimal_flake_lock(nar_hash: &str) -> String {
format!(
r#"{{
"nodes": {{
"nixpkgs": {{
"locked": {{
"type": "github",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "abc123",
"narHash": "{nar_hash}"
}},
"original": {{
"type": "github",
"owner": "NixOS",
"repo": "nixpkgs"
}}
}},
"root": {{
"inputs": {{
"nixpkgs": "nixpkgs"
}}
}}
}},
"root": "root",
"version": 7
}}"#
)
}
fn run_cuenv(current_dir: &Path, args: &[&str]) -> (String, String, bool) {
let output = clean_environment_command(CUENV_BIN)
.args(args)
.current_dir(current_dir)
.output()
.expect("Failed to run cuenv");
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
(stdout, stderr, output.status.success())
}
#[test]
fn sync_root_project_only_generates_root_ci() {
let tmp = create_repo();
let root = tmp.path();
fs::write(
root.join("env.cue"),
project_env_cue("root", "build", "build", "@root"),
)
.unwrap();
let (_stdout, stderr, success) = run_cuenv(root, &["sync"]);
assert!(success, "sync failed: {stderr}");
let workflows_dir = root.join(".github/workflows");
assert!(workflows_dir.join("root-build.yml").exists());
assert!(!workflows_dir.join("service-test.yml").exists());
}
#[test]
fn sync_nested_project_only_generates_nested_ci_in_repo_root() {
let tmp = create_repo();
let root = tmp.path();
fs::write(root.join("env.cue"), base_env_cue("@root", false)).unwrap();
let nested = root.join("apps/service");
fs::create_dir_all(&nested).unwrap();
fs::write(
nested.join("env.cue"),
project_env_cue("service", "test", "test", "@service"),
)
.unwrap();
let (_stdout, stderr, success) = run_cuenv(&nested, &["sync"]);
assert!(success, "sync failed: {stderr}");
let workflows_dir = root.join(".github/workflows");
assert!(workflows_dir.join("service-test.yml").exists());
assert!(!workflows_dir.join("root-build.yml").exists());
assert!(!nested.join(".github").exists());
}
#[test]
fn sync_all_from_nested_generates_all_ci_in_repo_root() {
let tmp = create_repo();
let root = tmp.path();
fs::write(root.join("env.cue"), base_env_cue("@root", false)).unwrap();
let nested = root.join("apps/service");
fs::create_dir_all(&nested).unwrap();
fs::write(
nested.join("env.cue"),
project_env_cue("service", "test", "test", "@service"),
)
.unwrap();
let other = root.join("apps/api");
fs::create_dir_all(&other).unwrap();
fs::write(
other.join("env.cue"),
project_env_cue("api", "build", "build", "@api"),
)
.unwrap();
let (_stdout, stderr, success) = run_cuenv(&nested, &["sync", "-A"]);
assert!(success, "sync -A failed: {stderr}");
let workflows_dir = root.join(".github/workflows");
assert!(workflows_dir.join("service-test.yml").exists());
assert!(workflows_dir.join("api-build.yml").exists());
assert!(!nested.join(".github").exists());
}
#[test]
fn sync_outside_project_errors() {
let tmp = create_repo();
let root = tmp.path();
fs::write(root.join("env.cue"), base_env_cue("@root", false)).unwrap();
let nested = root.join("apps/service");
fs::create_dir_all(&nested).unwrap();
fs::write(
nested.join("env.cue"),
project_env_cue("service", "test", "test", "@service"),
)
.unwrap();
let non_project = root.join("shared");
fs::create_dir_all(&non_project).unwrap();
fs::write(non_project.join("env.cue"), base_env_cue("@shared", false)).unwrap();
let (stdout, stderr, success) = run_cuenv(&non_project, &["sync"]);
assert!(!success, "sync should fail outside a project");
let output = format!("{stdout}{stderr}");
assert!(output.contains("project"));
assert!(output.contains("cuenv"));
assert!(output.contains("info"));
assert!(output.contains("-A"));
}
#[test]
fn sync_creates_lockfile_for_tools_projects() {
let tmp = create_repo();
let root = tmp.path();
fs::write(root.join("env.cue"), tools_project_env_cue("tools-project")).unwrap();
let (stdout, stderr, success) = run_cuenv(root, &["sync"]);
assert!(success, "sync failed: {stderr}");
assert!(
root.join("cuenv.lock").exists(),
"cuenv sync should create or update cuenv.lock"
);
assert!(
stdout.contains("[lock]"),
"sync output should include the lock provider: {stdout}"
);
}
#[test]
fn sync_all_creates_lockfile_for_tools_projects() {
let tmp = create_repo();
let root = tmp.path();
fs::write(root.join("env.cue"), base_env_cue("@root", false)).unwrap();
let nested = root.join("apps/tools");
fs::create_dir_all(&nested).unwrap();
fs::write(
nested.join("env.cue"),
tools_project_env_cue("tools-project"),
)
.unwrap();
let (stdout, stderr, success) = run_cuenv(root, &["sync", "-A"]);
assert!(success, "sync -A failed: {stderr}");
assert!(
root.join("cuenv.lock").exists(),
"cuenv sync -A should create or update cuenv.lock"
);
assert!(
stdout.contains("[lock]"),
"sync -A output should include the lock provider: {stdout}"
);
}
#[test]
fn sync_creates_runtime_lockfile_for_nix_runtime_projects() {
let tmp = create_repo();
let root = tmp.path();
fs::write(
root.join("env.cue"),
nix_runtime_project_env_cue("nix-project"),
)
.unwrap();
fs::write(
root.join("flake.lock"),
minimal_flake_lock("sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="),
)
.unwrap();
fs::write(root.join("cuenv.lock"), stale_lockfile()).unwrap();
let (stdout, stderr, success) = run_cuenv(root, &["sync"]);
assert!(success, "sync failed: {stderr}");
let lockfile = fs::read_to_string(root.join("cuenv.lock")).unwrap();
assert!(
root.join("cuenv.lock").exists(),
"cuenv sync should keep cuenv.lock for Nix runtime projects"
);
assert!(
lockfile.contains("[runtimes.\".\"]"),
"cuenv.lock should contain a root runtime entry: {lockfile}"
);
assert!(
lockfile.contains("type = \"nix\""),
"cuenv.lock should record the Nix runtime type: {lockfile}"
);
assert!(
lockfile.contains("lockfile = \"flake.lock\""),
"cuenv.lock should record the flake.lock path: {lockfile}"
);
assert!(
stdout.contains("[lock]"),
"sync output should include the lock provider: {stdout}"
);
}
#[test]
fn sync_check_fails_when_nix_runtime_lockfile_digest_changes() {
let tmp = create_repo();
let root = tmp.path();
fs::write(
root.join("env.cue"),
nix_runtime_project_env_cue("nix-project"),
)
.unwrap();
fs::write(
root.join("flake.lock"),
minimal_flake_lock("sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="),
)
.unwrap();
let (_stdout, stderr, success) = run_cuenv(root, &["sync"]);
assert!(success, "initial sync failed: {stderr}");
fs::write(
root.join("flake.lock"),
minimal_flake_lock("sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="),
)
.unwrap();
let (stdout, stderr, success) = run_cuenv(root, &["sync", "--check"]);
assert!(
!success,
"sync --check should fail after flake.lock changes"
);
let output = format!("{stdout}{stderr}");
assert!(
output.contains("Lockfile is out of date"),
"sync --check should report lock drift: {output}"
);
}