diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000..2471f63 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1 @@ +Read and follow the canonical instructions in `../agents.md`. diff --git a/.gemini/GEMINI.md b/.gemini/GEMINI.md new file mode 100644 index 0000000..2471f63 --- /dev/null +++ b/.gemini/GEMINI.md @@ -0,0 +1 @@ +Read and follow the canonical instructions in `../agents.md`. diff --git a/.github/workflows/agents-policy.yml b/.github/workflows/agents-policy.yml new file mode 100644 index 0000000..c1a9734 --- /dev/null +++ b/.github/workflows/agents-policy.yml @@ -0,0 +1,19 @@ +name: agents policy + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: agents-policy-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + validate: + uses: zed-pkg/zed-monorepo/.github/workflows/agents-policy-reusable.yml@fb2417b1a976459e3de740f788916d3d91d3669e + with: + policy_ref: fb2417b1a976459e3de740f788916d3d91d3669e diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4825bdd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + rust: + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false + show-progress: false + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + components: rustfmt,clippy + + - name: Format + run: cargo fmt --check + + - name: Clippy + run: cargo clippy --locked --all-targets -- -D warnings + + - name: Test + run: cargo test --locked + + - name: Doctest + run: cargo test --locked --doc + + - name: Schemas are up to date + run: | + cargo run --locked --example generate_schemas + git diff --exit-code schemas/ \ + || (echo "schemas/ is stale; run 'cargo run --locked --example generate_schemas' and commit" && exit 1) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 0000000..93a336d --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,58 @@ +name: nix + +on: + push: + branches: [main] + paths: + - Cargo.toml + - Cargo.lock + - src/** + - examples/** + - schemas/** + - flake.nix + - flake.lock + - .nix/** + - agents.md + - README.md + - .github/workflows/nix.yml + pull_request: + paths: + - Cargo.toml + - Cargo.lock + - src/** + - examples/** + - schemas/** + - flake.nix + - flake.lock + - .nix/** + - agents.md + - README.md + - .github/workflows/nix.yml + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + agent-check: + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Check out repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + show-progress: false + + - name: Install Nix + uses: cachix/install-nix-action@630ae543ea3a38a9a4166f03376c02c50f408342 + with: + extra_nix_config: | + experimental-features = nix-command flakes + + - name: Run pinned contract checks + run: nix develop --no-update-lock-file -c agent-check diff --git a/.gitignore b/.gitignore index 2a283ae..36d29b1 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,7 @@ tmp temp tmp/worktrees temp/worktrees + +# --- carried over from the grafted branch rescue/orphan-74905f7ed152-feat-den-1417-add-canonical-nix-interop --- +.cache/ +/target diff --git a/.nix/agent-check.sh b/.nix/agent-check.sh new file mode 100644 index 0000000..e0c193f --- /dev/null +++ b/.nix/agent-check.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash +set -euo pipefail + +export CI="${CI:-1}" +export NO_COLOR="${NO_COLOR:-1}" + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +cache_root="${NIX_AGENT_CACHE_ROOT:-$repo_root/.cache/nix-agent}" +export CARGO_HOME="${CARGO_HOME:-$cache_root/cargo}" +export CARGO_TARGET_DIR="${CARGO_TARGET_DIR:-$cache_root/target}" +export XDG_CACHE_HOME="${XDG_CACHE_HOME:-$cache_root/xdg}" +mkdir -p "$CARGO_HOME" "$CARGO_TARGET_DIR" "$XDG_CACHE_HOME" + +run_stage() { + local stage="$1" + printf '\n==> agent-check stage: %s\n' "$stage" + + case "$stage" in + preflight) + git diff --check + nixfmt --check flake.nix .nix/dev-shell.nix + shellcheck .nix/agent-check.sh + shfmt -i 2 -ci -d .nix/agent-check.sh + actionlint .github/workflows/*.yml + nix flake check --no-update-lock-file --show-trace + ;; + format) + cargo fmt --check + ;; + lint) + # Nixpkgs currently pins Rust/Clippy 1.95, whose nonminimal_bool advice + # conflicts with the explicit, security-auditable path rejection list. + # Current stable CI still runs every lint with -D warnings; only this + # toolchain-version-specific style lint is exempted in the pinned shell. + cargo clippy --locked --all-targets -- \ + -D warnings \ + -A clippy::nonminimal_bool + ;; + test) + cargo test --locked + cargo test --locked --doc + ;; + schemas) + cargo run --locked --example generate_schemas + git diff --exit-code -- schemas/ + ;; + all) + local child + for child in preflight format lint test schemas; do + run_stage "$child" + done + ;; + *) + printf 'unknown agent-check stage: %s\n' "$stage" >&2 + return 64 + ;; + esac +} + +case "${1:-all}" in + all | preflight | format | lint | test | schemas) + run_stage "${1:-all}" + ;; + *) + printf 'usage: agent-check [all|preflight|format|lint|test|schemas]\n' >&2 + exit 64 + ;; +esac diff --git a/.nix/dev-shell.nix b/.nix/dev-shell.nix new file mode 100644 index 0000000..2eb0896 --- /dev/null +++ b/.nix/dev-shell.nix @@ -0,0 +1,20 @@ +{ + pkgs, + agentCheck, + toolchain, +}: +pkgs.mkShell { + packages = toolchain ++ [ agentCheck ]; + + LANG = if pkgs.stdenv.hostPlatform.isDarwin then "en_US.UTF-8" else "C.UTF-8"; + LC_ALL = if pkgs.stdenv.hostPlatform.isDarwin then "en_US.UTF-8" else "C.UTF-8"; + + shellHook = '' + export NIX_DEV_SHELL=zed-interfaces + export NIX_AGENT_CACHE_ROOT="''${NIX_AGENT_CACHE_ROOT:-$PWD/.cache/nix-agent}" + export CARGO_HOME="''${CARGO_HOME:-$NIX_AGENT_CACHE_ROOT/cargo}" + export CARGO_TARGET_DIR="''${CARGO_TARGET_DIR:-$NIX_AGENT_CACHE_ROOT/target}" + export XDG_CACHE_HOME="''${XDG_CACHE_HOME:-$NIX_AGENT_CACHE_ROOT/xdg}" + mkdir -p "$CARGO_HOME" "$CARGO_TARGET_DIR" "$XDG_CACHE_HOME" + ''; +} diff --git a/.openai/AGENTS.md b/.openai/AGENTS.md new file mode 100644 index 0000000..2471f63 --- /dev/null +++ b/.openai/AGENTS.md @@ -0,0 +1 @@ +Read and follow the canonical instructions in `../agents.md`. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..4037a86 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,290 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "toml" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + +[[package]] +name = "zed-interfaces" +version = "0.1.0" +dependencies = [ + "schemars", + "semver", + "serde", + "serde_json", + "thiserror", + "toml", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ab18f0a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "zed-interfaces" +version = "0.1.0" +edition = "2024" +description = "Core interface definitions for the zed-pkg universal package manager" +license = "MIT" +repository = "https://github.com/zed-pkg/zed-interfaces" + +[dependencies] +schemars = "1.2.1" +semver = { version = "1.0.28", features = ["serde"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" +thiserror = "2.0.19" +toml = "1.1.3" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..613f14a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 zed-pkg contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/agents.md b/agents.md new file mode 100644 index 0000000..6983aed --- /dev/null +++ b/agents.md @@ -0,0 +1,46 @@ +# Agent instructions + +## Scope and hierarchy + +- These instructions apply to the whole `zed-pkg/zed-interfaces` repository unless a deeper lowercase `agents.md` adds narrower rules. +- Before editing, resolve the current working directory and load every readable ancestor `agents.md` from the filesystem root to the working directory. Do not search siblings. Resolve symlinks, deduplicate resolved files, and report unreadable or cyclic instruction files. +- `.claude/CLAUDE.md`, `.gemini/GEMINI.md`, and `.openai/AGENTS.md` are pointers only. Never duplicate instructions in tool-specific files. + +## Repository role + +This crate is the shared contract boundary for Zed manifests, lockfiles, registry models, language targets, paths, version requirements, synchronization, and VCS metadata. Changes here affect the CLI, API server, web server, sync engine, and generated clients. + +## Working rules + +- Preserve serialization compatibility and deterministic ordering unless an explicitly versioned migration says otherwise. +- Treat public Rust types, JSON schemas, TOML fields, lockfile formats, and path conventions as cross-repository APIs. +- Add round-trip and negative tests for every parser or schema change; do not weaken validation to make one consumer pass. +- Update interface consumers and monorepo pins in contract-first order when a change spans repositories. +- Keep the crate free of service-specific network, credential, database, and deployment policy. +- Never commit registry tokens, cloud credentials, generated secrets, or production environment files. +- Keep `Cargo.lock` and `flake.lock` committed; do not allow CI to update either lock implicitly. +- Pin GitHub Actions by immutable commit SHA and keep workflow permissions read-only unless a documented write is required. +- Resolve conflicts by preserving serialization compatibility, validation strength, generated-schema determinism, and consumer expectations rather than selecting an entire side. + +## Reproducible validation + +Use the pinned shell rather than mutable host toolchains: + +```sh +nix develop -c agent-check +``` + +Focused stages are available while iterating: + +```sh +nix develop -c agent-check format +nix develop -c agent-check lint +nix develop -c agent-check test +nix develop -c agent-check schemas +``` + +The default command runs Nix/workflow preflight, rustfmt, Clippy with warnings denied, unit tests, doctests, schema generation, and a clean-tree schema drift check. + +## Validation + +The pinned `agents policy` workflow validates this hierarchy and the three tool pointers. Run `nix develop -c agent-check` before requesting review. diff --git a/examples/generate_schemas.rs b/examples/generate_schemas.rs new file mode 100644 index 0000000..905caf9 --- /dev/null +++ b/examples/generate_schemas.rs @@ -0,0 +1,51 @@ +//! Regenerates the JSON Schemas under `schemas/`, which the non-Rust +//! client libraries in `zed-clients` use for codegen and validation. +//! +//! Run with: `cargo run --example generate_schemas` + +use std::fs; +use std::path::Path; + +use schemars::{JsonSchema, schema_for}; + +fn write(dir: &Path, name: &str) { + let schema = schema_for!(T); + let json = serde_json::to_string_pretty(&schema).expect("schema serializes"); + let path = dir.join(format!("{name}.json")); + fs::write(&path, json + "\n").expect("schema file writes"); + println!("wrote {}", path.display()); +} + +fn main() { + let dir = Path::new("schemas"); + fs::create_dir_all(dir).expect("schemas dir"); + + write::(dir, "manifest"); + write::(dir, "lockfile"); + write::(dir, "package-metadata"); + write::(dir, "version-metadata"); + write::(dir, "publish-meta"); + write::(dir, "publish-response"); + write::(dir, "search-response"); + write::(dir, "claim-org-request"); + write::(dir, "claim-org-response"); + write::(dir, "yank-request"); + write::(dir, "yank-response"); + // Governance/audit reads, so non-Rust clients can consume the trail and + // verify the chain against the same shape the server serves. + write::(dir, "audit-log-response"); + write::(dir, "audit-integrity-response"); + write::(dir, "api-error"); + + // Sync contract types shared with zed-sync + zed-clients. + write::(dir, "sync-change-event"); + write::(dir, "sync-write-mode"); + write::(dir, "sync-error-policy"); + write::(dir, "sync-conflict-resolution"); + + // Registry list + RAG/embedding search DTOs. + write::(dir, "package-list-response"); + write::(dir, "semantic-search-request"); + write::(dir, "semantic-search-response"); + write::(dir, "embedding-upsert-request"); +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..a94690d --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1782467914, + "narHash": "sha256-pGvFkM8N0xEkIIXDe5YYfbEAvHrk4IxBrjB/x8OomhE=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e73de5be04e0eff4190a1432b946d469c794e7b4", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..8af92a7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,79 @@ +{ + description = "Agent-first development environment for the zed-pkg interface contract"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; + }; + + outputs = + { self, nixpkgs, ... }: + let + systems = [ + "aarch64-darwin" + "aarch64-linux" + "x86_64-darwin" + "x86_64-linux" + ]; + forAllSystems = nixpkgs.lib.genAttrs systems; + pkgsFor = system: import nixpkgs { inherit system; }; + toolchainFor = + pkgs: with pkgs; [ + actionlint + bash + cargo + clippy + git + nix + nixfmt + rustc + rustfmt + shellcheck + shfmt + ]; + in + { + formatter = forAllSystems (system: (pkgsFor system).nixfmt); + + packages = forAllSystems ( + system: + let + pkgs = pkgsFor system; + agentCheck = pkgs.writeShellApplication { + name = "agent-check"; + runtimeInputs = toolchainFor pkgs; + text = builtins.readFile ./.nix/agent-check.sh; + }; + in + { + inherit agentCheck; + default = agentCheck; + } + ); + + apps = forAllSystems (system: { + agent-check = { + type = "app"; + program = "${self.packages.${system}.agentCheck}/bin/agent-check"; + }; + default = self.apps.${system}.agent-check; + }); + + checks = forAllSystems (system: { + agentCheck = self.packages.${system}.agentCheck; + }); + + devShells = forAllSystems ( + system: + let + pkgs = pkgsFor system; + in + { + default = import ./.nix/dev-shell.nix { + inherit pkgs; + agentCheck = self.packages.${system}.agentCheck; + toolchain = toolchainFor pkgs; + }; + } + ); + }; +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..e140726 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,5 @@ +# Pin the toolchain so CI cache keys stay stable and every machine builds the +# same way (zed-docs issue #8). +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/schemas/api-error.json b/schemas/api-error.json new file mode 100644 index 0000000..0c8f479 --- /dev/null +++ b/schemas/api-error.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ApiError", + "description": "Error body returned with any non-2xx status.", + "type": "object", + "properties": { + "code": { + "description": "Stable machine-readable code, e.g. `not_found`, `sha256_mismatch`,\n`tag_not_found`, `unauthorized`, `org_taken`.", + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "message" + ] +} diff --git a/schemas/audit-integrity-response.json b/schemas/audit-integrity-response.json new file mode 100644 index 0000000..63a7205 --- /dev/null +++ b/schemas/audit-integrity-response.json @@ -0,0 +1,48 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AuditIntegrityResponse", + "description": "The result of walking an org's audit chain end to end.", + "type": "object", + "properties": { + "entries_checked": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "first_bad_seq": { + "description": "The `seq` where verification first failed, if any.", + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "head_hash": { + "description": "The newest entry's hash — an anchor an operator can record externally\nso that later truncation of the whole tail is also detectable.", + "type": [ + "string", + "null" + ] + }, + "intact": { + "description": "True only when every entry's hash recomputes and every link matches.", + "type": "boolean" + }, + "org": { + "type": "string" + }, + "problem": { + "description": "Machine-readable failure kind: `hash_mismatch` (an entry was edited),\n`broken_link` (an entry's `prev_hash` does not match its predecessor),\nor `sequence_gap` (an entry was deleted).", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "org", + "intact", + "entries_checked" + ] +} diff --git a/schemas/audit-log-response.json b/schemas/audit-log-response.json new file mode 100644 index 0000000..fc01664 --- /dev/null +++ b/schemas/audit-log-response.json @@ -0,0 +1,117 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "AuditLogResponse", + "type": "object", + "properties": { + "entries": { + "description": "Most recent first.", + "type": "array", + "items": { + "$ref": "#/$defs/AuditEntry" + } + }, + "org": { + "type": "string" + } + }, + "required": [ + "org", + "entries" + ], + "$defs": { + "AuditAction": { + "description": "A state-changing action recorded in an org's audit log. Reads are never\naudited — only mutations of published state and of the namespace itself, so\nthe log answers \"who changed what\" without drowning in traffic.", + "oneOf": [ + { + "description": "A version was published.", + "type": "string", + "const": "publish" + }, + { + "description": "A version was yanked (hidden from fresh resolution).", + "type": "string", + "const": "yank" + }, + { + "description": "A previously yanked version was restored.", + "type": "string", + "const": "unyank" + }, + { + "description": "The org namespace was claimed.", + "type": "string", + "const": "org_claim" + } + ] + }, + "AuditEntry": { + "description": "One audit-log record. The actor is identified by the *token* that acted —\nits name and role, never its secret — which is the identity a registry\nactually has (zed-docs issue #7 governance).", + "type": "object", + "properties": { + "action": { + "description": "Raw action string; `action_kind` is the parsed form when recognized.", + "type": "string" + }, + "action_kind": { + "description": "Parsed action, absent when this server build doesn't recognize it.", + "anyOf": [ + { + "$ref": "#/$defs/AuditAction" + }, + { + "type": "null" + } + ] + }, + "actor_role": { + "description": "The acting token's role (`owner`/`publisher`/`reader`, or `admin` for\nunscoped tokens).", + "type": "string" + }, + "actor_token_name": { + "description": "Human-readable name of the token that acted.", + "type": "string" + }, + "at": { + "description": "RFC 3339 timestamp of the action.", + "type": "string" + }, + "detail": { + "description": "Extra context, e.g. the artifact sha256 for a publish.", + "type": [ + "string", + "null" + ] + }, + "entry_hash": { + "description": "`sha256(audit_chain_preimage(..))` for this entry, lowercase hex. Empty\nfrom a pre-chain server.", + "type": "string" + }, + "prev_hash": { + "description": "The previous entry's `entry_hash`; `None` for the first entry in an\norg's chain. Linking each entry to its predecessor is what makes a\nsilent deletion or edit detectable.", + "type": [ + "string", + "null" + ] + }, + "seq": { + "description": "Position in the org's append-only chain, starting at 1. Gaps mean\nentries were deleted. Defaults to 0 when read from a server that\npredates the chain.", + "type": "integer", + "format": "uint64", + "default": 0, + "minimum": 0 + }, + "subject": { + "description": "What was acted on, e.g. `acme/http-kit@1.2.0` or the org slug.", + "type": "string" + } + }, + "required": [ + "at", + "action", + "subject", + "actor_token_name", + "actor_role" + ] + } + } +} diff --git a/schemas/claim-org-request.json b/schemas/claim-org-request.json new file mode 100644 index 0000000..cf6c1d6 --- /dev/null +++ b/schemas/claim-org-request.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ClaimOrgRequest", + "type": "object", + "properties": { + "slug": { + "type": "string" + } + }, + "required": [ + "slug" + ] +} diff --git a/schemas/claim-org-response.json b/schemas/claim-org-response.json new file mode 100644 index 0000000..a96c2b9 --- /dev/null +++ b/schemas/claim-org-response.json @@ -0,0 +1,18 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "ClaimOrgResponse", + "type": "object", + "properties": { + "created": { + "description": "False when the caller already owned the org.", + "type": "boolean" + }, + "slug": { + "type": "string" + } + }, + "required": [ + "slug", + "created" + ] +} diff --git a/schemas/embedding-upsert-request.json b/schemas/embedding-upsert-request.json new file mode 100644 index 0000000..f6c51c2 --- /dev/null +++ b/schemas/embedding-upsert-request.json @@ -0,0 +1,28 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "EmbeddingUpsertRequest", + "description": "Body of `PUT /v1/packages/{org}/{name}/embedding` — upsert a package's\nembedding for one model (re-embedding replaces in place).", + "type": "object", + "properties": { + "content": { + "description": "The text that was embedded (kept for provenance / re-embedding).", + "type": "string" + }, + "embedding": { + "description": "The embedding (native width; padded to 2050 server-side).", + "type": "array", + "items": { + "type": "number", + "format": "float" + } + }, + "model": { + "type": "string" + } + }, + "required": [ + "model", + "embedding", + "content" + ] +} diff --git a/schemas/lockfile.json b/schemas/lockfile.json new file mode 100644 index 0000000..b1b3d31 --- /dev/null +++ b/schemas/lockfile.json @@ -0,0 +1,83 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Lockfile", + "description": "The `.zpkg.lock` file written next to `.zpkg.toml` after resolution.\n\nSerialized as TOML with one `[[package]]` table per locked package,\nCargo.lock-style. Every entry pins the exact artifact hash and the VCS\ntag it was published from, so installs are reproducible and every\nartifact is traceable back to source.", + "type": "object", + "properties": { + "package": { + "type": "array", + "items": { + "$ref": "#/$defs/LockedPackage" + } + }, + "version": { + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "version" + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + }, + "LockedPackage": { + "type": "object", + "properties": { + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "sha256": { + "description": "Hex sha256 of the artifact archive; also its store address.", + "type": "string" + }, + "size": { + "description": "Artifact size in bytes.", + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "source": { + "description": "Base URL of the registry the artifact was resolved from.", + "type": "string" + }, + "vcs_commit": { + "type": [ + "string", + "null" + ] + }, + "vcs_tag": { + "description": "VCS tag the version was published from, e.g. `v1.2.0`.", + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "org", + "name", + "version", + "sha256", + "size", + "vcs_tag", + "source" + ] + } + } +} diff --git a/schemas/manifest.json b/schemas/manifest.json new file mode 100644 index 0000000..bac3032 --- /dev/null +++ b/schemas/manifest.json @@ -0,0 +1,523 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Manifest", + "description": "The `.zpkg.toml` manifest at the root of every package repository.\nTOML only — never YAML or JSON.\n\n```toml\n[package]\norg = \"acme\"\nname = \"http-kit\"\nversion = \"1.2.0\"\ndescription = \"Tiny HTTP helpers\"\nlicense = \"MIT\"\n\n[package.repository]\nvcs = \"git\"\nurl = \"https://github.com/acme/http-kit\"\n\n[dependencies]\n\"acme/logkit\" = \"^0.3\"\n\n[publish]\nexclude = [\"benches/**\"]\nsmoke_test = \"sh scripts/smoke.sh\"\n```", + "type": "object", + "properties": { + "bin": { + "description": "Executables this package exposes, keyed by command name, valued by a\npath relative to the package root. On install they are hoisted into\n`zed_modules/.bin/` and runnable via `zed run ` (zed-docs\nissue #7).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "build": { + "description": "This package's own post-extract build step (compiled extensions,\ncodegen), run when the package ships source that needs compiling.\nBuilds run in an isolated staging copy — never inside the immutable\nsource store — and results are cached per (sha256, target, command).", + "anyOf": [ + { + "$ref": "#/$defs/BuildSection" + }, + { + "type": "null" + } + ] + }, + "build-dependencies": { + "description": "Tools needed only while running this package's `[build]` command\n(compilers, codegen). They are made available in the build sandbox and\nnever linked into a consumer's `zed_modules/`. See [`BuildSection`]\nand zed-docs issue #5. Canonical TOML key is Cargo-style\n`[build-dependencies]`; the snake_case spelling is accepted on read.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "dependencies": { + "description": "Dependencies keyed by `org/name`, valued by a semver requirement.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "install": { + "description": "Where zed materializes the (few, hand-picked) dependencies it sources —\nzed complements npm/maven/etc. rather than replacing them, so this dir\nsits alongside the native one and the ecosystem adapter wires it into\nthe toolchain (NODE_PATH / node_modules, the JVM classpath, …). `dir`\ndefaults to `zed_modules`; relocate it with e.g. `.vendor/.zed` or\n`.deps/.zed`.", + "$ref": "#/$defs/InstallSection" + }, + "overrides": { + "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing `[build]` step without waiting on upstream).", + "$ref": "#/$defs/OverridesSection" + }, + "package": { + "$ref": "#/$defs/PackageSection" + }, + "publish": { + "$ref": "#/$defs/PublishSection", + "default": { + "exclude": [], + "include_readme": false, + "smoke_test": null, + "tag_format": "v{version}" + } + }, + "scripts": { + "$ref": "#/$defs/ScriptsSection", + "default": { + "test": null + } + }, + "targets": { + "description": "Language subtrees for a **polyglot package** — one repo shipping the\nsame library for several ecosystems (e.g. `node/`, `python/`, `go/`).\nKeyed by ecosystem name; the value says which subdirectory is that\necosystem's package root. On install the consumer resolves one target\nand only that subtree is materialized, so a Python project gets the\nPython source at its import root rather than a tree it has to reach\ninto. Absent (the common case) = single-language package, whole tree.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/TargetSection" + } + }, + "workspace": { + "description": "Monorepo workspace declaration (zed-docs issue #7); only meaningful in\na workspace root manifest. When present, `zed install` at this root\nresolves every member against one store and writes one `.zpkg.lock`;\nmember→member dependencies link by path instead of going through the\nregistry.", + "anyOf": [ + { + "$ref": "#/$defs/WorkspaceSection" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "package" + ], + "$defs": { + "BuildSection": { + "description": "A post-extract build step. Because compiled output is OS/arch-specific,\nzed-pkg runs `command` via `sh -c` inside a sandboxed staging copy of the\nsource and caches the result in a build cache keyed by\n`(source sha256, target triple, command)` — separate from the universal,\nplatform-independent source store (zed-docs issue #5).", + "type": "object", + "properties": { + "command": { + "description": "Command executed after extraction, e.g. `make` or `cargo build --release`.", + "type": "string" + }, + "outputs": { + "description": "Paths (relative to the package root) to keep from the staging build.\nEmpty means keep everything.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "command" + ] + }, + "Ecosystem": { + "description": "How a consumer's build system takes a dependency in: the resolution\nmechanism, not the language. Drives the install guard and which toolchain\nwiring file `zed install` writes.", + "oneOf": [ + { + "type": "string", + "enum": [ + "cargo", + "cmake", + "composer", + "cran", + "gem", + "gomod", + "hackage", + "julia", + "luarocks", + "matlab", + "nimble", + "npm", + "nuget", + "opam", + "psgallery", + "pypi", + "shards", + "shell", + "swiftpm", + "zig" + ] + }, + { + "description": "No ecosystem constraint. Installs into any project; never gated.", + "type": "string", + "const": "universal" + }, + { + "description": "Elixir, Erlang and Gleam share Hex and the BEAM.", + "type": "string", + "const": "hex" + }, + { + "description": "Anything consumed off a JVM classpath: Java, Kotlin, Scala, Clojure.", + "type": "string", + "const": "jvm" + }, + { + "description": "Dart and Flutter (`pub`).", + "type": "string", + "const": "pub" + } + ] + }, + "ForgeRegistry": { + "type": "string", + "enum": [ + "github-packages", + "gitlab-packages", + "bitbucket-packages" + ] + }, + "InstallSection": { + "description": "Install-layout controls: where zed's dependency tree lands and which\necosystem adapter to emit so those deps are visible to the native toolchain.", + "type": "object", + "properties": { + "adapter": { + "description": "Force the ecosystem adapter: `node`, `java`, or `none`. Omitted =\nauto-detect (or the CLI `--adapter`). Mirrors the CLI's values.", + "type": [ + "string", + "null" + ] + }, + "dir": { + "description": "Project-relative directory for the installed tree (`//`).\nDefaults to `zed_modules`. Common overrides: `.vendor/.zed`, `.deps/.zed`.\nMust be a safe relative path (no leading `/`, no `..`).", + "type": [ + "string", + "null" + ] + }, + "target": { + "description": "Which language subtree to take from **polyglot** dependencies (see\n[`TargetSection`]). Omitted = infer from the project (`package.json` →\n`node`, `go.mod` → `go`, `pyproject.toml` → `python`, …). Naming a\ntarget a dependency does not publish is an error rather than a silent\nfallback: the consumer asked for something specific.", + "type": [ + "string", + "null" + ] + } + } + }, + "Language": { + "description": "The language a package's code is written for. Names the published package\n(`-`), and implies an [`Ecosystem`] via\n[`Language::ecosystem`].\n\nCanonical tokens are the colloquial names people search for — `nodejs`, not\n`node`; `golang`, not `go` — because that is what ends up in a package name\na human has to recall. The shorter spellings, and near-synonyms like\n`typescript`, are accepted by [`Language::from_token`] and by umbrella\nvariant aliases, so both spellings resolve.", + "oneOf": [ + { + "type": "string", + "enum": [ + "c", + "clojure", + "cpp", + "crystal", + "csharp", + "dart", + "elixir", + "erlang", + "fsharp", + "gleam", + "golang", + "haskell", + "java", + "julia", + "kotlin", + "lua", + "matlab", + "nim", + "ocaml", + "php", + "powershell", + "python", + "r", + "ruby", + "rust", + "scala", + "shell", + "swift", + "zig" + ] + }, + { + "description": "Not language-specific: protocol schemas, docs, `.proto` files, an\numbrella package. Never subject to the install ecosystem guard.", + "type": "string", + "const": "universal" + }, + { + "description": "Flutter is a framework rather than a language, but a Flutter client is a\nseparate package from a plain Dart one (different `pubspec.yaml`,\ndifferent deps), so it gets its own token.", + "type": "string", + "const": "flutter" + }, + { + "description": "Covers JavaScript and TypeScript alike: for a client library they are\none artifact published to one ecosystem, so `typescript`, `javascript`,\n`ts`, `js` and `node` all resolve here rather than splitting the package.", + "type": "string", + "const": "nodejs" + }, + { + "description": "Rust compiled to WebAssembly. Distinct from [`Language::Rust`] because\nthe artifact is consumed by a JS bundler, not by Cargo.", + "type": "string", + "const": "rust-wasm" + } + ] + }, + "NativeRegistry": { + "type": "string", + "enum": [ + "npm", + "crates-io", + "pub.dev", + "pypi", + "maven-central", + "rubygems", + "nuget", + "packagist", + "go-modules" + ] + }, + "NativeReleaseSection": { + "type": "object", + "properties": { + "forge": { + "description": "Optional copies in package registries run by source forges. The native\nregistry remains the canonical ecosystem destination; these mirrors\nuse the same native package format and version.", + "type": "array", + "items": { + "$ref": "#/$defs/ForgeRegistry" + } + }, + "package": { + "type": "string" + }, + "registry": { + "$ref": "#/$defs/NativeRegistry" + }, + "tag_format": { + "description": "Optional VCS tag template for native ecosystems whose package is\nresolved from a tag rather than uploaded. It must contain `{version}`.\nGo modules below a repository subdirectory need the directory prefix,\nfor example `clients/go/v{version}`.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "registry", + "package" + ] + }, + "OverridesSection": { + "description": "Consumer-side dependency patches, keyed by `org/name`.", + "type": "object", + "properties": { + "build": { + "description": "Replace or provide a dependency's `[build]` step.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/BuildSection" + } + } + } + }, + "PackageSection": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "ecosystem": { + "description": "How consumers take this package in: `jvm`, `npm`, `gomod`, … Drives the\ninstall-time guard that refuses to drop a Java client into a Node\nproject, and picks the toolchain wiring `zed install` writes.\n\nOmit it and it is derived from `language` (see\n[`Language::ecosystem`]) — declare it only when the language does not\ndetermine consumption. Read through [`PackageSection::ecosystem`]\nrather than touching this field, so the fallback always applies.", + "$ref": "#/$defs/Ecosystem" + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "language": { + "description": "The language this package's code targets. A multi-language repository\npublishes one package per language, all at one version, and each names\nits own — `acme-clients-java`, `acme-clients-nodejs`. Defaults to\n`universal` (language-agnostic), which is never subject to the install\necosystem guard.", + "$ref": "#/$defs/Language" + }, + "license": { + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Package name, unique within the org. Lowercase slug.", + "type": "string" + }, + "org": { + "description": "Namespace the package is published under. Lowercase slug.", + "type": "string" + }, + "repository": { + "$ref": "#/$defs/RepositorySection" + }, + "version": { + "description": "Version of this package, interpreted according to `version_scheme`\n(semver by default).", + "type": "string" + }, + "version_scheme": { + "description": "How `version` (and published tags) should be interpreted. Semver by\ndefault; `calver` for calendar versions, `opaque` for arbitrary tags.", + "$ref": "#/$defs/VersionScheme" + } + }, + "required": [ + "org", + "name", + "version", + "repository" + ] + }, + "PublishSection": { + "type": "object", + "properties": { + "exclude": { + "description": "Extra glob patterns to exclude on top of the built-in defaults.", + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "include_readme": { + "description": "Keep README files in the published artifact (stripped by default).", + "type": "boolean", + "default": false + }, + "native": { + "description": "Optional native-registry route for a single-language package whose\npackage-manager manifest lives at the repository root. Polyglot\npackages declare this metadata on each `[targets.*.native]` section\ninstead.", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] + }, + "smoke_test": { + "description": "Command run by `zed r2g` (alias `zed test-local`) inside a throwaway\nconsumer project that has this package installed the same way a real\nconsumer would.", + "type": [ + "string", + "null" + ], + "default": null + }, + "tag_format": { + "description": "VCS tag template that must exist and point at the published commit.\n`{version}` is substituted with `package.version`.", + "type": "string", + "default": "v{version}" + } + } + }, + "RepositorySection": { + "description": "Where the package's source of truth lives. Any Git or Mercurial host\nworks: GitHub, GitLab, Bitbucket, or a self-hosted server.", + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "vcs": { + "$ref": "#/$defs/Vcs", + "default": "git" + } + }, + "required": [ + "url" + ] + }, + "ScriptsSection": { + "type": "object", + "properties": { + "test": { + "description": "Test command run from the repository (not from published artifacts;\ntests are stripped at publish time).", + "type": [ + "string", + "null" + ], + "default": null + } + } + }, + "TargetSection": { + "description": "One ecosystem's slice of a polyglot package — and, on publish, its own\nindependently installable package.\n\nA repo like `fiducia-clients` carrying `clients/ts`, `clients/java`, and\n`clients/go` declares one target each. `zed publish` then emits **one\nartifact per target**, named `-` by default:\n\n```text\nfiducia/fiducia-clients-nodejs@1.1.2 <- clients/ts only\nfiducia/fiducia-clients-java@1.1.2 <- clients/java only\nfiducia/fiducia-clients-golang@1.1.2 <- clients/go only\n```\n\nOne source of truth and one version in the repo; N packages on the wire.\nA Java consumer downloads only Java bytes — the decisive advantage over\nshipping one fat artifact and slicing it at install time.", + "type": "object", + "properties": { + "adapter": { + "description": "Ecosystem adapter consumers of THIS target should use (`node`, `java`,\n`none`). Recorded in the published per-target manifest so a consumer\ngets the right wiring without configuring it.", + "type": [ + "string", + "null" + ] + }, + "dir": { + "description": "Package-relative directory that is this ecosystem's package root, e.g.\n`python` or `clients/go`. Must be a safe relative path (no leading `/`,\nno `..`) so a target can never escape the package.", + "type": "string" + }, + "ecosystem": { + "description": "Override the ecosystem this target publishes into. Omit it (the normal\ncase) and it is derived from the target key via [`Language::ecosystem`].\nDeclare it when the key does not determine consumption — a `rust-wasm`\ntarget is consumed by a JS bundler, not by Cargo.", + "$ref": "#/$defs/Ecosystem" + }, + "name": { + "description": "Published package name for this target. Defaults to\n`-` (e.g. `fiducia-clients-java`). Set it to\nbreak out of the suffix convention when an ecosystem expects a\ndifferent spelling.", + "type": [ + "string", + "null" + ] + }, + "native": { + "description": "Optional routing to this target's native ecosystem registry. This is\ndeclarative metadata only: the native manifest remains authoritative,\nand arbitrary commands are intentionally not representable here.\n\nNote the difference from [`TargetSection::ecosystem`]: `native` is\n**outbound** (where this slice is mirrored to — npm, crates.io), while\n`ecosystem` is **inbound** (what toolchain a consumer must have to\ninstall it from zed). They describe the same ecosystem from two sides,\nso when both are set they must agree — see\n[`NativeRegistry::ecosystem`] and the check in [`Manifest::validate`].", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "dir" + ] + }, + "Vcs": { + "description": "Version-control systems a package's source repository can live on.\n\nzed-pkg is VCS-agnostic by design: what gets installed is always a\nregistry artifact, and the VCS is where provenance (tags) is anchored.\nAuthors must create a matching tag on their declared backing repo\n(GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure\nDevOps, CodeCommit, Radicle, or self-hosted) before publishing.\n\n`jj` and Sapling are git-compatible and push to git remotes, so their\nprovenance is verified through git tags.", + "type": "string", + "enum": [ + "git", + "hg", + "jj", + "sapling", + "fossil", + "pijul" + ] + }, + "VersionScheme": { + "description": "How a package's `version` string (and its published tags) should be\ninterpreted. Defaults to [`VersionScheme::Semver`], which covers the vast\nmajority of modern ecosystems.", + "oneOf": [ + { + "description": "Semantic Versioning. `package.version` must be valid semver; ranges\n(`^1.2`, `>=0.2 <0.5`) resolve to the max satisfying stable version.", + "type": "string", + "const": "semver" + }, + { + "description": "Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver\ntotal order (leading zeros dropped, padded to major.minor.patch) so the\nsame range algebra applies. See [`normalize_calver`].", + "type": "string", + "const": "calver" + }, + { + "description": "Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra:\na requirement must match a published version **exactly**.", + "type": "string", + "const": "opaque" + } + ] + }, + "WorkspaceSection": { + "description": "Monorepo workspace membership. `members` are glob patterns (relative to\nthe workspace root) selecting directories that each contain a `.zpkg.toml`,\ne.g. `[\"packages/*\", \"apps/*\"]`. Dependencies that resolve to a member are\nlinked straight to the member's source directory instead of going through\nthe registry.", + "type": "object", + "properties": { + "members": { + "type": "array", + "default": [], + "items": { + "type": "string" + } + } + } + } + } +} diff --git a/schemas/package-list-response.json b/schemas/package-list-response.json new file mode 100644 index 0000000..78aecd9 --- /dev/null +++ b/schemas/package-list-response.json @@ -0,0 +1,60 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PackageListResponse", + "description": "Response for `GET /v1/packages` (list all).", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/PackageSummary" + } + }, + "total": { + "description": "Total packages matching the filter (before limit/offset).", + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "items", + "total" + ], + "$defs": { + "PackageSummary": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "latest": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "tags": { + "description": "Free-form tags for filtering/discovery.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "org", + "name" + ] + } + } +} diff --git a/schemas/package-metadata.json b/schemas/package-metadata.json new file mode 100644 index 0000000..5079cb0 --- /dev/null +++ b/schemas/package-metadata.json @@ -0,0 +1,90 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PackageMetadata", + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "latest": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "repo_url": { + "type": "string" + }, + "tags": { + "description": "Free-form tags for filtering/discovery (multi-tag lookup).", + "type": "array", + "items": { + "type": "string" + } + }, + "vcs": { + "$ref": "#/$defs/Vcs" + }, + "version_scheme": { + "description": "How this package's versions should be interpreted (semver by default).", + "$ref": "#/$defs/VersionScheme" + }, + "versions": { + "description": "All published, non-yanked versions, newest first.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "org", + "name", + "vcs", + "repo_url", + "versions" + ], + "$defs": { + "Vcs": { + "description": "Version-control systems a package's source repository can live on.\n\nzed-pkg is VCS-agnostic by design: what gets installed is always a\nregistry artifact, and the VCS is where provenance (tags) is anchored.\nAuthors must create a matching tag on their declared backing repo\n(GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure\nDevOps, CodeCommit, Radicle, or self-hosted) before publishing.\n\n`jj` and Sapling are git-compatible and push to git remotes, so their\nprovenance is verified through git tags.", + "type": "string", + "enum": [ + "git", + "hg", + "jj", + "sapling", + "fossil", + "pijul" + ] + }, + "VersionScheme": { + "description": "How a package's `version` string (and its published tags) should be\ninterpreted. Defaults to [`VersionScheme::Semver`], which covers the vast\nmajority of modern ecosystems.", + "oneOf": [ + { + "description": "Semantic Versioning. `package.version` must be valid semver; ranges\n(`^1.2`, `>=0.2 <0.5`) resolve to the max satisfying stable version.", + "type": "string", + "const": "semver" + }, + { + "description": "Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver\ntotal order (leading zeros dropped, padded to major.minor.patch) so the\nsame range algebra applies. See [`normalize_calver`].", + "type": "string", + "const": "calver" + }, + { + "description": "Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra:\na requirement must match a published version **exactly**.", + "type": "string", + "const": "opaque" + } + ] + } + } +} diff --git a/schemas/publish-meta.json b/schemas/publish-meta.json new file mode 100644 index 0000000..c3cec05 --- /dev/null +++ b/schemas/publish-meta.json @@ -0,0 +1,569 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PublishMeta", + "description": "JSON half of the multipart publish request; the artifact bytes travel in\nthe [`PUBLISH_ARTIFACT_FIELD`] part.", + "type": "object", + "properties": { + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "manifest": { + "$ref": "#/$defs/Manifest" + }, + "sha256": { + "description": "Client-computed sha256 of the uploaded archive; the server recomputes\nand rejects on mismatch.", + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "vcs_commit": { + "type": [ + "string", + "null" + ] + }, + "vcs_tag": { + "description": "Tag that exists in the source repository for this version.", + "type": "string" + } + }, + "required": [ + "manifest", + "vcs_tag", + "sha256", + "size" + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + }, + "BuildSection": { + "description": "A post-extract build step. Because compiled output is OS/arch-specific,\nzed-pkg runs `command` via `sh -c` inside a sandboxed staging copy of the\nsource and caches the result in a build cache keyed by\n`(source sha256, target triple, command)` — separate from the universal,\nplatform-independent source store (zed-docs issue #5).", + "type": "object", + "properties": { + "command": { + "description": "Command executed after extraction, e.g. `make` or `cargo build --release`.", + "type": "string" + }, + "outputs": { + "description": "Paths (relative to the package root) to keep from the staging build.\nEmpty means keep everything.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "command" + ] + }, + "Ecosystem": { + "description": "How a consumer's build system takes a dependency in: the resolution\nmechanism, not the language. Drives the install guard and which toolchain\nwiring file `zed install` writes.", + "oneOf": [ + { + "type": "string", + "enum": [ + "cargo", + "cmake", + "composer", + "cran", + "gem", + "gomod", + "hackage", + "julia", + "luarocks", + "matlab", + "nimble", + "npm", + "nuget", + "opam", + "psgallery", + "pypi", + "shards", + "shell", + "swiftpm", + "zig" + ] + }, + { + "description": "No ecosystem constraint. Installs into any project; never gated.", + "type": "string", + "const": "universal" + }, + { + "description": "Elixir, Erlang and Gleam share Hex and the BEAM.", + "type": "string", + "const": "hex" + }, + { + "description": "Anything consumed off a JVM classpath: Java, Kotlin, Scala, Clojure.", + "type": "string", + "const": "jvm" + }, + { + "description": "Dart and Flutter (`pub`).", + "type": "string", + "const": "pub" + } + ] + }, + "ForgeRegistry": { + "type": "string", + "enum": [ + "github-packages", + "gitlab-packages", + "bitbucket-packages" + ] + }, + "InstallSection": { + "description": "Install-layout controls: where zed's dependency tree lands and which\necosystem adapter to emit so those deps are visible to the native toolchain.", + "type": "object", + "properties": { + "adapter": { + "description": "Force the ecosystem adapter: `node`, `java`, or `none`. Omitted =\nauto-detect (or the CLI `--adapter`). Mirrors the CLI's values.", + "type": [ + "string", + "null" + ] + }, + "dir": { + "description": "Project-relative directory for the installed tree (`//`).\nDefaults to `zed_modules`. Common overrides: `.vendor/.zed`, `.deps/.zed`.\nMust be a safe relative path (no leading `/`, no `..`).", + "type": [ + "string", + "null" + ] + }, + "target": { + "description": "Which language subtree to take from **polyglot** dependencies (see\n[`TargetSection`]). Omitted = infer from the project (`package.json` →\n`node`, `go.mod` → `go`, `pyproject.toml` → `python`, …). Naming a\ntarget a dependency does not publish is an error rather than a silent\nfallback: the consumer asked for something specific.", + "type": [ + "string", + "null" + ] + } + } + }, + "Language": { + "description": "The language a package's code is written for. Names the published package\n(`-`), and implies an [`Ecosystem`] via\n[`Language::ecosystem`].\n\nCanonical tokens are the colloquial names people search for — `nodejs`, not\n`node`; `golang`, not `go` — because that is what ends up in a package name\na human has to recall. The shorter spellings, and near-synonyms like\n`typescript`, are accepted by [`Language::from_token`] and by umbrella\nvariant aliases, so both spellings resolve.", + "oneOf": [ + { + "type": "string", + "enum": [ + "c", + "clojure", + "cpp", + "crystal", + "csharp", + "dart", + "elixir", + "erlang", + "fsharp", + "gleam", + "golang", + "haskell", + "java", + "julia", + "kotlin", + "lua", + "matlab", + "nim", + "ocaml", + "php", + "powershell", + "python", + "r", + "ruby", + "rust", + "scala", + "shell", + "swift", + "zig" + ] + }, + { + "description": "Not language-specific: protocol schemas, docs, `.proto` files, an\numbrella package. Never subject to the install ecosystem guard.", + "type": "string", + "const": "universal" + }, + { + "description": "Flutter is a framework rather than a language, but a Flutter client is a\nseparate package from a plain Dart one (different `pubspec.yaml`,\ndifferent deps), so it gets its own token.", + "type": "string", + "const": "flutter" + }, + { + "description": "Covers JavaScript and TypeScript alike: for a client library they are\none artifact published to one ecosystem, so `typescript`, `javascript`,\n`ts`, `js` and `node` all resolve here rather than splitting the package.", + "type": "string", + "const": "nodejs" + }, + { + "description": "Rust compiled to WebAssembly. Distinct from [`Language::Rust`] because\nthe artifact is consumed by a JS bundler, not by Cargo.", + "type": "string", + "const": "rust-wasm" + } + ] + }, + "Manifest": { + "description": "The `.zpkg.toml` manifest at the root of every package repository.\nTOML only — never YAML or JSON.\n\n```toml\n[package]\norg = \"acme\"\nname = \"http-kit\"\nversion = \"1.2.0\"\ndescription = \"Tiny HTTP helpers\"\nlicense = \"MIT\"\n\n[package.repository]\nvcs = \"git\"\nurl = \"https://github.com/acme/http-kit\"\n\n[dependencies]\n\"acme/logkit\" = \"^0.3\"\n\n[publish]\nexclude = [\"benches/**\"]\nsmoke_test = \"sh scripts/smoke.sh\"\n```", + "type": "object", + "properties": { + "bin": { + "description": "Executables this package exposes, keyed by command name, valued by a\npath relative to the package root. On install they are hoisted into\n`zed_modules/.bin/` and runnable via `zed run ` (zed-docs\nissue #7).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "build": { + "description": "This package's own post-extract build step (compiled extensions,\ncodegen), run when the package ships source that needs compiling.\nBuilds run in an isolated staging copy — never inside the immutable\nsource store — and results are cached per (sha256, target, command).", + "anyOf": [ + { + "$ref": "#/$defs/BuildSection" + }, + { + "type": "null" + } + ] + }, + "build-dependencies": { + "description": "Tools needed only while running this package's `[build]` command\n(compilers, codegen). They are made available in the build sandbox and\nnever linked into a consumer's `zed_modules/`. See [`BuildSection`]\nand zed-docs issue #5. Canonical TOML key is Cargo-style\n`[build-dependencies]`; the snake_case spelling is accepted on read.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "dependencies": { + "description": "Dependencies keyed by `org/name`, valued by a semver requirement.", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "install": { + "description": "Where zed materializes the (few, hand-picked) dependencies it sources —\nzed complements npm/maven/etc. rather than replacing them, so this dir\nsits alongside the native one and the ecosystem adapter wires it into\nthe toolchain (NODE_PATH / node_modules, the JVM classpath, …). `dir`\ndefaults to `zed_modules`; relocate it with e.g. `.vendor/.zed` or\n`.deps/.zed`.", + "$ref": "#/$defs/InstallSection" + }, + "overrides": { + "description": "Consumer-side patches for dependencies (e.g. fixing a dependency's\nbroken or missing `[build]` step without waiting on upstream).", + "$ref": "#/$defs/OverridesSection" + }, + "package": { + "$ref": "#/$defs/PackageSection" + }, + "publish": { + "$ref": "#/$defs/PublishSection", + "default": { + "exclude": [], + "include_readme": false, + "smoke_test": null, + "tag_format": "v{version}" + } + }, + "scripts": { + "$ref": "#/$defs/ScriptsSection", + "default": { + "test": null + } + }, + "targets": { + "description": "Language subtrees for a **polyglot package** — one repo shipping the\nsame library for several ecosystems (e.g. `node/`, `python/`, `go/`).\nKeyed by ecosystem name; the value says which subdirectory is that\necosystem's package root. On install the consumer resolves one target\nand only that subtree is materialized, so a Python project gets the\nPython source at its import root rather than a tree it has to reach\ninto. Absent (the common case) = single-language package, whole tree.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/TargetSection" + } + }, + "workspace": { + "description": "Monorepo workspace declaration (zed-docs issue #7); only meaningful in\na workspace root manifest. When present, `zed install` at this root\nresolves every member against one store and writes one `.zpkg.lock`;\nmember→member dependencies link by path instead of going through the\nregistry.", + "anyOf": [ + { + "$ref": "#/$defs/WorkspaceSection" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "package" + ] + }, + "NativeRegistry": { + "type": "string", + "enum": [ + "npm", + "crates-io", + "pub.dev", + "pypi", + "maven-central", + "rubygems", + "nuget", + "packagist", + "go-modules" + ] + }, + "NativeReleaseSection": { + "type": "object", + "properties": { + "forge": { + "description": "Optional copies in package registries run by source forges. The native\nregistry remains the canonical ecosystem destination; these mirrors\nuse the same native package format and version.", + "type": "array", + "items": { + "$ref": "#/$defs/ForgeRegistry" + } + }, + "package": { + "type": "string" + }, + "registry": { + "$ref": "#/$defs/NativeRegistry" + }, + "tag_format": { + "description": "Optional VCS tag template for native ecosystems whose package is\nresolved from a tag rather than uploaded. It must contain `{version}`.\nGo modules below a repository subdirectory need the directory prefix,\nfor example `clients/go/v{version}`.", + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "registry", + "package" + ] + }, + "OverridesSection": { + "description": "Consumer-side dependency patches, keyed by `org/name`.", + "type": "object", + "properties": { + "build": { + "description": "Replace or provide a dependency's `[build]` step.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/BuildSection" + } + } + } + }, + "PackageSection": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "ecosystem": { + "description": "How consumers take this package in: `jvm`, `npm`, `gomod`, … Drives the\ninstall-time guard that refuses to drop a Java client into a Node\nproject, and picks the toolchain wiring `zed install` writes.\n\nOmit it and it is derived from `language` (see\n[`Language::ecosystem`]) — declare it only when the language does not\ndetermine consumption. Read through [`PackageSection::ecosystem`]\nrather than touching this field, so the fallback always applies.", + "$ref": "#/$defs/Ecosystem" + }, + "keywords": { + "type": "array", + "items": { + "type": "string" + } + }, + "language": { + "description": "The language this package's code targets. A multi-language repository\npublishes one package per language, all at one version, and each names\nits own — `acme-clients-java`, `acme-clients-nodejs`. Defaults to\n`universal` (language-agnostic), which is never subject to the install\necosystem guard.", + "$ref": "#/$defs/Language" + }, + "license": { + "type": [ + "string", + "null" + ] + }, + "name": { + "description": "Package name, unique within the org. Lowercase slug.", + "type": "string" + }, + "org": { + "description": "Namespace the package is published under. Lowercase slug.", + "type": "string" + }, + "repository": { + "$ref": "#/$defs/RepositorySection" + }, + "version": { + "description": "Version of this package, interpreted according to `version_scheme`\n(semver by default).", + "type": "string" + }, + "version_scheme": { + "description": "How `version` (and published tags) should be interpreted. Semver by\ndefault; `calver` for calendar versions, `opaque` for arbitrary tags.", + "$ref": "#/$defs/VersionScheme" + } + }, + "required": [ + "org", + "name", + "version", + "repository" + ] + }, + "PublishSection": { + "type": "object", + "properties": { + "exclude": { + "description": "Extra glob patterns to exclude on top of the built-in defaults.", + "type": "array", + "default": [], + "items": { + "type": "string" + } + }, + "include_readme": { + "description": "Keep README files in the published artifact (stripped by default).", + "type": "boolean", + "default": false + }, + "native": { + "description": "Optional native-registry route for a single-language package whose\npackage-manager manifest lives at the repository root. Polyglot\npackages declare this metadata on each `[targets.*.native]` section\ninstead.", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] + }, + "smoke_test": { + "description": "Command run by `zed r2g` (alias `zed test-local`) inside a throwaway\nconsumer project that has this package installed the same way a real\nconsumer would.", + "type": [ + "string", + "null" + ], + "default": null + }, + "tag_format": { + "description": "VCS tag template that must exist and point at the published commit.\n`{version}` is substituted with `package.version`.", + "type": "string", + "default": "v{version}" + } + } + }, + "RepositorySection": { + "description": "Where the package's source of truth lives. Any Git or Mercurial host\nworks: GitHub, GitLab, Bitbucket, or a self-hosted server.", + "type": "object", + "properties": { + "url": { + "type": "string" + }, + "vcs": { + "$ref": "#/$defs/Vcs", + "default": "git" + } + }, + "required": [ + "url" + ] + }, + "ScriptsSection": { + "type": "object", + "properties": { + "test": { + "description": "Test command run from the repository (not from published artifacts;\ntests are stripped at publish time).", + "type": [ + "string", + "null" + ], + "default": null + } + } + }, + "TargetSection": { + "description": "One ecosystem's slice of a polyglot package — and, on publish, its own\nindependently installable package.\n\nA repo like `fiducia-clients` carrying `clients/ts`, `clients/java`, and\n`clients/go` declares one target each. `zed publish` then emits **one\nartifact per target**, named `-` by default:\n\n```text\nfiducia/fiducia-clients-nodejs@1.1.2 <- clients/ts only\nfiducia/fiducia-clients-java@1.1.2 <- clients/java only\nfiducia/fiducia-clients-golang@1.1.2 <- clients/go only\n```\n\nOne source of truth and one version in the repo; N packages on the wire.\nA Java consumer downloads only Java bytes — the decisive advantage over\nshipping one fat artifact and slicing it at install time.", + "type": "object", + "properties": { + "adapter": { + "description": "Ecosystem adapter consumers of THIS target should use (`node`, `java`,\n`none`). Recorded in the published per-target manifest so a consumer\ngets the right wiring without configuring it.", + "type": [ + "string", + "null" + ] + }, + "dir": { + "description": "Package-relative directory that is this ecosystem's package root, e.g.\n`python` or `clients/go`. Must be a safe relative path (no leading `/`,\nno `..`) so a target can never escape the package.", + "type": "string" + }, + "ecosystem": { + "description": "Override the ecosystem this target publishes into. Omit it (the normal\ncase) and it is derived from the target key via [`Language::ecosystem`].\nDeclare it when the key does not determine consumption — a `rust-wasm`\ntarget is consumed by a JS bundler, not by Cargo.", + "$ref": "#/$defs/Ecosystem" + }, + "name": { + "description": "Published package name for this target. Defaults to\n`-` (e.g. `fiducia-clients-java`). Set it to\nbreak out of the suffix convention when an ecosystem expects a\ndifferent spelling.", + "type": [ + "string", + "null" + ] + }, + "native": { + "description": "Optional routing to this target's native ecosystem registry. This is\ndeclarative metadata only: the native manifest remains authoritative,\nand arbitrary commands are intentionally not representable here.\n\nNote the difference from [`TargetSection::ecosystem`]: `native` is\n**outbound** (where this slice is mirrored to — npm, crates.io), while\n`ecosystem` is **inbound** (what toolchain a consumer must have to\ninstall it from zed). They describe the same ecosystem from two sides,\nso when both are set they must agree — see\n[`NativeRegistry::ecosystem`] and the check in [`Manifest::validate`].", + "anyOf": [ + { + "$ref": "#/$defs/NativeReleaseSection" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "dir" + ] + }, + "Vcs": { + "description": "Version-control systems a package's source repository can live on.\n\nzed-pkg is VCS-agnostic by design: what gets installed is always a\nregistry artifact, and the VCS is where provenance (tags) is anchored.\nAuthors must create a matching tag on their declared backing repo\n(GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure\nDevOps, CodeCommit, Radicle, or self-hosted) before publishing.\n\n`jj` and Sapling are git-compatible and push to git remotes, so their\nprovenance is verified through git tags.", + "type": "string", + "enum": [ + "git", + "hg", + "jj", + "sapling", + "fossil", + "pijul" + ] + }, + "VersionScheme": { + "description": "How a package's `version` string (and its published tags) should be\ninterpreted. Defaults to [`VersionScheme::Semver`], which covers the vast\nmajority of modern ecosystems.", + "oneOf": [ + { + "description": "Semantic Versioning. `package.version` must be valid semver; ranges\n(`^1.2`, `>=0.2 <0.5`) resolve to the max satisfying stable version.", + "type": "string", + "const": "semver" + }, + { + "description": "Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver\ntotal order (leading zeros dropped, padded to major.minor.patch) so the\nsame range algebra applies. See [`normalize_calver`].", + "type": "string", + "const": "calver" + }, + { + "description": "Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra:\na requirement must match a published version **exactly**.", + "type": "string", + "const": "opaque" + } + ] + }, + "WorkspaceSection": { + "description": "Monorepo workspace membership. `members` are glob patterns (relative to\nthe workspace root) selecting directories that each contain a `.zpkg.toml`,\ne.g. `[\"packages/*\", \"apps/*\"]`. Dependencies that resolve to a member are\nlinked straight to the member's source directory instead of going through\nthe registry.", + "type": "object", + "properties": { + "members": { + "type": "array", + "default": [], + "items": { + "type": "string" + } + } + } + } + } +} diff --git a/schemas/publish-response.json b/schemas/publish-response.json new file mode 100644 index 0000000..30d0d41 --- /dev/null +++ b/schemas/publish-response.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "PublishResponse", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "sha256": { + "type": "string" + }, + "version": { + "type": "string" + } + }, + "required": [ + "org", + "name", + "version", + "sha256" + ] +} diff --git a/schemas/search-response.json b/schemas/search-response.json new file mode 100644 index 0000000..86d2a0c --- /dev/null +++ b/schemas/search-response.json @@ -0,0 +1,56 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SearchResponse", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/PackageSummary" + } + }, + "query": { + "type": "string" + } + }, + "required": [ + "query", + "items" + ], + "$defs": { + "PackageSummary": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "latest": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "tags": { + "description": "Free-form tags for filtering/discovery.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "org", + "name" + ] + } + } +} diff --git a/schemas/semantic-search-request.json b/schemas/semantic-search-request.json new file mode 100644 index 0000000..9682ee0 --- /dev/null +++ b/schemas/semantic-search-request.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SemanticSearchRequest", + "description": "Body of `POST /v1/search/semantic` (RAG). The caller computes the query\nembedding with its model; the server ranks stored package embeddings from\nthe SAME model by cosine distance. Vectors up to 2050 dims are accepted and\nzero-padded server-side.", + "type": "object", + "properties": { + "embedding": { + "description": "The query embedding (native width; padded to 2050 server-side).", + "type": "array", + "items": { + "type": "number", + "format": "float" + } + }, + "limit": { + "type": "integer", + "format": "uint32", + "default": 20, + "minimum": 0 + }, + "model": { + "description": "Embedding model id, e.g. `openai/text-embedding-3-small`. Only stored\nembeddings from this model are searched.", + "type": "string" + }, + "tags": { + "description": "Optional tag filter: results must carry all of these tags.", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "model", + "embedding" + ] +} diff --git a/schemas/semantic-search-response.json b/schemas/semantic-search-response.json new file mode 100644 index 0000000..9a89e4c --- /dev/null +++ b/schemas/semantic-search-response.json @@ -0,0 +1,51 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SemanticSearchResponse", + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/$defs/SemanticHit" + } + } + }, + "required": [ + "items" + ], + "$defs": { + "SemanticHit": { + "type": "object", + "properties": { + "description": { + "type": [ + "string", + "null" + ] + }, + "distance": { + "description": "Cosine distance (0 = identical direction, 2 = opposite). Lower is nearer.", + "type": "number", + "format": "double" + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "tags": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "org", + "name", + "distance" + ] + } + } +} diff --git a/schemas/sync-change-event.json b/schemas/sync-change-event.json new file mode 100644 index 0000000..7c46c10 --- /dev/null +++ b/schemas/sync-change-event.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SyncChangeEvent", + "description": "The change envelope both transports decode to (mirrors zed-sync\n`ChangeEvent`). `version` is the ONLY reconciliation key; `sync_sequence`\nis the catch-up cursor and never feeds reconciliation.", + "type": "object", + "properties": { + "at_ms": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "id": { + "type": "string" + }, + "op": { + "$ref": "#/$defs/SyncOp" + }, + "row": true, + "sync_sequence": { + "type": [ + "integer", + "null" + ], + "format": "uint64", + "minimum": 0 + }, + "table": { + "type": "string" + }, + "version": { + "$ref": "#/$defs/Hlc" + }, + "write_key": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "table", + "op", + "id", + "version", + "at_ms" + ], + "$defs": { + "Hlc": { + "description": "Hybrid Logical Clock stamp — the reconciliation key. Total order:\n`wall_ms`, then `counter`, then `actor`.", + "type": "object", + "properties": { + "actor": { + "type": "string" + }, + "counter": { + "type": "integer", + "format": "uint32", + "minimum": 0 + }, + "wall_ms": { + "type": "integer", + "format": "uint64", + "minimum": 0 + } + }, + "required": [ + "wall_ms", + "counter", + "actor" + ] + }, + "SyncOp": { + "type": "string", + "enum": [ + "upsert", + "delete" + ] + } + } +} diff --git a/schemas/sync-conflict-resolution.json b/schemas/sync-conflict-resolution.json new file mode 100644 index 0000000..620e053 --- /dev/null +++ b/schemas/sync-conflict-resolution.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SyncConflictResolution", + "description": "How a dirty-vs-newer-remote conflict resolves (mirrors zed-sync\n`ConflictResolution`).", + "type": "string", + "enum": [ + "server_wins", + "last_write_wins" + ] +} diff --git a/schemas/sync-error-policy.json b/schemas/sync-error-policy.json new file mode 100644 index 0000000..03afa98 --- /dev/null +++ b/schemas/sync-error-policy.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SyncErrorPolicy", + "description": "How write/flush errors are surfaced (mirrors zed-sync `ErrorPolicy`).", + "type": "string", + "enum": [ + "throw_only", + "emit_only", + "throw_and_emit", + "silent" + ] +} diff --git a/schemas/sync-write-mode.json b/schemas/sync-write-mode.json new file mode 100644 index 0000000..f2f2fe7 --- /dev/null +++ b/schemas/sync-write-mode.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "SyncWriteMode", + "description": "Per-write optimism level (mirrors zed-sync `WriteMode`). Enum, not a\nboolean: a write names its exact behavior.", + "type": "string", + "enum": [ + "local_only", + "optimistic_queue", + "optimistic_await_ack", + "server_first", + "server_only" + ] +} diff --git a/schemas/version-metadata.json b/schemas/version-metadata.json new file mode 100644 index 0000000..f458cb7 --- /dev/null +++ b/schemas/version-metadata.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "VersionMetadata", + "type": "object", + "properties": { + "download_url": { + "description": "Absolute or registry-relative URL the artifact can be fetched from.", + "type": "string" + }, + "format": { + "$ref": "#/$defs/ArtifactFormat", + "default": "tar.gz" + }, + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "published_at": { + "description": "RFC 3339 timestamp.", + "type": "string" + }, + "sha256": { + "type": "string" + }, + "size": { + "type": "integer", + "format": "uint64", + "minimum": 0 + }, + "vcs_commit": { + "type": [ + "string", + "null" + ] + }, + "vcs_tag": { + "type": "string" + }, + "version": { + "type": "string" + }, + "yanked": { + "type": "boolean", + "default": false + } + }, + "required": [ + "org", + "name", + "version", + "sha256", + "size", + "vcs_tag", + "download_url", + "published_at" + ], + "$defs": { + "ArtifactFormat": { + "description": "On-the-wire formats for published package artifacts.", + "type": "string", + "enum": [ + "tar.gz", + "zip" + ] + } + } +} diff --git a/schemas/yank-request.json b/schemas/yank-request.json new file mode 100644 index 0000000..28324b5 --- /dev/null +++ b/schemas/yank-request.json @@ -0,0 +1,14 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "YankRequest", + "description": "Body for the yank route. `yanked: false` restores a yanked version.", + "type": "object", + "properties": { + "yanked": { + "type": "boolean" + } + }, + "required": [ + "yanked" + ] +} diff --git a/schemas/yank-response.json b/schemas/yank-response.json new file mode 100644 index 0000000..4dfb9f7 --- /dev/null +++ b/schemas/yank-response.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "YankResponse", + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "org": { + "type": "string" + }, + "version": { + "type": "string" + }, + "yanked": { + "type": "boolean" + } + }, + "required": [ + "org", + "name", + "version", + "yanked" + ] +} diff --git a/src/artifact.rs b/src/artifact.rs new file mode 100644 index 0000000..180d0e7 --- /dev/null +++ b/src/artifact.rs @@ -0,0 +1,36 @@ +use std::fmt; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// On-the-wire formats for published package artifacts. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +pub enum ArtifactFormat { + #[default] + #[serde(rename = "tar.gz")] + TarGz, + #[serde(rename = "zip")] + Zip, +} + +impl ArtifactFormat { + pub fn extension(&self) -> &'static str { + match self { + ArtifactFormat::TarGz => "tar.gz", + ArtifactFormat::Zip => "zip", + } + } + + pub fn content_type(&self) -> &'static str { + match self { + ArtifactFormat::TarGz => "application/gzip", + ArtifactFormat::Zip => "application/zip", + } + } +} + +impl fmt::Display for ArtifactFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.extension()) + } +} diff --git a/src/excludes.rs b/src/excludes.rs new file mode 100644 index 0000000..4bed4fb --- /dev/null +++ b/src/excludes.rs @@ -0,0 +1,94 @@ +//! Publish-time exclusion rules. +//! +//! zed-pkg's core disk-space promise: published artifacts carry runtime files, +//! not development machinery. Tests, CI configuration, VCS metadata, and +//! release documentation are stripped by default; packages can opt into their +//! README and changelog together, while license files are always kept. + +/// Glob patterns excluded from every published artifact by default. +/// Matching is case-insensitive in the CLI. +pub const DEFAULT_EXCLUDES: &[&str] = &[ + ".git/**", + ".hg/**", + ".svn/**", + "tests/**", + "test/**", + "spec/**", + "src/test/**", + "**/__tests__/**", + "**/*.test.*", + "**/*.spec.*", + "**/*_test.go", + // Python tests come in both spellings; `test_*.py` is what unittest + // discovers by default, so omitting it leaks tests into every published + // Python slice. + "**/*_test.py", + "**/test_*.py", + "**/conftest.py", + // Ruby tests living outside `spec/` (minitest's `*_test.rb`, and RSpec + // files kept beside their subject). + "**/*_test.rb", + "**/*_spec.rb", + ".github/**", + ".gitlab/**", + ".gitlab-ci.yml", + "bitbucket-pipelines.yml", + ".circleci/**", + ".travis.yml", + "azure-pipelines.yml", + "README*", + "CHANGELOG*", + ".zedignore", + ".zed/**", + ".zed-pack/**", + ".zpkg.lock", + "zed_modules/**", + "node_modules/**", + "**/node_modules/**", + "target/**", + "**/target/**", + ".dart_tool/**", + "**/.dart_tool/**", + ".gradle/**", + "**/.gradle/**", + "build/**", + "**/build/**", + "__pycache__/**", + "**/__pycache__/**", + ".venv/**", + "**/.venv/**", + "_build/**", + "**/_build/**", + "deps/**", + "**/deps/**", +]; + +/// Patterns that are always kept, even when an exclude matches them. +/// Shipping license texts with artifacts is non-negotiable. +pub const ALWAYS_INCLUDE: &[&str] = &["LICENSE*", "LICENCE*", "COPYING*", "NOTICE*", ".zpkg.toml"]; + +/// Doc patterns `include_readme` un-excludes: the human-facing files a package +/// registry expects to find in a published artifact. +/// +/// `CHANGELOG` is here rather than stripped because native registries ask for it +/// by name — `dart pub publish` fails the package outright for its absence, and +/// `publish.exclude` can only *add* patterns, so a repo has no way to keep it +/// otherwise. A package that opted into shipping its README wants its changelog +/// shipped too. +const REGISTRY_DOC_PATTERNS: &[&str] = &["README", "CHANGELOG"]; + +/// The effective exclusion list for a package: built-in defaults (minus the +/// registry-facing doc patterns when `include_readme` is set), plus the +/// manifest's own `publish.exclude` globs. `.zedignore` lines are appended by +/// the CLI on top of this. +pub fn effective_excludes(extra: &[String], include_readme: bool) -> Vec { + let mut out: Vec = Vec::new(); + for pattern in DEFAULT_EXCLUDES { + if include_readme && REGISTRY_DOC_PATTERNS.iter().any(|d| pattern.starts_with(d)) { + continue; + } + out.push((*pattern).to_string()); + } + out.extend(extra.iter().cloned()); + out +} diff --git a/src/language.rs b/src/language.rs new file mode 100644 index 0000000..11a6ee2 --- /dev/null +++ b/src/language.rs @@ -0,0 +1,684 @@ +//! Language and ecosystem tagging for multi-language packages. +//! +//! A repository that generates API clients for many languages cannot ship as +//! one zed package: a Java consumer would download the Node, Python and Dart +//! trees it will never load, and nothing would stop a Node project from +//! installing the Java client and silently getting an unusable +//! `zed_modules` entry. Such a repo instead publishes one package **per +//! language**, all at one version — `acme/acme-clients-java@1.1.2`, +//! `acme/acme-clients-nodejs@1.1.2`, … — and each declares what it is for. +//! +//! Two axes are needed, and conflating them breaks one case or the other: +//! +//! * [`Language`] **names** the package. Java, Kotlin, Scala and Clojure are +//! four distinct clients a human asks for by name, so they are four packages. +//! * [`Ecosystem`] decides **who may install it** and how the toolchain sees +//! it. Those same four languages are one ecosystem ([`Ecosystem::Jvm`]): +//! consumed off a classpath, and all equally valid in a Gradle project. +//! +//! Keying only on language would leave the install guard unable to tell that a +//! Kotlin package belongs in a Gradle build. Keying only on ecosystem would +//! collapse `-java` and `-kotlin` into one name. Hence both, with +//! [`Language::ecosystem`] supplying the default mapping so a manifest normally +//! declares just `language`. +//! +//! Both default to `universal`, which is never gated — every manifest written +//! before this module existed keeps its old meaning. + +use std::collections::BTreeSet; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// The language a package's code is written for. Names the published package +/// (`-`), and implies an [`Ecosystem`] via +/// [`Language::ecosystem`]. +/// +/// Canonical tokens are the colloquial names people search for — `nodejs`, not +/// `node`; `golang`, not `go` — because that is what ends up in a package name +/// a human has to recall. The shorter spellings, and near-synonyms like +/// `typescript`, are accepted by [`Language::from_token`] and by umbrella +/// variant aliases, so both spellings resolve. +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum Language { + /// Not language-specific: protocol schemas, docs, `.proto` files, an + /// umbrella package. Never subject to the install ecosystem guard. + #[default] + Universal, + C, + Clojure, + Cpp, + Crystal, + Csharp, + Dart, + Elixir, + Erlang, + /// Flutter is a framework rather than a language, but a Flutter client is a + /// separate package from a plain Dart one (different `pubspec.yaml`, + /// different deps), so it gets its own token. + Flutter, + Fsharp, + Gleam, + Golang, + Haskell, + Java, + Julia, + Kotlin, + Lua, + Matlab, + Nim, + /// Covers JavaScript and TypeScript alike: for a client library they are + /// one artifact published to one ecosystem, so `typescript`, `javascript`, + /// `ts`, `js` and `node` all resolve here rather than splitting the package. + Nodejs, + Ocaml, + Php, + Powershell, + Python, + R, + Ruby, + Rust, + /// Rust compiled to WebAssembly. Distinct from [`Language::Rust`] because + /// the artifact is consumed by a JS bundler, not by Cargo. + #[serde(rename = "rust-wasm")] + RustWasm, + Scala, + Shell, + Swift, + Zig, +} + +/// How a consumer's build system takes a dependency in: the resolution +/// mechanism, not the language. Drives the install guard and which toolchain +/// wiring file `zed install` writes. +#[derive( + Debug, + Clone, + Copy, + Default, + PartialEq, + Eq, + PartialOrd, + Ord, + Hash, + Serialize, + Deserialize, + JsonSchema, +)] +#[serde(rename_all = "lowercase")] +pub enum Ecosystem { + /// No ecosystem constraint. Installs into any project; never gated. + #[default] + Universal, + Cargo, + Cmake, + Composer, + Cran, + Gem, + Gomod, + Hackage, + /// Elixir, Erlang and Gleam share Hex and the BEAM. + Hex, + /// Anything consumed off a JVM classpath: Java, Kotlin, Scala, Clojure. + Jvm, + Julia, + Luarocks, + Matlab, + Nimble, + Npm, + Nuget, + Opam, + Psgallery, + /// Dart and Flutter (`pub`). + Pub, + Pypi, + Shards, + Shell, + Swiftpm, + Zig, +} + +impl Language { + pub fn as_str(&self) -> &'static str { + use Language::*; + match self { + Universal => "universal", + C => "c", + Clojure => "clojure", + Cpp => "cpp", + Crystal => "crystal", + Csharp => "csharp", + Dart => "dart", + Elixir => "elixir", + Erlang => "erlang", + Flutter => "flutter", + Fsharp => "fsharp", + Gleam => "gleam", + Golang => "golang", + Haskell => "haskell", + Java => "java", + Julia => "julia", + Kotlin => "kotlin", + Lua => "lua", + Matlab => "matlab", + Nim => "nim", + Nodejs => "nodejs", + Ocaml => "ocaml", + Php => "php", + Powershell => "powershell", + Python => "python", + R => "r", + Ruby => "ruby", + Rust => "rust", + RustWasm => "rust-wasm", + Scala => "scala", + Shell => "shell", + Swift => "swift", + Zig => "zig", + } + } + + /// True for the default (`universal`); lets manifests omit the field. + pub fn is_default(&self) -> bool { + matches!(self, Language::Universal) + } + + /// The ecosystem this language publishes into by default. A package may + /// override it (`[package] ecosystem = "..."`) when the language does not + /// determine consumption — a `rust-wasm` client is consumed by a JS + /// bundler, but a Rust `cdylib` of the same source would be Cargo. + pub fn ecosystem(&self) -> Ecosystem { + use Language::*; + match self { + Universal => Ecosystem::Universal, + C | Cpp => Ecosystem::Cmake, + Clojure | Java | Kotlin | Scala => Ecosystem::Jvm, + Crystal => Ecosystem::Shards, + Csharp | Fsharp => Ecosystem::Nuget, + Dart | Flutter => Ecosystem::Pub, + Elixir | Erlang | Gleam => Ecosystem::Hex, + Golang => Ecosystem::Gomod, + Haskell => Ecosystem::Hackage, + Julia => Ecosystem::Julia, + Lua => Ecosystem::Luarocks, + Matlab => Ecosystem::Matlab, + Nim => Ecosystem::Nimble, + // wasm-pack output is a JS package, not a Cargo one. + Nodejs | RustWasm => Ecosystem::Npm, + Ocaml => Ecosystem::Opam, + Php => Ecosystem::Composer, + Powershell => Ecosystem::Psgallery, + Python => Ecosystem::Pypi, + R => Ecosystem::Cran, + Ruby => Ecosystem::Gem, + Rust => Ecosystem::Cargo, + Shell => Ecosystem::Shell, + Swift => Ecosystem::Swiftpm, + Zig => Ecosystem::Zig, + } + } + + /// Resolve a user-supplied token — a canonical name, a common short form, + /// or a source-directory name — to a language. This is what makes + /// `zed add …-go` find `…-golang`, and what lets a repo whose directory is + /// `clients/ts/` publish as `-nodejs`. + /// + /// Case- and separator-insensitive: `RustWasm`, `rust_wasm` and + /// `rust-wasm` are the same token. + pub fn from_token(token: &str) -> Option { + use Language::*; + let normalized: String = token + .trim() + .to_ascii_lowercase() + .chars() + .map(|c| if c == '_' || c == ' ' { '-' } else { c }) + .collect(); + Some(match normalized.as_str() { + "universal" | "any" | "none" => Universal, + "c" => C, + "clojure" | "clj" => Clojure, + "cpp" | "c++" | "cxx" => Cpp, + "crystal" => Crystal, + "csharp" | "c#" | "cs" | "dotnet" | "net" => Csharp, + "dart" => Dart, + "elixir" | "ex" => Elixir, + "erlang" | "erl" => Erlang, + "flutter" => Flutter, + "fsharp" | "f#" | "fs" => Fsharp, + "gleam" => Gleam, + "golang" | "go" => Golang, + "haskell" | "hs" => Haskell, + "java" => Java, + "julia" | "jl" => Julia, + "kotlin" | "kt" => Kotlin, + "lua" => Lua, + "matlab" => Matlab, + "nim" => Nim, + "nodejs" | "node" | "javascript" | "js" | "typescript" | "ts" => Nodejs, + "ocaml" | "ml" => Ocaml, + "php" => Php, + "powershell" | "pwsh" | "ps1" => Powershell, + "python" | "py" => Python, + "r" => R, + "ruby" | "rb" => Ruby, + "rust" | "rs" => Rust, + "rust-wasm" | "rustwasm" | "wasm" => RustWasm, + "scala" => Scala, + "shell" | "sh" | "bash" => Shell, + "swift" => Swift, + "zig" => Zig, + _ => return None, + }) + } + + /// Parse a stored value, degrading unknown input to `universal` rather than + /// failing a read. Mirrors [`crate::version::VersionScheme::from_str_lenient`]: + /// a value written by a newer zed must never wedge an older one. + pub fn from_str_lenient(s: &str) -> Language { + Language::from_token(s).unwrap_or(Language::Universal) + } +} + +impl std::fmt::Display for Language { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl Ecosystem { + pub fn as_str(&self) -> &'static str { + use Ecosystem::*; + match self { + Universal => "universal", + Cargo => "cargo", + Cmake => "cmake", + Composer => "composer", + Cran => "cran", + Gem => "gem", + Gomod => "gomod", + Hackage => "hackage", + Hex => "hex", + Jvm => "jvm", + Julia => "julia", + Luarocks => "luarocks", + Matlab => "matlab", + Nimble => "nimble", + Npm => "npm", + Nuget => "nuget", + Opam => "opam", + Psgallery => "psgallery", + Pub => "pub", + Pypi => "pypi", + Shards => "shards", + Shell => "shell", + Swiftpm => "swiftpm", + Zig => "zig", + } + } + + /// True for the default (`universal`); lets manifests omit the field. + pub fn is_default(&self) -> bool { + matches!(self, Ecosystem::Universal) + } + + pub fn from_token(token: &str) -> Option { + use Ecosystem::*; + Some(match token.trim().to_ascii_lowercase().as_str() { + "universal" | "any" | "none" => Universal, + "cargo" | "crates" | "crates-io" => Cargo, + "cmake" => Cmake, + "composer" => Composer, + "cran" => Cran, + "gem" | "rubygems" => Gem, + "gomod" | "go" | "goproxy" => Gomod, + "hackage" => Hackage, + "hex" => Hex, + "jvm" | "maven" | "gradle" => Jvm, + "julia" => Julia, + "luarocks" => Luarocks, + "matlab" => Matlab, + "nimble" => Nimble, + "npm" | "node" | "nodejs" | "yarn" | "pnpm" => Npm, + "nuget" | "dotnet" => Nuget, + "opam" => Opam, + "psgallery" | "powershell" => Psgallery, + "pub" | "dart" | "flutter" => Pub, + "pypi" | "pip" | "python" => Pypi, + "shards" => Shards, + "shell" | "sh" => Shell, + "swiftpm" | "spm" => Swiftpm, + "zig" => Zig, + _ => return None, + }) + } + + pub fn from_str_lenient(s: &str) -> Ecosystem { + Ecosystem::from_token(s).unwrap_or(Ecosystem::Universal) + } + + /// Filenames in a project root that identify this ecosystem. A leading + /// `*.` matches by extension; everything else is an exact filename. + /// + /// Empty for ecosystems with no conventional marker file + /// ([`Ecosystem::Matlab`], [`Ecosystem::Shell`]) — those are never + /// auto-detected, so a project consuming one must say so explicitly with + /// `[install] adapter` or `--adapter`. + pub fn marker_files(&self) -> &'static [&'static str] { + use Ecosystem::*; + match self { + Universal | Matlab | Shell => &[], + Cargo => &["Cargo.toml"], + Cmake => &["CMakeLists.txt"], + Composer => &["composer.json"], + Cran => &["DESCRIPTION"], + Gem => &["Gemfile", "*.gemspec"], + Gomod => &["go.mod", "go.work"], + Hackage => &["stack.yaml", "*.cabal"], + Hex => &["mix.exs", "rebar.config", "gleam.toml"], + Jvm => &[ + "pom.xml", + "build.gradle", + "build.gradle.kts", + "build.sbt", + "deps.edn", + "project.clj", + ], + Julia => &["Project.toml", "JuliaProject.toml"], + Luarocks => &["*.rockspec"], + Nimble => &["*.nimble"], + Npm => &["package.json"], + Nuget => &["*.csproj", "*.fsproj", "*.sln"], + Opam => &["dune-project", "*.opam"], + Psgallery => &["*.psd1"], + Pub => &["pubspec.yaml"], + Pypi => &[ + "pyproject.toml", + "setup.py", + "setup.cfg", + "requirements.txt", + ], + Shards => &["shard.yml"], + Swiftpm => &["Package.swift"], + Zig => &["build.zig.zon", "build.zig"], + } + } + + /// Every ecosystem, for exhaustive detection and for error messages that + /// list what a project could be. + pub const ALL: &'static [Ecosystem] = &[ + Ecosystem::Cargo, + Ecosystem::Cmake, + Ecosystem::Composer, + Ecosystem::Cran, + Ecosystem::Gem, + Ecosystem::Gomod, + Ecosystem::Hackage, + Ecosystem::Hex, + Ecosystem::Jvm, + Ecosystem::Julia, + Ecosystem::Luarocks, + Ecosystem::Matlab, + Ecosystem::Nimble, + Ecosystem::Npm, + Ecosystem::Nuget, + Ecosystem::Opam, + Ecosystem::Psgallery, + Ecosystem::Pub, + Ecosystem::Pypi, + Ecosystem::Shards, + Ecosystem::Shell, + Ecosystem::Swiftpm, + Ecosystem::Zig, + ]; + + /// True when `filename` is one of this ecosystem's markers. + pub fn matches_marker(&self, filename: &str) -> bool { + self.marker_files().iter().any(|pattern| { + match pattern.strip_prefix("*.") { + // Extension pattern: `*.gemspec` matches `acme.gemspec` but not + // a file literally named `.gemspec`. + Some(ext) => filename + .rsplit_once('.') + .is_some_and(|(stem, e)| !stem.is_empty() && e == ext), + None => filename == *pattern, + } + }) + } +} + +impl std::fmt::Display for Ecosystem { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +/// Identify every ecosystem present in a project root, given its top-level +/// filenames. Returns a set because polyglot repos are normal — a Rust service +/// with a TypeScript frontend is both `cargo` and `npm`, and a dependency for +/// either belongs there. +/// +/// Takes filenames rather than a path so it stays pure and trivially testable; +/// callers in `zed-cli` supply a directory listing. +pub fn detect_ecosystems<'a, I>(filenames: I) -> BTreeSet +where + I: IntoIterator, +{ + let names: Vec<&str> = filenames.into_iter().collect(); + Ecosystem::ALL + .iter() + .filter(|eco| names.iter().any(|name| eco.matches_marker(name))) + .copied() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_language_round_trips_through_its_own_token() { + // Guards the three parallel match arms (as_str / from_token / + // ecosystem): adding a variant to one and forgetting another is the + // easy mistake, and it would silently mis-tag a published package. + for lang in [ + Language::Universal, + Language::C, + Language::Clojure, + Language::Cpp, + Language::Crystal, + Language::Csharp, + Language::Dart, + Language::Elixir, + Language::Erlang, + Language::Flutter, + Language::Fsharp, + Language::Gleam, + Language::Golang, + Language::Haskell, + Language::Java, + Language::Julia, + Language::Kotlin, + Language::Lua, + Language::Matlab, + Language::Nim, + Language::Nodejs, + Language::Ocaml, + Language::Php, + Language::Powershell, + Language::Python, + Language::R, + Language::Ruby, + Language::Rust, + Language::RustWasm, + Language::Scala, + Language::Shell, + Language::Swift, + Language::Zig, + ] { + assert_eq!( + Language::from_token(lang.as_str()), + Some(lang), + "`{lang}` does not parse back from its own as_str()" + ); + } + } + + #[test] + fn language_serde_uses_the_canonical_token() { + // The manifest is the wire format; a rename here silently invalidates + // every published manifest. + let json = serde_json::to_string(&Language::RustWasm).unwrap(); + assert_eq!(json, "\"rust-wasm\""); + assert_eq!( + serde_json::from_str::("\"nodejs\"").unwrap(), + Language::Nodejs + ); + assert_eq!( + serde_json::to_string(&Language::Golang).unwrap(), + "\"golang\"" + ); + } + + #[test] + fn colloquial_and_short_tokens_resolve_to_one_package() { + // The whole point of the alias table: a user who types `-go` or `-ts` + // must land on the package that actually exists. + for token in ["go", "golang", "GoLang"] { + assert_eq!(Language::from_token(token), Some(Language::Golang)); + } + for token in ["node", "nodejs", "js", "javascript", "ts", "typescript"] { + assert_eq!(Language::from_token(token), Some(Language::Nodejs)); + } + assert_eq!(Language::from_token("rust_wasm"), Some(Language::RustWasm)); + assert_eq!(Language::from_token(" Python "), Some(Language::Python)); + assert_eq!(Language::from_token("cobol"), None); + } + + #[test] + fn unknown_tokens_degrade_to_universal_rather_than_failing() { + // A manifest written by a newer zed must not wedge an older one. + assert_eq!(Language::from_str_lenient("brainfuck"), Language::Universal); + assert_eq!(Ecosystem::from_str_lenient("bazel"), Ecosystem::Universal); + } + + #[test] + fn jvm_languages_share_one_ecosystem_but_keep_distinct_names() { + // This is the reason for two axes. Same ecosystem => a Kotlin client + // installs fine in a Gradle project. Distinct languages => `-java` and + // `-kotlin` are separately installable packages. + for lang in [ + Language::Java, + Language::Kotlin, + Language::Scala, + Language::Clojure, + ] { + assert_eq!(lang.ecosystem(), Ecosystem::Jvm); + } + assert_ne!(Language::Java.as_str(), Language::Kotlin.as_str()); + } + + #[test] + fn rust_wasm_is_consumed_by_npm_not_cargo() { + assert_eq!(Language::Rust.ecosystem(), Ecosystem::Cargo); + assert_eq!(Language::RustWasm.ecosystem(), Ecosystem::Npm); + } + + #[test] + fn universal_maps_to_universal_so_untagged_packages_are_never_gated() { + assert_eq!(Language::Universal.ecosystem(), Ecosystem::Universal); + assert!(Language::default().is_default()); + assert!(Ecosystem::default().is_default()); + assert!(Ecosystem::Universal.marker_files().is_empty()); + } + + #[test] + fn detects_a_single_ecosystem_from_its_marker() { + assert_eq!( + detect_ecosystems(["package.json", "README.md"]), + BTreeSet::from([Ecosystem::Npm]) + ); + assert_eq!( + detect_ecosystems(["build.gradle.kts"]), + BTreeSet::from([Ecosystem::Jvm]) + ); + assert_eq!( + detect_ecosystems(["go.mod"]), + BTreeSet::from([Ecosystem::Gomod]) + ); + } + + #[test] + fn detects_every_ecosystem_in_a_polyglot_project() { + // A Rust service with a TS frontend is both; a dependency for either + // one belongs here, so the guard must not pick a single winner. + let found = detect_ecosystems(["Cargo.toml", "package.json", "pyproject.toml"]); + assert_eq!( + found, + BTreeSet::from([Ecosystem::Cargo, Ecosystem::Npm, Ecosystem::Pypi]) + ); + } + + #[test] + fn extension_markers_match_by_suffix_only() { + assert_eq!( + detect_ecosystems(["acme-client.gemspec"]), + BTreeSet::from([Ecosystem::Gem]) + ); + assert_eq!( + detect_ecosystems(["Acme.Client.csproj"]), + BTreeSet::from([Ecosystem::Nuget]) + ); + // A bare dotfile named after the extension is not a project marker. + assert!(detect_ecosystems([".gemspec"]).is_empty()); + assert!(detect_ecosystems(["gemspec"]).is_empty()); + } + + #[test] + fn detects_nothing_in_a_directory_with_no_markers() { + // Must stay empty rather than guessing: the guard treats "no detected + // ecosystem" as "cannot verify", not as "wrong ecosystem". + assert!(detect_ecosystems(["README.md", "LICENSE"]).is_empty()); + assert!(detect_ecosystems(Vec::<&str>::new()).is_empty()); + } + + #[test] + fn all_covers_every_non_default_ecosystem() { + // A new ecosystem missing from ALL would never be detected, so the + // guard would silently pass everything in such a project. + assert!(!Ecosystem::ALL.contains(&Ecosystem::Universal)); + for lang in [ + Language::Java, + Language::Nodejs, + Language::Golang, + Language::Python, + Language::Rust, + Language::Dart, + Language::Ruby, + Language::Php, + Language::Swift, + Language::Csharp, + Language::Gleam, + Language::Zig, + ] { + assert!( + Ecosystem::ALL.contains(&lang.ecosystem()), + "{lang} maps to {} which is missing from ALL", + lang.ecosystem() + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..72db9ee --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,31 @@ +//! Core interface definitions for the zed-pkg universal package manager. +//! +//! This crate is the single source of truth for the on-disk formats +//! (`.zpkg.toml`, `.zpkg.lock`, store layout), the registry REST API DTOs, and +//! the publish-time exclusion rules. It is consumed by `zed-cli`, +//! `zed-api-server`, `zed-web-server`, and (via generated JSON Schemas in +//! `schemas/`) by the non-Rust client libraries in `zed-clients`. + +pub mod artifact; +pub mod excludes; +pub mod language; +pub mod lockfile; +pub mod manifest; +pub mod nix; +pub mod paths; +pub mod registry; +pub mod sync; +pub mod vcs; +pub mod version; + +pub use artifact::ArtifactFormat; +pub use language::{Ecosystem, Language, detect_ecosystems}; +pub use lockfile::{LockedPackage, Lockfile}; +pub use manifest::{Manifest, ManifestError}; +pub use nix::{ + NIX_ADAPTER_SCHEMA_V1, NixAdapterRecord, NixBuilderNetwork, NixExportMode, NixExportSection, + NixInteropArtifact, NixInteropError, NixOutputOrigin, NixPackageIdentity, NixPolicyEvidence, + NixPolicyProfile, NixRealizedOutput, NixStoreReference, ZedArtifactOrigin, +}; +pub use vcs::Vcs; +pub use version::{Requirement, VersionScheme}; diff --git a/src/lockfile.rs b/src/lockfile.rs new file mode 100644 index 0000000..23a01ae --- /dev/null +++ b/src/lockfile.rs @@ -0,0 +1,94 @@ +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::artifact::ArtifactFormat; + +/// The `.zpkg.lock` file written next to `.zpkg.toml` after resolution. +/// +/// Serialized as TOML with one `[[package]]` table per locked package, +/// Cargo.lock-style. Every entry pins the exact artifact hash and the VCS +/// tag it was published from, so installs are reproducible and every +/// artifact is traceable back to source. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Lockfile { + pub version: u32, + #[serde(default, rename = "package", skip_serializing_if = "Vec::is_empty")] + pub packages: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct LockedPackage { + pub org: String, + pub name: String, + pub version: String, + /// Hex sha256 of the artifact archive; also its store address. + pub sha256: String, + /// Artifact size in bytes. + pub size: u64, + #[serde(default)] + pub format: ArtifactFormat, + /// VCS tag the version was published from, e.g. `v1.2.0`. + pub vcs_tag: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vcs_commit: Option, + /// Base URL of the registry the artifact was resolved from. + pub source: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum LockfileError { + #[error("lockfile toml error: {0}")] + Toml(String), + #[error("unsupported lockfile version {0} (this build supports {1})")] + UnsupportedVersion(u32, u32), +} + +impl Default for Lockfile { + fn default() -> Self { + Self { + version: Self::CURRENT_VERSION, + packages: Vec::new(), + } + } +} + +impl Lockfile { + pub const CURRENT_VERSION: u32 = 1; + + pub fn parse(input: &str) -> Result { + let lockfile: Lockfile = + toml::from_str(input).map_err(|e| LockfileError::Toml(e.to_string()))?; + if lockfile.version > Self::CURRENT_VERSION { + return Err(LockfileError::UnsupportedVersion( + lockfile.version, + Self::CURRENT_VERSION, + )); + } + Ok(lockfile) + } + + pub fn to_toml_string(&self) -> Result { + toml::to_string_pretty(self).map_err(|e| LockfileError::Toml(e.to_string())) + } + + pub fn find(&self, org: &str, name: &str) -> Option<&LockedPackage> { + self.packages + .iter() + .find(|p| p.org == org && p.name == name) + } + + /// Insert or replace the entry for `org/name`, keeping entries sorted. + pub fn upsert(&mut self, package: LockedPackage) { + self.packages + .retain(|p| !(p.org == package.org && p.name == package.name)); + self.packages.push(package); + self.packages + .sort_by(|a, b| (&a.org, &a.name).cmp(&(&b.org, &b.name))); + } +} + +impl LockedPackage { + pub fn full_name(&self) -> String { + format!("{}/{}", self.org, self.name) + } +} diff --git a/src/manifest.rs b/src/manifest.rs new file mode 100644 index 0000000..9b26b81 --- /dev/null +++ b/src/manifest.rs @@ -0,0 +1,1335 @@ +use std::collections::BTreeMap; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::language::{Ecosystem, Language}; +use crate::vcs::Vcs; +use crate::version::{Requirement, VersionScheme}; + +/// The `.zpkg.toml` manifest at the root of every package repository. +/// TOML only — never YAML or JSON. +/// +/// ```toml +/// [package] +/// org = "acme" +/// name = "http-kit" +/// version = "1.2.0" +/// description = "Tiny HTTP helpers" +/// license = "MIT" +/// +/// [package.repository] +/// vcs = "git" +/// url = "https://github.com/acme/http-kit" +/// +/// [dependencies] +/// "acme/logkit" = "^0.3" +/// +/// [publish] +/// exclude = ["benches/**"] +/// smoke_test = "sh scripts/smoke.sh" +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct Manifest { + pub package: PackageSection, + /// Monorepo workspace declaration (zed-docs issue #7); only meaningful in + /// a workspace root manifest. When present, `zed install` at this root + /// resolves every member against one store and writes one `.zpkg.lock`; + /// member→member dependencies link by path instead of going through the + /// registry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace: Option, + /// Dependencies keyed by `org/name`, valued by a semver requirement. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub dependencies: BTreeMap, + /// Tools needed only while running this package's `[build]` command + /// (compilers, codegen). They are made available in the build sandbox and + /// never linked into a consumer's `zed_modules/`. See [`BuildSection`] + /// and zed-docs issue #5. Canonical TOML key is Cargo-style + /// `[build-dependencies]`; the snake_case spelling is accepted on read. + #[serde( + default, + rename = "build-dependencies", + alias = "build_dependencies", + skip_serializing_if = "BTreeMap::is_empty" + )] + pub build_dependencies: BTreeMap, + /// This package's own post-extract build step (compiled extensions, + /// codegen), run when the package ships source that needs compiling. + /// Builds run in an isolated staging copy — never inside the immutable + /// source store — and results are cached per (sha256, target, command). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub build: Option, + /// Consumer-side patches for dependencies (e.g. fixing a dependency's + /// broken or missing `[build]` step without waiting on upstream). + #[serde(default, skip_serializing_if = "OverridesSection::is_empty")] + pub overrides: OverridesSection, + /// Executables this package exposes, keyed by command name, valued by a + /// path relative to the package root. On install they are hoisted into + /// `zed_modules/.bin/` and runnable via `zed run ` (zed-docs + /// issue #7). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub bin: BTreeMap, + #[serde(default)] + pub publish: PublishSection, + #[serde(default)] + pub scripts: ScriptsSection, + /// Where zed materializes the (few, hand-picked) dependencies it sources — + /// zed complements npm/maven/etc. rather than replacing them, so this dir + /// sits alongside the native one and the ecosystem adapter wires it into + /// the toolchain (NODE_PATH / node_modules, the JVM classpath, …). `dir` + /// defaults to `zed_modules`; relocate it with e.g. `.vendor/.zed` or + /// `.deps/.zed`. + #[serde(default, skip_serializing_if = "InstallSection::is_empty")] + pub install: InstallSection, + /// Language subtrees for a **polyglot package** — one repo shipping the + /// same library for several ecosystems (e.g. `node/`, `python/`, `go/`). + /// Keyed by ecosystem name; the value says which subdirectory is that + /// ecosystem's package root. On install the consumer resolves one target + /// and only that subtree is materialized, so a Python project gets the + /// Python source at its import root rather than a tree it has to reach + /// into. Absent (the common case) = single-language package, whole tree. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub targets: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PackageSection { + /// Namespace the package is published under. Lowercase slug. + pub org: String, + /// Package name, unique within the org. Lowercase slug. + pub name: String, + /// Version of this package, interpreted according to `version_scheme` + /// (semver by default). + pub version: String, + /// How `version` (and published tags) should be interpreted. Semver by + /// default; `calver` for calendar versions, `opaque` for arbitrary tags. + #[serde( + default, + skip_serializing_if = "crate::version::VersionScheme::is_default" + )] + pub version_scheme: VersionScheme, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub license: Option, + pub repository: RepositorySection, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub keywords: Vec, + /// The language this package's code targets. A multi-language repository + /// publishes one package per language, all at one version, and each names + /// its own — `acme-clients-java`, `acme-clients-nodejs`. Defaults to + /// `universal` (language-agnostic), which is never subject to the install + /// ecosystem guard. + #[serde(default, skip_serializing_if = "Language::is_default")] + pub language: Language, + /// How consumers take this package in: `jvm`, `npm`, `gomod`, … Drives the + /// install-time guard that refuses to drop a Java client into a Node + /// project, and picks the toolchain wiring `zed install` writes. + /// + /// Omit it and it is derived from `language` (see + /// [`Language::ecosystem`]) — declare it only when the language does not + /// determine consumption. Read through [`PackageSection::ecosystem`] + /// rather than touching this field, so the fallback always applies. + #[serde(default, skip_serializing_if = "Ecosystem::is_default")] + pub ecosystem: Ecosystem, +} + +impl PackageSection { + /// The effective ecosystem: the explicit `ecosystem` when declared, else + /// the one implied by `language`. + /// + /// Always use this instead of reading the field directly — a manifest that + /// says only `language = "java"` must still guard as `jvm`. + pub fn ecosystem(&self) -> Ecosystem { + if self.ecosystem.is_default() { + self.language.ecosystem() + } else { + self.ecosystem + } + } +} + +/// Where the package's source of truth lives. Any Git or Mercurial host +/// works: GitHub, GitLab, Bitbucket, or a self-hosted server. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct RepositorySection { + #[serde(default)] + pub vcs: Vcs, + pub url: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct PublishSection { + /// Extra glob patterns to exclude on top of the built-in defaults. + pub exclude: Vec, + /// Keep README files in the published artifact (stripped by default). + pub include_readme: bool, + /// Command run by `zed r2g` (alias `zed test-local`) inside a throwaway + /// consumer project that has this package installed the same way a real + /// consumer would. + pub smoke_test: Option, + /// VCS tag template that must exist and point at the published commit. + /// `{version}` is substituted with `package.version`. + pub tag_format: String, + /// Optional native-registry route for a single-language package whose + /// package-manager manifest lives at the repository root. Polyglot + /// packages declare this metadata on each `[targets.*.native]` section + /// instead. + #[serde(skip_serializing_if = "Option::is_none")] + pub native: Option, +} + +impl Default for PublishSection { + fn default() -> Self { + Self { + exclude: Vec::new(), + include_readme: false, + smoke_test: None, + tag_format: "v{version}".to_string(), + native: None, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct ScriptsSection { + /// Test command run from the repository (not from published artifacts; + /// tests are stripped at publish time). + pub test: Option, +} + +/// Install-layout controls: where zed's dependency tree lands and which +/// ecosystem adapter to emit so those deps are visible to the native toolchain. +#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct InstallSection { + /// Project-relative directory for the installed tree (`//`). + /// Defaults to `zed_modules`. Common overrides: `.vendor/.zed`, `.deps/.zed`. + /// Must be a safe relative path (no leading `/`, no `..`). + #[serde(skip_serializing_if = "Option::is_none")] + pub dir: Option, + /// Force the ecosystem adapter: `node`, `java`, or `none`. Omitted = + /// auto-detect (or the CLI `--adapter`). Mirrors the CLI's values. + #[serde(skip_serializing_if = "Option::is_none")] + pub adapter: Option, + /// Which language subtree to take from **polyglot** dependencies (see + /// [`TargetSection`]). Omitted = infer from the project (`package.json` → + /// `node`, `go.mod` → `go`, `pyproject.toml` → `python`, …). Naming a + /// target a dependency does not publish is an error rather than a silent + /// fallback: the consumer asked for something specific. + #[serde(skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +impl InstallSection { + pub fn is_empty(&self) -> bool { + self.dir.is_none() && self.adapter.is_none() && self.target.is_none() + } +} + +/// One ecosystem's slice of a polyglot package — and, on publish, its own +/// independently installable package. +/// +/// A repo like `fiducia-clients` carrying `clients/ts`, `clients/java`, and +/// `clients/go` declares one target each. `zed publish` then emits **one +/// artifact per target**, named `-` by default: +/// +/// ```text +/// fiducia/fiducia-clients-nodejs@1.1.2 <- clients/ts only +/// fiducia/fiducia-clients-java@1.1.2 <- clients/java only +/// fiducia/fiducia-clients-golang@1.1.2 <- clients/go only +/// ``` +/// +/// One source of truth and one version in the repo; N packages on the wire. +/// A Java consumer downloads only Java bytes — the decisive advantage over +/// shipping one fat artifact and slicing it at install time. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TargetSection { + /// Package-relative directory that is this ecosystem's package root, e.g. + /// `python` or `clients/go`. Must be a safe relative path (no leading `/`, + /// no `..`) so a target can never escape the package. + pub dir: String, + /// Published package name for this target. Defaults to + /// `-` (e.g. `fiducia-clients-java`). Set it to + /// break out of the suffix convention when an ecosystem expects a + /// different spelling. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Ecosystem adapter consumers of THIS target should use (`node`, `java`, + /// `none`). Recorded in the published per-target manifest so a consumer + /// gets the right wiring without configuring it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub adapter: Option, + /// Optional routing to this target's native ecosystem registry. This is + /// declarative metadata only: the native manifest remains authoritative, + /// and arbitrary commands are intentionally not representable here. + /// + /// Note the difference from [`TargetSection::ecosystem`]: `native` is + /// **outbound** (where this slice is mirrored to — npm, crates.io), while + /// `ecosystem` is **inbound** (what toolchain a consumer must have to + /// install it from zed). They describe the same ecosystem from two sides, + /// so when both are set they must agree — see + /// [`NativeRegistry::ecosystem`] and the check in [`Manifest::validate`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub native: Option, + /// Override the ecosystem this target publishes into. Omit it (the normal + /// case) and it is derived from the target key via [`Language::ecosystem`]. + /// Declare it when the key does not determine consumption — a `rust-wasm` + /// target is consumed by a JS bundler, not by Cargo. + #[serde(default, skip_serializing_if = "Ecosystem::is_default")] + pub ecosystem: Ecosystem, +} + +impl TargetSection { + /// The language this target ships, from its explicit key. `universal` when + /// the key is not a language zed knows — such a target still publishes and + /// installs, it just is not ecosystem-gated. + pub fn language_for(&self, target_key: &str) -> Language { + Language::from_token(target_key).unwrap_or_default() + } + + /// The ecosystem a consumer must have to install this target: the explicit + /// `ecosystem`, else the one implied by the target key. + pub fn ecosystem_for(&self, target_key: &str) -> Ecosystem { + if !self.ecosystem.is_default() { + return self.ecosystem; + } + self.language_for(target_key).ecosystem() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseSection { + pub registry: NativeRegistry, + pub package: String, + /// Optional VCS tag template for native ecosystems whose package is + /// resolved from a tag rather than uploaded. It must contain `{version}`. + /// Go modules below a repository subdirectory need the directory prefix, + /// for example `clients/go/v{version}`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tag_format: Option, + /// Optional copies in package registries run by source forges. The native + /// registry remains the canonical ecosystem destination; these mirrors + /// use the same native package format and version. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub forge: Vec, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum NativeRegistry { + Npm, + CratesIo, + #[serde(rename = "pub.dev")] + PubDev, + #[serde(rename = "pypi")] + PyPi, + MavenCentral, + #[serde(rename = "rubygems")] + RubyGems, + #[serde(rename = "nuget")] + NuGet, + Packagist, + GoModules, +} + +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, JsonSchema, +)] +#[serde(rename_all = "kebab-case")] +pub enum ForgeRegistry { + GithubPackages, + GitlabPackages, + BitbucketPackages, +} + +impl ForgeRegistry { + pub fn as_str(self) -> &'static str { + match self { + Self::GithubPackages => "github-packages", + Self::GitlabPackages => "gitlab-packages", + Self::BitbucketPackages => "bitbucket-packages", + } + } + + /// Package-manager protocols currently documented by each forge. Failing + /// closed here prevents a manifest from promising e.g. Cargo support in + /// GitHub Packages when that registry has no Cargo endpoint. + pub fn supports(self, native: NativeRegistry) -> bool { + match self { + Self::GithubPackages => matches!( + native, + NativeRegistry::Npm + | NativeRegistry::MavenCentral + | NativeRegistry::RubyGems + | NativeRegistry::NuGet + ), + Self::GitlabPackages => matches!( + native, + NativeRegistry::Npm + | NativeRegistry::PyPi + | NativeRegistry::MavenCentral + | NativeRegistry::RubyGems + | NativeRegistry::NuGet + | NativeRegistry::Packagist + | NativeRegistry::GoModules + ), + Self::BitbucketPackages => { + matches!(native, NativeRegistry::Npm | NativeRegistry::MavenCentral) + } + } + } +} + +impl std::fmt::Display for ForgeRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.as_str()) + } +} + +impl NativeRegistry { + pub fn as_str(self) -> &'static str { + match self { + Self::Npm => "npm", + Self::CratesIo => "crates-io", + Self::PubDev => "pub.dev", + Self::PyPi => "pypi", + Self::MavenCentral => "maven-central", + Self::RubyGems => "rubygems", + Self::NuGet => "nuget", + Self::Packagist => "packagist", + Self::GoModules => "go-modules", + } + } + + fn validate_package(self, package: &str) -> Result<(), String> { + let valid = match self { + Self::Npm => is_valid_npm_package(package), + Self::CratesIo => is_valid_crates_package(package), + Self::PubDev => is_valid_pubdev_package(package), + Self::PyPi => is_valid_pypi_package(package), + Self::MavenCentral => is_valid_maven_package(package), + Self::RubyGems => is_valid_rubygems_package(package), + Self::NuGet => is_valid_nuget_package(package), + Self::Packagist => is_valid_packagist_package(package), + Self::GoModules => is_valid_go_module(package), + }; + if valid { + Ok(()) + } else { + Err(format!( + "package `{package}` is not a valid {} package identity", + self.as_str() + )) + } + } + + fn canonical_package(self, package: &str) -> String { + match self { + Self::PyPi => normalize_pypi_package(package), + Self::NuGet => package.to_ascii_lowercase(), + _ => package.to_string(), + } + } + + /// The zed [`Ecosystem`] this native registry corresponds to. + /// + /// The two enums describe the same thing from opposite directions — + /// `NativeRegistry` is where a slice is *mirrored to*, `Ecosystem` is what a + /// consumer needs to *install* it — so a target declaring both must not + /// disagree. Mapping them here keeps that check in one place instead of + /// letting each caller re-derive it. + pub fn ecosystem(self) -> Ecosystem { + match self { + Self::Npm => Ecosystem::Npm, + Self::CratesIo => Ecosystem::Cargo, + Self::PubDev => Ecosystem::Pub, + Self::PyPi => Ecosystem::Pypi, + Self::MavenCentral => Ecosystem::Jvm, + Self::RubyGems => Ecosystem::Gem, + Self::NuGet => Ecosystem::Nuget, + Self::Packagist => Ecosystem::Composer, + Self::GoModules => Ecosystem::Gomod, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NativeReleaseRoute { + pub target: String, + pub dir: String, + pub registry: NativeRegistry, + pub package: String, + pub vcs_tag: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ForgeReleaseRoute { + pub target: String, + pub dir: String, + pub registry: ForgeRegistry, + pub format: NativeRegistry, + pub package: String, + pub vcs_tag: String, +} + +fn is_valid_npm_component(value: &str) -> bool { + !value.is_empty() + && value.len() <= 214 + && !value.starts_with('.') + && !value.starts_with('_') + && !value.contains("..") + && value.chars().all(|c| { + c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '-' | '_' | '.' | '~') + }) +} + +fn is_valid_npm_package(value: &str) -> bool { + if let Some(scoped) = value.strip_prefix('@') { + let Some((scope, package)) = scoped.split_once('/') else { + return false; + }; + !package.contains('/') && is_valid_npm_component(scope) && is_valid_npm_component(package) + } else { + !value.contains('/') && is_valid_npm_component(value) + } +} + +fn is_valid_crates_package(value: &str) -> bool { + !value.is_empty() + && value.len() <= 64 + && !value.starts_with('-') + && !value.starts_with('_') + && !value.ends_with('-') + && !value.ends_with('_') + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')) +} + +fn is_valid_pypi_package(value: &str) -> bool { + !value.is_empty() + && value.as_bytes()[0].is_ascii_alphanumeric() + && value.as_bytes()[value.len() - 1].is_ascii_alphanumeric() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn normalize_pypi_package(value: &str) -> String { + let mut normalized = String::with_capacity(value.len()); + let mut separator = false; + for byte in value.bytes() { + if matches!(byte, b'.' | b'_' | b'-') { + separator = true; + continue; + } + if separator && !normalized.is_empty() { + normalized.push('-'); + } + separator = false; + normalized.push((byte as char).to_ascii_lowercase()); + } + normalized +} + +fn is_valid_pubdev_package(value: &str) -> bool { + const RESERVED: &[&str] = &[ + "assert", "break", "case", "catch", "class", "const", "continue", "default", "do", "else", + "enum", "extends", "false", "final", "finally", "for", "if", "in", "is", "new", "null", + "rethrow", "return", "super", "switch", "this", "throw", "true", "try", "var", "void", + "while", "with", "async", "await", "yield", + ]; + !value.is_empty() + && !value.as_bytes()[0].is_ascii_digit() + && !RESERVED.contains(&value) + && value + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_') +} + +fn is_valid_maven_component(value: &str) -> bool { + !value.is_empty() + && !value.starts_with(['.', '-']) + && !value.ends_with(['.', '-']) + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_')) +} + +fn is_valid_maven_package(value: &str) -> bool { + let Some((group, artifact)) = value.split_once(':') else { + return false; + }; + !artifact.contains(':') && is_valid_maven_component(group) && is_valid_maven_component(artifact) +} + +fn is_valid_rubygems_package(value: &str) -> bool { + !value.is_empty() + && value.as_bytes()[0].is_ascii_alphanumeric() + && value.as_bytes()[value.len() - 1].is_ascii_alphanumeric() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn is_valid_nuget_package(value: &str) -> bool { + value.len() <= 100 && is_valid_rubygems_package(value) +} + +fn is_valid_packagist_package(value: &str) -> bool { + let Some((vendor, package)) = value.split_once('/') else { + return false; + }; + !package.contains('/') && is_valid_npm_component(vendor) && is_valid_npm_component(package) +} + +fn is_valid_go_module(value: &str) -> bool { + !value.is_empty() + && value.contains('/') + && !value.starts_with('/') + && !value.ends_with('/') + && !value.contains("..") + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '/' | '-' | '_' | '~')) +} + +/// A post-extract build step. Because compiled output is OS/arch-specific, +/// zed-pkg runs `command` via `sh -c` inside a sandboxed staging copy of the +/// source and caches the result in a build cache keyed by +/// `(source sha256, target triple, command)` — separate from the universal, +/// platform-independent source store (zed-docs issue #5). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct BuildSection { + /// Command executed after extraction, e.g. `make` or `cargo build --release`. + pub command: String, + /// Paths (relative to the package root) to keep from the staging build. + /// Empty means keep everything. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub outputs: Vec, +} + +/// Monorepo workspace membership. `members` are glob patterns (relative to +/// the workspace root) selecting directories that each contain a `.zpkg.toml`, +/// e.g. `["packages/*", "apps/*"]`. Dependencies that resolve to a member are +/// linked straight to the member's source directory instead of going through +/// the registry. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct WorkspaceSection { + pub members: Vec, +} + +/// Consumer-side dependency patches, keyed by `org/name`. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct OverridesSection { + /// Replace or provide a dependency's `[build]` step. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub build: BTreeMap, +} + +impl OverridesSection { + pub fn is_empty(&self) -> bool { + self.build.is_empty() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ManifestError { + #[error("invalid org slug `{0}`: must match [a-z0-9][a-z0-9-]*[a-z0-9]")] + InvalidOrg(String), + #[error("invalid package name `{0}`: must match [a-z0-9][a-z0-9-]*[a-z0-9]")] + InvalidName(String), + #[error("invalid version `{0}`: {1}")] + InvalidVersion(String, String), + #[error("invalid dependency key `{0}`: expected `org/name`")] + InvalidDependencyKey(String), + #[error("invalid requirement `{1}` for dependency `{0}`: {2}")] + InvalidDependencyReq(String, String, String), + #[error("invalid repository url `{0}`: {1}")] + InvalidRepositoryUrl(String, String), + #[error("invalid bin entry `{0}`: {1}")] + InvalidBin(String, String), + #[error("invalid build section: {0}")] + InvalidBuild(String), + #[error("invalid workspace member pattern `{0}`")] + InvalidWorkspaceMember(String), + #[error("invalid install dir `{0}`: {1}")] + InvalidInstallDir(String, String), + #[error("invalid target `{0}`: {1}")] + InvalidTarget(String, String), + #[error("invalid native release route for target `{0}`: {1}")] + InvalidNativeRoute(String, String), + #[error("manifest toml error: {0}")] + Toml(String), +} + +/// Ecosystem adapter names accepted by `[install].adapter` and +/// `[targets.*].adapter`. Kept here rather than in the CLI so a manifest is +/// validated the same way by the CLI, the registry, and the web UI. +/// +/// Each name is a toolchain zed can wire dependencies into. `none` opts out, +/// installing to `zed_modules/` only. This is deliberately smaller than +/// [`crate::language::Ecosystem`]: an ecosystem says what a package *is*, an +/// adapter says what zed can *wire*, and the second list grows more slowly. +pub const ADAPTERS: &[&str] = &["node", "java", "go", "python", "rust", "dart", "none"]; + +/// True for the lowercase slugs zed-pkg accepts as org and package names. +pub fn is_slug(s: &str) -> bool { + !s.is_empty() + && !s.starts_with('-') + && !s.ends_with('-') + && s.chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') +} + +/// True for a well-formed polyglot target name (`node`, `python`, `go`, …). +/// Same shape as a slug: these appear in manifests, CLI flags, and messages. +pub fn is_target_name(s: &str) -> bool { + is_slug(s) +} + +/// True for a well-formed `org/name` dependency key. +pub fn is_dependency_key(key: &str) -> bool { + let mut parts = key.splitn(2, '/'); + match (parts.next(), parts.next()) { + (Some(org), Some(name)) => is_slug(org) && is_slug(name), + _ => false, + } +} + +/// True for a 64-character lowercase-hex sha256 digest. Registry responses +/// and lockfiles feed digests into filesystem paths, so anything else is +/// rejected before it can reach the disk layer. +pub fn is_sha256_hex(s: &str) -> bool { + s.len() == 64 + && s.chars() + .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)) +} + +/// True when `path` is a relative path with no `..` components — the only +/// shape allowed for manifest-declared paths (bin targets, build outputs). +/// Keeps hoisted bins and build outputs contained inside the package. +pub fn is_safe_relative_path(path: &str) -> bool { + !path.is_empty() + && !path.starts_with('/') + && !path.starts_with('\\') + && !path.contains('\0') + // windows drive/UNC prefixes + && !(path.len() >= 2 && path.as_bytes()[1] == b':') + && !path + .split(['/', '\\']) + .any(|seg| seg == ".." || seg.is_empty()) +} + +fn is_allowed_repo_url(url: &str) -> bool { + // The repo URL renders as a link in registry UIs and is shelled to VCS + // tooling, so restrict it to the schemes those consumers expect. + ["https://", "http://", "ssh://", "git://", "git+ssh://"] + .iter() + .any(|scheme| url.starts_with(scheme)) + // scp-like git syntax: git@github.com:org/repo.git + || (url.contains('@') && url.contains(':') && !url.contains("://")) +} + +fn validate_native_release_section( + native: &NativeReleaseSection, + route_name: &str, + route_dir: &str, + inbound: Ecosystem, +) -> Result<(), ManifestError> { + native + .registry + .validate_package(&native.package) + .map_err(|reason| ManifestError::InvalidNativeRoute(route_name.to_string(), reason))?; + + // The outbound mirror and the inbound install gate describe the same + // ecosystem. If they disagree, one of them is wrong and the package would + // either be mirrored to the wrong registry or refused for the wrong + // consumers — both silent until someone hits it, so fail here instead. + let outbound = native.registry.ecosystem(); + if !inbound.is_default() && inbound != outbound { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "routes to {} (ecosystem `{outbound}`) but installs as `{inbound}`; \ + set the package or target ecosystem if the mirror is right", + native.registry.as_str() + ), + )); + } + + if let Some(tag_format) = &native.tag_format { + if !tag_format.contains("{version}") { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + "native `tag_format` must contain `{version}`".to_string(), + )); + } + if tag_format.trim() != tag_format + || tag_format.is_empty() + || tag_format.chars().any(char::is_whitespace) + { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + "native `tag_format` must be a non-empty VCS ref without whitespace".to_string(), + )); + } + } + if native.registry == NativeRegistry::GoModules && route_dir != "." { + let required_prefix = format!("{}/", route_dir.trim_end_matches('/')); + let Some(tag_format) = &native.tag_format else { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "Go module in `{route_dir}` requires `tag_format = \"{required_prefix}v{{version}}\"`" + ), + )); + }; + if !tag_format.starts_with(&required_prefix) { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "Go module in `{route_dir}` requires a native tag prefixed by `{required_prefix}`" + ), + )); + } + } + + let mut forge_registries = std::collections::BTreeSet::new(); + for forge in &native.forge { + if !forge_registries.insert(*forge) { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!("forge registry `{forge}` is listed more than once"), + )); + } + if !forge.supports(native.registry) { + return Err(ManifestError::InvalidNativeRoute( + route_name.to_string(), + format!( + "forge registry `{forge}` does not support {} packages", + native.registry.as_str() + ), + )); + } + } + Ok(()) +} + +impl Manifest { + /// Parse and validate a `.zpkg.toml` document. + pub fn parse(input: &str) -> Result { + let manifest: Manifest = + toml::from_str(input).map_err(|e| ManifestError::Toml(e.to_string()))?; + manifest.validate()?; + Ok(manifest) + } + + pub fn to_toml_string(&self) -> Result { + toml::to_string_pretty(self).map_err(|e| ManifestError::Toml(e.to_string())) + } + + pub fn validate(&self) -> Result<(), ManifestError> { + if !is_slug(&self.package.org) { + return Err(ManifestError::InvalidOrg(self.package.org.clone())); + } + if !is_slug(&self.package.name) { + return Err(ManifestError::InvalidName(self.package.name.clone())); + } + self.package + .version_scheme + .validate_version(&self.package.version) + .map_err(|e| ManifestError::InvalidVersion(self.package.version.clone(), e))?; + if !is_allowed_repo_url(&self.package.repository.url) { + return Err(ManifestError::InvalidRepositoryUrl( + self.package.repository.url.clone(), + "expected an https/http/ssh/git URL or scp-like git syntax".to_string(), + )); + } + for (key, req) in self.dependencies.iter().chain(&self.build_dependencies) { + if !is_dependency_key(key) { + return Err(ManifestError::InvalidDependencyKey(key.clone())); + } + // A requirement is either a semver range or an exact (opaque) tag. + if req.trim().is_empty() { + return Err(ManifestError::InvalidDependencyReq( + key.clone(), + req.clone(), + "requirement must not be empty".to_string(), + )); + } + // Ranges that *look* like semver ranges but do not parse are + // rejected rather than silently degrading to an opaque tag. + if let Err(reason) = Requirement::validate(req) { + return Err(ManifestError::InvalidDependencyReq( + key.clone(), + req.clone(), + reason, + )); + } + } + let mut target_dirs = BTreeMap::<&str, &str>::new(); + let mut published_names = BTreeMap::::new(); + let mut native_routes = BTreeMap::<(NativeRegistry, String), &str>::new(); + if let Some(native) = &self.publish.native { + if !self.targets.is_empty() { + return Err(ManifestError::InvalidNativeRoute( + "repository".to_string(), + "root `[publish.native]` is only valid for a single-language package; \ + polyglot packages declare `[targets..native]`" + .to_string(), + )); + } + validate_native_release_section(native, "repository", ".", self.package.ecosystem())?; + native_routes.insert( + ( + native.registry, + native.registry.canonical_package(&native.package), + ), + "repository", + ); + } + for (name, target) in &self.targets { + if !is_target_name(name) { + return Err(ManifestError::InvalidTarget( + name.clone(), + "target names use [a-z0-9][a-z0-9-]* (e.g. `node`, `python`, `go`)".to_string(), + )); + } + if !is_safe_relative_path(&target.dir) { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!("dir `{}` must be a relative path without `..`", target.dir), + )); + } + if let Some(previous) = target_dirs.insert(target.dir.as_str(), name.as_str()) { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "dir `{}` is already owned by target `{previous}`; every target must have an isolated source root", + target.dir + ), + )); + } + let published_name = target + .name + .clone() + .unwrap_or_else(|| format!("{}-{name}", self.package.name)); + if !is_slug(&published_name) { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "published name `{published_name}` must match [a-z0-9][a-z0-9-]*[a-z0-9]" + ), + )); + } + if published_name == self.package.name { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "published name `{published_name}` collides with the polyglot source package" + ), + )); + } + if let Some(previous) = published_names.insert(published_name.clone(), name.as_str()) { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "published name `{published_name}` is already used by target `{previous}`" + ), + )); + } + if let Some(adapter) = target.adapter.as_deref() + && !ADAPTERS.contains(&adapter) + { + return Err(ManifestError::InvalidTarget( + name.clone(), + format!( + "adapter `{adapter}` is unsupported; expected one of {}", + ADAPTERS.join(", ") + ), + )); + } + if let Some(native) = &target.native { + if target.dir == "." { + return Err(ManifestError::InvalidNativeRoute( + name.clone(), + "the whole-repository target cannot publish to a native registry" + .to_string(), + )); + } + validate_native_release_section( + native, + name, + &target.dir, + target.ecosystem_for(name), + )?; + let canonical_package = native.registry.canonical_package(&native.package); + let route = (native.registry, canonical_package); + if let Some(previous) = native_routes.insert(route, name.as_str()) { + return Err(ManifestError::InvalidNativeRoute( + name.clone(), + format!( + "{} package `{}` is already routed by target `{previous}`", + native.registry.as_str(), + native.package + ), + )); + } + } + } + // A blank request means "no target", the same way a blank + // `[install].dir` falls back to the default rather than erroring. + if let Some(requested) = self.requested_target() + && !is_target_name(requested) + { + return Err(ManifestError::InvalidTarget( + requested.to_string(), + "target names use [a-z0-9][a-z0-9-]*".to_string(), + )); + } + for (bin_name, target) in &self.bin { + if bin_name.is_empty() + || bin_name.starts_with('.') + || bin_name + .chars() + .any(|c| !(c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')) + { + return Err(ManifestError::InvalidBin( + bin_name.clone(), + "names use [A-Za-z0-9._-] and cannot start with `.`".to_string(), + )); + } + if !is_safe_relative_path(target) { + return Err(ManifestError::InvalidBin( + bin_name.clone(), + format!("target `{target}` must be a relative path without `..`"), + )); + } + } + let overriding = self.overrides.build.values(); + for build in self.build.iter().chain(overriding) { + if build.command.trim().is_empty() { + return Err(ManifestError::InvalidBuild( + "command must not be empty".to_string(), + )); + } + for output in &build.outputs { + if !is_safe_relative_path(output) { + return Err(ManifestError::InvalidBuild(format!( + "output `{output}` must be a relative path without `..`" + ))); + } + } + } + for key in self.overrides.build.keys() { + if !is_dependency_key(key) { + return Err(ManifestError::InvalidDependencyKey(key.clone())); + } + } + if let Some(ws) = &self.workspace { + for pat in &ws.members { + if pat.trim().is_empty() { + return Err(ManifestError::InvalidWorkspaceMember(pat.clone())); + } + } + } + if let Some(dir) = &self.install.dir + && !is_safe_relative_path(dir) + { + return Err(ManifestError::InvalidInstallDir( + dir.clone(), + "must be a relative path without `..` or a leading `/`".to_string(), + )); + } + Ok(()) + } + + /// Project-relative directory dependencies install into. Honors + /// `[install].dir` (e.g. `.vendor/.zed`), else the default `zed_modules`. + pub fn modules_dir(&self) -> &str { + self.install + .dir + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or(crate::paths::MODULES_DIR) + } + + /// The consumer's requested polyglot target, if it named one explicitly. + pub fn requested_target(&self) -> Option<&str> { + self.install + .target + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + } + + /// True when this package ships per-ecosystem subtrees. + pub fn is_polyglot(&self) -> bool { + !self.targets.is_empty() + } + + /// The package name a target publishes under: its explicit `name`, else + /// the `-` convention (`fiducia-clients` + `java` → + /// `fiducia-clients-java`). + pub fn target_package_name(&self, target: &str) -> Option { + self.targets.get(target).map(|t| { + t.name + .clone() + .unwrap_or_else(|| format!("{}-{}", self.package.name, target)) + }) + } + + /// Native release routes sorted by target name, suitable for deterministic + /// credential-free planning before any registry adapter executes. + pub fn native_release_routes(&self) -> Vec { + self.publish + .native + .iter() + .map(|native| NativeReleaseRoute { + target: "repository".to_string(), + dir: ".".to_string(), + registry: native.registry, + package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), + }) + .chain(self.targets.iter().filter_map(|(target, section)| { + section.native.as_ref().map(|native| NativeReleaseRoute { + target: target.clone(), + dir: section.dir.clone(), + registry: native.registry, + package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), + }) + })) + .collect() + } + + /// Forge package-registry mirrors, flattened and sorted by target then + /// registry for deterministic release plans and CI matrices. + pub fn forge_release_routes(&self) -> Vec { + self.publish + .native + .iter() + .flat_map(|native| { + native + .forge + .iter() + .copied() + .map(move |registry| ForgeReleaseRoute { + target: "repository".to_string(), + dir: ".".to_string(), + registry, + format: native.registry, + package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), + }) + }) + .chain(self.targets.iter().flat_map(|(target, section)| { + section.native.iter().flat_map(move |native| { + native + .forge + .iter() + .copied() + .map(move |registry| ForgeReleaseRoute { + target: target.clone(), + dir: section.dir.clone(), + registry, + format: native.registry, + package: native.package.clone(), + vcs_tag: native + .tag_format + .as_deref() + .unwrap_or(&self.publish.tag_format) + .replace("{version}", &self.package.version), + }) + }) + })) + .collect() + } + + /// Every `(target, published name)` pair this manifest fans out to, sorted + /// by target for deterministic publish order and output. + pub fn target_package_names(&self) -> Vec<(String, String)> { + let mut names: Vec<(String, String)> = self + .targets + .keys() + .filter_map(|target| { + self.target_package_name(target) + .map(|name| (target.clone(), name)) + }) + .collect(); + names.sort(); + names + } + + /// Derive the per-target manifest that ships *inside* that target's + /// artifact: same org/version/repo, the target's own package name, its + /// adapter, and no `[targets]` (the slice is single-language by + /// construction). Dependencies are carried over so a target's own zed + /// deps still resolve. + pub fn manifest_for_target(&self, target: &str) -> Option { + let name = self.target_package_name(target)?; + let section = self.targets.get(target)?; + let mut derived = self.clone(); + derived.package.name = name; + derived.package.description = Some(match &self.package.description { + Some(base) => format!("{base} ({target})"), + None => format!("{} ({target} client)", self.package.name), + }); + // Once re-rooted, a polyglot slice is a standalone package. Preserve + // its outbound native/forge routing under the single-package shape so + // the manifest inside the Zed artifact remains self-describing. + derived.publish.native = section.native.clone(); + derived.targets = BTreeMap::new(); + derived.workspace = None; + // The consumer-facing wiring for this ecosystem. + derived.install.adapter = section.adapter.clone().or(self.install.adapter.clone()); + derived.install.target = None; + // Stamp the slice's identity so the *published* package self-describes + // as single-language. This is what lets a consumer's install refuse to + // drop `-java` into a Node-only project: the artifact says `jvm`, and + // the guard has something to compare against. + if let Some(language) = Language::from_token(target) { + derived.package.language = language; + derived.package.ecosystem = section.ecosystem_for(target); + } + Some(derived) + } + + /// The target key matching `requested`, honoring language synonyms. + /// + /// Target keys are chosen by the package author (`nodejs`, `node`, `ts`) + /// while a consumer's request comes from a flag, their manifest, or + /// inference — so the two spellings routinely differ for the same language. + /// Exact match wins; otherwise `requested` and each key are normalized + /// through [`Language::from_token`] and compared, which is what makes a + /// project detected as `node` resolve a `[targets.nodejs]` package (and + /// `go` reach `golang`). + pub fn resolve_target_key(&self, requested: &str) -> Option<&str> { + if let Some((key, _)) = self.targets.get_key_value(requested) { + return Some(key.as_str()); + } + let wanted = Language::from_token(requested).filter(|l| !l.is_default())?; + let mut matches = self + .targets + .keys() + .filter(|key| Language::from_token(key) == Some(wanted)); + let first = matches.next()?; + if matches.next().is_none() { + return Some(first.as_str()); + } + // Several targets share this language — a repo shipping separate `ts` + // and `js` clients has two `nodejs` targets. A synonym request like + // `node` must land somewhere predictable rather than on whichever key + // sorts first, so prefer the one named after the language itself. + self.targets + .keys() + .find(|key| key.as_str() == wanted.as_str()) + .or_else(|| { + self.targets + .keys() + .find(|key| Language::from_token(key) == Some(wanted)) + }) + .map(String::as_str) + } + + /// Resolve which subdirectory of *this* (dependency) package a consumer + /// asking for `requested` should get. + /// + /// * Not polyglot → `Ok(None)`: the whole tree, exactly as before. + /// * Polyglot + a matching target → `Ok(Some(dir))`. + /// * Polyglot + `requested` names a target this package does not publish + /// → `Err` listing what it does publish. An explicit request that cannot + /// be honored is a mistake worth surfacing, not something to paper over + /// by installing a tree the consumer's toolchain cannot read. + /// * Polyglot + nothing requested → `Ok(None)` (whole tree), so a consumer + /// that has not opted in keeps working. + pub fn target_subdir(&self, requested: Option<&str>) -> Result, ManifestError> { + if self.targets.is_empty() { + return Ok(None); + } + let Some(requested) = requested else { + return Ok(None); + }; + // Synonym-aware: a project inferred as `node` must resolve a package + // that spells its target `nodejs`, or every such consumer would hit the + // error below despite the package shipping exactly what they need. + match self + .resolve_target_key(requested) + .and_then(|key| self.targets.get(key)) + { + Some(target) => Ok(Some(target.dir.as_str())), + None => { + let mut available: Vec<&str> = self.targets.keys().map(String::as_str).collect(); + available.sort_unstable(); + Err(ManifestError::InvalidTarget( + requested.to_string(), + format!( + "package `{}/{}` publishes no such target; it provides: {}", + self.package.org, + self.package.name, + available.join(", ") + ), + )) + } + } + } + + /// True when this manifest declares a non-empty monorepo workspace. + pub fn is_workspace_root(&self) -> bool { + self.workspace + .as_ref() + .is_some_and(|w| !w.members.is_empty()) + } + + /// The effective build step for a dependency `org/name`: this manifest's + /// `[overrides.build]` entry if present, else the dependency's own + /// `[build]`. Returns `None` when neither declares one. + pub fn effective_build( + &self, + dep_key: &str, + dep_build: Option<&BuildSection>, + ) -> Option { + self.overrides.build.get(dep_key).or(dep_build).cloned() + } + + /// `org/name`, the canonical package identifier. + pub fn full_name(&self) -> String { + format!("{}/{}", self.package.org, self.package.name) + } + + /// Parsed semver version. Only call after `validate()` has passed. + pub fn version(&self) -> Result { + semver::Version::parse(&self.package.version) + .map_err(|e| ManifestError::InvalidVersion(self.package.version.clone(), e.to_string())) + } + + /// The VCS tag that must exist for this version, e.g. `v1.2.0`. + pub fn vcs_tag(&self) -> String { + self.publish + .tag_format + .replace("{version}", &self.package.version) + } +} diff --git a/src/nix.rs b/src/nix.rs new file mode 100644 index 0000000..6556c9d --- /dev/null +++ b/src/nix.rs @@ -0,0 +1,1003 @@ +use std::collections::BTreeSet; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::artifact::ArtifactFormat; +use crate::manifest::{is_sha256_hex, is_slug, is_target_name}; + +/// Major-versioned identifier for the first immutable Nix ↔ Zed adapter +/// contract. Unknown major versions must fail closed. +pub const NIX_ADAPTER_SCHEMA_V1: &str = "zed.nix-adapter/v1"; + +/// Store-object JSON versions accepted by the v1 contract. The CLI still +/// records the concrete Nix version because these new-CLI formats are +/// versioned independently of this crate. +pub const SUPPORTED_STORE_INFO_JSON_VERSIONS: &[u32] = &[1, 2, 3]; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum NixExportMode { + /// Export the exact immutable Zed artifact. Source-builder translation is + /// intentionally not inferred from native manifests in contract v1. + #[default] + Artifact, +} + +/// Author intent for exporting a package or target to Nix. +/// +/// This structure contains no realized store paths, hashes, commands, +/// credentials, cache keys, or service deployment policy. Those belong in a +/// versioned adapter record after planning/realization. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(default)] +pub struct NixExportSection { + pub mode: NixExportMode, + /// Optional Nix package attribute. Omit it to use the Zed package name. + #[serde(skip_serializing_if = "Option::is_none")] + pub attribute: Option, + /// Explicit Nix systems this package claims to support. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub systems: Vec, + /// Explicit derivation outputs. Contract v1 never silently selects the + /// first output of a multi-output derivation. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub outputs: Vec, +} + +impl NixExportSection { + pub fn resolved_attribute(&self, default_attribute: &str) -> String { + self.attribute + .clone() + .unwrap_or_else(|| default_attribute.to_string()) + } + + pub fn validate(&self, default_attribute: &str) -> Result<(), NixInteropError> { + let attribute = self.resolved_attribute(default_attribute); + if !is_nix_identifier(&attribute) { + return Err(NixInteropError::InvalidExportIntent(format!( + "attribute `{attribute}` must be a Nix identifier" + ))); + } + if attribute == "default" { + return Err(NixInteropError::InvalidExportIntent( + "attribute `default` is reserved for the generated flake alias; set an explicit non-default attribute" + .to_string(), + )); + } + if self.systems.is_empty() { + return Err(NixInteropError::InvalidExportIntent( + "at least one explicit Nix system is required".to_string(), + )); + } + if self.outputs.is_empty() { + return Err(NixInteropError::InvalidExportIntent( + "at least one explicit Nix output is required (usually `out`)".to_string(), + )); + } + ensure_unique(&self.systems, "Nix systems")?; + ensure_unique(&self.outputs, "Nix outputs")?; + for system in &self.systems { + if !is_nix_system(system) { + return Err(NixInteropError::InvalidExportIntent(format!( + "system `{system}` must be a lowercase Nix system such as `x86_64-linux`" + ))); + } + } + for output in &self.outputs { + if !is_nix_identifier(output) { + return Err(NixInteropError::InvalidExportIntent(format!( + "output `{output}` must be a Nix identifier" + ))); + } + } + Ok(()) + } + + fn normalize(&mut self) { + self.systems.sort(); + self.outputs.sort(); + } +} + +/// Public Zed identity chosen for either translation direction. A Nix +/// attribute is a selector and never silently claims a Zed organization. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixPackageIdentity { + pub org: String, + pub name: String, + pub version: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub target: Option, +} + +impl NixPackageIdentity { + pub fn validate(&self) -> Result<(), NixInteropError> { + if !is_slug(&self.org) { + return Err(NixInteropError::InvalidPackageIdentity(format!( + "invalid org slug `{}`", + self.org + ))); + } + if !is_slug(&self.name) { + return Err(NixInteropError::InvalidPackageIdentity(format!( + "invalid package name `{}`", + self.name + ))); + } + if self.version.is_empty() + || self.version.trim() != self.version + || self.version.chars().any(char::is_whitespace) + { + return Err(NixInteropError::InvalidPackageIdentity( + "version must be non-empty and contain no whitespace".to_string(), + )); + } + if let Some(target) = &self.target + && !is_target_name(target) + { + return Err(NixInteropError::InvalidPackageIdentity(format!( + "invalid target `{target}`" + ))); + } + Ok(()) + } +} + +/// Immutable Zed artifact identity used by both a Zed-origin source and the +/// translated artifact produced by Nix → Zed sealing. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixInteropArtifact { + #[serde(default)] + pub format: ArtifactFormat, + /// Lowercase hexadecimal SHA-256 of the exact archive bytes. + pub sha256: String, + pub size: u64, +} + +impl NixInteropArtifact { + pub fn validate(&self, field: &str) -> Result<(), NixInteropError> { + validate_sha256_hex(field, &self.sha256)?; + if self.size == 0 { + return Err(NixInteropError::InvalidArtifact(format!( + "{field} size must be greater than zero" + ))); + } + Ok(()) + } +} + +/// Immutable source evidence when Zed is the dependency-resolution authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct ZedArtifactOrigin { + /// Registry base URL used to resolve the exact artifact. + pub registry: String, + pub artifact: NixInteropArtifact, + pub vcs_tag: String, + pub vcs_commit: String, + /// Hash of the exact `.zpkg.lock` bytes, omitted only for a dependency-free + /// package whose export plan proves no lock is required. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub lock_sha256: Option, +} + +impl ZedArtifactOrigin { + pub fn validate(&self) -> Result<(), NixInteropError> { + if !is_registry_url(&self.registry) { + return Err(NixInteropError::InvalidZedOrigin( + "registry must be an http(s) or file URL without whitespace".to_string(), + )); + } + self.artifact.validate("Zed source artifact")?; + if !is_ref_token(&self.vcs_tag) { + return Err(NixInteropError::InvalidZedOrigin( + "VCS tag must be non-empty and contain no whitespace".to_string(), + )); + } + if self.vcs_commit.len() < 7 || !is_ref_token(&self.vcs_commit) { + return Err(NixInteropError::InvalidZedOrigin( + "VCS commit must be an immutable non-whitespace identifier".to_string(), + )); + } + if let Some(lock_sha256) = &self.lock_sha256 { + validate_sha256_hex("Zed lock", lock_sha256)?; + } + Ok(()) + } +} + +/// One referenced Nix store object. Portable Nix → Zed imports reject any +/// runtime references in contract v1; Zed → Nix output attestations may still +/// retain them as evidence. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixStoreReference { + pub store_path: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nar_hash: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub nar_size: Option, +} + +impl NixStoreReference { + pub fn validate(&self) -> Result<(), NixInteropError> { + if !is_nix_store_path(&self.store_path) { + return Err(NixInteropError::InvalidNixOutput(format!( + "invalid referenced store path `{}`", + self.store_path + ))); + } + if let Some(nar_hash) = &self.nar_hash + && !is_sha256_sri(nar_hash) + { + return Err(NixInteropError::InvalidNixOutput(format!( + "reference `{}` has an invalid SHA-256 SRI NAR hash", + self.store_path + ))); + } + if self.nar_size == Some(0) { + return Err(NixInteropError::InvalidNixOutput(format!( + "reference `{}` has zero NAR size", + self.store_path + ))); + } + Ok(()) + } +} + +/// Realization evidence for exactly one Nix system/output pair. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixRealizedOutput { + pub system: String, + pub output: String, + pub derivation_json_sha256: String, + /// Diagnostic path only; `nar_hash` is the portable output identity. + pub store_path: String, + pub nar_hash: String, + pub nar_size: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub references: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub signatures: Vec, + pub nix_version: String, + pub store_info_json_version: u32, +} + +impl NixRealizedOutput { + pub fn validate(&self) -> Result<(), NixInteropError> { + if !is_nix_system(&self.system) { + return Err(NixInteropError::InvalidNixOutput(format!( + "invalid Nix system `{}`", + self.system + ))); + } + if !is_nix_identifier(&self.output) { + return Err(NixInteropError::InvalidNixOutput(format!( + "invalid Nix output `{}`", + self.output + ))); + } + validate_sha256_hex("derivation JSON", &self.derivation_json_sha256)?; + if !is_nix_store_path(&self.store_path) { + return Err(NixInteropError::InvalidNixOutput(format!( + "invalid store path `{}`", + self.store_path + ))); + } + if !is_sha256_sri(&self.nar_hash) { + return Err(NixInteropError::InvalidNixOutput( + "NAR hash must be a SHA-256 SRI value".to_string(), + )); + } + if self.nar_size == 0 { + return Err(NixInteropError::InvalidNixOutput( + "NAR size must be greater than zero".to_string(), + )); + } + let mut reference_paths = BTreeSet::new(); + for reference in &self.references { + reference.validate()?; + if !reference_paths.insert(reference.store_path.as_str()) { + return Err(NixInteropError::InvalidNixOutput(format!( + "store reference `{}` appears more than once", + reference.store_path + ))); + } + } + ensure_unique(&self.signatures, "Nix signatures")?; + if self + .signatures + .iter() + .any(|signature| signature.is_empty() || signature.chars().any(char::is_whitespace)) + { + return Err(NixInteropError::InvalidNixOutput( + "Nix signatures must be non-empty tokens without whitespace".to_string(), + )); + } + if self.nix_version.trim().is_empty() { + return Err(NixInteropError::InvalidNixOutput( + "Nix version must be recorded".to_string(), + )); + } + if !SUPPORTED_STORE_INFO_JSON_VERSIONS.contains(&self.store_info_json_version) { + return Err(NixInteropError::InvalidNixOutput(format!( + "unsupported store-info JSON version {}; supported versions are 1, 2, and 3", + self.store_info_json_version + ))); + } + Ok(()) + } + + fn normalize(&mut self) { + self.references + .sort_by(|a, b| a.store_path.cmp(&b.store_path)); + self.signatures.sort(); + } +} + +/// Immutable Nix source selector plus the exact realized output selected for a +/// Nix → Zed translation. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixOutputOrigin { + pub locked_ref: String, + pub flake_lock_sha256: String, + /// Standard flake attribute path, e.g. `packages.x86_64-linux.tool`. + pub attribute: String, + pub realized: NixRealizedOutput, +} + +impl NixOutputOrigin { + pub fn validate(&self) -> Result<(), NixInteropError> { + if !is_immutable_nix_ref(&self.locked_ref) { + return Err(NixInteropError::InvalidNixOrigin( + "locked ref must contain immutable revision or NAR-hash evidence and no whitespace" + .to_string(), + )); + } + validate_sha256_hex("flake.lock", &self.flake_lock_sha256)?; + if !is_nix_attribute_path(&self.attribute) { + return Err(NixInteropError::InvalidNixOrigin(format!( + "invalid standard flake attribute path `{}`", + self.attribute + ))); + } + self.realized.validate() + } + + fn normalize(&mut self) { + self.realized.normalize(); + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum NixPolicyProfile { + StrictV1, + Development, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum NixBuilderNetwork { + Disabled, + PreparationOnly, + Allowed, +} + +/// Evidence that the translation was planned/realized under a named policy. +/// This records policy state; it does not grant credentials or execute Nix. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct NixPolicyEvidence { + pub profile: NixPolicyProfile, + pub pure_evaluation: bool, + pub import_from_derivation: bool, + pub sandbox_required: bool, + pub builder_network: NixBuilderNetwork, + pub dirty_source: bool, + pub publishable: bool, +} + +impl NixPolicyEvidence { + pub fn validate(&self) -> Result<(), NixInteropError> { + match self.profile { + NixPolicyProfile::StrictV1 => { + if !self.pure_evaluation + || self.import_from_derivation + || !self.sandbox_required + || self.builder_network != NixBuilderNetwork::Disabled + || self.dirty_source + || !self.publishable + { + return Err(NixInteropError::InvalidPolicy( + "strict-v1 requires pure evaluation, IFD disabled, sandbox required, builder network disabled, clean source, and publishable output" + .to_string(), + )); + } + } + NixPolicyProfile::Development => { + if self.publishable { + return Err(NixInteropError::InvalidPolicy( + "development policy records are never publishable".to_string(), + )); + } + } + } + Ok(()) + } +} + +/// Final, immutable provenance record for one completed translation. +/// +/// `direction` is internally tagged in JSON so consumers cannot deserialize a +/// direction whose required origin/result fields are absent. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "direction", rename_all = "kebab-case")] +pub enum NixAdapterRecord { + ZedToNix { + schema: String, + package: NixPackageIdentity, + source: ZedArtifactOrigin, + intent: NixExportSection, + /// Hash of a canonical inventory of the generated standalone flake + /// bundle, not a filesystem-dependent archive of the output directory. + flake_bundle_sha256: String, + outputs: Vec, + policy: NixPolicyEvidence, + }, + NixToZed { + schema: String, + package: NixPackageIdentity, + source: NixOutputOrigin, + artifact: NixInteropArtifact, + policy: NixPolicyEvidence, + }, +} + +impl NixAdapterRecord { + pub fn zed_to_nix( + package: NixPackageIdentity, + source: ZedArtifactOrigin, + intent: NixExportSection, + flake_bundle_sha256: String, + outputs: Vec, + policy: NixPolicyEvidence, + ) -> Self { + Self::ZedToNix { + schema: NIX_ADAPTER_SCHEMA_V1.to_string(), + package, + source, + intent, + flake_bundle_sha256, + outputs, + policy, + } + } + + pub fn nix_to_zed( + package: NixPackageIdentity, + source: NixOutputOrigin, + artifact: NixInteropArtifact, + policy: NixPolicyEvidence, + ) -> Self { + Self::NixToZed { + schema: NIX_ADAPTER_SCHEMA_V1.to_string(), + package, + source, + artifact, + policy, + } + } + + pub fn validate(&self) -> Result<(), NixInteropError> { + match self { + Self::ZedToNix { + schema, + package, + source, + intent, + flake_bundle_sha256, + outputs, + policy, + } => { + validate_schema(schema)?; + package.validate()?; + source.validate()?; + intent.validate(&package.name)?; + validate_sha256_hex("flake bundle", flake_bundle_sha256)?; + policy.validate()?; + if outputs.is_empty() { + return Err(NixInteropError::InvalidAdapter( + "a final Zed → Nix adapter record requires at least one realized output" + .to_string(), + )); + } + let declared_systems: BTreeSet<&str> = + intent.systems.iter().map(String::as_str).collect(); + let declared_outputs: BTreeSet<&str> = + intent.outputs.iter().map(String::as_str).collect(); + let mut seen = BTreeSet::new(); + let mut realized_systems = BTreeSet::new(); + for output in outputs { + output.validate()?; + if !declared_systems.contains(output.system.as_str()) { + return Err(NixInteropError::InvalidAdapter(format!( + "realized system `{}` was not declared by the export intent", + output.system + ))); + } + if !declared_outputs.contains(output.output.as_str()) { + return Err(NixInteropError::InvalidAdapter(format!( + "realized output `{}` was not declared by the export intent", + output.output + ))); + } + if !seen.insert((output.system.as_str(), output.output.as_str())) { + return Err(NixInteropError::InvalidAdapter(format!( + "system/output pair `{}/{}` appears more than once", + output.system, output.output + ))); + } + realized_systems.insert(output.system.as_str()); + } + for system in declared_systems { + if !realized_systems.contains(system) { + return Err(NixInteropError::InvalidAdapter(format!( + "declared system `{system}` has no realized output evidence" + ))); + } + } + Ok(()) + } + Self::NixToZed { + schema, + package, + source, + artifact, + policy, + } => { + validate_schema(schema)?; + package.validate()?; + source.validate()?; + artifact.validate("translated Zed artifact")?; + policy.validate()?; + if !source.realized.references.is_empty() { + return Err(NixInteropError::InvalidAdapter( + "contract v1 Nix → Zed imports must be closure-free; runtime store references are not portable" + .to_string(), + )); + } + Ok(()) + } + } + } + + /// Deterministic compact JSON bytes for hashing/signing. Validation occurs + /// before normalization, so duplicate unordered values cannot be silently + /// collapsed. Arrays whose order is not semantic are then sorted, and all + /// object keys are emitted lexicographically. + pub fn canonical_json_bytes(&self) -> Result, NixInteropError> { + self.validate()?; + let mut normalized = self.clone(); + normalized.normalize(); + let value = serde_json::to_value(normalized) + .map_err(|error| NixInteropError::Json(error.to_string()))?; + serde_json::to_vec(&canonicalize_json(value)) + .map_err(|error| NixInteropError::Json(error.to_string())) + } + + pub fn canonical_json_string(&self) -> Result { + String::from_utf8(self.canonical_json_bytes()?) + .map_err(|error| NixInteropError::Json(error.to_string())) + } + + fn normalize(&mut self) { + match self { + Self::ZedToNix { + intent, outputs, .. + } => { + intent.normalize(); + for output in outputs.iter_mut() { + output.normalize(); + } + outputs.sort_by(|a, b| { + (&a.system, &a.output, &a.store_path).cmp(&( + &b.system, + &b.output, + &b.store_path, + )) + }); + } + Self::NixToZed { source, .. } => source.normalize(), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum NixInteropError { + #[error("unsupported Nix adapter schema `{0}`")] + UnsupportedSchema(String), + #[error("invalid Nix export intent: {0}")] + InvalidExportIntent(String), + #[error("invalid Nix interop package identity: {0}")] + InvalidPackageIdentity(String), + #[error("invalid Nix interop artifact: {0}")] + InvalidArtifact(String), + #[error("invalid Zed origin: {0}")] + InvalidZedOrigin(String), + #[error("invalid Nix origin: {0}")] + InvalidNixOrigin(String), + #[error("invalid Nix output evidence: {0}")] + InvalidNixOutput(String), + #[error("invalid Nix interop policy: {0}")] + InvalidPolicy(String), + #[error("invalid Nix adapter record: {0}")] + InvalidAdapter(String), + #[error("Nix adapter JSON error: {0}")] + Json(String), +} + +pub fn is_nix_identifier(value: &str) -> bool { + let mut chars = value.chars(); + let Some(first) = chars.next() else { + return false; + }; + (first.is_ascii_alphabetic() || first == '_') + && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '\'')) +} + +pub fn is_nix_attribute_path(value: &str) -> bool { + !value.is_empty() && value.split('.').all(is_nix_identifier) +} + +pub fn is_nix_system(value: &str) -> bool { + !value.is_empty() + && value.contains('-') + && !value.starts_with('-') + && !value.ends_with('-') + && value + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '-')) +} + +pub fn is_nix_store_path(value: &str) -> bool { + let Some(rest) = value.strip_prefix("/nix/store/") else { + return false; + }; + if rest.len() < 34 || rest.as_bytes().get(32) != Some(&b'-') { + return false; + } + const NIX_BASE32: &str = "0123456789abcdfghijklmnpqrsvwxyz"; + rest[..32].chars().all(|c| NIX_BASE32.contains(c)) + && rest[33..] + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.' | '_' | '?')) +} + +pub fn is_sha256_sri(value: &str) -> bool { + let Some(payload) = value.strip_prefix("sha256-") else { + return false; + }; + payload.len() == 44 + && payload.ends_with('=') + && payload[..43] + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '/')) +} + +pub fn is_immutable_nix_ref(value: &str) -> bool { + if value.is_empty() + || value.trim() != value + || value.chars().any(char::is_whitespace) + || value.contains("<") + || value.contains('>') + { + return false; + } + if value.starts_with("/nix/store/") || value.starts_with("path:/nix/store/") { + return true; + } + if value.contains("narHash=sha256-") { + return true; + } + if let Some(rev) = query_value(value, "rev") + && is_hex_revision(rev) + { + return true; + } + value + .split(|c: char| !c.is_ascii_hexdigit()) + .any(is_hex_revision) +} + +fn is_hex_revision(value: &str) -> bool { + matches!(value.len(), 40 | 64) && value.chars().all(|c| c.is_ascii_hexdigit()) +} + +fn query_value<'a>(value: &'a str, name: &str) -> Option<&'a str> { + value + .split(['?', '&']) + .find_map(|part| part.strip_prefix(&format!("{name}="))) +} + +fn is_registry_url(value: &str) -> bool { + value.trim() == value + && !value.chars().any(char::is_whitespace) + && ["https://", "http://", "file://"] + .iter() + .any(|prefix| value.starts_with(prefix)) +} + +fn is_ref_token(value: &str) -> bool { + !value.is_empty() && value.trim() == value && !value.chars().any(char::is_whitespace) +} + +fn validate_schema(schema: &str) -> Result<(), NixInteropError> { + if schema != NIX_ADAPTER_SCHEMA_V1 { + return Err(NixInteropError::UnsupportedSchema(schema.to_string())); + } + Ok(()) +} + +fn validate_sha256_hex(field: &str, value: &str) -> Result<(), NixInteropError> { + if !is_sha256_hex(value) { + return Err(NixInteropError::InvalidArtifact(format!( + "{field} SHA-256 must be 64 lowercase hexadecimal characters" + ))); + } + Ok(()) +} + +fn ensure_unique(values: &[String], field: &str) -> Result<(), NixInteropError> { + let mut seen = BTreeSet::new(); + for value in values { + if !seen.insert(value.as_str()) { + return Err(NixInteropError::InvalidAdapter(format!( + "{field} contains duplicate `{value}`" + ))); + } + } + Ok(()) +} + +fn canonicalize_json(value: Value) -> Value { + match value { + Value::Object(map) => { + let mut entries: Vec<_> = map.into_iter().collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + let mut sorted = serde_json::Map::new(); + for (key, value) in entries { + sorted.insert(key, canonicalize_json(value)); + } + Value::Object(sorted) + } + Value::Array(values) => Value::Array(values.into_iter().map(canonicalize_json).collect()), + scalar => scalar, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HEX_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + const HEX_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + const NAR_A: &str = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + const STORE_A: &str = "/nix/store/00000000000000000000000000000000-tool-1.2.3"; + + fn strict_policy() -> NixPolicyEvidence { + NixPolicyEvidence { + profile: NixPolicyProfile::StrictV1, + pure_evaluation: true, + import_from_derivation: false, + sandbox_required: true, + builder_network: NixBuilderNetwork::Disabled, + dirty_source: false, + publishable: true, + } + } + + fn output(system: &str, output: &str) -> NixRealizedOutput { + NixRealizedOutput { + system: system.to_string(), + output: output.to_string(), + derivation_json_sha256: HEX_B.to_string(), + store_path: STORE_A.to_string(), + nar_hash: NAR_A.to_string(), + nar_size: 128, + references: Vec::new(), + signatures: vec!["cache.example-1:signature".to_string()], + nix_version: "2.35.2".to_string(), + store_info_json_version: 3, + } + } + + fn package() -> NixPackageIdentity { + NixPackageIdentity { + org: "acme".to_string(), + name: "tool".to_string(), + version: "1.2.3".to_string(), + target: None, + } + } + + #[test] + fn export_intent_requires_explicit_systems_and_outputs() { + let empty = NixExportSection::default(); + assert!(empty.validate("tool").is_err()); + + let valid = NixExportSection { + mode: NixExportMode::Artifact, + attribute: None, + systems: vec!["x86_64-linux".to_string()], + outputs: vec!["out".to_string()], + }; + valid.validate("tool").unwrap(); + + let mut reserved = valid.clone(); + reserved.attribute = Some("default".to_string()); + assert!(reserved.validate("tool").is_err()); + } + + #[test] + fn strict_policy_cannot_silently_downgrade() { + strict_policy().validate().unwrap(); + let mut impure = strict_policy(); + impure.pure_evaluation = false; + assert!(impure.validate().is_err()); + + let development = NixPolicyEvidence { + profile: NixPolicyProfile::Development, + publishable: false, + ..strict_policy() + }; + development.validate().unwrap(); + } + + #[test] + fn nix_to_zed_v1_rejects_runtime_store_references() { + let mut realized = output("x86_64-linux", "out"); + realized.references.push(NixStoreReference { + store_path: "/nix/store/11111111111111111111111111111111-glibc".to_string(), + nar_hash: Some(NAR_A.to_string()), + nar_size: Some(256), + }); + let record = NixAdapterRecord::nix_to_zed( + package(), + NixOutputOrigin { + locked_ref: format!("github:acme/tool/{HEX_A}"), + flake_lock_sha256: HEX_A.to_string(), + attribute: "packages.x86_64-linux.tool".to_string(), + realized, + }, + NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_B.to_string(), + size: 512, + }, + strict_policy(), + ); + assert!(matches!( + record.validate(), + Err(NixInteropError::InvalidAdapter(_)) + )); + } + + #[test] + fn canonical_json_normalizes_non_semantic_array_order() { + let intent_a = NixExportSection { + mode: NixExportMode::Artifact, + attribute: Some("tool".to_string()), + systems: vec!["x86_64-linux".to_string(), "aarch64-linux".to_string()], + outputs: vec!["out".to_string()], + }; + let intent_b = NixExportSection { + systems: intent_a.systems.iter().cloned().rev().collect(), + ..intent_a.clone() + }; + let source = ZedArtifactOrigin { + registry: "https://zpkg.example".to_string(), + artifact: NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_A.to_string(), + size: 256, + }, + vcs_tag: "v1.2.3".to_string(), + vcs_commit: HEX_B[..40].to_string(), + lock_sha256: Some(HEX_B.to_string()), + }; + let outputs_a = vec![ + output("x86_64-linux", "out"), + output("aarch64-linux", "out"), + ]; + let outputs_b = outputs_a.iter().cloned().rev().collect(); + let first = NixAdapterRecord::zed_to_nix( + package(), + source.clone(), + intent_a, + HEX_A.to_string(), + outputs_a, + strict_policy(), + ); + let second = NixAdapterRecord::zed_to_nix( + package(), + source, + intent_b, + HEX_A.to_string(), + outputs_b, + strict_policy(), + ); + assert_eq!( + first.canonical_json_bytes().unwrap(), + second.canonical_json_bytes().unwrap() + ); + } + + #[test] + fn canonical_json_has_a_stable_compact_golden_vector() { + let record = NixAdapterRecord::nix_to_zed( + package(), + NixOutputOrigin { + locked_ref: format!("github:acme/tool/{HEX_A}"), + flake_lock_sha256: HEX_A.to_string(), + attribute: "packages.x86_64-linux.tool".to_string(), + realized: output("x86_64-linux", "out"), + }, + NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_B.to_string(), + size: 512, + }, + strict_policy(), + ); + let canonical = record.canonical_json_string().unwrap(); + assert!(!canonical.contains('\n')); + assert_eq!( + serde_json::from_str::(&canonical).unwrap(), + serde_json::to_value(record).unwrap() + ); + assert!(canonical.starts_with("{\"artifact\":")); + assert!( + canonical.ends_with("}") && canonical.contains("\"schema\":\"zed.nix-adapter/v1\"") + ); + } + + #[test] + fn unknown_schema_and_unknown_store_info_version_fail_closed() { + let mut record = NixAdapterRecord::nix_to_zed( + package(), + NixOutputOrigin { + locked_ref: format!("github:acme/tool/{HEX_A}"), + flake_lock_sha256: HEX_A.to_string(), + attribute: "packages.x86_64-linux.tool".to_string(), + realized: output("x86_64-linux", "out"), + }, + NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_B.to_string(), + size: 512, + }, + strict_policy(), + ); + if let NixAdapterRecord::NixToZed { schema, source, .. } = &mut record { + *schema = "zed.nix-adapter/v2".to_string(); + source.realized.store_info_json_version = 99; + } + assert!(matches!( + record.validate(), + Err(NixInteropError::UnsupportedSchema(_)) + )); + } + + #[test] + fn immutable_ref_and_hash_helpers_are_strict() { + assert!(is_immutable_nix_ref(&format!("github:acme/tool/{HEX_A}"))); + assert!(is_immutable_nix_ref( + "git+https://example.invalid/repo?rev=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + )); + assert!(!is_immutable_nix_ref("github:acme/tool/main")); + assert!(is_sha256_sri(NAR_A)); + assert!(!is_sha256_sri("sha256-not-a-hash")); + assert!(is_nix_store_path(STORE_A)); + assert!(!is_nix_store_path("/tmp/tool")); + } +} diff --git a/src/paths.rs b/src/paths.rs new file mode 100644 index 0000000..f03a1e7 --- /dev/null +++ b/src/paths.rs @@ -0,0 +1,63 @@ +//! Filesystem layout conventions shared by the CLI, servers, and docs. +//! +//! The global store lives under `$HOME/.zed-pkg` (overridable with +//! `ZED_PKG_HOME`). Projects never copy packages by default: installed +//! versions are extracted once into the content-addressed store and +//! symlinked into the project's `zed_modules/` directory, pnpm-style. +//! In containers (`--install-mode copy`) the symlink step is replaced by a +//! copy so image layers stay self-contained. + +/// Name of the per-user global directory, resolved against `$HOME`. +pub const ZED_HOME_DIR_NAME: &str = ".zed-pkg"; + +/// Directory inside a project where installed packages appear +/// (`zed_modules//`). +pub const MODULES_DIR: &str = "zed_modules"; + +/// Package manifest file name, at the repository root. TOML only. +pub const MANIFEST_FILE: &str = ".zpkg.toml"; + +/// Lockfile name, written next to the manifest. +pub const LOCKFILE_FILE: &str = ".zpkg.lock"; + +/// Optional file with extra publish-exclusion globs, one per line. +pub const IGNORE_FILE: &str = ".zedignore"; + +/// Store schema version segment; bump when the on-disk layout changes. +pub const STORE_VERSION: &str = "v1"; + +/// Directory inside an extracted store entry that holds the package files. +pub const STORE_PKG_DIR: &str = "pkg"; + +/// Root directory inside every artifact archive under which package files +/// are placed (like npm's `package/` prefix). +pub const ARCHIVE_ROOT: &str = "pkg"; + +/// Where `zed pack` writes artifacts, relative to the project root. +pub const PACK_OUT_DIR: &str = ".zed/pack"; + +/// Directory inside `zed_modules/` where package-declared executables are +/// hoisted (`zed_modules/.bin/`), runnable via `zed run `. +pub const BIN_DIR: &str = ".bin"; + +/// Store entry path for an artifact, relative to the zed home directory, +/// e.g. `store/v1/ab/abcdef.../`. +pub fn store_entry_rel(sha256: &str) -> String { + let prefix = sha256.get(..2).unwrap_or("xx"); + format!("store/{STORE_VERSION}/{prefix}/{sha256}") +} + +/// Build-cache entry for an artifact on one platform, relative to the zed +/// home directory, e.g. `builds/v1/macos-aarch64/ab/abcdef.../`. Source +/// extraction (`store/`) is platform-independent; compiled results are not, +/// so they cache separately per (sha256, platform) and the source store +/// stays immutable. +pub fn build_entry_rel(platform: &str, sha256: &str) -> String { + let prefix = sha256.get(..2).unwrap_or("xx"); + format!("builds/{STORE_VERSION}/{platform}/{prefix}/{sha256}") +} + +/// The `os-arch` pair used to key the build cache, e.g. `linux-x86_64`. +pub fn current_platform() -> String { + format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH) +} diff --git a/src/registry.rs b/src/registry.rs new file mode 100644 index 0000000..9a62c2b --- /dev/null +++ b/src/registry.rs @@ -0,0 +1,441 @@ +//! REST API contract between clients (CLI, language SDKs, web UI) and +//! `zed-api-server`. Paths are built by the helpers here so every consumer +//! agrees on the URL scheme; DTOs are the JSON bodies. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::artifact::ArtifactFormat; +use crate::manifest::Manifest; +use crate::vcs::Vcs; + +/// Default public registry (production host: zpkg.tech). Override with +/// `--registry` / `ZED_PKG_REGISTRY`; self-hosted deployments point this at +/// their own `zed-api-server`. +pub const DEFAULT_REGISTRY_URL: &str = "https://registry.zpkg.tech"; + +pub const API_V1: &str = "/v1"; + +/// Multipart field carrying the JSON-encoded [`PublishMeta`]. +pub const PUBLISH_META_FIELD: &str = "meta"; +/// Multipart field carrying the artifact archive bytes. +pub const PUBLISH_ARTIFACT_FIELD: &str = "artifact"; + +/// `GET` — package metadata and version list. +pub fn package_path(org: &str, name: &str) -> String { + format!("{API_V1}/packages/{org}/{name}") +} + +/// `GET` — metadata for one published version. +/// `PUT` (multipart, bearer token) — publish this version. +pub fn version_path(org: &str, name: &str, version: &str) -> String { + format!("{API_V1}/packages/{org}/{name}/versions/{version}") +} + +/// `GET` — download (or get redirected to) the artifact with this sha256. +pub fn artifact_path(sha256: &str) -> String { + format!("{API_V1}/artifacts/{sha256}") +} + +/// `GET ?q=` — search packages by name/description. Also accepts repeatable +/// `?tag=` filters (a package must carry all given tags) and `?limit=`. +pub fn search_path() -> String { + format!("{API_V1}/search") +} + +/// `GET` — list all packages, newest first. Accepts `?tag=` (repeatable), +/// `?limit=`, and `?offset=` for pagination. +pub fn packages_list_path() -> String { + format!("{API_V1}/packages") +} + +/// `POST` — RAG / semantic search: nearest packages to a query embedding +/// within one model's space. Body is [`SemanticSearchRequest`]. +pub fn semantic_search_path() -> String { + format!("{API_V1}/search/semantic") +} + +/// `PUT` (bearer token) — upsert a package's embedding for one model. Body is +/// [`EmbeddingUpsertRequest`]. +pub fn embedding_path(org: &str, name: &str) -> String { + format!("{API_V1}/packages/{org}/{name}/embedding") +} + +/// `GET` — serve one file out of a published artifact, unpkg-style +/// (`/v1/files/acme/http-kit/1.2.0/dist/style.css`). Lets the web consume +/// package contents directly from the edge without installing. +pub fn file_path(org: &str, name: &str, version: &str, path: &str) -> String { + format!("{API_V1}/files/{org}/{name}/{version}/{path}") +} + +/// `POST` (bearer token, org-scoped) — mark a published version as yanked +/// (or restore it). Yanked versions stay downloadable for existing +/// lockfiles but are hidden from resolution and search. +pub fn yank_path(org: &str, name: &str, version: &str) -> String { + format!("{API_V1}/packages/{org}/{name}/versions/{version}/yank") +} + +/// `POST` (bearer token) — claim an org namespace. +pub fn orgs_path() -> String { + format!("{API_V1}/orgs") +} + +/// `GET ?limit=&action=&before=` (bearer token, org `owner` or admin) — the +/// org's audit log: who changed published state, what, and when (zed-docs +/// issue #7 governance). `action` filters to one action; `before` pages +/// backwards by taking entries with a lower `seq` than the one given. +pub fn audit_path(org: &str) -> String { + format!("{API_V1}/orgs/{org}/audit") +} + +/// `GET` (bearer token, org `owner` or admin) — walk the org's audit chain and +/// report whether it is intact. See [`audit_chain_preimage`]. +pub fn audit_verify_path(org: &str) -> String { + format!("{API_V1}/orgs/{org}/audit/verify") +} + +/// `GET` — liveness probe. +pub fn healthz_path() -> String { + "/healthz".to_string() +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PackageSummary { + pub org: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest: Option, + /// Free-form tags for filtering/discovery. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PackageMetadata { + pub org: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub vcs: Vcs, + pub repo_url: String, + /// How this package's versions should be interpreted (semver by default). + #[serde( + default, + skip_serializing_if = "crate::version::VersionScheme::is_default" + )] + pub version_scheme: crate::version::VersionScheme, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub latest: Option, + /// Free-form tags for filtering/discovery (multi-tag lookup). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// All published, non-yanked versions, newest first. + pub versions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct VersionMetadata { + pub org: String, + pub name: String, + pub version: String, + pub sha256: String, + pub size: u64, + #[serde(default)] + pub format: ArtifactFormat, + pub vcs_tag: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vcs_commit: Option, + /// Absolute or registry-relative URL the artifact can be fetched from. + pub download_url: String, + /// RFC 3339 timestamp. + pub published_at: String, + #[serde(default)] + pub yanked: bool, +} + +/// JSON half of the multipart publish request; the artifact bytes travel in +/// the [`PUBLISH_ARTIFACT_FIELD`] part. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PublishMeta { + pub manifest: Manifest, + /// Tag that exists in the source repository for this version. + pub vcs_tag: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vcs_commit: Option, + /// Client-computed sha256 of the uploaded archive; the server recomputes + /// and rejects on mismatch. + pub sha256: String, + pub size: u64, + #[serde(default)] + pub format: ArtifactFormat, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PublishResponse { + pub org: String, + pub name: String, + pub version: String, + pub sha256: String, +} + +/// Body for the yank route. `yanked: false` restores a yanked version. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct YankRequest { + pub yanked: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct YankResponse { + pub org: String, + pub name: String, + pub version: String, + pub yanked: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ClaimOrgRequest { + pub slug: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ClaimOrgResponse { + pub slug: String, + /// False when the caller already owned the org. + pub created: bool, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SearchResponse { + pub query: String, + pub items: Vec, +} + +/// Response for `GET /v1/packages` (list all). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct PackageListResponse { + pub items: Vec, + /// Total packages matching the filter (before limit/offset). + pub total: u64, +} + +/// Body of `POST /v1/search/semantic` (RAG). The caller computes the query +/// embedding with its model; the server ranks stored package embeddings from +/// the SAME model by cosine distance. Vectors up to 2050 dims are accepted and +/// zero-padded server-side. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SemanticSearchRequest { + /// Embedding model id, e.g. `openai/text-embedding-3-small`. Only stored + /// embeddings from this model are searched. + pub model: String, + /// The query embedding (native width; padded to 2050 server-side). + pub embedding: Vec, + #[serde(default = "default_semantic_limit")] + pub limit: u32, + /// Optional tag filter: results must carry all of these tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +fn default_semantic_limit() -> u32 { + 20 +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SemanticHit { + pub org: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Cosine distance (0 = identical direction, 2 = opposite). Lower is nearer. + pub distance: f64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SemanticSearchResponse { + pub items: Vec, +} + +/// Body of `PUT /v1/packages/{org}/{name}/embedding` — upsert a package's +/// embedding for one model (re-embedding replaces in place). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct EmbeddingUpsertRequest { + pub model: String, + /// The embedding (native width; padded to 2050 server-side). + pub embedding: Vec, + /// The text that was embedded (kept for provenance / re-embedding). + pub content: String, +} + +/// A state-changing action recorded in an org's audit log. Reads are never +/// audited — only mutations of published state and of the namespace itself, so +/// the log answers "who changed what" without drowning in traffic. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum AuditAction { + /// A version was published. + Publish, + /// A version was yanked (hidden from fresh resolution). + Yank, + /// A previously yanked version was restored. + Unyank, + /// The org namespace was claimed. + OrgClaim, +} + +impl AuditAction { + pub fn as_str(&self) -> &'static str { + match self { + AuditAction::Publish => "publish", + AuditAction::Yank => "yank", + AuditAction::Unyank => "unyank", + AuditAction::OrgClaim => "org_claim", + } + } + + /// Parse a stored action string; unknown values are preserved as `None` so + /// a newer server's rows never break an older client's read. + pub fn parse(s: &str) -> Option { + match s { + "publish" => Some(AuditAction::Publish), + "yank" => Some(AuditAction::Yank), + "unyank" => Some(AuditAction::Unyank), + "org_claim" => Some(AuditAction::OrgClaim), + _ => None, + } + } +} + +/// One audit-log record. The actor is identified by the *token* that acted — +/// its name and role, never its secret — which is the identity a registry +/// actually has (zed-docs issue #7 governance). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AuditEntry { + /// RFC 3339 timestamp of the action. + pub at: String, + /// Raw action string; `action_kind` is the parsed form when recognized. + pub action: String, + /// Parsed action, absent when this server build doesn't recognize it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub action_kind: Option, + /// What was acted on, e.g. `acme/http-kit@1.2.0` or the org slug. + pub subject: String, + /// Human-readable name of the token that acted. + pub actor_token_name: String, + /// The acting token's role (`owner`/`publisher`/`reader`, or `admin` for + /// unscoped tokens). + pub actor_role: String, + /// Extra context, e.g. the artifact sha256 for a publish. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub detail: Option, + /// Position in the org's append-only chain, starting at 1. Gaps mean + /// entries were deleted. Defaults to 0 when read from a server that + /// predates the chain. + #[serde(default)] + pub seq: u64, + /// `sha256(audit_chain_preimage(..))` for this entry, lowercase hex. Empty + /// from a pre-chain server. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub entry_hash: String, + /// The previous entry's `entry_hash`; `None` for the first entry in an + /// org's chain. Linking each entry to its predecessor is what makes a + /// silent deletion or edit detectable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prev_hash: Option, +} + +/// The exact bytes an audit entry's `entry_hash` is computed over. +/// +/// Defined here, in the shared contract, so the server and *any* client derive +/// byte-identical input and a client can verify the chain itself rather than +/// trusting the server's own verdict — the point of a tamper-evident log is +/// that the party who could tamper is not the only party who can check. +/// +/// Every field is length-prefixed (`:`). A plain separator +/// would be forgeable: a token named `x|publish` could otherwise shift field +/// boundaries and reproduce another entry's digest. Length prefixes make the +/// encoding unambiguous, so distinct entries can never share a preimage. +/// +/// `at` must be the RFC 3339 timestamp exactly as stored/serialized, and +/// `prev_hash` is the empty string for the first entry in a chain. +/// +/// The fields are named rather than positional on purpose: with ten strings in +/// a row, transposing `subject` and `actor_token_name` would compile silently +/// and quietly change every digest. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct AuditChainInput<'a> { + pub org_id: &'a str, + pub seq: u64, + /// RFC 3339, exactly as stored and serialized. + pub at: &'a str, + pub action: &'a str, + pub subject: &'a str, + pub actor_token_id: Option<&'a str>, + pub actor_token_name: &'a str, + pub actor_role: &'a str, + pub detail: Option<&'a str>, + /// The previous entry's hash; empty for the first entry in a chain. + pub prev_hash: &'a str, +} + +/// Build the canonical preimage for [`AuditChainInput`]. See the module note +/// on why every field is length-prefixed. +pub fn audit_chain_preimage(input: &AuditChainInput<'_>) -> String { + fn field(out: &mut String, value: &str) { + out.push_str(&value.len().to_string()); + out.push(':'); + out.push_str(value); + } + let mut out = String::new(); + field(&mut out, input.org_id); + field(&mut out, &input.seq.to_string()); + field(&mut out, input.at); + field(&mut out, input.action); + field(&mut out, input.subject); + field(&mut out, input.actor_token_id.unwrap_or("")); + field(&mut out, input.actor_token_name); + field(&mut out, input.actor_role); + field(&mut out, input.detail.unwrap_or("")); + field(&mut out, input.prev_hash); + out +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AuditLogResponse { + pub org: String, + /// Most recent first. + pub entries: Vec, +} + +/// The result of walking an org's audit chain end to end. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct AuditIntegrityResponse { + pub org: String, + /// True only when every entry's hash recomputes and every link matches. + pub intact: bool, + pub entries_checked: u64, + /// The `seq` where verification first failed, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub first_bad_seq: Option, + /// Machine-readable failure kind: `hash_mismatch` (an entry was edited), + /// `broken_link` (an entry's `prev_hash` does not match its predecessor), + /// or `sequence_gap` (an entry was deleted). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub problem: Option, + /// The newest entry's hash — an anchor an operator can record externally + /// so that later truncation of the whole tail is also detectable. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub head_hash: Option, +} + +/// Error body returned with any non-2xx status. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct ApiError { + /// Stable machine-readable code, e.g. `not_found`, `sha256_mismatch`, + /// `tag_not_found`, `unauthorized`, `org_taken`. + pub code: String, + pub message: String, +} diff --git a/src/sync.rs b/src/sync.rs new file mode 100644 index 0000000..fe31d71 --- /dev/null +++ b/src/sync.rs @@ -0,0 +1,104 @@ +//! Shared sync contract types for cross-language consumers. +//! +//! [`zed-sync`](https://github.com/zed-pkg/zed-sync) is the behavioral source +//! of truth (its `protocol/*.schema.json` and `conformance.json` are +//! canonical). This module re-declares the *wire* enums and the change +//! envelope so Rust services and `zed-clients` share one set of types, and so +//! the generated JSON Schemas in `schemas/` stay aligned with zed-sync. Keep +//! these in lockstep with zed-sync's protocol. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Per-write optimism level (mirrors zed-sync `WriteMode`). Enum, not a +/// boolean: a write names its exact behavior. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SyncWriteMode { + LocalOnly, + #[default] + OptimisticQueue, + OptimisticAwaitAck, + ServerFirst, + ServerOnly, +} + +/// How write/flush errors are surfaced (mirrors zed-sync `ErrorPolicy`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SyncErrorPolicy { + ThrowOnly, + #[default] + EmitOnly, + ThrowAndEmit, + Silent, +} + +/// How a dirty-vs-newer-remote conflict resolves (mirrors zed-sync +/// `ConflictResolution`). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SyncConflictResolution { + #[default] + ServerWins, + LastWriteWins, +} + +/// Hybrid Logical Clock stamp — the reconciliation key. Total order: +/// `wall_ms`, then `counter`, then `actor`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct Hlc { + pub wall_ms: u64, + pub counter: u32, + pub actor: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum SyncOp { + Upsert, + Delete, +} + +/// The change envelope both transports decode to (mirrors zed-sync +/// `ChangeEvent`). `version` is the ONLY reconciliation key; `sync_sequence` +/// is the catch-up cursor and never feeds reconciliation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct SyncChangeEvent { + pub table: String, + pub op: SyncOp, + pub id: String, + pub version: Hlc, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub row: Option, + pub at_ms: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub write_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sync_sequence: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_values_match_zed_sync_protocol() { + assert_eq!( + serde_json::to_string(&SyncWriteMode::OptimisticQueue).unwrap(), + "\"optimistic_queue\"" + ); + assert_eq!( + serde_json::to_string(&SyncErrorPolicy::EmitOnly).unwrap(), + "\"emit_only\"" + ); + assert_eq!( + serde_json::to_string(&SyncConflictResolution::ServerWins).unwrap(), + "\"server_wins\"" + ); + assert_eq!( + serde_json::to_string(&SyncOp::Upsert).unwrap(), + "\"upsert\"" + ); + } +} diff --git a/src/vcs.rs b/src/vcs.rs new file mode 100644 index 0000000..d7b337f --- /dev/null +++ b/src/vcs.rs @@ -0,0 +1,72 @@ +use std::fmt; +use std::str::FromStr; + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// Version-control systems a package's source repository can live on. +/// +/// zed-pkg is VCS-agnostic by design: what gets installed is always a +/// registry artifact, and the VCS is where provenance (tags) is anchored. +/// Authors must create a matching tag on their declared backing repo +/// (GitHub, GitLab, Bitbucket, Codeberg, SourceHut, Forgejo, Gitea, Azure +/// DevOps, CodeCommit, Radicle, or self-hosted) before publishing. +/// +/// `jj` and Sapling are git-compatible and push to git remotes, so their +/// provenance is verified through git tags. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum Vcs { + #[default] + Git, + Hg, + Jj, + Sapling, + Fossil, + Pijul, +} + +impl Vcs { + pub fn as_str(&self) -> &'static str { + match self { + Vcs::Git => "git", + Vcs::Hg => "hg", + Vcs::Jj => "jj", + Vcs::Sapling => "sapling", + Vcs::Fossil => "fossil", + Vcs::Pijul => "pijul", + } + } + + /// True for systems whose tags live in git ref namespaces (git itself + /// plus the git-compatible layers), so git-based tag verification works. + pub fn uses_git_tags(&self) -> bool { + matches!(self, Vcs::Git | Vcs::Jj | Vcs::Sapling) + } +} + +impl fmt::Display for Vcs { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.as_str()) + } +} + +#[derive(Debug, thiserror::Error)] +#[error("unknown vcs `{0}` (expected git, hg, jj, sapling, fossil, or pijul)")] +pub struct ParseVcsError(pub String); + +impl FromStr for Vcs { + type Err = ParseVcsError; + + fn from_str(s: &str) -> Result { + match s { + "git" => Ok(Vcs::Git), + "hg" => Ok(Vcs::Hg), + "jj" => Ok(Vcs::Jj), + "sapling" => Ok(Vcs::Sapling), + "fossil" => Ok(Vcs::Fossil), + "pijul" => Ok(Vcs::Pijul), + other => Err(ParseVcsError(other.to_string())), + } + } +} diff --git a/src/version.rs b/src/version.rs new file mode 100644 index 0000000..70a0f9f --- /dev/null +++ b/src/version.rs @@ -0,0 +1,380 @@ +//! Polyglot version handling (zed-docs issue #3). +//! +//! zed-pkg standardizes on **semver** as its resolution algebra, but the +//! ecosystems it federates label releases inconsistently: Go tags `v1.2.3` +//! (and `+incompatible`), Python uses PEP 440 (`1.2.3rc1`, `.postN`), and many +//! tools use calendar versions (`2026.07.24`). A package declares its +//! [`VersionScheme`] so `package.version` is validated the right way, and the +//! resolver normalizes foreign tag spellings to a total order at the edges. +//! +//! The scheme is a property of the *published package*, not of the consumer's +//! requirement — a dependency on an opaque-versioned package is written as an +//! exact string, everything else as a semver range. + +use schemars::JsonSchema; +use semver::{Version, VersionReq}; +use serde::{Deserialize, Serialize}; + +/// How a package's `version` string (and its published tags) should be +/// interpreted. Defaults to [`VersionScheme::Semver`], which covers the vast +/// majority of modern ecosystems. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum VersionScheme { + /// Semantic Versioning. `package.version` must be valid semver; ranges + /// (`^1.2`, `>=0.2 <0.5`) resolve to the max satisfying stable version. + #[default] + Semver, + /// Calendar Versioning (`2026.07.24`, `2026.07`). Normalized to a semver + /// total order (leading zeros dropped, padded to major.minor.patch) so the + /// same range algebra applies. See [`normalize_calver`]. + Calver, + /// Arbitrary tags (`release-candidate-1`, `legacy-api`). No range algebra: + /// a requirement must match a published version **exactly**. + Opaque, +} + +impl VersionScheme { + pub fn as_str(&self) -> &'static str { + match self { + VersionScheme::Semver => "semver", + VersionScheme::Calver => "calver", + VersionScheme::Opaque => "opaque", + } + } + + /// True for the default scheme (semver); lets manifests omit the field. + pub fn is_default(&self) -> bool { + matches!(self, VersionScheme::Semver) + } + + /// Parse a scheme name, defaulting to semver for empty/unknown input so a + /// stored value can never wedge a read. + pub fn from_str_lenient(s: &str) -> VersionScheme { + match s { + "calver" => VersionScheme::Calver, + "opaque" => VersionScheme::Opaque, + _ => VersionScheme::Semver, + } + } + + /// Validate that `version` is a legal identity under this scheme. + pub fn validate_version(&self, version: &str) -> Result<(), String> { + match self { + VersionScheme::Semver => Version::parse(version) + .map(|_| ()) + .map_err(|e| e.to_string()), + VersionScheme::Calver => normalize_calver(version) + .map(|_| ()) + .ok_or_else(|| format!("`{version}` is not a calendar version (e.g. 2026.07.24)")), + VersionScheme::Opaque => { + if version.trim().is_empty() { + Err("opaque version must be non-empty".to_string()) + } else { + Ok(()) + } + } + } + } +} + +/// Normalize a calendar version to a semver string with a total order. +/// +/// `2026.07.24` → `2026.7.24`, `2026.07` → `2026.7.0`, `2026` → `2026.0.0`. +/// Leading zeros are stripped (semver forbids them); 1–3 dot-separated numeric +/// segments are accepted and padded to `major.minor.patch`. An optional +/// `-prerelease` suffix is preserved. Returns `None` if the shape isn't +/// calendar-like. +pub fn normalize_calver(raw: &str) -> Option { + let raw = raw.strip_prefix('v').unwrap_or(raw); + let (core, pre) = match raw.split_once('-') { + Some((core, pre)) if !pre.is_empty() => (core, Some(pre)), + _ => (raw, None), + }; + let mut parts: Vec = Vec::new(); + for seg in core.split('.') { + if seg.is_empty() || !seg.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + parts.push(seg.parse().ok()?); + } + if parts.is_empty() || parts.len() > 3 { + return None; + } + while parts.len() < 3 { + parts.push(0); + } + let core = format!("{}.{}.{}", parts[0], parts[1], parts[2]); + match pre { + Some(pre) => Some(format!("{core}-{pre}")), + None => Some(core), + } +} + +/// Drop Go's `+incompatible` (and any other build metadata) so a bare Go tag +/// like `v2.0.0+incompatible` parses. Build metadata is ignored by semver +/// precedence anyway, but we strip it for a clean identity. +fn normalize_go(raw: &str) -> Option { + let raw = raw.strip_prefix('v').unwrap_or(raw); + let core = raw.split('+').next().unwrap_or(raw); + Version::parse(core).ok().map(|v| v.to_string()) +} + +/// Map a PEP 440 pre/dev/post spelling onto a semver prerelease so ordering +/// stays sane: `1.2.3rc1` → `1.2.3-rc.1`, `1.2a1` → `1.2.0-a.1`, +/// `1.2.3.dev4` → `1.2.3-dev.4`. Post-releases (`1.2.3.post1`) become build +/// metadata (`1.2.3+post.1`) since semver has no "after the release" ordering. +/// Conservative by design — only the common shapes; returns `None` otherwise. +fn normalize_pep440(raw: &str) -> Option { + let raw = raw.strip_prefix('v').unwrap_or(raw).to_ascii_lowercase(); + // Split the numeric release from the first non-digit/non-dot marker. + let marker = raw.find(|c: char| c.is_ascii_alphabetic())?; + let (release, suffix) = raw.split_at(marker); + let release = release.trim_end_matches('.'); + let mut nums: Vec = Vec::new(); + for seg in release.split('.') { + if seg.is_empty() { + continue; + } + nums.push(seg.parse().ok()?); + } + while nums.len() < 3 { + nums.push(0); + } + if nums.len() > 3 { + return None; + } + let core = format!("{}.{}.{}", nums[0], nums[1], nums[2]); + + // suffix is e.g. "rc1", "a1", "b2", "dev4", "post1" (dots already gone). + let suffix = suffix.trim_start_matches('.'); + let split = suffix + .find(|c: char| c.is_ascii_digit()) + .unwrap_or(suffix.len()); + let (label, num) = suffix.split_at(split); + let num = if num.is_empty() { "0" } else { num }; + if num.bytes().any(|b| !b.is_ascii_digit()) { + return None; + } + let (label, kind) = match label { + "a" | "alpha" => ("alpha", '-'), + "b" | "beta" => ("beta", '-'), + "c" | "rc" | "pre" | "preview" => ("rc", '-'), + "dev" => ("dev", '-'), + "post" | "rev" | "r" => ("post", '+'), + _ => return None, + }; + Some(format!("{core}{kind}{label}.{num}")) +} + +/// Parse a version string to a comparable semver [`Version`], tolerating the +/// common foreign spellings (bare `v`, calendar versions, Go `+incompatible`, +/// a subset of PEP 440). Build metadata is dropped so `2.0.0+incompatible` and +/// `2.0.0` share one identity (semver precedence ignores build metadata). +/// Returns `None` for genuinely opaque strings. +pub fn parse_version(raw: &str) -> Option { + let mut version = parse_version_inner(raw)?; + version.build = semver::BuildMetadata::EMPTY; + Some(version) +} + +fn parse_version_inner(raw: &str) -> Option { + if let Ok(v) = Version::parse(raw) { + return Some(v); + } + if let Some(stripped) = raw.strip_prefix('v') + && let Ok(v) = Version::parse(stripped) + { + return Some(v); + } + if let Some(go) = normalize_go(raw) + && let Ok(v) = Version::parse(&go) + { + return Some(v); + } + if let Some(cal) = normalize_calver(raw) + && let Ok(v) = Version::parse(&cal) + { + return Some(v); + } + if let Some(pep) = normalize_pep440(raw) + && let Ok(v) = Version::parse(&pep) + { + return Some(v); + } + None +} + +/// A resolved dependency requirement. Semver and calendar requirements are +/// ranges; opaque requirements are exact strings. +#[derive(Debug, Clone, PartialEq)] +pub enum Requirement { + Range(VersionReq), + Exact(String), +} + +impl Requirement { + /// Interpret a manifest requirement string. A valid semver range becomes + /// [`Requirement::Range`]; anything else is an exact (opaque) match. + /// + /// npm-style whitespace-AND ranges (`>=1.2 <2`) are accepted by + /// normalizing the whitespace between comparators to the comma the semver + /// crate expects — but only as a fallback, so an opaque tag containing a + /// space is still treated as exact when the normalized form is not a valid + /// range. + pub fn parse(input: &str) -> Requirement { + if let Ok(req) = VersionReq::parse(input) { + return Requirement::Range(req); + } + if input.split_whitespace().count() > 1 { + let normalized = input.split_whitespace().collect::>().join(","); + if let Ok(req) = VersionReq::parse(&normalized) { + return Requirement::Range(req); + } + } + Requirement::Exact(input.to_string()) + } + + /// Does a published version string satisfy this requirement? + pub fn matches(&self, version: &str) -> bool { + match self { + Requirement::Range(req) => parse_version(version).is_some_and(|v| req.matches(&v)), + Requirement::Exact(want) => want == version, + } + } + + /// Reject requirement strings that *look* like semver ranges but fail to + /// parse (e.g. `^1.x.y`, `>= banana`). Without this, a typo'd range would + /// silently degrade to an opaque exact-match tag and never resolve. + /// Strings with no range operators are legitimate opaque tags and pass. + pub fn validate(input: &str) -> Result<(), String> { + let looks_like_range = input.starts_with(['^', '~', '>', '<', '=']) + || input.contains(['*', ',']) + || input.split_whitespace().count() > 1; + if !looks_like_range { + return Ok(()); + } + match Self::parse(input) { + Requirement::Range(_) => Ok(()), + Requirement::Exact(_) => Err(format!( + "`{input}` looks like a version range but is not a valid one" + )), + } + } +} + +/// Pick the version string satisfying `req` from `versions`. +/// +/// For a range: the highest stable (non-prerelease) parseable version that +/// matches, returned in its **original** spelling so the store address and tag +/// stay faithful to what the publisher tagged. For an exact requirement: the +/// verbatim match if present. +pub fn resolve<'a>(req: &Requirement, versions: &'a [String]) -> Option<&'a str> { + match req { + Requirement::Exact(want) => versions + .iter() + .find(|v| v.as_str() == want) + .map(String::as_str), + Requirement::Range(range) => versions + .iter() + .filter_map(|v| parse_version(v).map(|parsed| (parsed, v))) + .filter(|(parsed, _)| parsed.pre.is_empty() && range.matches(parsed)) + .max_by(|a, b| a.0.cmp(&b.0)) + .map(|(_, original)| original.as_str()), + } +} + +/// Sort version strings newest-first, tolerant of foreign spellings. +/// Unparseable (opaque) versions sort after parseable ones, lexically. +pub fn sort_desc(versions: &mut [String]) { + versions.sort_by(|a, b| match (parse_version(a), parse_version(b)) { + (Some(va), Some(vb)) => vb.cmp(&va), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => b.cmp(a), + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn calver_normalizes_to_semver_order() { + assert_eq!(normalize_calver("2026.07.24").as_deref(), Some("2026.7.24")); + assert_eq!(normalize_calver("2026.07").as_deref(), Some("2026.7.0")); + assert_eq!(normalize_calver("2026").as_deref(), Some("2026.0.0")); + assert_eq!(normalize_calver("v2026.01.02").as_deref(), Some("2026.1.2")); + assert_eq!(normalize_calver("not-a-date"), None); + assert_eq!(normalize_calver("1.2.3.4"), None); + + // Total order holds across months and days. + let older = parse_version("2026.07.24").unwrap(); + let newer = parse_version("2026.08.01").unwrap(); + assert!(newer > older); + } + + #[test] + fn parse_version_tolerates_foreign_spellings() { + assert_eq!(parse_version("1.2.3").unwrap().to_string(), "1.2.3"); + assert_eq!(parse_version("v1.2.3").unwrap().to_string(), "1.2.3"); + // Go +incompatible is stripped to a clean identity. + assert_eq!( + parse_version("v2.0.0+incompatible").unwrap().to_string(), + "2.0.0" + ); + // PEP 440 pre-releases map onto semver prereleases (order preserved). + assert_eq!(parse_version("1.2.3rc1").unwrap().to_string(), "1.2.3-rc.1"); + assert_eq!(parse_version("1.2a1").unwrap().to_string(), "1.2.0-alpha.1"); + assert!(parse_version("1.2.3rc1").unwrap() < parse_version("1.2.3").unwrap()); + assert_eq!(parse_version("legacy-api"), None); + } + + #[test] + fn resolve_range_picks_max_satisfying_stable() { + let versions: Vec = ["1.0.0", "1.2.3", "1.3.0-alpha.1", "2.0.0"] + .iter() + .map(|s| s.to_string()) + .collect(); + let req = Requirement::parse("^1.2"); + assert_eq!(resolve(&req, &versions), Some("1.2.3")); + assert_eq!(resolve(&Requirement::parse("^3"), &versions), None); + } + + #[test] + fn resolve_calver_range() { + let versions: Vec = ["2025.12.01", "2026.07.24", "2026.08.01"] + .iter() + .map(|s| s.to_string()) + .collect(); + // A semver range over calendar versions: pick the newest in 2026. + let req = Requirement::parse(">=2026.0.0, <2027.0.0"); + assert!(matches!(req, Requirement::Range(_))); + assert_eq!(resolve(&req, &versions), Some("2026.08.01")); + } + + #[test] + fn opaque_requires_exact_match() { + let versions: Vec = ["release-candidate-1", "legacy-api", "beta"] + .iter() + .map(|s| s.to_string()) + .collect(); + let req = Requirement::parse("legacy-api"); + assert!(matches!(req, Requirement::Exact(_))); + assert_eq!(resolve(&req, &versions), Some("legacy-api")); + assert_eq!(resolve(&Requirement::parse("nope"), &versions), None); + } + + #[test] + fn scheme_validation() { + assert!(VersionScheme::Semver.validate_version("1.2.3").is_ok()); + assert!( + VersionScheme::Semver + .validate_version("2026.07.24") + .is_err() + ); + assert!(VersionScheme::Calver.validate_version("2026.07.24").is_ok()); + assert!(VersionScheme::Opaque.validate_version("legacy-api").is_ok()); + assert!(VersionScheme::Opaque.validate_version(" ").is_err()); + } +} diff --git a/tests/native_release.rs b/tests/native_release.rs new file mode 100644 index 0000000..2f69ab3 --- /dev/null +++ b/tests/native_release.rs @@ -0,0 +1,478 @@ +use zed_interfaces::manifest::{ForgeRegistry, Manifest, ManifestError, NativeRegistry}; + +fn manifest(targets: &str) -> String { + format!( + r#" +[package] +org = "acme" +name = "clients" +version = "1.2.3" + +[package.repository] +url = "https://github.com/acme/clients" + +{targets} +"# + ) +} + +#[test] +fn native_routes_parse_roundtrip_and_stay_sorted() { + let parsed = Manifest::parse(&manifest( + r#" +[targets.rust] +dir = "clients/rust" + +[targets.rust.native] +registry = "crates-io" +package = "acme-client" + +[targets.nodejs] +dir = "clients/typescript" +adapter = "node" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" + +[targets.dart] +dir = "clients/dart" + +[targets.dart.native] +registry = "pub.dev" +package = "acme_client" + +[targets.python] +dir = "clients/python" + +[targets.python.native] +registry = "pypi" +package = "Acme.Client" +"#, + )) + .unwrap(); + + let routes = parsed.native_release_routes(); + assert_eq!(routes.len(), 4); + assert_eq!(routes[0].target, "dart"); + assert_eq!(routes[0].registry, NativeRegistry::PubDev); + assert_eq!(routes[0].package, "acme_client"); + assert_eq!(routes[1].target, "nodejs"); + assert_eq!(routes[1].registry, NativeRegistry::Npm); + assert_eq!(routes[1].package, "@acme/client"); + assert_eq!(routes[2].target, "python"); + assert_eq!(routes[2].registry, NativeRegistry::PyPi); + assert_eq!(routes[2].package, "Acme.Client"); + assert_eq!(routes[3].target, "rust"); + assert_eq!(routes[3].registry, NativeRegistry::CratesIo); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("[targets.nodejs.native]")); + assert!(encoded.contains("registry = \"npm\"")); + assert!(encoded.contains("registry = \"pub.dev\"")); + Manifest::parse(&encoded).unwrap(); +} + +#[test] +fn a_single_language_repository_can_declare_native_and_forge_routes() { + let parsed = Manifest::parse(&manifest( + r#" +[publish.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] +"#, + )) + .unwrap(); + + let native = parsed.native_release_routes(); + assert_eq!(native.len(), 1); + assert_eq!(native[0].target, "repository"); + assert_eq!(native[0].dir, "."); + assert_eq!(native[0].registry, NativeRegistry::Npm); + assert_eq!(native[0].package, "@acme/client"); + assert_eq!(native[0].vcs_tag, "v1.2.3"); + + let forge = parsed.forge_release_routes(); + assert_eq!(forge.len(), 3); + assert!(forge.iter().all(|route| route.target == "repository")); + assert!(forge.iter().all(|route| route.dir == ".")); + assert_eq!(forge[0].registry, ForgeRegistry::GithubPackages); + assert_eq!(forge[1].registry, ForgeRegistry::GitlabPackages); + assert_eq!(forge[2].registry, ForgeRegistry::BitbucketPackages); + assert!(forge.iter().all(|route| route.vcs_tag == "v1.2.3")); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("[publish.native]")); + assert_eq!(Manifest::parse(&encoded).unwrap(), parsed); +} + +#[test] +fn a_polyglot_package_cannot_mix_root_and_target_native_routes() { + let error = Manifest::parse(&manifest( + r#" +[publish.native] +registry = "npm" +package = "@acme/all-clients" + +[targets.nodejs] +dir = "clients/typescript" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +"#, + )) + .unwrap_err(); + + let message = error.to_string(); + assert!(matches!(error, ManifestError::InvalidNativeRoute(_, _))); + assert!(message.contains("single-language"), "{message}"); +} + +#[test] +fn whole_repository_target_cannot_route_to_a_native_registry() { + let error = Manifest::parse(&manifest( + r#" +[targets.repository] +dir = "." + +[targets.repository.native] +registry = "npm" +package = "acme-repository" +"#, + )) + .unwrap_err(); + assert!(matches!(error, ManifestError::InvalidNativeRoute(_, _))); +} + +#[test] +fn native_package_identities_are_registry_specific() { + for targets in [ + r#" +[targets.nodejs] +dir = "clients/typescript" +[targets.nodejs.native] +registry = "npm" +package = "Bad Package" +"#, + r#" +[targets.rust] +dir = "clients/rust" +[targets.rust.native] +registry = "crates-io" +package = "acme/client" +"#, + r#" +[targets.dart] +dir = "clients/dart" +[targets.dart.native] +registry = "pub.dev" +package = "Bad-Dart-Package" +"#, + r#" +[targets.dart] +dir = "clients/dart" +[targets.dart.native] +registry = "pub.dev" +package = "123_client" +"#, + r#" +[targets.dart] +dir = "clients/dart" +[targets.dart.native] +registry = "pub.dev" +package = "class" +"#, + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "-bad-name" +"#, + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "bad name" +"#, + ] { + assert!(matches!( + Manifest::parse(&manifest(targets)), + Err(ManifestError::InvalidNativeRoute(_, _)) + )); + } +} + +#[test] +fn duplicate_native_destinations_are_rejected() { + let error = Manifest::parse(&manifest( + r#" +[targets.nodejs] +dir = "clients/typescript" +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" + +[targets.browser] +dir = "clients/browser" +[targets.browser.native] +registry = "npm" +package = "@acme/client" +"#, + )) + .unwrap_err(); + assert!(matches!(error, ManifestError::InvalidNativeRoute(_, _))); +} + +#[test] +fn pypi_duplicate_destinations_use_normalized_names() { + let error = Manifest::parse(&manifest( + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "Friendly_Bard" + +[targets.python-async] +dir = "clients/python-async" +[targets.python-async.native] +registry = "pypi" +package = "friendly...bard" +"#, + )) + .unwrap_err(); + let message = error.to_string(); + assert!(message.contains("already routed"), "{message}"); +} + +// --- the two ecosystem axes must agree ------------------------------------ + +#[test] +fn a_native_route_that_contradicts_the_install_ecosystem_is_rejected() { + // `native` is outbound (mirror to npm), `ecosystem` is inbound (what a + // consumer needs to install). A python slice routed to npm means one of the + // two is wrong, and either way it stays silent until someone hits it. + let err = Manifest::parse(&manifest( + r#" +[targets.python] +dir = "clients/python" + +[targets.python.native] +registry = "npm" +package = "acme-client" +"#, + )) + .expect_err("a python target mirrored to npm must not validate"); + let msg = err.to_string(); + assert!(msg.contains("npm"), "{msg}"); + assert!(msg.contains("pypi"), "{msg}"); +} + +#[test] +fn a_native_route_matching_the_target_language_validates() { + for (target, dir, registry, package) in [ + ("nodejs", "clients/ts", "npm", "@acme/client"), + ("rust", "clients/rust", "crates-io", "acme-client"), + ("python", "clients/python", "pypi", "acme-client"), + ("dart", "clients/dart", "pub.dev", "acme_client"), + ] { + let toml = manifest(&format!( + r#" +[targets.{target}] +dir = "{dir}" + +[targets.{target}.native] +registry = "{registry}" +package = "{package}" +"# + )); + let parsed = Manifest::parse(&toml) + .unwrap_or_else(|e| panic!("{target} -> {registry} must validate: {e}")); + let route = &parsed.native_release_routes()[0]; + assert_eq!(route.target, target); + assert_eq!( + route.registry.ecosystem(), + parsed.targets[target].ecosystem_for(target) + ); + } +} + +#[test] +fn an_explicit_ecosystem_override_reconciles_a_deliberate_mismatch() { + // rust-wasm is Rust source consumed by a JS bundler: the mirror really is + // npm, so declaring the inbound ecosystem makes the pair consistent rather + // than requiring the check to be weakened. + let toml = manifest( + r#" +[targets.rust-wasm] +dir = "clients/rust-wasm" +ecosystem = "npm" + +[targets.rust-wasm.native] +registry = "npm" +package = "@acme/client-wasm" +"#, + ); + let parsed = Manifest::parse(&toml).expect("an explicit npm ecosystem must reconcile"); + assert_eq!( + parsed.targets["rust-wasm"].ecosystem_for("rust-wasm"), + NativeRegistry::Npm.ecosystem() + ); +} + +#[test] +fn a_target_key_that_is_not_a_language_never_contradicts_its_route() { + // An arbitrary target slug carries no ecosystem claim, so there is nothing + // for the native route to disagree with. + let toml = manifest( + r#" +[targets.wasm3] +dir = "clients/wasm3" + +[targets.wasm3.native] +registry = "npm" +package = "acme-wasm3" +"#, + ); + assert!(Manifest::parse(&toml).is_ok()); +} + +#[test] +fn forge_package_routes_roundtrip_and_flatten_deterministically() { + let parsed = Manifest::parse(&manifest( + r#" +[targets.nodejs] +dir = "clients/typescript" + +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "gitlab-packages", "bitbucket-packages"] + +[targets.python] +dir = "clients/python" + +[targets.python.native] +registry = "pypi" +package = "acme-client" +forge = ["gitlab-packages"] +"#, + )) + .unwrap(); + + let routes = parsed.forge_release_routes(); + assert_eq!(routes.len(), 4); + assert_eq!(routes[0].target, "nodejs"); + assert_eq!(routes[0].registry, ForgeRegistry::GithubPackages); + assert_eq!(routes[0].format, NativeRegistry::Npm); + assert_eq!(routes[1].registry, ForgeRegistry::GitlabPackages); + assert_eq!(routes[2].registry, ForgeRegistry::BitbucketPackages); + assert_eq!(routes[3].target, "python"); + assert_eq!(routes[3].registry, ForgeRegistry::GitlabPackages); + assert_eq!(routes[3].format, NativeRegistry::PyPi); + + let encoded = parsed.to_toml_string().unwrap(); + assert!(encoded.contains("github-packages")); + assert_eq!(Manifest::parse(&encoded).unwrap(), parsed); + + let derived = parsed.manifest_for_target("nodejs").unwrap(); + assert!(derived.targets.is_empty()); + assert_eq!(derived.publish.native, parsed.targets["nodejs"].native); + assert_eq!(derived.native_release_routes()[0].target, "repository"); + assert_eq!( + Manifest::parse(&derived.to_toml_string().unwrap()).unwrap(), + derived + ); +} + +#[test] +fn unsupported_and_duplicate_forge_routes_are_rejected() { + for targets in [ + r#" +[targets.rust] +dir = "clients/rust" +[targets.rust.native] +registry = "crates-io" +package = "acme-client" +forge = ["github-packages"] +"#, + r#" +[targets.python] +dir = "clients/python" +[targets.python.native] +registry = "pypi" +package = "acme-client" +forge = ["bitbucket-packages"] +"#, + r#" +[targets.nodejs] +dir = "clients/typescript" +[targets.nodejs.native] +registry = "npm" +package = "@acme/client" +forge = ["github-packages", "github-packages"] +"#, + ] { + assert!(matches!( + Manifest::parse(&manifest(targets)), + Err(ManifestError::InvalidNativeRoute(_, _)) + )); + } +} + +#[test] +fn major_native_registry_identities_and_ecosystems_validate() { + for (target, registry, package, ecosystem) in [ + ("java", "maven-central", "com.acme:client", "jvm"), + ("ruby", "rubygems", "acme-client", "gem"), + ("csharp", "nuget", "Acme.Client", "nuget"), + ("php", "packagist", "acme/client", "composer"), + ("golang", "go-modules", "github.com/acme/client", "gomod"), + ] { + let tag_format = if registry == "go-modules" { + format!("tag_format = \"clients/{target}/v{{version}}\"") + } else { + String::new() + }; + let parsed = Manifest::parse(&manifest(&format!( + r#" +[targets.{target}] +dir = "clients/{target}" + +[targets.{target}.native] +registry = "{registry}" +package = "{package}" +{tag_format} +"# + ))) + .unwrap_or_else(|error| panic!("{registry} route must validate: {error}")); + let route = &parsed.native_release_routes()[0]; + assert_eq!(route.registry.ecosystem().as_str(), ecosystem); + if registry == "go-modules" { + assert_eq!(route.vcs_tag, format!("clients/{target}/v1.2.3")); + } + } +} + +#[test] +fn a_subdirectory_go_module_requires_its_native_tag_prefix() { + for tag_format in ["", "tag_format = \"v{version}\""] { + let error = Manifest::parse(&manifest(&format!( + r#" +[targets.golang] +dir = "clients/go" + +[targets.golang.native] +registry = "go-modules" +package = "github.com/acme/client" +{tag_format} +"# + ))) + .unwrap_err(); + assert!(error.to_string().contains("clients/go/")); + } +} diff --git a/tests/nix_interop_contract.rs b/tests/nix_interop_contract.rs new file mode 100644 index 0000000..ac84955 --- /dev/null +++ b/tests/nix_interop_contract.rs @@ -0,0 +1,97 @@ +use zed_interfaces::{ + ArtifactFormat, NixAdapterRecord, NixBuilderNetwork, NixInteropArtifact, NixOutputOrigin, + NixPackageIdentity, NixPolicyEvidence, NixPolicyProfile, NixRealizedOutput, NixStoreReference, +}; + +const HEX_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const HEX_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +const NAR_A: &str = "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; +const STORE_A: &str = "/nix/store/00000000000000000000000000000000-tool-1.2.3"; + +fn strict_policy() -> NixPolicyEvidence { + NixPolicyEvidence { + profile: NixPolicyProfile::StrictV1, + pure_evaluation: true, + import_from_derivation: false, + sandbox_required: true, + builder_network: NixBuilderNetwork::Disabled, + dirty_source: false, + publishable: true, + } +} + +fn source(references: Vec) -> NixOutputOrigin { + NixOutputOrigin { + locked_ref: format!("github:acme/tool/{HEX_A}"), + flake_lock_sha256: HEX_A.to_string(), + attribute: "packages.x86_64-linux.tool".to_string(), + realized: NixRealizedOutput { + system: "x86_64-linux".to_string(), + output: "out".to_string(), + derivation_json_sha256: HEX_B.to_string(), + store_path: STORE_A.to_string(), + nar_hash: NAR_A.to_string(), + nar_size: 512, + references, + signatures: vec!["cache.example-1:signature".to_string()], + nix_version: "2.35.2".to_string(), + store_info_json_version: 3, + }, + } +} + +fn package() -> NixPackageIdentity { + NixPackageIdentity { + org: "acme".to_string(), + name: "tool".to_string(), + version: "1.2.3".to_string(), + target: None, + } +} + +fn artifact() -> NixInteropArtifact { + NixInteropArtifact { + format: ArtifactFormat::TarGz, + sha256: HEX_B.to_string(), + size: 1024, + } +} + +#[test] +fn public_contract_round_trips_canonical_json() { + let record = + NixAdapterRecord::nix_to_zed(package(), source(Vec::new()), artifact(), strict_policy()); + + let canonical = record.canonical_json_string().unwrap(); + let parsed: NixAdapterRecord = serde_json::from_str(&canonical).unwrap(); + + assert_eq!(record, parsed); + assert_eq!(canonical, parsed.canonical_json_string().unwrap()); + assert!(canonical.contains("\"direction\":\"nix-to-zed\"")); +} + +#[test] +fn public_contract_rejects_a_mutable_flake_selector() { + let mut mutable = source(Vec::new()); + mutable.locked_ref = "github:acme/tool/main".to_string(); + let record = NixAdapterRecord::nix_to_zed(package(), mutable, artifact(), strict_policy()); + + assert!(record.validate().is_err()); +} + +#[test] +fn public_contract_rejects_a_closure_bearing_import() { + let reference = NixStoreReference { + store_path: "/nix/store/11111111111111111111111111111111-glibc".to_string(), + nar_hash: Some(NAR_A.to_string()), + nar_size: Some(2048), + }; + let record = NixAdapterRecord::nix_to_zed( + package(), + source(vec![reference]), + artifact(), + strict_policy(), + ); + + assert!(record.validate().is_err()); +} diff --git a/tests/roundtrip.rs b/tests/roundtrip.rs new file mode 100644 index 0000000..63145be --- /dev/null +++ b/tests/roundtrip.rs @@ -0,0 +1,836 @@ +use zed_interfaces::ArtifactFormat; +use zed_interfaces::excludes::{ALWAYS_INCLUDE, DEFAULT_EXCLUDES, effective_excludes}; +use zed_interfaces::language::{Ecosystem, Language}; +use zed_interfaces::lockfile::{LockedPackage, Lockfile}; +use zed_interfaces::manifest::{Manifest, ManifestError}; +use zed_interfaces::paths::store_entry_rel; +use zed_interfaces::vcs::Vcs; + +const SAMPLE: &str = r#" +[package] +org = "acme" +name = "http-kit" +version = "1.2.0" +description = "Tiny HTTP helpers" +license = "MIT" + +[package.repository] +vcs = "git" +url = "https://github.com/acme/http-kit" + +[dependencies] +"acme/logkit" = "^0.3" + +[publish] +exclude = ["benches/**"] +smoke_test = "sh scripts/smoke.sh" + +[scripts] +test = "make test" +"#; + +#[test] +fn manifest_roundtrip() { + let m = Manifest::parse(SAMPLE).unwrap(); + assert_eq!(m.full_name(), "acme/http-kit"); + assert_eq!(m.package.repository.vcs, Vcs::Git); + assert_eq!(m.vcs_tag(), "v1.2.0"); + assert_eq!(m.publish.exclude, vec!["benches/**".to_string()]); + assert!(!m.publish.include_readme); + assert_eq!(m.scripts.test.as_deref(), Some("make test")); + + let serialized = m.to_toml_string().unwrap(); + let reparsed = Manifest::parse(&serialized).unwrap(); + assert_eq!(m, reparsed); +} + +#[test] +fn install_dir_defaults_and_overrides() { + // No [install] section -> the default dep dir. + assert_eq!( + Manifest::parse(SAMPLE).unwrap().modules_dir(), + "zed_modules" + ); + + // A configured dir relocates the tree and round-trips. + let with_dir = format!("{SAMPLE}\n[install]\ndir = \".vendor/.zed\"\n"); + let m = Manifest::parse(&with_dir).unwrap(); + assert_eq!(m.modules_dir(), ".vendor/.zed"); + assert_eq!(Manifest::parse(&m.to_toml_string().unwrap()).unwrap(), m); + + // Unsafe dirs are rejected. + for bad in ["/abs/path", "../escape", "a/../../b"] { + let src = format!("{SAMPLE}\n[install]\ndir = \"{bad}\"\n"); + assert!( + matches!( + Manifest::parse(&src), + Err(ManifestError::InvalidInstallDir(_, _)) + ), + "expected {bad} rejected" + ); + } +} + +#[test] +fn manifest_rejects_bad_input() { + let bad_org = SAMPLE.replace("org = \"acme\"", "org = \"Acme!\""); + assert!(matches!( + Manifest::parse(&bad_org), + Err(ManifestError::InvalidOrg(_)) + )); + + let bad_dep = SAMPLE.replace("\"acme/logkit\"", "\"logkit\""); + assert!(matches!( + Manifest::parse(&bad_dep), + Err(ManifestError::InvalidDependencyKey(_)) + )); + + let bad_version = SAMPLE.replace("version = \"1.2.0\"", "version = \"not-semver\""); + assert!(matches!( + Manifest::parse(&bad_version), + Err(ManifestError::InvalidVersion(_, _)) + )); +} + +#[test] +fn lockfile_roundtrip() { + let mut lock = Lockfile::default(); + lock.upsert(LockedPackage { + org: "acme".into(), + name: "http-kit".into(), + version: "1.2.0".into(), + sha256: "ab".repeat(32), + size: 4096, + format: ArtifactFormat::TarGz, + vcs_tag: "v1.2.0".into(), + vcs_commit: Some("deadbeef".into()), + source: "https://registry.zed-pkg.dev".into(), + }); + + let text = lock.to_toml_string().unwrap(); + assert!(text.contains("[[package]]")); + let reparsed = Lockfile::parse(&text).unwrap(); + assert_eq!(lock, reparsed); + assert!(reparsed.find("acme", "http-kit").is_some()); +} + +#[test] +fn excludes_respect_include_readme() { + let with_readme_stripped = effective_excludes(&[], false); + assert!(with_readme_stripped.iter().any(|p| p == "README*")); + assert!(with_readme_stripped.iter().any(|p| p == "tests/**")); + + let readme_kept = effective_excludes(&["extra/**".to_string()], true); + assert!(!readme_kept.iter().any(|p| p == "README*")); + assert!(readme_kept.iter().any(|p| p == "extra/**")); + + assert!(DEFAULT_EXCLUDES.contains(&".github/**")); + assert!(DEFAULT_EXCLUDES.contains(&".zed-pack/**")); + assert!(DEFAULT_EXCLUDES.contains(&"**/node_modules/**")); + assert!(DEFAULT_EXCLUDES.contains(&"**/.dart_tool/**")); + assert!(DEFAULT_EXCLUDES.contains(&"**/build/**")); + assert!(ALWAYS_INCLUDE.contains(&"LICENSE*")); + assert!(ALWAYS_INCLUDE.contains(&".zpkg.toml")); +} + +#[test] +fn store_paths_are_sharded() { + let sha = "abcdef0123".to_string() + &"0".repeat(54); + assert_eq!(store_entry_rel(&sha), format!("store/v1/ab/{sha}")); +} + +/// A polyglot package declares one subtree per ecosystem; consumers pick one. +const POLYGLOT: &str = r#" +[package] +org = "zedtest" +name = "polyglot-lib" +version = "1.0.0" + +[package.repository] +vcs = "git" +url = "https://github.com/zed-pkg-test/polyglot-lib" + +[targets.node] +dir = "node" + +[targets.python] +dir = "python" + +[targets.go] +dir = "go" +"#; + +#[test] +fn polyglot_targets_roundtrip_and_resolve() { + let m = Manifest::parse(POLYGLOT).unwrap(); + assert!(m.is_polyglot()); + assert_eq!(m.targets.len(), 3); + assert_eq!(m.target_subdir(Some("python")).unwrap(), Some("python")); + assert_eq!(m.target_subdir(Some("node")).unwrap(), Some("node")); + // Round-trips through TOML unchanged. + assert_eq!(Manifest::parse(&m.to_toml_string().unwrap()).unwrap(), m); +} + +#[test] +fn a_single_language_package_ignores_target_selection() { + // No [targets] => always the whole tree, even if a consumer asks for one. + // Existing packages must keep installing exactly as before. + let m = Manifest::parse(SAMPLE).unwrap(); + assert!(!m.is_polyglot()); + assert_eq!(m.target_subdir(None).unwrap(), None); + assert_eq!(m.target_subdir(Some("python")).unwrap(), None); +} + +#[test] +fn a_polyglot_package_without_a_request_yields_the_whole_tree() { + // A consumer that has not opted into a target still installs fine. + let m = Manifest::parse(POLYGLOT).unwrap(); + assert_eq!(m.target_subdir(None).unwrap(), None); +} + +#[test] +fn requesting_an_unpublished_target_is_an_error_listing_what_exists() { + let m = Manifest::parse(POLYGLOT).unwrap(); + let err = m + .target_subdir(Some("ruby")) + .expect_err("a target the package does not publish must not silently fall back"); + let msg = err.to_string(); + assert!(msg.contains("ruby"), "{msg}"); + assert!(msg.contains("zedtest/polyglot-lib"), "{msg}"); + // The message enumerates the real targets so the fix is obvious. + for target in ["go", "node", "python"] { + assert!(msg.contains(target), "expected `{target}` listed in: {msg}"); + } +} + +#[test] +fn target_dirs_and_names_are_validated() { + // `..` in a target dir would escape the package on install. + let escaping = POLYGLOT.replace(r#"dir = "python""#, r#"dir = "../../etc""#); + assert!(matches!( + Manifest::parse(&escaping), + Err(ManifestError::InvalidTarget(_, _)) + )); + + // Absolute dirs likewise. + let absolute = POLYGLOT.replace(r#"dir = "python""#, r#"dir = "/etc/passwd""#); + assert!(matches!( + Manifest::parse(&absolute), + Err(ManifestError::InvalidTarget(_, _)) + )); + + // Target names are slugs. + let bad_name = POLYGLOT.replace("[targets.node]", "[targets.\"Node JS\"]"); + assert!(matches!( + Manifest::parse(&bad_name), + Err(ManifestError::InvalidTarget(_, _)) + )); + + // And so is a consumer's requested target. + let bad_request = format!("{SAMPLE}\n[install]\ntarget = \"Python 3\"\n"); + assert!(matches!( + Manifest::parse(&bad_request), + Err(ManifestError::InvalidTarget(_, _)) + )); + + let bad_published_name = POLYGLOT.replace( + "[targets.node]\ndir = \"node\"", + "[targets.node]\ndir = \"node\"\nname = \"Node SDK\"", + ); + assert!(matches!( + Manifest::parse(&bad_published_name), + Err(ManifestError::InvalidTarget(_, _)) + )); + + let duplicate_dir = POLYGLOT.replace(r#"dir = "python""#, r#"dir = "node""#); + assert!(matches!( + Manifest::parse(&duplicate_dir), + Err(ManifestError::InvalidTarget(_, _)) + )); + + let duplicate_name = POLYGLOT.replace( + "[targets.python]\ndir = \"python\"", + "[targets.python]\ndir = \"python\"\nname = \"polyglot-lib-node\"", + ); + assert!(matches!( + Manifest::parse(&duplicate_name), + Err(ManifestError::InvalidTarget(_, _)) + )); + + let bad_adapter = POLYGLOT.replace( + "[targets.node]\ndir = \"node\"", + "[targets.node]\ndir = \"node\"\nadapter = \"npm\"", + ); + assert!(matches!( + Manifest::parse(&bad_adapter), + Err(ManifestError::InvalidTarget(_, _)) + )); +} + +#[test] +fn nested_target_dirs_are_allowed() { + // Real repos often nest, e.g. clients/go. + let nested = POLYGLOT.replace(r#"dir = "go""#, r#"dir = "clients/go""#); + let m = Manifest::parse(&nested).unwrap(); + assert_eq!(m.target_subdir(Some("go")).unwrap(), Some("clients/go")); +} + +#[test] +fn consumer_requested_target_is_read_from_the_install_section() { + let consumer = format!("{SAMPLE}\n[install]\ndir = \".vendor/.zed\"\ntarget = \"python\"\n"); + let m = Manifest::parse(&consumer).unwrap(); + assert_eq!(m.requested_target(), Some("python")); + assert_eq!(m.modules_dir(), ".vendor/.zed"); + assert_eq!(Manifest::parse(&m.to_toml_string().unwrap()).unwrap(), m); + + // Absent or blank = no request. + assert_eq!(Manifest::parse(SAMPLE).unwrap().requested_target(), None); + let blank = format!("{SAMPLE}\n[install]\ntarget = \" \"\n"); + assert_eq!(Manifest::parse(&blank).unwrap().requested_target(), None); +} + +/// The real shape: a client repo publishing one package per language. +const CLIENTS: &str = r#" +[package] +org = "fiducia" +name = "fiducia-clients" +version = "1.1.2" +description = "Fiducia API clients" + +[package.repository] +vcs = "git" +url = "https://github.com/fiducia-cloud/fiducia-clients" + +[targets.nodejs] +dir = "clients/ts" +adapter = "node" + +[targets.java] +dir = "clients/java" +adapter = "java" + +[targets.golang] +dir = "clients/go" +"#; + +#[test] +fn each_target_publishes_under_its_own_package_name() { + let m = Manifest::parse(CLIENTS).unwrap(); + assert_eq!( + m.target_package_name("nodejs").as_deref(), + Some("fiducia-clients-nodejs") + ); + assert_eq!( + m.target_package_name("java").as_deref(), + Some("fiducia-clients-java") + ); + assert_eq!( + m.target_package_name("golang").as_deref(), + Some("fiducia-clients-golang") + ); + assert_eq!(m.target_package_name("ruby"), None); + + // Deterministic, sorted fan-out list. + assert_eq!( + m.target_package_names(), + vec![ + ("golang".to_string(), "fiducia-clients-golang".to_string()), + ("java".to_string(), "fiducia-clients-java".to_string()), + ("nodejs".to_string(), "fiducia-clients-nodejs".to_string()), + ] + ); +} + +#[test] +fn an_explicit_target_name_overrides_the_suffix_convention() { + let custom = CLIENTS.replace( + "[targets.nodejs]\ndir = \"clients/ts\"", + "[targets.nodejs]\ndir = \"clients/ts\"\nname = \"fiducia-js-sdk\"", + ); + let m = Manifest::parse(&custom).unwrap(); + assert_eq!( + m.target_package_name("nodejs").as_deref(), + Some("fiducia-js-sdk") + ); + // Other targets keep the convention. + assert_eq!( + m.target_package_name("java").as_deref(), + Some("fiducia-clients-java") + ); +} + +#[test] +fn the_per_target_manifest_is_a_standalone_single_language_package() { + let base = Manifest::parse(CLIENTS).unwrap(); + let java = base + .manifest_for_target("java") + .expect("java target exists"); + + // It is its own package, sharing org/version/repo with the parent. + assert_eq!(java.package.name, "fiducia-clients-java"); + assert_eq!(java.package.org, "fiducia"); + assert_eq!(java.package.version, "1.1.2"); + assert_eq!(java.full_name(), "fiducia/fiducia-clients-java"); + assert_eq!( + java.package.repository.url, base.package.repository.url, + "the artifact still points back at the source repo" + ); + + // It is NOT itself polyglot — the slice is single-language by construction, + // so a consumer can never recurse into another fan-out. + assert!(!java.is_polyglot()); + assert!(java.targets.is_empty()); + + // It carries the ecosystem wiring its consumers need. + assert_eq!(java.install.adapter.as_deref(), Some("java")); + assert_eq!( + base.manifest_for_target("nodejs") + .unwrap() + .install + .adapter + .as_deref(), + Some("node") + ); + // A target with no adapter inherits the base (here: none set). + assert_eq!( + base.manifest_for_target("golang").unwrap().install.adapter, + None + ); + + // The derived manifest is valid on its own and round-trips. + java.validate().expect("derived manifest must be valid"); + assert_eq!( + Manifest::parse(&java.to_toml_string().unwrap()).unwrap(), + java + ); + + // The description makes the language obvious in registry listings. + assert_eq!( + java.package.description.as_deref(), + Some("Fiducia API clients (java)") + ); + + assert!(base.manifest_for_target("ruby").is_none()); +} + +#[test] +fn derived_target_names_must_still_be_valid_package_names() { + // The suffix convention has to produce a legal slug, or publish would + // emit an unusable package name. + let m = Manifest::parse(CLIENTS).unwrap(); + for (_, name) in m.target_package_names() { + assert!( + zed_interfaces::manifest::is_slug(&name), + "derived name `{name}` is not a valid package name" + ); + } +} + +// --- language / ecosystem tagging ----------------------------------------- + +/// A repo naming its targets the way the published packages read — the +/// colloquial `nodejs` / `golang` a human recalls — rather than the short +/// tokens project inference produces. +const COLLOQUIAL: &str = r#" +[package] +org = "fiducia" +name = "fiducia-clients" +version = "1.1.2" + +[package.repository] +vcs = "git" +url = "https://github.com/fiducia-cloud/fiducia-clients" + +[targets.nodejs] +dir = "clients/ts" + +[targets.golang] +dir = "clients/go" + +[targets.java] +dir = "clients/java" + +[targets.kotlin] +dir = "clients/kotlin" + +[targets.rust-wasm] +dir = "clients/rust-wasm" +ecosystem = "npm" +"#; + +#[test] +fn a_project_inferred_as_node_resolves_a_nodejs_target() { + // The decisive case for synonym resolution. Project inference yields + // `node`/`go` from package.json/go.mod, but these packages publish as + // `-nodejs`/`-golang` because that is what the names should read. Without + // synonym matching every such consumer would hit "publishes no such + // target" while the package ships exactly what they need. + let m = Manifest::parse(COLLOQUIAL).unwrap(); + assert_eq!(m.target_subdir(Some("node")).unwrap(), Some("clients/ts")); + assert_eq!(m.target_subdir(Some("go")).unwrap(), Some("clients/go")); + // …and the spelling the author used keeps working too. + assert_eq!(m.target_subdir(Some("nodejs")).unwrap(), Some("clients/ts")); + assert_eq!(m.target_subdir(Some("golang")).unwrap(), Some("clients/go")); + // As do the ecosystem's own near-synonyms. + assert_eq!( + m.target_subdir(Some("typescript")).unwrap(), + Some("clients/ts") + ); + assert_eq!(m.target_subdir(Some("ts")).unwrap(), Some("clients/ts")); +} + +#[test] +fn synonym_resolution_does_not_collapse_distinct_languages() { + // Java and Kotlin share an ecosystem but are separate packages; asking for + // one must never hand back the other. + let m = Manifest::parse(COLLOQUIAL).unwrap(); + assert_eq!(m.target_subdir(Some("java")).unwrap(), Some("clients/java")); + assert_eq!( + m.target_subdir(Some("kotlin")).unwrap(), + Some("clients/kotlin") + ); + // A JVM language the repo does not publish is still an error, not a + // silent substitution of a sibling JVM target. + assert!(m.target_subdir(Some("scala")).is_err()); +} + +#[test] +fn an_exact_target_key_wins_over_a_synonym() { + // With both `node` and `nodejs` declared, `node` must mean the `node` one. + let both = COLLOQUIAL.replace( + "[targets.golang]\ndir = \"clients/go\"", + "[targets.node]\ndir = \"clients/js-legacy\"", + ); + let m = Manifest::parse(&both).unwrap(); + assert_eq!( + m.target_subdir(Some("node")).unwrap(), + Some("clients/js-legacy") + ); + assert_eq!(m.target_subdir(Some("nodejs")).unwrap(), Some("clients/ts")); +} + +#[test] +fn an_unknown_target_is_still_an_error_after_synonym_expansion() { + // Synonyms must widen what resolves, never turn a real mistake into a + // silent whole-tree install. + let m = Manifest::parse(COLLOQUIAL).unwrap(); + let err = m + .target_subdir(Some("cobol")) + .expect_err("unknown language"); + assert!(err.to_string().contains("cobol"), "{err}"); +} + +#[test] +fn each_published_target_declares_its_own_language_and_ecosystem() { + // This is what the consumer-side guard reads: the artifact for `-java` must + // say `jvm` so an npm-only project can be told it is the wrong one. + let m = Manifest::parse(COLLOQUIAL).unwrap(); + + let java = m.manifest_for_target("java").unwrap(); + assert_eq!(java.package.name, "fiducia-clients-java"); + assert_eq!(java.package.language, Language::Java); + assert_eq!(java.package.ecosystem(), Ecosystem::Jvm); + + let node = m.manifest_for_target("nodejs").unwrap(); + assert_eq!(node.package.name, "fiducia-clients-nodejs"); + assert_eq!(node.package.language, Language::Nodejs); + assert_eq!(node.package.ecosystem(), Ecosystem::Npm); + + // Kotlin shares Java's ecosystem while keeping its own name — the reason + // language and ecosystem are separate axes. + let kotlin = m.manifest_for_target("kotlin").unwrap(); + assert_eq!(kotlin.package.name, "fiducia-clients-kotlin"); + assert_eq!(kotlin.package.language, Language::Kotlin); + assert_eq!(kotlin.package.ecosystem(), Ecosystem::Jvm); + + // Each slice round-trips as a standalone single-language manifest. + for target in ["java", "nodejs", "kotlin"] { + let derived = m.manifest_for_target(target).unwrap(); + let reparsed = Manifest::parse(&derived.to_toml_string().unwrap()).unwrap(); + assert_eq!(reparsed, derived, "{target} manifest must round-trip"); + assert!(!reparsed.is_polyglot()); + } +} + +#[test] +fn an_explicit_target_ecosystem_overrides_the_language_default() { + // rust-wasm is Rust source consumed by a JS bundler, so the package must be + // gated as npm even though the language is a Rust dialect. + let m = Manifest::parse(COLLOQUIAL).unwrap(); + let wasm = m.manifest_for_target("rust-wasm").unwrap(); + assert_eq!(wasm.package.name, "fiducia-clients-rust-wasm"); + assert_eq!(wasm.package.language, Language::RustWasm); + assert_eq!(wasm.package.ecosystem(), Ecosystem::Npm); +} + +#[test] +fn a_target_key_that_is_not_a_known_language_stays_ungated() { + // Arbitrary slugs remain legal target names (pre-existing behavior). Such a + // package publishes and installs, it just carries no ecosystem claim, so + // the guard cannot and must not reject it anywhere. + let custom = POLYGLOT.replace( + "[targets.go]\ndir = \"go\"", + "[targets.wasm3]\ndir = \"w3\"", + ); + let m = Manifest::parse(&custom).unwrap(); + let derived = m.manifest_for_target("wasm3").unwrap(); + assert_eq!(derived.package.name, "polyglot-lib-wasm3"); + assert!(derived.package.language.is_default()); + assert_eq!(derived.package.ecosystem(), Ecosystem::Universal); +} + +#[test] +fn untagged_manifests_keep_their_meaning() { + // Every manifest written before language tagging existed must parse and + // stay ungated — this is the backwards-compatibility contract. + let m = Manifest::parse(SAMPLE).unwrap(); + assert!(m.package.language.is_default()); + assert_eq!(m.package.ecosystem(), Ecosystem::Universal); + // …and must not gain the fields when serialized back out. + let toml = m.to_toml_string().unwrap(); + assert!(!toml.contains("language"), "{toml}"); + assert!(!toml.contains("ecosystem"), "{toml}"); +} + +#[test] +fn include_readme_also_keeps_the_changelog_registries_ask_for() { + // `dart pub publish` fails a package outright for a missing CHANGELOG, and + // `publish.exclude` can only add patterns — so if the default excludes strip + // it, no repo can ship one. A package that opted into its README wants its + // changelog too. + let stripped = effective_excludes(&[], false); + assert!(stripped.iter().any(|p| p.starts_with("README"))); + assert!(stripped.iter().any(|p| p.starts_with("CHANGELOG"))); + + let kept = effective_excludes(&[], true); + assert!( + !kept.iter().any(|p| p.starts_with("README")), + "include_readme must un-exclude READMEs" + ); + assert!( + !kept.iter().any(|p| p.starts_with("CHANGELOG")), + "include_readme must un-exclude CHANGELOGs: {kept:?}" + ); + // Everything else still goes. + assert!(kept.iter().any(|p| p.contains("test"))); +} + +#[test] +fn a_synonym_request_prefers_the_target_named_after_the_language() { + // shared-auth-clients ships separate TypeScript and JavaScript clients, so + // two targets are both `nodejs`. A consumer inferred as `node` must land on + // the canonical one, not on whichever key happens to sort first. + let two_node = r#" +[package] +org = "shared-auth" +name = "shared-auth-clients" +version = "0.1.0" + +[package.repository] +url = "https://github.com/shared-auth/shared-auth-clients" + +[targets.javascript] +dir = "clients/js" + +[targets.nodejs] +dir = "clients/ts" +"#; + let m = Manifest::parse(two_node).unwrap(); + // `javascript` sorts before `nodejs`, so a naive first-match would pick it. + assert_eq!(m.resolve_target_key("node"), Some("nodejs")); + assert_eq!(m.target_subdir(Some("node")).unwrap(), Some("clients/ts")); + // An exact key still wins over the canonical preference. + assert_eq!(m.resolve_target_key("javascript"), Some("javascript")); + assert_eq!( + m.target_subdir(Some("javascript")).unwrap(), + Some("clients/js") + ); + // And a single-match repo is unaffected. + let one = two_node.replace("[targets.javascript]\ndir = \"clients/js\"\n", ""); + let m1 = Manifest::parse(&one).unwrap(); + assert_eq!(m1.resolve_target_key("js"), Some("nodejs")); +} + +// --- Audit chain (tamper-evident log) ----------------------------------- + +/// New chain fields must not break deserialization of a body produced by a +/// server that predates them: `seq` defaults to 0 and the hashes to empty. +#[test] +fn audit_entry_from_a_pre_chain_server_still_parses() { + let legacy = r#"{ + "at": "2026-07-25T00:00:00Z", + "action": "publish", + "subject": "acme/http-kit@1.0.0", + "actor_token_name": "ci", + "actor_role": "publisher" + }"#; + let entry: zed_interfaces::registry::AuditEntry = serde_json::from_str(legacy).unwrap(); + assert_eq!(entry.seq, 0); + assert_eq!(entry.entry_hash, ""); + assert_eq!(entry.prev_hash, None); + assert_eq!(entry.subject, "acme/http-kit@1.0.0"); +} + +/// A full entry round-trips, and the empty chain fields stay off the wire so +/// the serialized shape is unchanged for servers that do not set them. +#[test] +fn audit_entry_roundtrips_and_omits_empty_chain_fields() { + use zed_interfaces::registry::{AuditAction, AuditEntry}; + let entry = AuditEntry { + at: "2026-07-25T00:00:00Z".to_string(), + action: "yank".to_string(), + action_kind: Some(AuditAction::Yank), + subject: "acme/http-kit@1.0.0".to_string(), + actor_token_name: "ci".to_string(), + actor_role: "owner".to_string(), + detail: None, + seq: 7, + entry_hash: "ab".repeat(32), + prev_hash: Some("cd".repeat(32)), + }; + let json = serde_json::to_string(&entry).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), entry); + + let bare = AuditEntry { + entry_hash: String::new(), + prev_hash: None, + ..entry + }; + let value: serde_json::Value = serde_json::to_value(&bare).unwrap(); + assert!( + value.get("entry_hash").is_none(), + "empty hash must be omitted" + ); + assert!( + value.get("prev_hash").is_none(), + "absent prev must be omitted" + ); +} + +/// The preimage must be injective. A separator-joined encoding would let a +/// crafted field (a token literally named `x|publish`) shift boundaries and +/// collide with a different entry; length prefixes must prevent that. +#[test] +fn audit_preimage_resists_field_injection() { + use zed_interfaces::registry::{AuditChainInput, audit_chain_preimage}; + + let base = AuditChainInput { + org_id: "org", + seq: 1, + at: "t", + action: "publish", + subject: "s", + actor_token_id: Some("tok"), + actor_token_name: "ci", + actor_role: "owner", + detail: Some("d"), + prev_hash: "prev", + }; + let digest = |i: &AuditChainInput| audit_chain_preimage(i); + + // Same characters, different field boundaries, must not collide. + let honest = digest(&AuditChainInput { + actor_role: "owner", + detail: None, + ..base + }); + let shifted = digest(&AuditChainInput { + actor_role: "own", + detail: Some("er"), + ..base + }); + assert_ne!(honest, shifted, "field boundaries must be unambiguous"); + + // A value that mimics the length-prefix syntax cannot fake a layout. + let sneaky = digest(&AuditChainInput { + actor_token_name: "5:owner", + actor_role: "x", + detail: None, + ..base + }); + assert_ne!(honest, sneaky); + + // Every distinguishing field must actually participate in the digest. + let reference = digest(&base); + let variants = [ + ( + "org_id", + AuditChainInput { + org_id: "OTHER", + ..base + }, + ), + ("seq", AuditChainInput { seq: 2, ..base }), + ("at", AuditChainInput { at: "T", ..base }), + ( + "action", + AuditChainInput { + action: "yank", + ..base + }, + ), + ( + "subject", + AuditChainInput { + subject: "S", + ..base + }, + ), + ( + "actor_token_id", + AuditChainInput { + actor_token_id: Some("TOK"), + ..base + }, + ), + ( + "actor_token_name", + AuditChainInput { + actor_token_name: "CI", + ..base + }, + ), + ( + "actor_role", + AuditChainInput { + actor_role: "reader", + ..base + }, + ), + ( + "detail", + AuditChainInput { + detail: Some("D"), + ..base + }, + ), + ( + "prev_hash", + AuditChainInput { + prev_hash: "PREV", + ..base + }, + ), + ]; + for (field, variant) in variants { + assert_ne!( + reference, + digest(&variant), + "changing {field} must change the preimage" + ); + } + + // None and the empty string are deliberately the same absence. + assert_eq!( + digest(&AuditChainInput { + actor_token_id: None, + detail: None, + ..base + }), + digest(&AuditChainInput { + actor_token_id: Some(""), + detail: Some(""), + ..base + }), + ); +}