aboutsummaryrefslogtreecommitdiffhomepage
path: root/crates/mozart/src/commands/status.rs
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 18:06:22 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 18:06:22 +0900
commite3967edfacf69253e5aade350b9eb933f7a9c786 (patch)
treec386d4283bf4acf7eb56809710fb8408360d4a90 /crates/mozart/src/commands/status.rs
parent2db52ebd5cd4a6b7511ce71f2a3f03abed971f10 (diff)
downloadphp-mozart-e3967edfacf69253e5aade350b9eb933f7a9c786.tar.gz
php-mozart-e3967edfacf69253e5aade350b9eb933f7a9c786.tar.zst
php-mozart-e3967edfacf69253e5aade350b9eb933f7a9c786.zip
feat(status): implement status command to detect local vendor modifications
Downloads original dist archives, hashes files with SHA-1, and compares against installed copies to detect modified, added, and removed files. Supports verbose file listing and exits with code 1 when changes found. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat (limited to 'crates/mozart/src/commands/status.rs')
-rw-r--r--crates/mozart/src/commands/status.rs594
1 files changed, 591 insertions, 3 deletions
diff --git a/crates/mozart/src/commands/status.rs b/crates/mozart/src/commands/status.rs
index 2eed119..ae38621 100644
--- a/crates/mozart/src/commands/status.rs
+++ b/crates/mozart/src/commands/status.rs
@@ -1,8 +1,596 @@
use clap::Args;
+use sha1::{Digest, Sha1};
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
#[derive(Args)]
-pub struct StatusArgs {}
+pub struct StatusArgs {
+ /// Show a list of files for each modified package (implied by -v)
+ #[arg(short, long)]
+ pub verbose: bool,
+}
+
+// ─── Data structures ────────────────────────────────────────────────────────
+
+/// Information extracted from a package's dist field.
+struct DistInfo {
+ dist_type: String,
+ url: String,
+ shasum: Option<String>,
+}
+
+/// 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 {
+ name: String,
+ changes: Vec<FileChange>,
+}
+
+// ─── Main entry point ────────────────────────────────────────────────────────
+
+pub fn execute(args: &StatusArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ let working_dir = match &cli.working_dir {
+ Some(dir) => PathBuf::from(dir),
+ None => std::env::current_dir()?,
+ };
+
+ let vendor_dir = working_dir.join("vendor");
+ let installed = crate::installed::InstalledPackages::read(&vendor_dir)?;
+
+ if installed.packages.is_empty() {
+ println!("No packages installed.");
+ return Ok(());
+ }
+
+ let cache_config = crate::cache::build_cache_config(cli);
+ let files_cache = crate::cache::Cache::files(&cache_config);
+
+ let show_files = args.verbose || 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 {
+ eprintln!(" Skipping {} — no dist info available", pkg.name);
+ }
+ continue;
+ }
+ };
+
+ // Resolve install path
+ let install_path = resolve_install_path(pkg, &vendor_dir);
+ if !install_path.exists() {
+ if cli.verbose > 0 {
+ eprintln!(
+ " Skipping {} — install path does not exist: {}",
+ pkg.name,
+ install_path.display()
+ );
+ }
+ continue;
+ }
+
+ if cli.verbose > 0 {
+ eprintln!(" Checking {} ...", pkg.name);
+ }
+
+ // Download original archive to a temp dir
+ let tmp_dir = make_temp_dir(&pkg.name)?;
+ let downloaded = crate::downloader::download_dist(
+ &dist.url,
+ dist.shasum.as_deref(),
+ None,
+ Some(&files_cache),
+ );
+
+ let bytes = match downloaded {
+ Ok(b) => b,
+ Err(e) => {
+ eprintln!(" 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" => crate::downloader::extract_zip(&bytes, &tmp_dir),
+ "tar" | "tar.gz" | "tgz" => crate::downloader::extract_tar_gz(&bytes, &tmp_dir),
+ other => {
+ eprintln!(
+ " Warning: unsupported dist type '{}' for {}",
+ other, pkg.name
+ );
+ let _ = std::fs::remove_dir_all(&tmp_dir);
+ continue;
+ }
+ };
+
+ if let Err(e) = extract_result {
+ eprintln!(" 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 {
+ name: pkg.name.clone(),
+ changes,
+ });
+ }
+ }
+
+ if modified_packages.is_empty() {
+ println!("No local changes");
+ return Ok(());
+ }
+
+ println!("You have changes in the following dependencies:\n");
+
+ for pkg_status in &modified_packages {
+ // Show package name with indicator
+ println!("vendor/{} (M)", pkg_status.name);
+
+ if show_files {
+ let mut sorted_changes: Vec<&FileChange> = pkg_status.changes.iter().collect();
+ sorted_changes.sort_by_key(|c| c.path.as_str());
+
+ for change in sorted_changes {
+ let prefix = match change.kind {
+ ChangeKind::Modified => 'M',
+ ChangeKind::Added => '+',
+ ChangeKind::Removed => '-',
+ };
+ println!(" {} {}", prefix, change.path);
+ }
+ }
+ }
+
+ // Exit with code 1 if modifications found
+ std::process::exit(1);
+}
+
+// ─── Helpers ──────────────────────────────────────────────────────────────────
+
+/// Extract dist info from an installed package entry.
+fn extract_dist_info(pkg: &crate::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;
+ }
+
+ 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: &crate::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();
+ }
+ "." | "" => {}
+ p => components.push(p),
+ }
+ }
+ 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<HashMap<String, String>> {
+ let mut map = HashMap::new();
+ hash_dir_recursive(dir, dir, &mut map)?;
+ Ok(map)
+}
+
+fn hash_dir_recursive(
+ root: &Path,
+ current: &Path,
+ map: &mut HashMap<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
+ }
+
+ Ok(())
+}
+
+/// Compare two hash maps (original vs installed) and return a list of changes.
+fn compute_diff(
+ original: &HashMap<String, String>,
+ installed: &HashMap<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
+}
+
+// ─── Tests ───────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::fs;
+ use tempfile::tempdir;
+
+ // ── hash_directory ────────────────────────────────────────────────────────
+
+ #[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"]);
+ }
+
+ // ── compute_diff_no_changes ───────────────────────────────────────────────
+
+ #[test]
+ fn test_compute_diff_no_changes() {
+ let mut map: HashMap<String, String> = HashMap::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());
+ }
+
+ // ── compute_diff_modified ─────────────────────────────────────────────────
+
+ #[test]
+ fn test_compute_diff_modified() {
+ let mut original: HashMap<String, String> = HashMap::new();
+ original.insert("src/Foo.php".to_string(), "abc123".to_string());
+
+ let mut installed: HashMap<String, String> = HashMap::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");
+ }
+
+ // ── compute_diff_added ────────────────────────────────────────────────────
+
+ #[test]
+ fn test_compute_diff_added() {
+ let original: HashMap<String, String> = HashMap::new();
+
+ let mut installed: HashMap<String, String> = HashMap::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");
+ }
+
+ // ── compute_diff_removed ──────────────────────────────────────────────────
+
+ #[test]
+ fn test_compute_diff_removed() {
+ let mut original: HashMap<String, String> = HashMap::new();
+ original.insert("src/OldFile.php".to_string(), "112233".to_string());
+
+ let installed: HashMap<String, String> = HashMap::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");
+ }
+
+ // ── compute_diff_mixed ────────────────────────────────────────────────────
+
+ #[test]
+ fn test_compute_diff_mixed() {
+ let mut original: HashMap<String, String> = HashMap::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: HashMap<String, String> = HashMap::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");
+ }
+
+ // ── extract_dist_info ─────────────────────────────────────────────────────
+
+ #[test]
+ fn test_extract_dist_info_from_dist_field() {
+ use std::collections::BTreeMap;
+
+ let pkg = crate::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![],
+ 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 = crate::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![],
+ extra_fields: BTreeMap::new(),
+ };
+
+ assert!(extract_dist_info(&pkg).is_none());
+ }
+
+ #[test]
+ fn test_extract_dist_info_absent() {
+ use std::collections::BTreeMap;
+
+ let pkg = crate::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![],
+ extra_fields: BTreeMap::new(),
+ };
+
+ assert!(extract_dist_info(&pkg).is_none());
+ }
+
+ // ── resolve_install_path ──────────────────────────────────────────────────
+
+ #[test]
+ fn test_resolve_install_path_default() {
+ use std::collections::BTreeMap;
+
+ let pkg = crate::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![],
+ 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 = crate::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![],
+ extra_fields: BTreeMap::new(),
+ };
-pub fn execute(_args: &StatusArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+ let vendor = PathBuf::from("/project/vendor");
+ let path = resolve_install_path(&pkg, &vendor);
+ assert_eq!(path, PathBuf::from("/project/vendor/monolog/monolog"));
+ }
}