diff --git a/.ai-fast-graph-source-check-trigger b/.ai-fast-graph-source-check-trigger new file mode 100644 index 000000000..c19f0b9ce --- /dev/null +++ b/.ai-fast-graph-source-check-trigger @@ -0,0 +1 @@ +temporary branch-only CI trigger; remove after source-check completes diff --git a/.github/workflows/ai-fast-graph-source-check.yml b/.github/workflows/ai-fast-graph-source-check.yml new file mode 100644 index 000000000..80bdb923d --- /dev/null +++ b/.github/workflows/ai-fast-graph-source-check.yml @@ -0,0 +1,29 @@ +name: temporary fast graph source check + +on: + push: + branches: ["ai/fast-manifest-dependency-graph"] + +permissions: + contents: read + +jobs: + source-check: + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 + with: + persist-credentials: false + - uses: dtolnay/rust-toolchain@4be7066ada62dd38de10e7b70166bc74ed198c30 + with: + toolchain: 1.98.1 + components: rustfmt + - name: Format + run: cargo fmt --all -- --check + - name: Refresh lock only inside the ephemeral runner + run: cargo generate-lockfile + - name: Compile all targets + run: cargo check --all-targets + - name: Run focused local graph tests + run: cargo test graph_local diff --git a/.graph-cli-flags.toml b/.graph-cli-flags.toml index b2b687016..496b304c4 100644 --- a/.graph-cli-flags.toml +++ b/.graph-cli-flags.toml @@ -14,7 +14,7 @@ errors_env = "ZED_PKG_PARSE_ERRORS" allow_unknown = false [help] -url = "https://github.com/zed-pkg/zed-cli/blob/main/docs/package-dependency-graph.md" +url = "https://github.com/zed-pkg/zed-cli/blob/main/docs/dependency-graph-cli.md" [flags.registry] env = "ZED_PKG_REGISTRY" @@ -52,14 +52,14 @@ env = "ZED_PKG_INTERACTIVE" aliases = ["interactive"] type = "bool" default = "false" -help = "Global lifecycle confirmation mode; graph downloads remain non-interactive." +help = "Global lifecycle confirmation mode; graph commands remain non-interactive." [flags.git_submodules] env = "ZED_PKG_GIT_SUBMODULES" aliases = ["git-submodules"] type = "bool" default = "false" -help = "Global Git submodule compatibility mode; graph downloads never mutate submodules." +help = "Global Git submodule compatibility mode; graph commands never mutate submodules." [flags.no_mirrors] env = "ZED_PKG_NO_MIRRORS" @@ -132,3 +132,47 @@ aliases = ["metadata-json"] type = "bool" default = "false" help = "Emit deterministic response metadata as JSON on stderr." + +[commands.graph.commands.local] +help = "Resolve the complete prospective dependency graph for a local .zpkg.toml." + +[commands.graph.commands.local.flags.manifest] +env = "ZED_PKG_GRAPH_MANIFEST" +aliases = ["manifest"] +type = "string" +default = ".zpkg.toml" +help = "Path to .zpkg.toml, or a directory containing it." + +[commands.graph.commands.local.flags.output] +env = "ZED_PKG_GRAPH_OUTPUT" +aliases = ["output"] +type = "string" +help = "Output JSON path; omit or use - for stdout." + +[commands.graph.commands.local.flags.pretty] +env = "ZED_PKG_GRAPH_PRETTY" +aliases = ["pretty"] +type = "bool" +default = "false" +help = "Pretty-print JSON instead of compact machine output." + +[commands.graph.commands.local.flags.runtime_only] +env = "ZED_PKG_GRAPH_RUNTIME_ONLY" +aliases = ["runtime-only"] +type = "bool" +default = "false" +help = "Resolve runtime dependencies only; omit build dependencies." + +[commands.graph.commands.local.flags.allow_artifact_fallback] +env = "ZED_PKG_GRAPH_ALLOW_ARTIFACT_FALLBACK" +aliases = ["allow-artifact-fallback"] +type = "bool" +default = "false" +help = "Permit verified package artifact downloads when declared graph metadata is unavailable." + +[commands.graph.commands.local.flags.max_metadata_bytes] +env = "ZED_PKG_GRAPH_MAX_METADATA_BYTES" +aliases = ["max-metadata-bytes"] +type = "integer" +default = "33554432" +help = "Maximum bytes accepted from one immutable declared graph response." diff --git a/docs/dependency-graph-cli.md b/docs/dependency-graph-cli.md new file mode 100644 index 000000000..2f77f7f23 --- /dev/null +++ b/docs/dependency-graph-cli.md @@ -0,0 +1,92 @@ +# Dependency graph CLI + +`zed graph` has two deliberately different graph operations: + +- `zed graph package /@` downloads the registry's immutable graph artifact for one exact package version. +- `zed graph local` resolves the complete **prospective** graph for a local `.zpkg.toml` without installing packages or writing a project lockfile. + +The local command is intended for AI agents, editors, CI admission checks, dependency auditors, and other tools that need the whole selected graph before they decide whether to mutate a checkout. + +## Fast local resolution + +From a package root: + +```sh +zed graph local +``` + +Or point at a manifest explicitly: + +```sh +zed graph local --manifest path/to/.zpkg.toml +``` + +Compact deterministic JSON is written to stdout by default. Use `--pretty` for humans or `--output graph.json` for an atomic no-clobber file write. + +The default graph includes runtime and build dependencies because both can affect a successful package operation. Use `--runtime-only` when a consumer specifically wants the runtime projection. + +## Why this is faster than install/tree inspection + +The ordinary installer must be able to materialize artifacts. Historical dependency inspection paths also rely on a lockfile and/or manifests from already-materialized dependencies. Neither is ideal for a fresh `.zpkg.toml`. + +`zed graph local` instead asks the registry for each selected package version's immutable `view=declared` graph document. That small document carries the dependency requirements needed for recursive solving, so the client does not download and extract every package archive merely to discover its manifest. + +The solver keeps package, candidate, and declared-graph metadata in memory for the duration of the calculation. Version selection is deterministic and backtracks only on semantic constraint conflicts. Transport, authentication, malformed graph, identity mismatch, and integrity errors are operational failures and are never reinterpreted as reasons to select an older version. + +## Output contract + +The prospective output uses `zpkg/local-dependency-graph/v1`, not the authoritative resolved `zpkg/dependency-graph/v1` shape. A pre-lock analysis does not yet possess the registry-snapshot and lock provenance required by the resolved wire contract, so it must not pretend to be that artifact. + +The JSON contains: + +- `root`: the local package coordinate; +- `nodes`: one exact selected version per package, including source (`root`, `registry`, `workspace`, or `path_override`) and registry artifact SHA-256 when applicable; +- `edges`: exact selected `from`/`to` coordinates plus the original requirement and dependency kind; +- `complete: true`: emitted only after the whole active graph resolves successfully; +- `stats`: registry reads, declared-graph cache hits, and any explicit artifact fallback work; +- `analysis_digest`: SHA-256 over the semantic graph fields (`schema`, `complete`, `root`, `nodes`, `edges`). Performance counters are intentionally excluded so repeated equivalent analyses have the same digest. + +Nodes and edges are sorted and deduplicated before serialization. Compact output is therefore suitable for hashing, caching, diffing, and direct model/tool ingestion. + +## Workspace and override behavior + +The command honors workspace members and `[overrides.path]` using the same local manifest sources as package resolution. Explicit path overrides take precedence over workspace candidates. + +The ambient machine-wide local registry is intentionally not consulted by this command. A graph intended for automation should not silently change because an unrelated checkout was registered on one developer machine. Put local dependencies in the workspace or declare a path override when they are part of the intended graph. + +## Artifact fallback + +Fast mode fails closed when an HTTP registry does not expose immutable declared-graph metadata. For an older registry or a `file://` registry, explicitly permit the traditional verified artifact-manifest path: + +```sh +zed graph local --allow-artifact-fallback +``` + +That mode may download and extract package artifacts into the ordinary Zed store. The JSON `stats.artifact_downloads` and `stats.artifact_manifest_fallbacks` fields make that visible to automation. + +## Safety and bounds + +The metadata path: + +- requires HTTPS outside explicit loopback registries; +- does not follow HTTP redirects; +- applies a 30-second request timeout; +- accepts at most 32 MiB per graph document by default (`--max-metadata-bytes` can lower the bound, but cannot exceed the shared graph-contract limit); +- requires canonical JSON with a valid semantic graph digest and verifies a present digest response header against the document; +- verifies requested package identity against both registry version metadata and declared graph identity; +- rejects cross-registry edges rather than silently resolving them against the wrong registry; +- caps recursive provenance depth at 256 and active package coordinates at 10,000; +- preserves the shared graph-contract limits of 50,000 nodes and 500,000 edges; +- bounds conflict provenance shown in diagnostics. + +Output files are created atomically beside their destination and refuse to clobber an existing file. + +## AI/tooling pattern + +A tooling process can treat successful stdout as one self-contained dependency snapshot: + +```sh +zed graph local --manifest .zpkg.toml > /tmp/graph.json +``` + +For a successful fast-path calculation, `stats.artifact_downloads` should be `0`. Cache downstream analysis by `analysis_digest`, then use the exact `nodes` and `edges` arrays for traversal, impact analysis, cycle detection, policy checks, or context selection. diff --git a/src/graph_export.rs b/src/graph_export.rs index ad4e088ca..9c9056f1e 100644 --- a/src/graph_export.rs +++ b/src/graph_export.rs @@ -1,8 +1,9 @@ -//! Immutable package dependency-graph downloads. +//! Dependency-graph CLI surface. //! -//! `zed graph package /@` is a byte-preserving client for -//! the registry graph endpoints. It never resolves a mutable version, rewrites -//! a graph, or treats a convenience projection as lockfile authority. +//! `zed graph package /@` remains a byte-preserving client +//! for immutable registry graph artifacts. `zed graph local` is the read-only +//! prospective resolver for a local `.zpkg.toml`; it emits deterministic JSON +//! intended for automation and AI tooling without mutating the project. use std::env; use std::ffi::OsString; @@ -16,6 +17,9 @@ use serde::Serialize; use crate::cli::Globals; use crate::config::Config; +mod graph_local; + +use graph_local::LocalGraphOptions; mod coordinate; mod download; @@ -64,6 +68,44 @@ pub struct PackageGraphArgs { pub metadata_json: bool, } +#[derive(Debug, Clone, Args)] +pub struct LocalGraphArgs { + /// `.zpkg.toml` to resolve, or a directory containing it. + #[arg( + long, + env = "ZED_PKG_GRAPH_MANIFEST", + default_value = ".zpkg.toml", + value_name = "PATH" + )] + pub manifest: PathBuf, + + /// Output file. Omit or pass `-` for stdout. + #[arg(long, short = 'o', env = "ZED_PKG_GRAPH_OUTPUT", value_name = "PATH")] + pub output: Option, + + /// Pretty-print JSON instead of compact deterministic JSON. + #[arg(long, env = "ZED_PKG_GRAPH_PRETTY")] + pub pretty: bool, + + /// Resolve only runtime dependencies; the default includes build edges too. + #[arg(long, env = "ZED_PKG_GRAPH_RUNTIME_ONLY")] + pub runtime_only: bool, + + /// Permit verified artifact downloads when a registry lacks declared graph metadata. + /// Fast analysis fails closed by default instead of downloading package archives. + #[arg(long, env = "ZED_PKG_GRAPH_ALLOW_ARTIFACT_FALLBACK")] + pub allow_artifact_fallback: bool, + + /// Maximum bytes accepted from one immutable declared-graph response. + #[arg( + long, + env = "ZED_PKG_GRAPH_MAX_METADATA_BYTES", + default_value_t = DEFAULT_MAX_BYTES, + value_name = "BYTES" + )] + pub max_metadata_bytes: u64, +} + #[derive(Debug, Clone, Args)] struct GraphArgs { #[command(subcommand)] @@ -74,6 +116,8 @@ struct GraphArgs { enum GraphSubcommand { /// Download one immutable package-version dependency graph. Package(PackageGraphArgs), + /// Resolve the complete prospective graph for a local `.zpkg.toml`. + Local(LocalGraphArgs), } #[derive(Debug, Parser)] @@ -119,8 +163,8 @@ struct DownloadMetadata { } /// Route only `zed graph ...`; established commands remain on the ordinary -/// CLI parser. This modular boundary leaves `zed graph github` available as a -/// sibling command without coupling package downloads to GitHub inventory. +/// parser. Keeping graph modular prevents this command family from stealing +/// existing top-level command names. pub fn dispatch(args: Vec) -> Option> { match route(&args) { Route::Graph => Some(run_cli(args)), @@ -134,8 +178,7 @@ pub fn dispatch(args: Vec) -> Option> { } } -/// Add the graph namespace and immutable package downloader to root help and -/// shell completion generation. +/// Add graph commands to root help and shell completion generation. pub fn augment_root_command(command: clap::Command) -> clap::Command { if command .get_subcommands() @@ -147,12 +190,17 @@ pub fn augment_root_command(command: clap::Command) -> clap::Command { clap::Command::new("package") .about("Download one immutable package-version dependency graph"), ); + let local = ::augment_args( + clap::Command::new("local") + .about("Resolve the complete prospective graph for a local .zpkg.toml"), + ); command.subcommand( clap::Command::new("graph") .about("Inspect and export dependency graphs") .subcommand_required(true) .arg_required_else_help(true) - .subcommand(package), + .subcommand(package) + .subcommand(local), ) } @@ -174,6 +222,19 @@ fn run_cli(args: Vec) -> Result { GraphCommand::Graph(GraphArgs { command: GraphSubcommand::Package(options), }) => run_package(&config, options), + GraphCommand::Graph(GraphArgs { + command: GraphSubcommand::Local(options), + }) => graph_local::run( + &config, + LocalGraphOptions { + manifest: options.manifest, + output: options.output, + pretty: options.pretty, + runtime_only: options.runtime_only, + allow_artifact_fallback: options.allow_artifact_fallback, + max_metadata_bytes: options.max_metadata_bytes, + }, + ), } } @@ -362,6 +423,9 @@ fn normalize_boolean_environment() -> Result<()> { "ZED_PKG_TRUST_MIRROR_METADATA", "ZED_PKG_SOURCE_FALLBACK", "ZED_PKG_GRAPH_METADATA_JSON", + "ZED_PKG_GRAPH_PRETTY", + "ZED_PKG_GRAPH_RUNTIME_ONLY", + "ZED_PKG_GRAPH_ALLOW_ARTIFACT_FALLBACK", ] { let Some(raw) = env::var_os(key) else { continue; @@ -400,47 +464,77 @@ mod tests { values.iter().map(|value| (*value).to_owned()).collect() } + fn os_argv(values: &[&str]) -> Vec { + values.iter().map(OsString::from).collect() + } + #[test] fn route_detects_graph_and_help_without_stealing_existing_commands() { - let argv = |values: &[&str]| values.iter().map(OsString::from).collect::>(); assert_eq!( - route(&argv(&["zed", "graph", "package", "acme/pkg@1.0.0"])), + route(&os_argv(&[ + "zed", + "graph", + "package", + "acme/pkg@1.0.0" + ])), + Route::Graph + ); + assert_eq!( + route(&os_argv(&["zed", "graph", "local", "--pretty"])), Route::Graph ); assert_eq!( - route(&argv(&[ + route(&os_argv(&[ "zed", "--registry", "https://r", "help", "graph", - "package" + "local" ])), Route::GraphHelp { help_index: 3 } ); - assert_eq!(route(&argv(&["zed", "task", "graph"])), Route::Existing); + assert_eq!(route(&os_argv(&["zed", "task", "graph"])), Route::Existing); } #[test] - fn embedded_graph_contract_is_fail_closed_and_accepts_public_options() { - let parsed = parse_embedded(&string_argv(&[ - "zed", - "graph", - "package", - "acme/pkg@1.0.0", - "--format", - "json", - "--output", - "graph.json", - "--etag", - "\"abc\"", - "--max-bytes", - "4096", - "--metadata-json", - ])) - .expect("graph flags contract should parse its public command surface"); - assert!(parsed.unknown_options.is_empty()); - assert!(parsed.errors.is_empty()); + fn embedded_graph_contract_accepts_package_and_local_options() { + for argv in [ + string_argv(&[ + "zed", + "graph", + "package", + "acme/pkg@1.0.0", + "--format", + "json", + "--output", + "graph.json", + "--etag", + "\"abc\"", + "--max-bytes", + "4096", + "--metadata-json", + ]), + string_argv(&[ + "zed", + "graph", + "local", + "--manifest", + ".zpkg.toml", + "--output", + "graph.json", + "--pretty", + "--runtime-only", + "--allow-artifact-fallback", + "--max-metadata-bytes", + "4096", + ]), + ] { + let parsed = parse_embedded(&argv) + .expect("graph flags contract should parse its public command surface"); + assert!(parsed.unknown_options.is_empty(), "{parsed:?}"); + assert!(parsed.errors.is_empty(), "{parsed:?}"); + } } #[test] @@ -448,8 +542,7 @@ mod tests { let parsed = parse_embedded(&string_argv(&[ "zed", "graph", - "package", - "acme/pkg@1.0.0", + "local", "--not-a-graph-option", ])) .expect("flags2env should return structured rejection evidence"); diff --git a/src/graph_export/graph_local.rs b/src/graph_export/graph_local.rs new file mode 100644 index 000000000..88dd9ad20 --- /dev/null +++ b/src/graph_export/graph_local.rs @@ -0,0 +1,1274 @@ +//! Prospective dependency-graph resolution for a local `.zpkg.toml`. +//! +//! This path is read-only with respect to the project. It prefers immutable +//! declared-graph metadata from the registry so AI and other tooling can +//! calculate a complete prospective graph without downloading every package +//! artifact just to discover its manifest. Older/file registries can use an +//! explicit verified artifact-manifest fallback when requested. + +use std::collections::BTreeMap; +use std::fs; +use std::io::{Read, Write}; +use std::net::IpAddr; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result, bail, ensure}; +use globset::Glob; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use zed_interfaces::dependency_graph::{ + DEPENDENCY_GRAPH_DEFAULT_MAX_EDGES, DEPENDENCY_GRAPH_DEFAULT_MAX_ENCODED_BYTES, + DEPENDENCY_GRAPH_DEFAULT_MAX_NODES, DEPENDENCY_GRAPH_DIGEST_HEADER, DependencyGraphData, + DependencyGraphDocument, DependencyKind, +}; +use zed_interfaces::manifest::{Manifest, is_slug}; +use zed_interfaces::paths::MANIFEST_FILE; +use zed_interfaces::registry::{PackageMetadata, VersionMetadata}; +use zed_interfaces::version::{self, VersionScheme}; +use zed_lib::requirement_matches; + +use crate::config::{Config, read_manifest}; +use crate::install_graph::ensure_artifact; +use crate::registry::{Registry, registry_for}; +use crate::store::Store; + +const ANALYSIS_SCHEMA: &str = "zpkg/local-dependency-graph/v1"; +const MAX_DEPENDENCY_DEPTH: usize = 256; +const MAX_GRAPH_COORDINATES: usize = 10_000; +const MAX_ERROR_PATHS: usize = 32; +const METADATA_REQUEST_TIMEOUT: Duration = Duration::from_secs(30); + +#[derive(Debug, Clone)] +pub(super) struct LocalGraphOptions { + pub(super) manifest: PathBuf, + pub(super) output: Option, + pub(super) pretty: bool, + pub(super) runtime_only: bool, + pub(super) allow_artifact_fallback: bool, + pub(super) max_metadata_bytes: u64, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +struct LocalDependencyGraph { + schema: String, + complete: bool, + root: GraphNode, + nodes: Vec, + edges: Vec, + stats: GraphStats, + #[serde(skip_serializing_if = "Option::is_none")] + analysis_digest: Option, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord)] +struct GraphNode { + package: String, + version: String, + source: NodeSource, + #[serde(skip_serializing_if = "Option::is_none")] + artifact_sha256: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "snake_case")] +enum NodeSource { + Root, + Registry, + Workspace, + PathOverride, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord)] +struct GraphEdge { + from: String, + to: String, + requirement: String, + kind: DependencyKind, +} + +#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)] +struct GraphStats { + registry_package_reads: usize, + registry_version_reads: usize, + declared_graph_reads: usize, + declared_graph_cache_hits: usize, + artifact_manifest_fallbacks: usize, + artifact_downloads: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct DependencySpec { + key: String, + requirement: String, + kind: DependencyKind, +} + +#[derive(Debug, Clone)] +struct Candidate { + key: String, + version: String, + scheme: VersionScheme, + dependencies: Vec, + source: NodeSource, + artifact_sha256: Option, + yanked: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct Constraint { + requirement: String, + path: Vec, + propagate: bool, +} + +#[derive(Debug, Clone, Default)] +struct SolveState { + constraints: BTreeMap>, + selected: BTreeMap, +} + +impl SolveState { + fn add_constraint(&mut self, key: String, constraint: Constraint) -> Result { + split_key(&key)?; + let depth = constraint.path.len().saturating_sub(1); + if depth > MAX_DEPENDENCY_DEPTH { + bail!( + "dependency graph exceeds the maximum depth of {MAX_DEPENDENCY_DEPTH} while resolving `{key}` via {}", + render_path(&constraint.path) + ); + } + if !self.constraints.contains_key(&key) && self.constraints.len() >= MAX_GRAPH_COORDINATES { + bail!( + "dependency graph exceeds the {MAX_GRAPH_COORDINATES}-coordinate limit while adding `{key}`; refusing" + ); + } + let constraints = self.constraints.entry(key).or_default(); + if constraints.contains(&constraint) { + return Ok(false); + } + constraints.push(constraint); + constraints.sort(); + Ok(true) + } + + fn unresolved_key(&self) -> Option { + self.constraints + .iter() + .filter(|(key, _)| !self.selected.contains_key(*key)) + .min_by(|(left_key, left), (right_key, right)| { + constraint_depth(left) + .cmp(&constraint_depth(right)) + .then_with(|| left_key.cmp(right_key)) + }) + .map(|(key, _)| key.clone()) + } +} + +fn constraint_depth(constraints: &[Constraint]) -> usize { + constraints + .iter() + .map(|constraint| constraint.path.len()) + .min() + .unwrap_or(usize::MAX) +} + +fn render_path(path: &[String]) -> String { + const HEAD: usize = 4; + const TAIL: usize = 8; + if path.len() <= HEAD + TAIL { + return path.join(" -> "); + } + let omitted = path.len() - HEAD - TAIL; + format!( + "{} -> ... {omitted} segment(s) omitted ... -> {}", + path[..HEAD].join(" -> "), + path[path.len() - TAIL..].join(" -> ") + ) +} + +trait SolveSource { + fn package(&mut self, org: &str, name: &str) -> Result; + fn candidate( + &mut self, + key: &str, + org: &str, + name: &str, + version: &str, + scheme: VersionScheme, + ) -> Result; +} + +struct AnalyzerSource<'a> { + cfg: &'a Config, + registry: Box, + store: Store, + token: Option, + http: reqwest::blocking::Client, + runtime_only: bool, + allow_artifact_fallback: bool, + max_metadata_bytes: u64, + packages: BTreeMap, + candidates: BTreeMap<(String, String), Candidate>, + declared_graphs: BTreeMap<(String, String), Vec>, + stats: GraphStats, +} + +impl<'a> AnalyzerSource<'a> { + fn new( + cfg: &'a Config, + runtime_only: bool, + allow_artifact_fallback: bool, + max_metadata_bytes: u64, + ) -> Result { + ensure!( + max_metadata_bytes > 0 + && max_metadata_bytes <= DEPENDENCY_GRAPH_DEFAULT_MAX_ENCODED_BYTES, + "--max-metadata-bytes must be between 1 and {}", + DEPENDENCY_GRAPH_DEFAULT_MAX_ENCODED_BYTES + ); + Ok(Self { + cfg, + registry: registry_for(&cfg.registry)?, + store: Store::new(&cfg.home), + token: cfg.resolve_token()?, + http: reqwest::blocking::Client::builder() + .user_agent(concat!("zed-cli/", env!("CARGO_PKG_VERSION"))) + .redirect(reqwest::redirect::Policy::none()) + .timeout(METADATA_REQUEST_TIMEOUT) + .build()?, + runtime_only, + allow_artifact_fallback, + max_metadata_bytes, + packages: BTreeMap::new(), + candidates: BTreeMap::new(), + declared_graphs: BTreeMap::new(), + stats: GraphStats::default(), + }) + } + + #[allow(clippy::too_many_lines)] + fn declared_dependencies( + &mut self, + org: &str, + name: &str, + version: &str, + ) -> Result>> { + let cache_key = (format!("{org}/{name}"), version.to_string()); + if let Some(dependencies) = self.declared_graphs.get(&cache_key) { + self.stats.declared_graph_cache_hits += 1; + return Ok(Some(dependencies.clone())); + } + + let mut url = match reqwest::Url::parse(&self.cfg.registry) { + Ok(url) if matches!(url.scheme(), "http" | "https") => url, + _ => return Ok(None), + }; + if !url.username().is_empty() + || url.password().is_some() + || url.query().is_some() + || url.fragment().is_some() + { + bail!("registry URL must not contain credentials, a query, or a fragment"); + } + ensure!( + url.scheme() == "https" || url_is_loopback(&url), + "fast dependency-graph metadata requires HTTPS outside loopback registries" + ); + { + let mut segments = url + .path_segments_mut() + .map_err(|_| anyhow::anyhow!("registry URL cannot be a base URL"))?; + segments.pop_if_empty(); + for segment in [ + "v1", + "packages", + org, + name, + "versions", + version, + "dependency-graph", + ] { + segments.push(segment); + } + } + url.query_pairs_mut() + .append_pair("view", "declared") + .append_pair("format", "json"); + + let mut request = self.http.get(url.clone()).header( + "Accept", + zed_interfaces::dependency_graph::DEPENDENCY_GRAPH_JSON_MEDIA_TYPE, + ); + if let Some(token) = self.token.as_deref() { + request = request.bearer_auth(token); + } + let response = match request.send() { + Ok(response) => response, + Err(_error) if self.allow_artifact_fallback => return Ok(None), + Err(error) => { + return Err(error).with_context(|| { + format!("requesting declared dependency graph for {org}/{name}@{version}") + }); + } + }; + if matches!( + response.status(), + reqwest::StatusCode::NOT_FOUND + | reqwest::StatusCode::METHOD_NOT_ALLOWED + | reqwest::StatusCode::NOT_IMPLEMENTED + ) { + return Ok(None); + } + if !response.status().is_success() { + if self.allow_artifact_fallback { + return Ok(None); + } + bail!( + "declared dependency graph request for {org}/{name}@{version} failed with HTTP {}", + response.status() + ); + } + if let Some(length) = response.content_length() { + ensure!( + length <= self.max_metadata_bytes, + "declared dependency graph for {org}/{name}@{version} declares {length} bytes, above the {} byte analysis limit", + self.max_metadata_bytes + ); + } + let header_digest = response + .headers() + .get(DEPENDENCY_GRAPH_DIGEST_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + + let mut body = Vec::new(); + response + .take(self.max_metadata_bytes.saturating_add(1)) + .read_to_end(&mut body) + .with_context(|| { + format!("reading declared dependency graph for {org}/{name}@{version}") + })?; + ensure!( + body.len() as u64 <= self.max_metadata_bytes, + "declared dependency graph for {org}/{name}@{version} exceeds the {} byte analysis limit", + self.max_metadata_bytes + ); + let document = DependencyGraphDocument::parse_verified_canonical(&body).with_context(|| { + format!("verifying declared dependency graph for {org}/{name}@{version}") + })?; + if let Some(header_digest) = header_digest { + ensure!( + document.graph_digest.as_deref() == Some(header_digest.as_str()), + "declared dependency graph header digest disagrees with the verified document for {org}/{name}@{version}" + ); + } + let (package, dependencies) = match document.graph { + DependencyGraphData::Declared { + package, + dependencies, + } => (package, dependencies), + DependencyGraphData::Resolved { .. } => { + bail!( + "registry returned a resolved graph for declared package route {org}/{name}@{version}" + ) + } + }; + ensure!( + package.org == org && package.name == name && package.version == version, + "declared dependency graph identity mismatch: requested {org}/{name}@{version}, received {package}" + ); + + let mut specs = Vec::new(); + for dependency in dependencies { + if self.runtime_only && dependency.kind != DependencyKind::Runtime { + continue; + } + if dependency.optional { + continue; + } + ensure!( + dependency.registry_id == package.registry_id, + "cross-registry dependency `{}/{}` from {org}/{name}@{version} requires an explicit registry-aware resolver", + dependency.org, + dependency.name + ); + specs.push(DependencySpec { + key: format!("{}/{}", dependency.org, dependency.name), + requirement: dependency.requirement, + kind: dependency.kind, + }); + } + specs.sort(); + specs.dedup(); + self.stats.declared_graph_reads += 1; + self.declared_graphs.insert(cache_key, specs.clone()); + Ok(Some(specs)) + } + + fn artifact_dependencies( + &mut self, + key: &str, + version: &VersionMetadata, + ) -> Result> { + let (package_dir, downloaded) = + ensure_artifact(self.registry.as_ref(), &self.store, version).with_context(|| { + format!( + "loading artifact manifest fallback for {key}@{}", + version.version + ) + })?; + self.stats.artifact_downloads += usize::from(downloaded); + let manifest_path = package_dir.join(MANIFEST_FILE); + if !manifest_path.is_file() { + self.stats.artifact_manifest_fallbacks += 1; + return Ok(Vec::new()); + } + let manifest = read_manifest(&package_dir).with_context(|| { + format!( + "reading artifact dependency manifest for {key}@{} from {}", + version.version, + manifest_path.display() + ) + })?; + ensure!( + manifest.full_name() == key && manifest.package.version == version.version, + "artifact manifest declares {}@{} while registry metadata selected {key}@{}", + manifest.full_name(), + manifest.package.version, + version.version + ); + self.stats.artifact_manifest_fallbacks += 1; + Ok(manifest_dependencies(&manifest, self.runtime_only)) + } +} + +impl SolveSource for AnalyzerSource<'_> { + fn package(&mut self, org: &str, name: &str) -> Result { + let key = format!("{org}/{name}"); + if let Some(package) = self.packages.get(&key) { + return Ok(package.clone()); + } + let package = self.registry.get_package(org, name)?; + ensure!( + package.org == org && package.name == name, + "registry returned package `{}/{}` while resolving `{key}`", + package.org, + package.name + ); + self.stats.registry_package_reads += 1; + self.packages.insert(key, package.clone()); + Ok(package) + } + + fn candidate( + &mut self, + key: &str, + org: &str, + name: &str, + version: &str, + scheme: VersionScheme, + ) -> Result { + let cache_key = (key.to_string(), version.to_string()); + if let Some(candidate) = self.candidates.get(&cache_key) { + return Ok(candidate.clone()); + } + let metadata = self.registry.get_version(org, name, version)?; + ensure!( + metadata.org == org && metadata.name == name && metadata.version == version, + "registry returned `{}/{}@{}` while resolving `{key}@{version}`", + metadata.org, + metadata.name, + metadata.version + ); + self.stats.registry_version_reads += 1; + let dependencies = if metadata.yanked { + Vec::new() + } else { + match self.declared_dependencies(org, name, version)? { + Some(dependencies) => dependencies, + None if self.allow_artifact_fallback => { + self.artifact_dependencies(key, &metadata)? + } + None => bail!( + "declared dependency graph metadata is unavailable for {key}@{version}; refusing artifact download in fast analysis mode (pass --allow-artifact-fallback to permit verified artifact-manifest fallback)" + ), + } + }; + let candidate = Candidate { + key: key.to_string(), + version: version.to_string(), + scheme, + dependencies, + source: NodeSource::Registry, + artifact_sha256: Some(metadata.sha256), + yanked: metadata.yanked, + }; + self.candidates.insert(cache_key, candidate.clone()); + Ok(candidate) + } +} + +fn url_is_loopback(url: &reqwest::Url) -> bool { + match url.host_str() { + Some(host) if host.eq_ignore_ascii_case("localhost") => true, + Some(host) => host + .parse::() + .is_ok_and(|address| address.is_loopback()), + None => false, + } +} + +fn manifest_dependencies(manifest: &Manifest, runtime_only: bool) -> Vec { + let mut dependencies = manifest + .dependencies + .iter() + .map(|(key, requirement)| DependencySpec { + key: key.clone(), + requirement: requirement.clone(), + kind: DependencyKind::Runtime, + }) + .collect::>(); + if !runtime_only { + dependencies.extend( + manifest + .build_dependencies + .iter() + .map(|(key, requirement)| DependencySpec { + key: key.clone(), + requirement: requirement.clone(), + kind: DependencyKind::Build, + }), + ); + } + dependencies.sort(); + dependencies.dedup(); + dependencies +} + +fn local_candidate(manifest: Manifest, source: NodeSource, runtime_only: bool) -> Candidate { + Candidate { + key: manifest.full_name(), + version: manifest.package.version.clone(), + scheme: manifest.package.version_scheme, + dependencies: manifest_dependencies(&manifest, runtime_only), + source, + artifact_sha256: None, + yanked: false, + } +} + +fn discover_local_candidates( + project: &Path, + root_manifest: &Manifest, + runtime_only: bool, +) -> Result> { + let mut candidates = BTreeMap::new(); + let mut current = Some(project); + while let Some(directory) = current { + if directory.join(MANIFEST_FILE).is_file() { + let manifest = read_manifest(directory)?; + if let Some(workspace) = manifest.workspace.as_ref() { + for pattern in &workspace.members { + for member_dir in expand_workspace_pattern(directory, pattern)? { + let member = read_manifest(&member_dir).with_context(|| { + format!("reading workspace member {}", member_dir.display()) + })?; + let candidate = + local_candidate(member, NodeSource::Workspace, runtime_only); + candidates.insert(candidate.key.clone(), candidate); + } + } + break; + } + } + current = directory.parent(); + } + + let raw_overrides = crate::local_overrides::read(project)?; + if !raw_overrides.is_empty() { + let resolved = crate::local_overrides::resolve( + project, + root_manifest.modules_dir(), + &raw_overrides, + )?; + for (key, directory) in resolved { + let manifest = read_manifest(&directory).with_context(|| { + format!( + "reading local path override `{key}` from {}", + directory.display() + ) + })?; + ensure!( + manifest.full_name() == key, + "local path override `{key}` points to package `{}` at {}", + manifest.full_name(), + directory.display() + ); + candidates.insert( + key, + local_candidate(manifest, NodeSource::PathOverride, runtime_only), + ); + } + } + + let root_key = root_manifest.full_name(); + if root_manifest.dependencies.contains_key(&root_key) + || (!runtime_only && root_manifest.build_dependencies.contains_key(&root_key)) + { + candidates.remove(&root_key); + } + Ok(candidates) +} + +fn expand_workspace_pattern(root: &Path, pattern: &str) -> Result> { + let mut candidates = vec![root.to_path_buf()]; + for segment in pattern.split('/') { + let mut next = Vec::new(); + for base in &candidates { + if segment.contains('*') { + let glob = Glob::new(segment) + .with_context(|| format!("invalid workspace glob segment `{segment}`"))?; + let matcher = glob.compile_matcher(); + let entries = match fs::read_dir(base) { + Ok(entries) => entries, + Err(_) => continue, + }; + for entry in entries.flatten() { + let name = entry.file_name(); + if entry.path().is_dir() + && matcher.is_match(Path::new(&name)) + && !name.to_string_lossy().starts_with('.') + { + next.push(entry.path()); + } + } + } else { + let candidate = base.join(segment); + if candidate.is_dir() { + next.push(candidate); + } + } + } + candidates = next; + } + candidates.sort(); + candidates.dedup(); + Ok(candidates) +} + +enum SearchOutcome { + Solved(SolveState), + Unsatisfiable(String), +} + +struct Solver<'a, S> { + source: &'a mut S, + locals: &'a BTreeMap, +} + +impl Solver<'_, S> { + fn solve(&mut self, mut state: SolveState) -> Result { + if let Some(conflict) = self.propagate(&mut state)? { + return Ok(SearchOutcome::Unsatisfiable(conflict)); + } + let Some(key) = state.unresolved_key() else { + return Ok(SearchOutcome::Solved(state)); + }; + let constraints = state.constraints.get(&key).cloned().unwrap_or_default(); + + if let Some(local) = self.locals.get(&key) { + if constraints.iter().any(|constraint| { + !requirement_matches(local.scheme, &constraint.requirement, &local.version) + }) { + return Ok(SearchOutcome::Unsatisfiable(render_conflict( + &key, + &constraints, + Some(&local.version), + &[], + ))); + } + state.selected.insert(key, local.clone()); + return self.solve(state); + } + + let (org, name) = split_key(&key)?; + let package = self.source.package(org, name)?; + let mut versions = package.versions.clone(); + version::sort_desc(&mut versions); + let mut failures = Vec::new(); + let mut matching = false; + let mut saw_non_yanked = false; + + for published in &versions { + if constraints.iter().any(|constraint| { + !requirement_matches(package.version_scheme, &constraint.requirement, published) + }) { + continue; + } + matching = true; + // Registry/auth/metadata/integrity failures are operational errors, + // not evidence that this version is semantically unsatisfiable. + // Bubble them out rather than silently trying an older package. + let candidate = + self.source + .candidate(&key, org, name, published, package.version_scheme)?; + if candidate.yanked { + continue; + } + saw_non_yanked = true; + let mut branch = state.clone(); + branch.selected.insert(key.clone(), candidate); + match self.solve(branch)? { + SearchOutcome::Solved(solved) => return Ok(SearchOutcome::Solved(solved)), + SearchOutcome::Unsatisfiable(failure) => { + failures.push((published.clone(), failure)); + } + } + } + + if matching && !saw_non_yanked { + return Ok(SearchOutcome::Unsatisfiable(format!( + "version conflict for {key}: all matching versions are yanked; use an existing lock with `zed install --frozen` to replay a previously selected version" + ))); + } + Ok(SearchOutcome::Unsatisfiable(render_conflict( + &key, + &constraints, + None, + &failures, + ))) + } + + fn propagate(&self, state: &mut SolveState) -> Result> { + loop { + for (key, selected) in &state.selected { + let constraints = state.constraints.get(key).cloned().unwrap_or_default(); + if constraints.iter().any(|constraint| { + !requirement_matches(selected.scheme, &constraint.requirement, &selected.version) + }) { + return Ok(Some(render_conflict( + key, + &constraints, + Some(&selected.version), + &[], + ))); + } + } + + let mut additions = Vec::new(); + for (key, selected) in &state.selected { + let parents = state.constraints.get(key).cloned().unwrap_or_default(); + for parent in parents { + if !parent.propagate { + continue; + } + for dependency in &selected.dependencies { + additions.push(( + dependency.key.clone(), + child_constraint( + &parent, + key, + &selected.version, + &dependency.key, + &dependency.requirement, + ), + )); + } + } + } + + let mut changed = false; + for (key, constraint) in additions { + changed |= state.add_constraint(key, constraint)?; + } + if !changed { + return Ok(None); + } + } + } +} + +fn child_constraint( + parent: &Constraint, + parent_key: &str, + parent_version: &str, + dependency: &str, + requirement: &str, +) -> Constraint { + let cycle_back_edge = parent.path.iter().any(|segment| { + segment + .split_once('@') + .map_or(segment.as_str(), |(coordinate, _)| coordinate) + == dependency + }); + let mut path = parent.path.clone(); + if let Some(last) = path.last_mut() { + *last = format!("{parent_key}@{parent_version}"); + } + path.push(dependency.to_string()); + Constraint { + requirement: requirement.to_string(), + path, + propagate: !cycle_back_edge, + } +} + +fn render_conflict( + key: &str, + constraints: &[Constraint], + selected: Option<&str>, + failures: &[(String, String)], +) -> String { + let mut lines = vec![match selected { + Some(version) => format!( + "version conflict for {key}: selected {version}, but it does not satisfy every active requirement" + ), + None => format!( + "version conflict for {key}: no version satisfies every active requirement" + ), + }]; + for constraint in constraints.iter().take(MAX_ERROR_PATHS) { + lines.push(format!( + " - `{}` via {}", + constraint.requirement, + render_path(&constraint.path) + )); + } + if constraints.len() > MAX_ERROR_PATHS { + lines.push(format!( + " - ... {} additional requirement paths omitted", + constraints.len() - MAX_ERROR_PATHS + )); + } + for (version, error) in failures.iter().take(8) { + lines.push(format!( + " candidate {version} led to: {}", + error.lines().next().unwrap_or("unknown conflict") + )); + } + lines.join("\n") +} + +fn split_key(key: &str) -> Result<(&str, &str)> { + let Some((org, name)) = key.split_once('/') else { + bail!("invalid package spec `{key}` (expected org/name)"); + }; + ensure!( + is_slug(org) && is_slug(name) && !name.contains('/'), + "invalid package spec `{key}` (expected slug/slug without path traversal or extra segments)" + ); + Ok((org, name)) +} + +fn build_graph( + manifest: &Manifest, + state: SolveState, + stats: GraphStats, + runtime_only: bool, +) -> Result { + let root_key = manifest.full_name(); + let root = GraphNode { + package: root_key.clone(), + version: manifest.package.version.clone(), + source: NodeSource::Root, + artifact_sha256: None, + }; + let mut nodes = vec![root.clone()]; + nodes.extend( + state + .selected + .values() + .filter(|candidate| { + candidate.key != root_key || candidate.version != manifest.package.version + }) + .map(|candidate| GraphNode { + package: candidate.key.clone(), + version: candidate.version.clone(), + source: candidate.source, + artifact_sha256: candidate.artifact_sha256.clone(), + }) + .collect::>(), + ); + nodes.sort(); + nodes.dedup(); + + let mut edges = Vec::new(); + let root_dependencies = manifest_dependencies(manifest, runtime_only); + for dependency in root_dependencies { + if let Some(selected) = state.selected.get(&dependency.key) { + edges.push(GraphEdge { + from: format!("{}@{}", root_key, manifest.package.version), + to: format!("{}@{}", dependency.key, selected.version), + requirement: dependency.requirement, + kind: dependency.kind, + }); + } + } + for candidate in state.selected.values() { + for dependency in &candidate.dependencies { + if let Some(selected) = state.selected.get(&dependency.key) { + edges.push(GraphEdge { + from: format!("{}@{}", candidate.key, candidate.version), + to: format!("{}@{}", dependency.key, selected.version), + requirement: dependency.requirement.clone(), + kind: dependency.kind, + }); + } else if dependency.key == root_key { + edges.push(GraphEdge { + from: format!("{}@{}", candidate.key, candidate.version), + to: format!("{}@{}", root_key, manifest.package.version), + requirement: dependency.requirement.clone(), + kind: dependency.kind, + }); + } + } + } + edges.sort(); + edges.dedup(); + ensure!( + nodes.len() <= DEPENDENCY_GRAPH_DEFAULT_MAX_NODES as usize, + "resolved graph exceeds the {} node limit", + DEPENDENCY_GRAPH_DEFAULT_MAX_NODES + ); + ensure!( + edges.len() <= DEPENDENCY_GRAPH_DEFAULT_MAX_EDGES as usize, + "resolved graph exceeds the {} edge limit", + DEPENDENCY_GRAPH_DEFAULT_MAX_EDGES + ); + + let mut graph = LocalDependencyGraph { + schema: ANALYSIS_SCHEMA.to_string(), + complete: true, + root, + nodes, + edges, + stats, + analysis_digest: None, + }; + #[derive(Serialize)] + struct SemanticGraph<'a> { + schema: &'a str, + complete: bool, + root: &'a GraphNode, + nodes: &'a [GraphNode], + edges: &'a [GraphEdge], + } + let payload = serde_json::to_vec(&SemanticGraph { + schema: &graph.schema, + complete: graph.complete, + root: &graph.root, + nodes: &graph.nodes, + edges: &graph.edges, + }) + .context("serializing local graph semantic digest payload")?; + graph.analysis_digest = Some(format!( + "sha256:{}", + hex::encode(Sha256::digest(payload)) + )); + Ok(graph) +} + +fn manifest_project(manifest_path: &Path) -> Result<(PathBuf, Manifest)> { + let path = if manifest_path.is_dir() { + manifest_path.join(MANIFEST_FILE) + } else { + manifest_path.to_path_buf() + }; + ensure!( + path.file_name().is_some_and(|name| name == MANIFEST_FILE), + "--manifest must name `{MANIFEST_FILE}` or a directory containing it" + ); + let parent = path.parent().unwrap_or_else(|| Path::new(".")); + let project = fs::canonicalize(parent) + .with_context(|| format!("canonicalizing manifest directory {}", parent.display()))?; + let manifest = read_manifest(&project)?; + Ok((project, manifest)) +} + +fn resolve(config: &Config, options: &LocalGraphOptions) -> Result { + let (project, manifest) = manifest_project(&options.manifest)?; + let locals = discover_local_candidates(&project, &manifest, options.runtime_only)?; + let mut state = SolveState::default(); + let root = format!("{}@{}", manifest.full_name(), manifest.package.version); + for dependency in manifest_dependencies(&manifest, options.runtime_only) { + state.add_constraint( + dependency.key.clone(), + Constraint { + requirement: dependency.requirement, + path: vec![root.clone(), dependency.key], + propagate: true, + }, + )?; + } + + let mut source = AnalyzerSource::new( + config, + options.runtime_only, + options.allow_artifact_fallback, + options.max_metadata_bytes, + )?; + let solved = match (Solver { + source: &mut source, + locals: &locals, + }) + .solve(state)? + { + SearchOutcome::Solved(solved) => solved, + SearchOutcome::Unsatisfiable(failure) => bail!(failure), + }; + build_graph(&manifest, solved, source.stats, options.runtime_only) +} + +pub(super) fn run(config: &Config, options: LocalGraphOptions) -> Result { + let graph = resolve(config, &options)?; + let bytes = if options.pretty { + let mut bytes = serde_json::to_vec_pretty(&graph)?; + bytes.push(b'\n'); + bytes + } else { + let mut bytes = serde_json::to_vec(&graph)?; + bytes.push(b'\n'); + bytes + }; + write_output(options.output.as_deref(), &bytes)?; + Ok(0) +} + +fn write_output(path: Option<&Path>, bytes: &[u8]) -> Result<()> { + match path { + None => write_stdout(bytes), + Some(path) if path == Path::new("-") => write_stdout(bytes), + Some(path) => write_atomic_file(path, bytes), + } +} + +fn write_stdout(bytes: &[u8]) -> Result<()> { + let mut stdout = std::io::stdout().lock(); + stdout + .write_all(bytes) + .context("writing local dependency graph to stdout")?; + stdout + .flush() + .context("flushing local dependency graph stdout")?; + Ok(()) +} + +fn write_atomic_file(path: &Path, bytes: &[u8]) -> Result<()> { + ensure!( + !path.as_os_str().is_empty(), + "local dependency graph output path may not be empty" + ); + match fs::symlink_metadata(path) { + Ok(_) => bail!( + "local dependency graph output already exists: {}", + path.display() + ), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "checking local dependency graph output {}", + path.display() + ) + }); + } + } + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .unwrap_or_else(|| Path::new(".")); + let metadata = fs::metadata(parent) + .with_context(|| format!("reading output directory {}", parent.display()))?; + ensure!( + metadata.is_dir(), + "local dependency graph output parent is not a directory: {}", + parent.display() + ); + let mut temporary = tempfile::NamedTempFile::new_in(parent) + .with_context(|| format!("creating atomic output beside {}", path.display()))?; + temporary + .write_all(bytes) + .with_context(|| format!("writing temporary graph output for {}", path.display()))?; + temporary + .as_file_mut() + .sync_all() + .with_context(|| format!("syncing temporary graph output for {}", path.display()))?; + temporary.persist_noclobber(path).map_err(|error| { + anyhow::anyhow!( + "publishing local dependency graph output {}: {}", + path.display(), + error.error + ) + })?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use zed_interfaces::vcs::Vcs; + + #[derive(Default)] + struct MemorySource { + packages: BTreeMap, + candidates: BTreeMap<(String, String), Candidate>, + candidate_failures: BTreeMap<(String, String), String>, + } + + impl MemorySource { + fn publish(&mut self, key: &str, version: &str, dependencies: &[(&str, &str)]) { + let (org, name) = split_key(key).unwrap(); + self.candidates.insert( + (key.to_string(), version.to_string()), + Candidate { + key: key.to_string(), + version: version.to_string(), + scheme: VersionScheme::Semver, + dependencies: dependencies + .iter() + .map(|(key, requirement)| DependencySpec { + key: (*key).to_string(), + requirement: (*requirement).to_string(), + kind: DependencyKind::Runtime, + }) + .collect(), + source: NodeSource::Registry, + artifact_sha256: Some(format!("{:064x}", self.candidates.len() + 1)), + yanked: false, + }, + ); + let package = self + .packages + .entry(key.to_string()) + .or_insert_with(|| PackageMetadata { + org: org.to_string(), + name: name.to_string(), + description: None, + vcs: Vcs::Git, + repo_url: format!("https://example.invalid/{key}"), + version_scheme: VersionScheme::Semver, + latest: None, + tags: Vec::new(), + versions: Vec::new(), + mirrors: Vec::new(), + signing_keys: Vec::new(), + }); + package.versions.push(version.to_string()); + version::sort_desc(&mut package.versions); + package.latest = package.versions.first().cloned(); + } + } + + impl SolveSource for MemorySource { + fn package(&mut self, org: &str, name: &str) -> Result { + self.packages + .get(&format!("{org}/{name}")) + .cloned() + .with_context(|| format!("missing package {org}/{name}")) + } + + fn candidate( + &mut self, + key: &str, + _org: &str, + _name: &str, + version: &str, + _scheme: VersionScheme, + ) -> Result { + let cache_key = (key.to_string(), version.to_string()); + if let Some(message) = self.candidate_failures.get(&cache_key) { + bail!("{message}"); + } + self.candidates + .get(&cache_key) + .cloned() + .with_context(|| format!("missing candidate {key}@{version}")) + } + } + + fn solve_memory(source: &mut MemorySource, dependencies: &[(&str, &str)]) -> Result { + let root = "consumer/app@1.0.0".to_string(); + let mut state = SolveState::default(); + for (key, requirement) in dependencies { + state.add_constraint( + (*key).to_string(), + Constraint { + requirement: (*requirement).to_string(), + path: vec![root.clone(), (*key).to_string()], + propagate: true, + }, + )?; + } + let locals = BTreeMap::new(); + match (Solver { + source, + locals: &locals, + }) + .solve(state)? + { + SearchOutcome::Solved(solved) => Ok(solved), + SearchOutcome::Unsatisfiable(failure) => bail!(failure), + } + } + + #[test] + fn diamond_graph_selects_one_shared_version() { + let mut source = MemorySource::default(); + source.publish("test/shared", "1.0.0", &[]); + source.publish("test/left", "1.0.0", &[("test/shared", "^1")]); + source.publish("test/right", "1.0.0", &[("test/shared", "^1")]); + let solved = solve_memory( + &mut source, + &[("test/left", "^1"), ("test/right", "^1")], + ) + .unwrap(); + assert_eq!(solved.selected.len(), 3); + assert_eq!(solved.selected["test/shared"].version, "1.0.0"); + } + + #[test] + fn solver_backtracks_when_latest_candidate_conflicts() { + let mut source = MemorySource::default(); + source.publish("test/shared", "1.0.0", &[]); + source.publish("test/shared", "2.0.0", &[]); + source.publish("test/router", "1.0.0", &[("test/shared", "^1")]); + source.publish("test/router", "2.0.0", &[("test/shared", "^2")]); + source.publish("test/policy", "1.0.0", &[("test/shared", "^1")]); + let solved = solve_memory( + &mut source, + &[("test/router", ">=1"), ("test/policy", "=1.0.0")], + ) + .unwrap(); + assert_eq!(solved.selected["test/router"].version, "1.0.0"); + assert_eq!(solved.selected["test/shared"].version, "1.0.0"); + } + + #[test] + fn operational_candidate_errors_are_not_reinterpreted_as_version_conflicts() { + let mut source = MemorySource::default(); + source.publish("test/router", "1.0.0", &[]); + source.publish("test/router", "2.0.0", &[]); + source.candidate_failures.insert( + ("test/router".to_string(), "2.0.0".to_string()), + "registry transport failed".to_string(), + ); + + let error = solve_memory(&mut source, &[("test/router", ">=1")]) + .unwrap_err() + .to_string(); + assert!(error.contains("registry transport failed"), "{error}"); + assert!(!error.contains("version conflict"), "{error}"); + } + + #[test] + fn cycles_terminate_without_duplicate_coordinates() { + let mut source = MemorySource::default(); + source.publish("test/a", "1.0.0", &[("test/b", "^1")]); + source.publish("test/b", "1.0.0", &[("test/a", "^1")]); + let solved = solve_memory(&mut source, &[("test/a", "^1")]).unwrap(); + assert_eq!(solved.selected.len(), 2); + assert_eq!(solved.selected["test/a"].version, "1.0.0"); + assert_eq!(solved.selected["test/b"].version, "1.0.0"); + } +}