aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/mozart/src')
-rw-r--r--crates/mozart/src/commands/status.rs713
1 files changed, 156 insertions, 557 deletions
diff --git a/crates/mozart/src/commands/status.rs b/crates/mozart/src/commands/status.rs
index 60db8ac..8647078 100644
--- a/crates/mozart/src/commands/status.rs
+++ b/crates/mozart/src/commands/status.rs
@@ -1,610 +1,209 @@
use clap::Args;
use indexmap::IndexMap;
+use mozart_core::composer::{Composer, InstallationSource, LocalPackage};
+use mozart_core::console::Console;
+use mozart_core::console_format;
use mozart_core::console_writeln;
-use sha1::{Digest, Sha1};
-use std::path::{Path, PathBuf};
+use mozart_core::console_writeln_error;
+use mozart_core::exit_code;
+use mozart_registry::download_manager::DownloadManager;
+use mozart_vcs::version_guesser::VersionGuesser;
#[derive(Args)]
pub struct StatusArgs {}
-/// Information extracted from a package's dist field.
-struct DistInfo {
- dist_type: String,
- url: String,
- shasum: Option<String>,
+struct VcsVerChange {
+ previous: VerRef,
+ current: VerRef,
}
-/// The kind of change detected for a file.
-#[derive(Debug, PartialEq)]
-enum ChangeKind {
- /// File was modified (exists in both, different hash).
- Modified,
- /// File was added in the installed copy (not in original archive).
- Added,
- /// File was removed from the installed copy (in original archive, not installed).
- Removed,
-}
-
-/// A single file change within a package.
-struct FileChange {
- kind: ChangeKind,
- path: String,
-}
-
-/// Changes detected for one package.
-struct PackageStatus {
- install_path: String,
- note: Option<String>,
- changes: Vec<FileChange>,
+struct VerRef {
+ version: String,
+ reference: String,
}
pub async fn execute(
_args: &StatusArgs,
cli: &super::Cli,
- console: &mozart_core::console::Console,
+ console: &Console,
) -> anyhow::Result<()> {
let working_dir = cli.working_dir()?;
+ let composer = Composer::require(&working_dir)?;
+ let installed_repo = composer.repository_manager().local_repository();
+ let im = composer.installation_manager();
+ let dm = DownloadManager::new(im.vendor_dir().join(".cache").join("git"));
+ let guesser = VersionGuesser::new();
- let vendor_dir = working_dir.join("vendor");
- let installed = mozart_registry::installed::InstalledPackages::read(&vendor_dir)?;
-
- if installed.packages.is_empty() {
- console.info("No packages installed.");
- return Ok(());
- }
-
- let cache_config = mozart_registry::cache::build_cache_config(cli.no_cache);
- let files_cache = mozart_registry::cache::Cache::files(&cache_config);
+ let mut errors: IndexMap<String, String> = IndexMap::new();
+ let mut unpushed_changes: IndexMap<String, String> = IndexMap::new();
+ let mut vcs_version_changes: IndexMap<String, VcsVerChange> = IndexMap::new();
- let show_files = cli.verbose > 0;
-
- let mut modified_packages: Vec<PackageStatus> = Vec::new();
-
- for pkg in &installed.packages {
- let dist = match extract_dist_info(pkg) {
- Some(d) => d,
- None => {
- if cli.verbose > 1 {
- console.verbose(&format!(" Skipping {} — no dist info available", pkg.name));
- }
- continue;
- }
+ for package in installed_repo.canonical_packages() {
+ let Some(downloader) = dm.for_package(package) else {
+ continue;
};
-
- // Resolve install path
- let install_path = resolve_install_path(pkg, &vendor_dir);
- if !install_path.exists() {
- if cli.verbose > 0 {
- console.verbose(&format!(
- " Skipping {} — install path does not exist: {}",
- pkg.name,
- install_path.display()
- ));
- }
+ let Some(target_dir) = im.get_install_path(package) else {
continue;
- }
+ };
+ let target_key = target_dir.display().to_string();
- // Check if the install path is a symlink — skip archive comparison
- if install_path.is_symlink() {
- let note = format!("{} is a symbolic link.", install_path.display());
- modified_packages.push(PackageStatus {
- install_path: install_path.to_string_lossy().into_owned(),
- note: Some(note),
- changes: Vec::new(),
- });
- continue;
+ // ChangeReportInterface — Composer mirrors the symlink branch and
+ // the local-changes branch unconditionally; the latter overrides
+ // the former when both fire.
+ if std::fs::symlink_metadata(&target_dir)
+ .map(|m| m.file_type().is_symlink())
+ .unwrap_or(false)
+ {
+ errors.insert(
+ target_key.clone(),
+ format!("{target_key} is a symbolic link."),
+ );
}
-
- if cli.verbose > 0 {
- console.verbose(&format!(" Checking {} ...", pkg.name));
+ if let Some(changes) = downloader.local_changes(&target_dir)? {
+ errors.insert(target_key.clone(), changes);
}
- // Download original archive to a temp dir
- let tmp_dir = make_temp_dir(&pkg.name)?;
- let downloaded = mozart_registry::downloader::download_dist(
- &dist.url,
- dist.shasum.as_deref(),
- None,
- &files_cache,
- )
- .await;
-
- let bytes = match downloaded {
- Ok(b) => b,
- Err(e) => {
- console.info(&format!(
- " Warning: could not download dist for {}: {}",
- pkg.name, e
- ));
- let _ = std::fs::remove_dir_all(&tmp_dir);
- continue;
- }
- };
-
- // Extract archive to temp dir
- let extract_result = match dist.dist_type.as_str() {
- "zip" => mozart_registry::downloader::extract_zip(&bytes, &tmp_dir),
- "tar" | "tar.gz" | "tgz" => {
- mozart_registry::downloader::extract_tar_gz(&bytes, &tmp_dir)
- }
- other => {
- console.info(&format!(
- " Warning: unsupported dist type '{}' for {}",
- other, pkg.name
- ));
- let _ = std::fs::remove_dir_all(&tmp_dir);
- continue;
+ // VcsCapableDownloaderInterface
+ if downloader.vcs_reference(&target_dir)?.is_some() {
+ let previous_ref = match package.installation_source() {
+ Some(InstallationSource::Source) => package.source_reference(),
+ Some(InstallationSource::Dist) => package.dist_reference(),
+ _ => None,
+ };
+ let pkg_config = build_package_config(package);
+ let current_version = guesser.guess_version(&pkg_config, &target_dir);
+ if let (Some(previous_ref), Some(current_version)) = (previous_ref, current_version) {
+ let cur_commit = current_version.commit.as_deref().unwrap_or("");
+ let cur_pretty = current_version.pretty_version.as_deref().unwrap_or("");
+ if cur_commit != previous_ref && cur_pretty != previous_ref {
+ vcs_version_changes.insert(
+ target_key.clone(),
+ VcsVerChange {
+ previous: VerRef {
+ version: package.pretty_version().to_string(),
+ reference: previous_ref.to_string(),
+ },
+ current: VerRef {
+ version: cur_pretty.to_string(),
+ reference: cur_commit.to_string(),
+ },
+ },
+ );
+ }
}
- };
-
- if let Err(e) = extract_result {
- console.info(&format!(
- " Warning: could not extract dist for {}: {}",
- pkg.name, e
- ));
- let _ = std::fs::remove_dir_all(&tmp_dir);
- continue;
}
- // Hash both directories
- let original_hashes = hash_directory(&tmp_dir)?;
- let installed_hashes = hash_directory(&install_path)?;
- let _ = std::fs::remove_dir_all(&tmp_dir);
-
- // Compute diff
- let changes = compute_diff(&original_hashes, &installed_hashes);
-
- if !changes.is_empty() {
- modified_packages.push(PackageStatus {
- install_path: install_path.to_string_lossy().into_owned(),
- note: None,
- changes,
- });
+ // DvcsDownloaderInterface
+ if let Some(unpushed) = downloader.unpushed_changes(&target_dir)? {
+ unpushed_changes.insert(target_key.clone(), unpushed);
}
}
- if modified_packages.is_empty() {
- console.info("No local changes");
+ if errors.is_empty() && unpushed_changes.is_empty() && vcs_version_changes.is_empty() {
+ console_writeln_error!(console, &console_format!("<info>No local changes</info>"));
return Ok(());
}
- console.info("You have changes in the following dependencies:\n");
-
- for pkg_status in &modified_packages {
- if let Some(ref note) = pkg_status.note {
- console_writeln!(console, note);
- } else {
- console_writeln!(console, &pkg_status.install_path);
-
- if show_files {
- let mut sorted_changes: Vec<&FileChange> = pkg_status.changes.iter().collect();
- sorted_changes.sort_by_key(|c| c.path.as_str());
+ let verbose = cli.verbose > 0;
+ let very_verbose = cli.verbose >= 2;
- for change in sorted_changes {
- let prefix = match change.kind {
- ChangeKind::Modified => 'M',
- ChangeKind::Added => '+',
- ChangeKind::Removed => '-',
- };
- console_writeln!(console, &format!(" {} {}", prefix, change.path),);
- }
+ if !errors.is_empty() {
+ console_writeln_error!(
+ console,
+ &console_format!("<error>You have changes in the following dependencies:</error>")
+ );
+ for (path, changes) in &errors {
+ if verbose {
+ console_writeln!(console, &console_format!("<info>{path}</info>:"));
+ console_writeln!(console, &indent_block(changes));
+ } else {
+ console_writeln!(console, path);
}
}
}
- // Hint about --verbose if not already showing files and there are modified packages
- if !show_files {
- console.info("Use --verbose (-v) to see a list of files");
- }
-
- // Exit with code 1 if modifications found
- Err(mozart_core::exit_code::bail_silent(1))
-}
-
-/// Extract dist info from an installed package entry.
-fn extract_dist_info(pkg: &mozart_registry::installed::InstalledPackageEntry) -> Option<DistInfo> {
- // Try the strongly-typed `dist` field first
- let dist_val = pkg.dist.as_ref().or_else(|| pkg.extra_fields.get("dist"))?;
-
- let dist_type = dist_val.get("type").and_then(|v| v.as_str())?.to_string();
- let url = dist_val.get("url").and_then(|v| v.as_str())?.to_string();
- let shasum = dist_val
- .get("shasum")
- .and_then(|v| v.as_str())
- .filter(|s| !s.is_empty())
- .map(|s| s.to_string());
-
- if url.is_empty() {
- return None;
+ if !unpushed_changes.is_empty() {
+ console_writeln_error!(
+ console,
+ &console_format!(
+ "<warning>You have unpushed changes on the current branch in the following dependencies:</warning>"
+ )
+ );
+ for (path, changes) in &unpushed_changes {
+ if verbose {
+ console_writeln!(console, &console_format!("<info>{path}</info>:"));
+ console_writeln!(console, &indent_block(changes));
+ } else {
+ console_writeln!(console, path);
+ }
+ }
}
- Some(DistInfo {
- dist_type,
- url,
- shasum,
- })
-}
-
-/// Resolve the on-disk install path for a package.
-///
-/// Prefers the `install-path` field from installed.json when available,
-/// since it is a path relative to `vendor/composer/`. Falls back to
-/// `vendor/<package-name>`.
-fn resolve_install_path(
- pkg: &mozart_registry::installed::InstalledPackageEntry,
- vendor_dir: &Path,
-) -> PathBuf {
- if let Some(ref rel) = pkg.install_path {
- // install-path is relative to vendor/composer/
- let base = vendor_dir.join("composer");
- let resolved = base.join(rel);
- // Normalize out ".." segments using canonicalize-like logic
- let resolved_str = resolved.to_string_lossy().into_owned();
- let mut components: Vec<&str> = Vec::new();
- for part in resolved_str.split('/') {
- match part {
- ".." => {
- components.pop();
+ if !vcs_version_changes.is_empty() {
+ console_writeln_error!(
+ console,
+ &console_format!(
+ "<warning>You have version variations in the following dependencies:</warning>"
+ )
+ );
+ for (path, change) in &vcs_version_changes {
+ if verbose {
+ let mut prev = if change.previous.version.is_empty() {
+ change.previous.reference.clone()
+ } else {
+ change.previous.version.clone()
+ };
+ let mut curr = if change.current.version.is_empty() {
+ change.current.reference.clone()
+ } else {
+ change.current.version.clone()
+ };
+ if very_verbose {
+ prev.push_str(&format!(" ({})", change.previous.reference));
+ curr.push_str(&format!(" ({})", change.current.reference));
}
- "." | "" => {}
- p => components.push(p),
+ console_writeln!(console, &console_format!("<info>{path}</info>:"));
+ console_writeln!(
+ console,
+ &console_format!(
+ " From <comment>{prev}</comment> to <comment>{curr}</comment>"
+ )
+ );
+ } else {
+ console_writeln!(console, path);
}
}
- PathBuf::from("/".to_string() + &components.join("/"))
- } else {
- vendor_dir.join(&pkg.name)
}
-}
-/// Create a unique temporary directory for extracting a package archive.
-///
-/// The directory is placed under the system temp dir and named using the
-/// package name (with `/` replaced) and a timestamp-derived suffix so that
-/// concurrent runs are unlikely to collide. The caller is responsible for
-/// removing the directory when done.
-fn make_temp_dir(package_name: &str) -> anyhow::Result<PathBuf> {
- use std::time::{SystemTime, UNIX_EPOCH};
- let nanos = SystemTime::now()
- .duration_since(UNIX_EPOCH)
- .unwrap_or_default()
- .subsec_nanos();
- let safe_name = package_name.replace('/', "_");
- let dir = std::env::temp_dir().join(format!("mozart_status_{}_{}", safe_name, nanos));
- std::fs::create_dir_all(&dir)?;
- Ok(dir)
-}
-
-/// Recursively hash all files in a directory.
-///
-/// Returns a map from relative path string to SHA-1 hex digest.
-fn hash_directory(dir: &Path) -> anyhow::Result<IndexMap<String, String>> {
- let mut map = IndexMap::new();
- hash_dir_recursive(dir, dir, &mut map)?;
- Ok(map)
-}
-
-fn hash_dir_recursive(
- root: &Path,
- current: &Path,
- map: &mut IndexMap<String, String>,
-) -> anyhow::Result<()> {
- let entries = match std::fs::read_dir(current) {
- Ok(e) => e,
- Err(_) => return Ok(()),
- };
-
- for entry in entries {
- let entry = entry?;
- let path = entry.path();
- let metadata = entry.metadata()?;
-
- if metadata.is_dir() {
- hash_dir_recursive(root, &path, map)?;
- } else if metadata.is_file() {
- let relative = path
- .strip_prefix(root)
- .map(|p| p.to_string_lossy().replace('\\', "/"))
- .unwrap_or_default();
-
- let contents = std::fs::read(&path)?;
- let mut hasher = Sha1::new();
- hasher.update(&contents);
- let hex = format!("{:x}", hasher.finalize());
-
- map.insert(relative, hex);
- }
- // Symlinks are skipped
+ if !verbose {
+ console_writeln_error!(console, "Use --verbose (-v) to see a list of files");
}
+ let code = (if !errors.is_empty() { 1 } else { 0 })
+ | (if !unpushed_changes.is_empty() { 2 } else { 0 })
+ | (if !vcs_version_changes.is_empty() {
+ 4
+ } else {
+ 0
+ });
+ if code != 0 {
+ return Err(exit_code::bail_silent(code));
+ }
Ok(())
}
-/// Compare two hash maps (original vs installed) and return a list of changes.
-fn compute_diff(
- original: &IndexMap<String, String>,
- installed: &IndexMap<String, String>,
-) -> Vec<FileChange> {
- let mut changes: Vec<FileChange> = Vec::new();
-
- // Files in original: check for modifications and removals
- for (path, orig_hash) in original {
- match installed.get(path) {
- Some(inst_hash) if inst_hash != orig_hash => {
- changes.push(FileChange {
- kind: ChangeKind::Modified,
- path: path.clone(),
- });
- }
- Some(_) => {} // unchanged
- None => {
- changes.push(FileChange {
- kind: ChangeKind::Removed,
- path: path.clone(),
- });
- }
- }
- }
-
- // Files in installed but not in original: added
- for path in installed.keys() {
- if !original.contains_key(path) {
- changes.push(FileChange {
- kind: ChangeKind::Added,
- path: path.clone(),
- });
- }
- }
-
- changes
+fn indent_block(s: &str) -> String {
+ s.split('\n')
+ .map(|line| format!(" {}", line.trim_start()))
+ .collect::<Vec<_>>()
+ .join("\n")
}
-#[cfg(test)]
-mod tests {
- use super::*;
- use std::fs;
- use tempfile::tempdir;
-
- #[test]
- fn test_hash_directory() {
- let dir = tempdir().unwrap();
-
- fs::write(dir.path().join("file.txt"), b"hello").unwrap();
- fs::create_dir(dir.path().join("sub")).unwrap();
- fs::write(dir.path().join("sub/nested.txt"), b"world").unwrap();
-
- let hashes = hash_directory(dir.path()).unwrap();
- assert_eq!(hashes.len(), 2);
- assert!(hashes.contains_key("file.txt"));
- assert!(hashes.contains_key("sub/nested.txt"));
-
- // Same content → same hash
- let dir2 = tempdir().unwrap();
- fs::write(dir2.path().join("file.txt"), b"hello").unwrap();
- fs::create_dir(dir2.path().join("sub")).unwrap();
- fs::write(dir2.path().join("sub/nested.txt"), b"world").unwrap();
-
- let hashes2 = hash_directory(dir2.path()).unwrap();
- assert_eq!(hashes["file.txt"], hashes2["file.txt"]);
- assert_eq!(hashes["sub/nested.txt"], hashes2["sub/nested.txt"]);
-
- // Different content → different hash
- let dir3 = tempdir().unwrap();
- fs::write(dir3.path().join("file.txt"), b"different").unwrap();
- let hashes3 = hash_directory(dir3.path()).unwrap();
- assert_ne!(hashes["file.txt"], hashes3["file.txt"]);
- }
-
- #[test]
- fn test_compute_diff_no_changes() {
- let mut map: IndexMap<String, String> = IndexMap::new();
- map.insert("src/Foo.php".to_string(), "abc123".to_string());
- map.insert("src/Bar.php".to_string(), "def456".to_string());
-
- let changes = compute_diff(&map, &map);
- assert!(changes.is_empty());
- }
-
- #[test]
- fn test_compute_diff_modified() {
- let mut original: IndexMap<String, String> = IndexMap::new();
- original.insert("src/Foo.php".to_string(), "abc123".to_string());
-
- let mut installed: IndexMap<String, String> = IndexMap::new();
- installed.insert("src/Foo.php".to_string(), "xyz999".to_string());
-
- let changes = compute_diff(&original, &installed);
- assert_eq!(changes.len(), 1);
- assert_eq!(changes[0].kind, ChangeKind::Modified);
- assert_eq!(changes[0].path, "src/Foo.php");
- }
-
- #[test]
- fn test_compute_diff_added() {
- let original: IndexMap<String, String> = IndexMap::new();
-
- let mut installed: IndexMap<String, String> = IndexMap::new();
- installed.insert("src/NewFile.php".to_string(), "aabbcc".to_string());
-
- let changes = compute_diff(&original, &installed);
- assert_eq!(changes.len(), 1);
- assert_eq!(changes[0].kind, ChangeKind::Added);
- assert_eq!(changes[0].path, "src/NewFile.php");
- }
-
- #[test]
- fn test_compute_diff_removed() {
- let mut original: IndexMap<String, String> = IndexMap::new();
- original.insert("src/OldFile.php".to_string(), "112233".to_string());
-
- let installed: IndexMap<String, String> = IndexMap::new();
-
- let changes = compute_diff(&original, &installed);
- assert_eq!(changes.len(), 1);
- assert_eq!(changes[0].kind, ChangeKind::Removed);
- assert_eq!(changes[0].path, "src/OldFile.php");
- }
-
- #[test]
- fn test_compute_diff_mixed() {
- let mut original: IndexMap<String, String> = IndexMap::new();
- original.insert("src/Unchanged.php".to_string(), "same".to_string());
- original.insert("src/Modified.php".to_string(), "old".to_string());
- original.insert("src/Removed.php".to_string(), "gone".to_string());
-
- let mut installed: IndexMap<String, String> = IndexMap::new();
- installed.insert("src/Unchanged.php".to_string(), "same".to_string());
- installed.insert("src/Modified.php".to_string(), "new".to_string());
- installed.insert("src/Added.php".to_string(), "extra".to_string());
-
- let changes = compute_diff(&original, &installed);
- assert_eq!(changes.len(), 3);
-
- let modified: Vec<_> = changes
- .iter()
- .filter(|c| c.kind == ChangeKind::Modified)
- .collect();
- let added: Vec<_> = changes
- .iter()
- .filter(|c| c.kind == ChangeKind::Added)
- .collect();
- let removed: Vec<_> = changes
- .iter()
- .filter(|c| c.kind == ChangeKind::Removed)
- .collect();
-
- assert_eq!(modified.len(), 1);
- assert_eq!(modified[0].path, "src/Modified.php");
-
- assert_eq!(added.len(), 1);
- assert_eq!(added[0].path, "src/Added.php");
-
- assert_eq!(removed.len(), 1);
- assert_eq!(removed[0].path, "src/Removed.php");
- }
-
- #[test]
- fn test_extract_dist_info_from_dist_field() {
- use std::collections::BTreeMap;
-
- let pkg = mozart_registry::installed::InstalledPackageEntry {
- name: "vendor/pkg".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: None,
- source: None,
- dist: Some(serde_json::json!({
- "type": "zip",
- "url": "https://example.com/pkg.zip",
- "reference": "abc123",
- "shasum": "deadbeef"
- })),
- package_type: None,
- install_path: None,
- autoload: None,
- aliases: vec![],
- homepage: None,
- support: None,
- extra_fields: BTreeMap::new(),
- };
-
- let info = extract_dist_info(&pkg).unwrap();
- assert_eq!(info.dist_type, "zip");
- assert_eq!(info.url, "https://example.com/pkg.zip");
- assert_eq!(info.shasum.as_deref(), Some("deadbeef"));
- }
-
- #[test]
- fn test_extract_dist_info_no_url() {
- use std::collections::BTreeMap;
-
- let pkg = mozart_registry::installed::InstalledPackageEntry {
- name: "vendor/pkg".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: None,
- source: None,
- dist: Some(serde_json::json!({
- "type": "zip",
- "url": "",
- "shasum": ""
- })),
- package_type: None,
- install_path: None,
- autoload: None,
- aliases: vec![],
- homepage: None,
- support: None,
- extra_fields: BTreeMap::new(),
- };
-
- assert!(extract_dist_info(&pkg).is_none());
- }
-
- #[test]
- fn test_extract_dist_info_absent() {
- use std::collections::BTreeMap;
-
- let pkg = mozart_registry::installed::InstalledPackageEntry {
- name: "vendor/pkg".to_string(),
- version: "1.0.0".to_string(),
- version_normalized: None,
- source: None,
- dist: None,
- package_type: None,
- install_path: None,
- autoload: None,
- aliases: vec![],
- homepage: None,
- support: None,
- extra_fields: BTreeMap::new(),
- };
-
- assert!(extract_dist_info(&pkg).is_none());
- }
-
- #[test]
- fn test_resolve_install_path_default() {
- use std::collections::BTreeMap;
-
- let pkg = mozart_registry::installed::InstalledPackageEntry {
- name: "monolog/monolog".to_string(),
- version: "3.0.0".to_string(),
- version_normalized: None,
- source: None,
- dist: None,
- package_type: None,
- install_path: None,
- autoload: None,
- aliases: vec![],
- homepage: None,
- support: None,
- extra_fields: BTreeMap::new(),
- };
-
- let vendor = PathBuf::from("/project/vendor");
- let path = resolve_install_path(&pkg, &vendor);
- assert_eq!(path, PathBuf::from("/project/vendor/monolog/monolog"));
- }
-
- #[test]
- fn test_resolve_install_path_with_install_path() {
- use std::collections::BTreeMap;
-
- let pkg = mozart_registry::installed::InstalledPackageEntry {
- name: "monolog/monolog".to_string(),
- version: "3.0.0".to_string(),
- version_normalized: None,
- source: None,
- dist: None,
- package_type: None,
- install_path: Some("../monolog/monolog".to_string()),
- autoload: None,
- aliases: vec![],
- homepage: None,
- support: None,
- extra_fields: BTreeMap::new(),
- };
-
- let vendor = PathBuf::from("/project/vendor");
- let path = resolve_install_path(&pkg, &vendor);
- assert_eq!(path, PathBuf::from("/project/vendor/monolog/monolog"));
- }
+/// Build the `package_config` shape that `VersionGuesser` reads. The PHP
+/// equivalent is `ArrayDumper::dump($package)`; we only need the fields
+/// that `VersionGuesser` actually inspects.
+fn build_package_config(package: &LocalPackage) -> serde_json::Value {
+ serde_json::json!({
+ "extra": package.extra(),
+ })
}