diff --git a/.cli-flags.toml b/.cli-flags.toml index c7acc3a4..3cc39e98 100644 --- a/.cli-flags.toml +++ b/.cli-flags.toml @@ -314,8 +314,13 @@ help = "Project-local schema-v2 environment plan path." env = "ZED_PKG_ENV_OUTPUT" aliases = ["output"] type = "string" -default = ".mise.toml" -help = "Project-local manager export path." +help = "Project-local manager export path; defaults are manager-specific." + +[commands.env.commands.export.flags.env_receipt] +env = "ZED_PKG_ENV_RECEIPT" +aliases = ["receipt"] +type = "string" +help = "Zed-owned deterministic receipt path for Devbox or Flox export." [commands.env.commands.export.flags.env_check] env = "ZED_PKG_ENV_CHECK" diff --git a/.github/workflows/devbox-flox-export.yml b/.github/workflows/devbox-flox-export.yml index 24c44f00..14f65153 100644 --- a/.github/workflows/devbox-flox-export.yml +++ b/.github/workflows/devbox-flox-export.yml @@ -3,9 +3,16 @@ name: Devbox and Flox environment export on: pull_request: paths: + - ".cli-flags.toml" + - "README.md" + - "src/cli.rs" + - "src/main.rs" + - "src/lib.rs" + - "src/environment_export_cli.rs" - "src/nix_environment_export.rs" - "src/bin/zed-env-export.rs" - "tests/nix_environment_export_cli.rs" + - "tests/nix_environment_export_main_cli.rs" - "docs/devbox-flox-interop.md" - ".github/workflows/devbox-flox-export.yml" - "Cargo.toml" @@ -13,9 +20,16 @@ on: push: branches: [main] paths: + - ".cli-flags.toml" + - "README.md" + - "src/cli.rs" + - "src/main.rs" + - "src/lib.rs" + - "src/environment_export_cli.rs" - "src/nix_environment_export.rs" - "src/bin/zed-env-export.rs" - "tests/nix_environment_export_cli.rs" + - "tests/nix_environment_export_main_cli.rs" - "docs/devbox-flox-interop.md" - ".github/workflows/devbox-flox-export.yml" - "Cargo.toml" @@ -37,7 +51,7 @@ jobs: deterministic-export: name: deterministic export / ${{ matrix.os }} runs-on: ${{ matrix.os }} - timeout-minutes: 30 + timeout-minutes: 35 strategy: fail-fast: false matrix: @@ -65,15 +79,73 @@ jobs: run: cargo fmt --all --check - name: Unit tests - run: cargo test --locked --bin zed-env-export + run: | + set -euo pipefail + cargo test --locked --lib environment_export_cli::tests + cargo test --locked --lib nix_environment_export::tests - - name: Black-box CLI tests - run: cargo test --locked --test nix_environment_export_cli + - name: Staged and canonical CLI tests + run: | + set -euo pipefail + cargo test --locked --test nix_environment_export_cli + cargo test --locked --test nix_environment_export_main_cli + cargo test --locked cli::tests::environment_export_is_typed_and_rejects_ambiguous_write_modes + cargo test --locked cli::tests::cli_flags_toml_is_in_sync_with_clap + cargo test --locked cli::tests::readme_documents_every_command - name: Clippy (strict) run: | set -euo pipefail - cargo clippy --locked --bin zed-env-export --test nix_environment_export_cli -- -D warnings + cargo clippy --locked --bin zed --bin zed-env-export \ + --test nix_environment_export_cli \ + --test nix_environment_export_main_cli -- -D warnings + + - name: Repeated deterministic parity canary + run: | + set -euo pipefail + cargo build --locked --bin zed --bin zed-env-export + zed_bin="$GITHUB_WORKSPACE/target/debug/zed" + staged_bin="$GITHUB_WORKSPACE/target/debug/zed-env-export" + if [[ "$RUNNER_OS" == "Windows" ]]; then + zed_bin="${zed_bin}.exe" + staged_bin="${staged_bin}.exe" + fi + root="$RUNNER_TEMP/devbox-flox-parity" + for side in canonical staged; do + mkdir -p "$root/$side/.zed" + cat > "$root/$side/.zed/environment-plan.json" <<'JSON' + { + "schema": 1, + "tools": { + "node": { + "requirement": "^22", + "resolved": "22.11.0", + "provider": "nixpkgs", + "backend": "nodejs_22", + "checksums": [{"algorithm": "sha256", "value": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}], + "platforms": [] + } + }, + "system-packages": {}, + "platforms": ["x86_64-linux"], + "activation": "frozen-install", + "sources": [] + } + JSON + done + ( + cd "$root/canonical" + "$zed_bin" env export devbox --json > result.json + ) + ( + cd "$root/staged" + "$staged_bin" devbox --json > result.json + ) + cmp "$root/canonical/result.json" "$root/staged/result.json" + cmp "$root/canonical/devbox.json" "$root/staged/devbox.json" + cmp \ + "$root/canonical/.zed/environment-exports/devbox.json" \ + "$root/staged/.zed/environment-exports/devbox.json" - name: Prove no generated files leaked into checkout run: | diff --git a/README.md b/README.md index c28f9986..5f9cf709 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ registry hosts both on S3/Cloudflare R2. | `zed env import mise [--config PATH] [--lock PATH] [--frozen] [--json]` | Import the supported project-local mise tool/lock subset as the shared normalized `EnvironmentPlan`; never loads parent/global config or executes hooks | | `zed env verify mise [--config PATH] [--lock PATH] --frozen [--json]` | Fail closed on missing lock coverage, drift, malformed checksums, unsupported semantics, or non-portable frozen state and report the stable plan digest | | `zed env export mise --plan PATH [--output .mise.toml] [--check|--write] [--json]` | Deterministically project a schema-v2 environment plan into conflict-safe project-local mise TOML without invoking mise or executing project code | +| `zed env export devbox [--plan PATH] [--output devbox.json] [--receipt PATH] [--json]` | Deterministically generate Devbox JSON and a Zed-owned receipt without invoking Devbox | +| `zed env export flox [--plan PATH] [--output .flox/env/manifest.toml] [--receipt PATH] [--json]` | Deterministically generate Flox manifest TOML and a Zed-owned receipt without invoking Flox | | `zed env import asdf [--config .tool-versions] [--lock .zed/asdf.lock.toml] [--frozen] [--json]` | Import project-local asdf selections and optional immutable plugin/artifact provenance without invoking asdf or plugin code | | `zed env verify asdf [--config .tool-versions] [--lock .zed/asdf.lock.toml] --frozen [--json]` | Verify exact asdf tool, plugin revision, artifact SHA-256, platform, and normalized plan identity without reading parent/global configuration | | `zed task list\|info\|graph\|run ...` | Use the shared schema-v2 runtime to discover, inspect, graph, dry-run, execute, confirm, parallelize, and content-cache project tasks; `zed-task` remains a compatibility binary | diff --git a/docs/devbox-flox-interop.md b/docs/devbox-flox-interop.md index c61a592a..52341fd8 100644 --- a/docs/devbox-flox-interop.md +++ b/docs/devbox-flox-interop.md @@ -1,35 +1,57 @@ # Deterministic Devbox and Flox exports -`zed-env-export` is the staged executable for the first DEN-1468 export slice. -It consumes the shared, frozen `EnvironmentPlan` and emits manager-native -configuration without invoking Devbox, Flox, Nix, package resolution, or any -activation hook. +The canonical product surface consumes the shared frozen `EnvironmentPlan` and +emits manager-native configuration without invoking Devbox, Flox, Nix, package +resolution, a shell, or an activation hook: -The module is kept separate from the moving `zed env` dispatcher so it can be -wired into `zed env export devbox|flox` after the common command branch lands -without reimplementing generation. +```console +zed env export devbox [--plan PATH] [--output PATH] [--receipt PATH] [--json] +zed env export flox [--plan PATH] [--output PATH] [--receipt PATH] [--json] +``` -## Commands +The separately installed compatibility binary remains available: -```text +```console zed-env-export devbox [--plan PATH] [--out PATH] [--receipt PATH] [--json] zed-env-export flox [--plan PATH] [--out PATH] [--receipt PATH] [--json] ``` -Defaults: +Both binaries call `zed_cli::environment_export_cli`, which delegates to one +`nix_environment_export` renderer and one persistence implementation. Output, +receipts, diagnostics, idempotence, and error behavior therefore cannot drift +between the canonical and staged routes. + +## Defaults and flags-to-environment contract | Manager | Output | Receipt | | --- | --- | --- | | Devbox | `devbox.json` | `.zed/environment-exports/devbox.json` | | Flox | `.flox/env/manifest.toml` | `.zed/environment-exports/flox.json` | -The default input is `.zed/environment-plan.json`. Every path must be normalized, -project-relative, and free of symlink traversal outside the project. +The default input is `.zed/environment-plan.json`. Canonical flags use: + +- `ZED_PKG_ENV_PLAN` +- `ZED_PKG_ENV_OUTPUT` +- `ZED_PKG_ENV_RECEIPT` +- `ZED_PKG_ENV_JSON` + +The staged binary retains `ZED_PKG_ENV_OUT` as a compatibility spelling for its +`--out` option. The canonical command does not create a second renderer or a +second receipt format. + +`--check` and `--write` remain mise-only because Devbox/Flox export is already a +fail-closed write operation: absent files are created, byte-identical files are +accepted unchanged, and any differing existing output or receipt is rejected. +`--receipt` is rejected for mise export. Import and verify retain their separate +`mise|asdf` manager enum; `devbox` and `flox` are export-only values. + +Every path must be normalized, project-relative, and free of symlink traversal +outside the project. ## Mapping contract -The initial exporter supports explicit Nixpkgs-backed entries only. It never -guesses that a language name maps one-to-one to a Nix attribute. +The exporter supports explicit Nixpkgs-backed entries only. It never guesses +that a language name maps one-to-one to a Nix attribute. A tool uses: @@ -77,7 +99,7 @@ entries with `pkg-path`, `version`, and optional `systems`, an optional global `[options].systems`, and the same fixed activation command under `[hook].on-activate`. -The initial Flox platform subset is the common Darwin/Linux matrix: +The certified Flox platform subset is: - `aarch64-darwin` - `aarch64-linux` @@ -115,6 +137,13 @@ writes; if receipt persistence fails after a new output was installed, the new output is removed so the command fails closed. Semantic merging of pre-existing human manager configuration remains a later -adapter phase. This first PR chooses explicit conflict diagnostics rather than -silently deleting scripts, services, includes, environment variables, or other -manager-owned state. +adapter phase. The current exporter chooses explicit conflict diagnostics rather +than silently deleting scripts, services, includes, environment variables, or +other manager-owned state. + +## Certification + +The permanent Ubuntu, macOS, and Windows workflow runs unit tests, staged and +canonical compiled-CLI tests, strict Clippy, checkout-cleanliness checks, and a +repeated parity canary that compares canonical/staged result JSON, manager +output bytes, and receipt bytes. diff --git a/src/bin/zed-env-export.rs b/src/bin/zed-env-export.rs index 1ee9826f..afdf8c05 100644 --- a/src/bin/zed-env-export.rs +++ b/src/bin/zed-env-export.rs @@ -1,12 +1,8 @@ -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use clap::{Args, Parser, Subcommand}; - -#[path = "../nix_environment_export.rs"] -#[allow(private_interfaces)] -mod nix_environment_export; - -use nix_environment_export::{ExportManager, export_environment}; +use zed_cli::environment_export_cli::{self, ExportOptions}; +use zed_cli::nix_environment_export::ExportManager; #[derive(Debug, Parser)] #[command( @@ -55,40 +51,17 @@ fn main() { } fn run(cli: Cli) -> anyhow::Result<()> { - let root = std::env::current_dir()?; - match cli.command { - Command::Devbox(args) => run_one(&root, ExportManager::Devbox, args), - Command::Flox(args) => run_one(&root, ExportManager::Flox, args), - } -} - -fn run_one(root: &Path, manager: ExportManager, args: ExportArgs) -> anyhow::Result<()> { - let result = export_environment( - root, + let (manager, args) = match cli.command { + Command::Devbox(args) => (ExportManager::Devbox, args), + Command::Flox(args) => (ExportManager::Flox, args), + }; + environment_export_cli::execute_current_dir( manager, - Some(&args.plan), - args.out.as_deref(), - args.receipt.as_deref(), - )?; - if args.json { - println!("{}", serde_json::to_string_pretty(&result)?); - } else { - println!( - "{} environment {}: {}", - result.manager.as_str(), - if result.changed { - "exported" - } else { - "unchanged" - }, - result.output_path - ); - println!("receipt: {}", result.receipt_path); - println!( - "environment-plan-sha256: {}", - result.environment_plan_sha256 - ); - println!("output-sha256: {}", result.output_sha256); - } - Ok(()) + ExportOptions { + plan: Some(args.plan), + output: args.out, + receipt: args.receipt, + json: args.json, + }, + ) } diff --git a/src/cli.rs b/src/cli.rs index 28c07731..94504a88 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -162,12 +162,22 @@ impl From for clap_complete::Shell { #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum EnvironmentManagerArg { - /// Import, verify, or export project-local mise configuration. + /// Import or verify project-local mise configuration. Mise, /// Import or verify project-local asdf configuration and Zed-owned provenance. Asdf, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +pub enum EnvironmentExportManagerArg { + /// Export deterministic mise TOML from a schema-v2 plan. + Mise, + /// Export deterministic Devbox JSON and a Zed-owned receipt. + Devbox, + /// Export deterministic Flox manifest TOML and a Zed-owned receipt. + Flox, +} + #[derive(Debug, Subcommand)] pub enum Cmd { /// Create a .zpkg.toml manifest in the current directory @@ -443,20 +453,23 @@ pub enum EnvCmd { #[arg(long, env = "ZED_PKG_ENV_JSON")] json: bool, }, - /// Export a schema-v2 EnvironmentPlan as deterministic mise TOML. + /// Export a schema-v2 EnvironmentPlan to deterministic manager configuration. Export { #[arg(value_enum)] - manager: EnvironmentManagerArg, - /// Project-local schema-v2 EnvironmentPlan (.toml or .json). + manager: EnvironmentExportManagerArg, + /// Project-local schema-v2 EnvironmentPlan. Devbox/Flox default to `.zed/environment-plan.json`; mise requires this flag. #[arg(long, env = "ZED_PKG_ENV_PLAN")] - plan: PathBuf, - /// Project-local mise output path. - #[arg(long, env = "ZED_PKG_ENV_OUTPUT", default_value = ".mise.toml")] - output: PathBuf, - /// Verify that the output already equals the deterministic projection. + plan: Option, + /// Project-local manager output path. Defaults are manager-specific. + #[arg(long, env = "ZED_PKG_ENV_OUTPUT")] + output: Option, + /// Zed-owned deterministic receipt path for Devbox/Flox export. + #[arg(long, env = "ZED_PKG_ENV_RECEIPT")] + receipt: Option, + /// Verify that the mise output already equals the deterministic projection. #[arg(long, env = "ZED_PKG_ENV_CHECK")] check: bool, - /// Transactionally create/update a Zed-owned manager view. + /// Transactionally create/update a Zed-owned mise view. #[arg(long, env = "ZED_PKG_ENV_WRITE")] write: bool, /// Emit a machine-readable export result. @@ -763,21 +776,23 @@ mod tests { } #[test] - fn environment_export_is_typed_and_rejects_ambiguous_write_modes() { - let cli = Cli::try_parse_from([ - "zed", - "env", - "export", - "mise", - "--plan", - "zed-env.toml", - "--output", - ".mise.toml", - "--check", - "--json", - ]) - .unwrap(); - assert!(matches!(cli.cmd, Cmd::Env { .. })); + fn environment_export_is_typed_and_preserves_manager_boundaries() { + for manager in ["mise", "devbox", "flox"] { + let mut args = vec!["zed", "env", "export", manager, "--json"]; + if manager == "mise" { + args.extend([ + "--plan", + "zed-env.toml", + "--output", + ".mise.toml", + "--check", + ]); + } else { + args.extend(["--receipt", ".zed/receipt.json"]); + } + let cli = Cli::try_parse_from(args).unwrap(); + assert!(matches!(cli.cmd, Cmd::Env { .. })); + } assert!(matches!( Cli::try_parse_from([ diff --git a/src/environment_export_cli.rs b/src/environment_export_cli.rs new file mode 100644 index 00000000..67a4d2b7 --- /dev/null +++ b/src/environment_export_cli.rs @@ -0,0 +1,78 @@ +//! Shared command/output layer for canonical `zed env export devbox|flox` +//! and the staged `zed-env-export` compatibility binary. + +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use crate::nix_environment_export::{ExportManager, ExportResult, export_environment}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExportOptions { + pub plan: Option, + pub output: Option, + pub receipt: Option, + pub json: bool, +} + +pub fn execute(root: &Path, manager: ExportManager, options: ExportOptions) -> Result<()> { + let result = export_environment( + root, + manager, + options.plan.as_deref(), + options.output.as_deref(), + options.receipt.as_deref(), + )?; + print_result(&result, options.json) +} + +pub fn execute_current_dir(manager: ExportManager, options: ExportOptions) -> Result<()> { + let root = std::env::current_dir()?; + execute(&root, manager, options) +} + +pub fn print_result(result: &ExportResult, json: bool) -> Result<()> { + if json { + println!("{}", serde_json::to_string_pretty(result)?); + } else { + println!( + "{} environment {}: {}", + result.manager.as_str(), + if result.changed { + "exported" + } else { + "unchanged" + }, + result.output_path + ); + println!("receipt: {}", result.receipt_path); + println!( + "environment-plan-sha256: {}", + result.environment_plan_sha256 + ); + println!("output-sha256: {}", result.output_sha256); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn options_retain_project_relative_paths_without_defaults() { + let options = ExportOptions { + plan: Some(PathBuf::from(".zed/environment-plan.json")), + output: None, + receipt: None, + json: true, + }; + assert_eq!( + options.plan.as_deref(), + Some(Path::new(".zed/environment-plan.json")) + ); + assert!(options.output.is_none()); + assert!(options.receipt.is_none()); + assert!(options.json); + } +} diff --git a/src/lib.rs b/src/lib.rs index fee2b353..800a674a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -10,6 +10,7 @@ pub mod config; mod dart_wiring; pub mod dev; pub mod environment; +pub mod environment_export_cli; pub mod external_subcommands; pub mod fetch; pub mod flags; @@ -94,6 +95,7 @@ pub mod mise_export { } pub mod mise_lock; pub mod nix_bundle_write; +pub mod nix_environment_export; pub mod nix_export_bundle; pub mod nix_export_plan; #[path = "ops_entry.rs"] diff --git a/src/main.rs b/src/main.rs index 53bbad23..0377b34d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,18 +4,21 @@ use zed_cli::asdf_environment; use zed_cli::auth; use zed_cli::cli::EnvCmd; use zed_cli::cli::{ - AuthCmd, CacheCmd, Cli, Cmd, EnvironmentManagerArg, OrgCmd, ReleaseCmd, StoreCmd, TaskCmd, + AuthCmd, CacheCmd, Cli, Cmd, EnvironmentExportManagerArg, EnvironmentManagerArg, OrgCmd, + ReleaseCmd, StoreCmd, TaskCmd, }; use zed_cli::completion; use zed_cli::config::Config; use zed_cli::dev; use zed_cli::environment; +use zed_cli::environment_export_cli::{self, ExportOptions}; use zed_cli::fetch; use zed_cli::git_submodules as submodules; use zed_cli::global; use zed_cli::managed_install; use zed_cli::mise_export::{self, MiseExportMode}; use zed_cli::nix_bundle_write; +use zed_cli::nix_environment_export::ExportManager; use zed_cli::nix_export_plan; use zed_cli::ops; use zed_cli::preflight; @@ -237,30 +240,58 @@ fn run(cli: Cli) -> anyhow::Result<()> { } }, EnvCmd::Export { - manager: EnvironmentManagerArg::Mise, + manager, plan, output, + receipt, check, write, json, - } => { - if check && write { - anyhow::bail!("the arguments '--check' and '--write' cannot be used together"); + } => match manager { + EnvironmentExportManagerArg::Mise => { + if check && write { + anyhow::bail!( + "the arguments '--check' and '--write' cannot be used together" + ); + } + if receipt.is_some() { + anyhow::bail!("--receipt is supported only for Devbox and Flox export"); + } + let Some(plan) = plan else { + anyhow::bail!("mise export requires --plan PATH"); + }; + let output = output.unwrap_or_else(|| std::path::PathBuf::from(".mise.toml")); + let mode = if check { + MiseExportMode::Check + } else if write { + MiseExportMode::Write + } else { + MiseExportMode::Print + }; + let exported = mise_export::export_mise(&cwd, &plan, &output, mode)?; + mise_export::print_export(&exported, json) } - let mode = if check { - MiseExportMode::Check - } else if write { - MiseExportMode::Write - } else { - MiseExportMode::Print - }; - let exported = mise_export::export_mise(&cwd, &plan, &output, mode)?; - mise_export::print_export(&exported, json) - } - EnvCmd::Export { - manager: EnvironmentManagerArg::Asdf, - .. - } => anyhow::bail!("asdf export is not implemented; use `zed env export mise`"), + EnvironmentExportManagerArg::Devbox | EnvironmentExportManagerArg::Flox => { + if check || write { + anyhow::bail!("--check and --write are supported only for mise export"); + } + let manager = match manager { + EnvironmentExportManagerArg::Devbox => ExportManager::Devbox, + EnvironmentExportManagerArg::Flox => ExportManager::Flox, + EnvironmentExportManagerArg::Mise => unreachable!(), + }; + environment_export_cli::execute( + &cwd, + manager, + ExportOptions { + plan, + output, + receipt, + json, + }, + ) + } + }, EnvCmd::Verify { manager, config, diff --git a/src/nix_environment_export.rs b/src/nix_environment_export.rs index 7cdcfe1a..48c6cbec 100644 --- a/src/nix_environment_export.rs +++ b/src/nix_environment_export.rs @@ -262,7 +262,7 @@ pub fn export_environment( }) } -pub fn generate_devbox(packages: &[ManagerPackage]) -> Result> { +fn generate_devbox(packages: &[ManagerPackage]) -> Result> { let mut output_packages = BTreeMap::new(); for package in packages { let candidate = DevboxPackage { @@ -292,7 +292,7 @@ pub fn generate_devbox(packages: &[ManagerPackage]) -> Result> { Ok(bytes) } -pub fn generate_flox(plan: &EnvironmentPlan, packages: &[ManagerPackage]) -> Result> { +fn generate_flox(plan: &EnvironmentPlan, packages: &[ManagerPackage]) -> Result> { let mut install = BTreeMap::new(); for package in packages { validate_flox_alias(&package.logical_name)?; diff --git a/tests/nix_environment_export_main_cli.rs b/tests/nix_environment_export_main_cli.rs new file mode 100644 index 00000000..2f59bcdd --- /dev/null +++ b/tests/nix_environment_export_main_cli.rs @@ -0,0 +1,226 @@ +use std::fs; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output}; + +fn zed() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_zed")) +} + +fn staged() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_zed-env-export")) +} + +fn write_plan(root: &Path, platform: &str) { + fs::create_dir_all(root.join(".zed")).unwrap(); + let plan = serde_json::json!({ + "schema": 1, + "tools": { + "node": { + "requirement": "^22", + "resolved": "22.11.0", + "provider": "nixpkgs", + "backend": "nodejs_22", + "checksums": [ + {"algorithm": "sha256", "value": "a".repeat(64)} + ], + "platforms": [] + } + }, + "system-packages": { + "git": { + "requirement": "2.47.0", + "resolved": "2.47.0", + "provider": "nixpkgs", + "package_ref": "gitFull", + "checksums": [ + {"algorithm": "sha256", "value": "b".repeat(64)} + ], + "platforms": [platform] + } + }, + "platforms": [platform], + "activation": "frozen-install", + "sources": [] + }); + fs::write( + root.join(".zed/environment-plan.json"), + serde_json::to_vec_pretty(&plan).unwrap(), + ) + .unwrap(); +} + +fn clean_command(binary: &Path, root: &Path) -> Command { + let mut command = Command::new(binary); + command + .current_dir(root) + .env_remove("ZED_PKG_ENV_PLAN") + .env_remove("ZED_PKG_ENV_OUTPUT") + .env_remove("ZED_PKG_ENV_OUT") + .env_remove("ZED_PKG_ENV_RECEIPT") + .env_remove("ZED_PKG_ENV_CHECK") + .env_remove("ZED_PKG_ENV_WRITE") + .env_remove("ZED_PKG_ENV_JSON") + .env_remove("ZED_PKG_UPDATE_CHECK"); + command +} + +fn run_main(root: &Path, manager: &str, args: &[&str]) -> Output { + clean_command(&zed(), root) + .args(["env", "export", manager]) + .args(args) + .output() + .unwrap() +} + +fn run_staged(root: &Path, manager: &str, args: &[&str]) -> Output { + clean_command(&staged(), root) + .arg(manager) + .args(args) + .output() + .unwrap() +} + +fn assert_success(output: &Output) { + assert!( + output.status.success(), + "stdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +#[test] +fn canonical_and_staged_default_exports_are_byte_identical() { + for (manager, platform, output, receipt) in [ + ( + "devbox", + "x86_64-linux", + "devbox.json", + ".zed/environment-exports/devbox.json", + ), + ( + "flox", + "aarch64-darwin", + ".flox/env/manifest.toml", + ".zed/environment-exports/flox.json", + ), + ] { + let canonical = tempfile::tempdir().unwrap(); + let compatibility = tempfile::tempdir().unwrap(); + write_plan(canonical.path(), platform); + write_plan(compatibility.path(), platform); + + let canonical_result = run_main(canonical.path(), manager, &["--json"]); + let staged_result = run_staged(compatibility.path(), manager, &["--json"]); + assert_success(&canonical_result); + assert_success(&staged_result); + assert_eq!(canonical_result.stdout, staged_result.stdout, "{manager}"); + assert_eq!(canonical_result.stderr, staged_result.stderr, "{manager}"); + assert_eq!( + fs::read(canonical.path().join(output)).unwrap(), + fs::read(compatibility.path().join(output)).unwrap(), + "{manager} output" + ); + assert_eq!( + fs::read(canonical.path().join(receipt)).unwrap(), + fs::read(compatibility.path().join(receipt)).unwrap(), + "{manager} receipt" + ); + } +} + +#[test] +fn canonical_and_staged_custom_paths_are_equivalent() { + let canonical = tempfile::tempdir().unwrap(); + let compatibility = tempfile::tempdir().unwrap(); + write_plan(canonical.path(), "x86_64-linux"); + write_plan(compatibility.path(), "x86_64-linux"); + + let main = run_main( + canonical.path(), + "devbox", + &[ + "--plan", + ".zed/environment-plan.json", + "--output", + "generated/devbox.json", + "--receipt", + "generated/devbox.receipt.json", + "--json", + ], + ); + let staged = run_staged( + compatibility.path(), + "devbox", + &[ + "--plan", + ".zed/environment-plan.json", + "--out", + "generated/devbox.json", + "--receipt", + "generated/devbox.receipt.json", + "--json", + ], + ); + assert_success(&main); + assert_success(&staged); + assert_eq!(main.stdout, staged.stdout); + assert_eq!( + fs::read(canonical.path().join("generated/devbox.json")).unwrap(), + fs::read(compatibility.path().join("generated/devbox.json")).unwrap() + ); + assert_eq!( + fs::read(canonical.path().join("generated/devbox.receipt.json")).unwrap(), + fs::read(compatibility.path().join("generated/devbox.receipt.json")).unwrap() + ); +} + +#[test] +fn manager_specific_options_fail_closed() { + let root = tempfile::tempdir().unwrap(); + write_plan(root.path(), "x86_64-linux"); + + let devbox_check = run_main(root.path(), "devbox", &["--check"]); + assert!(!devbox_check.status.success()); + assert!( + String::from_utf8_lossy(&devbox_check.stderr) + .contains("--check and --write are supported only for mise export") + ); + + let mise_receipt = run_main( + root.path(), + "mise", + &[ + "--plan", + ".zed/environment-plan.json", + "--receipt", + "receipt.json", + ], + ); + assert!(!mise_receipt.status.success()); + assert!( + String::from_utf8_lossy(&mise_receipt.stderr) + .contains("--receipt is supported only for Devbox and Flox export") + ); + + let asdf = run_main(root.path(), "asdf", &[]); + assert!(!asdf.status.success()); + assert!(String::from_utf8_lossy(&asdf.stderr).contains("invalid value 'asdf'")); +} + +#[test] +fn canonical_environment_flags_select_output_and_receipt() { + let root = tempfile::tempdir().unwrap(); + write_plan(root.path(), "x86_64-linux"); + + let output = clean_command(&zed(), root.path()) + .args(["env", "export", "devbox", "--json"]) + .env("ZED_PKG_ENV_PLAN", ".zed/environment-plan.json") + .env("ZED_PKG_ENV_OUTPUT", "generated/devbox.json") + .env("ZED_PKG_ENV_RECEIPT", "generated/devbox.receipt.json") + .output() + .unwrap(); + assert_success(&output); + assert!(root.path().join("generated/devbox.json").is_file()); + assert!(root.path().join("generated/devbox.receipt.json").is_file()); +}