aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2026-02-21 20:27:49 +0900
committernsfisis <nsfisis@gmail.com>2026-02-21 20:27:49 +0900
commit8949dfcab0bd81dd475db4cdfe9a3da43d33a5b7 (patch)
tree12ef4467b99babf40b2b4f4a671c34a1f07f3c52
parent28924141921856e12d77b5005e0d2567c8b17deb (diff)
downloadphp-mozart-8949dfcab0bd81dd475db4cdfe9a3da43d33a5b7.tar.gz
php-mozart-8949dfcab0bd81dd475db4cdfe9a3da43d33a5b7.tar.zst
php-mozart-8949dfcab0bd81dd475db4cdfe9a3da43d33a5b7.zip
feat(archive): implement command to create distributable archives
Add archive command supporting zip, tar, tar.gz, and tar.bz2 formats with .gitattributes export-ignore filtering, composer.json archive.exclude patterns, remote package archiving via Packagist, and self-exclusion to prevent archives from including themselves. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
-rw-r--r--Cargo.lock26
-rw-r--r--crates/mozart/Cargo.toml1
-rw-r--r--crates/mozart/src/archiver.rs1390
-rw-r--r--crates/mozart/src/commands/archive.rs298
-rw-r--r--crates/mozart/src/lib.rs1
5 files changed, 1714 insertions, 2 deletions
diff --git a/Cargo.lock b/Cargo.lock
index f132d9d..35236fe 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -150,6 +150,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
+name = "bzip2"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
+dependencies = [
+ "bzip2-sys",
+]
+
+[[package]]
+name = "bzip2-sys"
+version = "0.1.13+1.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
+dependencies = [
+ "cc",
+ "pkg-config",
+]
+
+[[package]]
name = "cc"
version = "1.2.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -958,6 +977,7 @@ name = "mozart"
version = "0.1.0"
dependencies = [
"anyhow",
+ "bzip2",
"clap",
"colored",
"dialoguer",
@@ -1036,6 +1056,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
name = "potential_utf"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
diff --git a/crates/mozart/Cargo.toml b/crates/mozart/Cargo.toml
index ced67ee..12da556 100644
--- a/crates/mozart/Cargo.toml
+++ b/crates/mozart/Cargo.toml
@@ -5,6 +5,7 @@ edition.workspace = true
[dependencies]
anyhow = "1.0.101"
+bzip2 = "0.5"
filetime = "0.2"
clap = { version = "4.5.57", features = ["derive"] }
colored = "3.1.1"
diff --git a/crates/mozart/src/archiver.rs b/crates/mozart/src/archiver.rs
new file mode 100644
index 0000000..2deb96f
--- /dev/null
+++ b/crates/mozart/src/archiver.rs
@@ -0,0 +1,1390 @@
+use anyhow::Context as _;
+use regex::Regex;
+use sha1::{Digest, Sha1};
+use std::fs;
+use std::io::Write as IoWrite;
+use std::path::{Path, PathBuf};
+
+// ─── Exclude filters ─────────────────────────────────────────────────────────
+
+/// A compiled exclude pattern derived from a gitignore-style rule.
+pub struct ExcludePattern {
+ regex: Regex,
+ /// If true, matching files are *re-included* (negation rule).
+ negate: bool,
+}
+
+/// Convert a glob pattern string to a regex string.
+///
+/// Mapping:
+/// - `**` → `.*` (matches any path segment sequence)
+/// - `*` → `[^/]*` (matches within a single path segment)
+/// - `?` → `[^/]` (matches a single non-separator char)
+/// - `[…]` → `[…]` (character class, passed through)
+/// - all other characters are regex-escaped
+fn glob_to_regex(glob: &str) -> String {
+ let mut result = String::new();
+ let chars: Vec<char> = glob.chars().collect();
+ let mut i = 0;
+ while i < chars.len() {
+ match chars[i] {
+ '*' if i + 1 < chars.len() && chars[i + 1] == '*' => {
+ result.push_str(".*");
+ i += 2;
+ }
+ '*' => {
+ result.push_str("[^/]*");
+ i += 1;
+ }
+ '?' => {
+ result.push_str("[^/]");
+ i += 1;
+ }
+ '[' => {
+ // Pass character classes through as-is until the closing `]`
+ result.push('[');
+ i += 1;
+ while i < chars.len() && chars[i] != ']' {
+ result.push(chars[i]);
+ i += 1;
+ }
+ if i < chars.len() {
+ result.push(']');
+ i += 1;
+ }
+ }
+ c => {
+ // Regex-escape special characters
+ if r"\.+^$|{}()?".contains(c) {
+ result.push('\\');
+ }
+ result.push(c);
+ i += 1;
+ }
+ }
+ }
+ result
+}
+
+/// Convert a single gitignore-style rule into an `ExcludePattern`.
+///
+/// Returns `None` if the rule is empty or a comment.
+pub fn parse_gitignore_pattern(rule: &str) -> Option<ExcludePattern> {
+ let rule = rule.trim();
+ if rule.is_empty() || rule.starts_with('#') {
+ return None;
+ }
+
+ // Leading `!` negates the pattern
+ let (negate, rule) = if let Some(rest) = rule.strip_prefix('!') {
+ (true, rest)
+ } else {
+ (false, rule)
+ };
+
+ // Strip trailing `/` before globbing
+ let rule = rule.trim_end_matches('/');
+ if rule.is_empty() {
+ return None;
+ }
+
+ // Determine anchor prefix:
+ // - leading `/` → anchored at root: `^/<glob_regex>`
+ // - no `/` inside pattern → matches anywhere: `/<glob_regex>`
+ // - `/` somewhere in middle → anchored at root: `^/<glob_regex>`
+ let (prefix, glob) = if let Some(without_leading_slash) = rule.strip_prefix('/') {
+ // Root-anchored
+ ("^/", without_leading_slash)
+ } else if rule.contains('/') {
+ // Slash in middle: treat as root-anchored
+ ("^/", rule)
+ } else {
+ // No slash: matches anywhere
+ ("/", rule)
+ };
+
+ let glob_regex = glob_to_regex(glob);
+ // The final regex: `<prefix><glob_regex>(/|$)`
+ // This matches the path component exactly (followed by a `/` or end-of-string).
+ let pattern = format!("{prefix}{glob_regex}(/|$)");
+ let regex = Regex::new(&pattern).ok()?;
+
+ Some(ExcludePattern { regex, negate })
+}
+
+/// Apply a chain of exclude patterns to a relative path (as a `/`-prefixed string).
+///
+/// Patterns are applied in order; later patterns override earlier ones.
+/// Returns `true` if the file is excluded by the final matching pattern
+/// (or by `initially_excluded` if no pattern matches).
+fn apply_filters(
+ path_with_slash: &str,
+ patterns: &[ExcludePattern],
+ initially_excluded: bool,
+) -> bool {
+ let mut excluded = initially_excluded;
+ for pat in patterns {
+ if pat.regex.is_match(path_with_slash) {
+ // A negate pattern re-includes; a normal pattern excludes
+ excluded = !pat.negate;
+ }
+ }
+ excluded
+}
+
+// ─── GitExcludeFilter ─────────────────────────────────────────────────────────
+
+/// Parse `.gitattributes` from the source directory.
+///
+/// Returns exclude patterns for lines containing `export-ignore` or
+/// `-export-ignore`.
+pub fn parse_gitattributes(source_dir: &Path) -> Vec<ExcludePattern> {
+ let path = source_dir.join(".gitattributes");
+ let content = match fs::read_to_string(&path) {
+ Ok(c) => c,
+ Err(_) => return vec![],
+ };
+
+ let mut patterns = Vec::new();
+ for line in content.lines() {
+ let line = line.trim();
+ if line.is_empty() || line.starts_with('#') {
+ continue;
+ }
+ let parts: Vec<&str> = line.split_whitespace().collect();
+ if parts.len() < 2 {
+ continue;
+ }
+ let file_pattern = parts[0];
+ // Check each attribute token for export-ignore / -export-ignore
+ for attr in &parts[1..] {
+ if *attr == "export-ignore" {
+ if let Some(p) = parse_gitignore_pattern(file_pattern) {
+ patterns.push(p);
+ }
+ } else if *attr == "-export-ignore" {
+ // Negation: re-include files that would otherwise be excluded
+ let negated = format!("!{}", file_pattern);
+ if let Some(p) = parse_gitignore_pattern(&negated) {
+ patterns.push(p);
+ }
+ }
+ }
+ }
+ patterns
+}
+
+// ─── ComposerExcludeFilter ────────────────────────────────────────────────────
+
+/// Convert `composer.json` `archive.exclude` rules into exclude patterns.
+pub fn parse_composer_excludes(excludes: &[String]) -> Vec<ExcludePattern> {
+ excludes
+ .iter()
+ .filter_map(|rule| parse_gitignore_pattern(rule))
+ .collect()
+}
+
+// ─── VCS directory names ──────────────────────────────────────────────────────
+
+const VCS_DIRS: &[&str] = &[".git", ".svn", ".hg", "CVS", ".bzr"];
+
+// ─── File collection ──────────────────────────────────────────────────────────
+
+/// Collect all archivable files from the source directory.
+///
+/// Returns paths relative to `source_dir`, sorted for deterministic output.
+/// Applies `exclude_patterns` to filter files. VCS directories are always
+/// skipped. Symlinks pointing outside `source_dir` are excluded.
+pub fn collect_archivable_files(
+ source_dir: &Path,
+ exclude_patterns: &[ExcludePattern],
+) -> anyhow::Result<Vec<PathBuf>> {
+ let source_dir = source_dir
+ .canonicalize()
+ .unwrap_or_else(|_| source_dir.to_path_buf());
+ let mut files = Vec::new();
+ collect_recursive(&source_dir, &source_dir, exclude_patterns, &mut files)?;
+ files.sort();
+ Ok(files)
+}
+
+fn collect_recursive(
+ source_dir: &Path,
+ current_dir: &Path,
+ exclude_patterns: &[ExcludePattern],
+ out: &mut Vec<PathBuf>,
+) -> anyhow::Result<()> {
+ let entries = fs::read_dir(current_dir)
+ .with_context(|| format!("Failed to read directory: {}", current_dir.display()))?;
+
+ let mut items: Vec<_> = entries.filter_map(|e| e.ok()).collect();
+ // Sort for determinism
+ items.sort_by_key(|e| e.file_name());
+
+ for entry in items {
+ let path = entry.path();
+ let file_name = entry.file_name();
+ let name_str = file_name.to_string_lossy();
+
+ // Skip VCS directories
+ if VCS_DIRS.contains(&name_str.as_ref()) {
+ continue;
+ }
+
+ // Compute the relative path (forward-slash, prefixed with `/` for filter matching)
+ let relative = path
+ .strip_prefix(source_dir)
+ .unwrap_or(&path)
+ .to_string_lossy()
+ .replace('\\', "/");
+ let path_with_slash = format!("/{}", relative);
+
+ // Check if this entry is excluded
+ if apply_filters(&path_with_slash, exclude_patterns, false) {
+ continue;
+ }
+
+ let metadata = match entry.metadata() {
+ Ok(m) => m,
+ Err(_) => continue,
+ };
+
+ if metadata.is_symlink() {
+ // Resolve the symlink; skip if it points outside source_dir
+ if let Ok(resolved) = fs::canonicalize(&path) {
+ if !resolved.starts_with(source_dir) {
+ continue;
+ }
+ out.push(PathBuf::from(&relative));
+ }
+ // If canonicalize fails, skip the symlink
+ } else if metadata.is_dir() {
+ // Collect children recursively
+ let mut children = Vec::new();
+ collect_recursive(source_dir, &path, exclude_patterns, &mut children)?;
+ if children.is_empty() {
+ // Include empty directory
+ out.push(PathBuf::from(&relative));
+ } else {
+ out.extend(children);
+ }
+ } else {
+ out.push(PathBuf::from(&relative));
+ }
+ }
+
+ Ok(())
+}
+
+// ─── Archive formats ──────────────────────────────────────────────────────────
+
+/// Supported archive formats.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ArchiveFormat {
+ Zip,
+ Tar,
+ TarGz,
+ TarBz2,
+}
+
+impl ArchiveFormat {
+ /// Parse a format string (case-insensitive). Returns `None` for unsupported formats.
+ pub fn parse(s: &str) -> Option<Self> {
+ match s.to_lowercase().as_str() {
+ "zip" => Some(Self::Zip),
+ "tar" => Some(Self::Tar),
+ "tar.gz" | "tgz" => Some(Self::TarGz),
+ "tar.bz2" => Some(Self::TarBz2),
+ _ => None,
+ }
+ }
+
+ /// File extension for this format.
+ pub fn extension(&self) -> &str {
+ match self {
+ Self::Zip => "zip",
+ Self::Tar => "tar",
+ Self::TarGz => "tar.gz",
+ Self::TarBz2 => "tar.bz2",
+ }
+ }
+}
+
+// ─── Archive creation ─────────────────────────────────────────────────────────
+
+/// Create an archive of the given files.
+///
+/// - `source_dir`: the root of the source tree
+/// - `files`: relative paths (as returned by `collect_archivable_files`)
+/// - `target`: full output path including extension
+/// - `format`: the archive format to create
+pub fn create_archive(
+ source_dir: &Path,
+ files: &[PathBuf],
+ target: &Path,
+ format: &ArchiveFormat,
+) -> anyhow::Result<()> {
+ match format {
+ ArchiveFormat::Zip => create_zip(source_dir, files, target),
+ ArchiveFormat::Tar => create_tar(source_dir, files, target),
+ ArchiveFormat::TarGz => create_tar_gz(source_dir, files, target),
+ ArchiveFormat::TarBz2 => create_tar_bz2(source_dir, files, target),
+ }
+}
+
+fn create_zip(source_dir: &Path, files: &[PathBuf], target: &Path) -> anyhow::Result<()> {
+ use zip::write::SimpleFileOptions;
+
+ let file = fs::File::create(target)
+ .with_context(|| format!("Failed to create archive: {}", target.display()))?;
+ let mut writer = zip::ZipWriter::new(file);
+
+ for rel in files {
+ let abs = source_dir.join(rel);
+ let rel_str = rel.to_string_lossy().replace('\\', "/");
+
+ if abs.is_dir() {
+ let opts = SimpleFileOptions::default();
+ writer.add_directory(&rel_str, opts)?;
+ } else {
+ let metadata = fs::metadata(&abs)?;
+
+ #[cfg(unix)]
+ let opts = {
+ use std::os::unix::fs::MetadataExt;
+ let mode = metadata.mode();
+ SimpleFileOptions::default()
+ .compression_method(zip::CompressionMethod::Deflated)
+ .unix_permissions(mode)
+ };
+
+ #[cfg(not(unix))]
+ let opts =
+ SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
+
+ let _ = metadata; // suppress unused warning on non-unix
+
+ writer.start_file(&rel_str, opts)?;
+ let content = fs::read(&abs)?;
+ writer.write_all(&content)?;
+ }
+ }
+
+ writer.finish()?;
+ Ok(())
+}
+
+fn create_tar(source_dir: &Path, files: &[PathBuf], target: &Path) -> anyhow::Result<()> {
+ let file = fs::File::create(target)
+ .with_context(|| format!("Failed to create archive: {}", target.display()))?;
+ let mut builder = tar::Builder::new(file);
+
+ for rel in files {
+ let abs = source_dir.join(rel);
+ if abs.is_dir() {
+ builder.append_dir(rel, &abs)?;
+ } else {
+ builder.append_path_with_name(&abs, rel)?;
+ }
+ }
+
+ builder.finish()?;
+ Ok(())
+}
+
+fn create_tar_gz(source_dir: &Path, files: &[PathBuf], target: &Path) -> anyhow::Result<()> {
+ let file = fs::File::create(target)
+ .with_context(|| format!("Failed to create archive: {}", target.display()))?;
+ let encoder = flate2::write::GzEncoder::new(file, flate2::Compression::default());
+ let mut builder = tar::Builder::new(encoder);
+
+ for rel in files {
+ let abs = source_dir.join(rel);
+ if abs.is_dir() {
+ builder.append_dir(rel, &abs)?;
+ } else {
+ builder.append_path_with_name(&abs, rel)?;
+ }
+ }
+
+ builder.into_inner()?.finish()?;
+ Ok(())
+}
+
+fn create_tar_bz2(source_dir: &Path, files: &[PathBuf], target: &Path) -> anyhow::Result<()> {
+ let file = fs::File::create(target)
+ .with_context(|| format!("Failed to create archive: {}", target.display()))?;
+ let encoder = bzip2::write::BzEncoder::new(file, bzip2::Compression::default());
+ let mut builder = tar::Builder::new(encoder);
+
+ for rel in files {
+ let abs = source_dir.join(rel);
+ if abs.is_dir() {
+ builder.append_dir(rel, &abs)?;
+ } else {
+ builder.append_path_with_name(&abs, rel)?;
+ }
+ }
+
+ builder.into_inner()?.finish()?;
+ Ok(())
+}
+
+// ─── Filename generation ──────────────────────────────────────────────────────
+
+/// Generate an archive filename (without extension) for a package.
+///
+/// Mirrors Composer's `ArchiveManager::getPackageFilenameParts()`.
+pub fn generate_archive_filename(
+ name: &str,
+ archive_name: Option<&str>,
+ version: Option<&str>,
+ dist_reference: Option<&str>,
+ dist_type: Option<&str>,
+ source_reference: Option<&str>,
+) -> String {
+ // Base: archive_name if set, otherwise replace non-alphanumeric chars with `-`
+ let base = if let Some(an) = archive_name {
+ an.to_string()
+ } else {
+ let re = Regex::new(r"[^a-zA-Z0-9_\-]").unwrap();
+ re.replace_all(name, "-").to_string()
+ };
+
+ let mut parts: Vec<String> = vec![base];
+
+ // Determine if dist_reference is a 40-char hex (SHA-1 commit hash)
+ let is_sha_dist_ref = dist_reference
+ .map(|r| r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit()))
+ .unwrap_or(false);
+
+ if is_sha_dist_ref {
+ // Append dist_reference and dist_type
+ if let Some(dr) = dist_reference {
+ parts.push(dr.to_string());
+ }
+ if let Some(dt) = dist_type {
+ parts.push(dt.to_string());
+ }
+ } else {
+ // Append version (if any), then dist_reference (if any)
+ if let Some(v) = version {
+ parts.push(v.to_string());
+ }
+ if let Some(dr) = dist_reference {
+ parts.push(dr.to_string());
+ }
+ }
+
+ // Append first 6 chars of SHA-1 of source_reference (if any)
+ if let Some(sr) = source_reference {
+ let mut hasher = Sha1::new();
+ hasher.update(sr.as_bytes());
+ let hash = format!("{:x}", hasher.finalize());
+ parts.push(hash[..6.min(hash.len())].to_string());
+ }
+
+ // Replace `/` with `-` in each part, then join
+ parts
+ .iter()
+ .map(|p| p.replace('/', "-"))
+ .collect::<Vec<_>>()
+ .join("-")
+}
+
+// ─── Self-exclusion patterns ──────────────────────────────────────────────────
+
+/// The set of archive extensions we support.
+const ARCHIVE_EXTENSIONS: &[&str] = &["zip", "tar", "tar.gz", "tar.bz2"];
+
+/// Generate patterns to exclude previous archives of this package from the archive.
+///
+/// If `has_extra_parts` is true (version/ref was appended), the pattern is
+/// `<base>-*.<ext>`. Otherwise it's `<base>.<ext>`.
+pub fn self_exclusion_patterns(base_name: &str, has_extra_parts: bool) -> Vec<String> {
+ ARCHIVE_EXTENSIONS
+ .iter()
+ .map(|ext| {
+ if has_extra_parts {
+ format!("/{}-*.{}", base_name, ext)
+ } else {
+ format!("/{}.{}", base_name, ext)
+ }
+ })
+ .collect()
+}
+
+// ─── Tests ────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tempfile::tempdir;
+
+ // ── glob_to_regex ─────────────────────────────────────────────────────────
+ // Note: glob_to_regex produces a *fragment* for use inside a larger pattern.
+ // We test it by embedding it in a full anchored regex.
+
+ fn full_pattern(glob: &str) -> Regex {
+ // Simulate the unanchored pattern: `/fragment(/|$)`
+ Regex::new(&format!("/{glob_re}(/|$)", glob_re = glob_to_regex(glob))).unwrap()
+ }
+
+ #[test]
+ fn test_glob_to_regex_star() {
+ let re = full_pattern("*.txt");
+ // Unanchored pattern: matches any .txt file at any depth
+ assert!(re.is_match("/foo.txt"));
+ // Also matches nested .txt files (unanchored `/` prefix)
+ assert!(re.is_match("/a/b.txt"));
+ // Does NOT match non-.txt files
+ assert!(!re.is_match("/foo.php"));
+ }
+
+ #[test]
+ fn test_glob_to_regex_double_star() {
+ // Double star matches across path separators
+ let frag = glob_to_regex("**/*.txt");
+ let re = Regex::new(&format!("/{frag}(/|$)")).unwrap();
+ assert!(re.is_match("/a/b/c.txt"));
+ }
+
+ #[test]
+ fn test_glob_to_regex_question() {
+ let frag = glob_to_regex("?.txt");
+ let re = Regex::new(&format!("/{frag}(/|$)")).unwrap();
+ assert!(re.is_match("/a.txt"));
+ assert!(!re.is_match("/ab.txt"));
+ }
+
+ #[test]
+ fn test_glob_to_regex_bracket() {
+ let frag = glob_to_regex("[abc].txt");
+ let re = Regex::new(&format!("/{frag}(/|$)")).unwrap();
+ assert!(re.is_match("/a.txt"));
+ assert!(re.is_match("/b.txt"));
+ assert!(!re.is_match("/d.txt"));
+ }
+
+ // ── parse_gitignore_pattern ───────────────────────────────────────────────
+
+ #[test]
+ fn test_parse_gitignore_simple() {
+ let pat = parse_gitignore_pattern("docs/").unwrap();
+ assert!(!pat.negate);
+ // "/docs" should match
+ assert!(pat.regex.is_match("/docs"));
+ }
+
+ #[test]
+ fn test_parse_gitignore_negated() {
+ let pat = parse_gitignore_pattern("!important.txt").unwrap();
+ assert!(pat.negate);
+ }
+
+ #[test]
+ fn test_parse_gitignore_rooted() {
+ let pat = parse_gitignore_pattern("/build").unwrap();
+ assert!(!pat.negate);
+ // Should match at root
+ assert!(pat.regex.is_match("/build"));
+ // Should NOT match in subdirectory (rooted pattern)
+ assert!(!pat.regex.is_match("/src/build"));
+ }
+
+ #[test]
+ fn test_parse_gitignore_unrooted() {
+ let pat = parse_gitignore_pattern("*.log").unwrap();
+ assert!(!pat.negate);
+ // Should match anywhere
+ assert!(pat.regex.is_match("/app.log"));
+ assert!(pat.regex.is_match("/sub/dir/foo.log"));
+ }
+
+ // ── parse_gitattributes ───────────────────────────────────────────────────
+
+ #[test]
+ fn test_parse_gitattributes_export_ignore() {
+ let dir = tempdir().unwrap();
+ fs::write(dir.path().join(".gitattributes"), "tests/ export-ignore\n").unwrap();
+ let patterns = parse_gitattributes(dir.path());
+ assert_eq!(patterns.len(), 1);
+ assert!(!patterns[0].negate);
+ assert!(patterns[0].regex.is_match("/tests"));
+ }
+
+ #[test]
+ fn test_parse_gitattributes_neg_export_ignore() {
+ let dir = tempdir().unwrap();
+ fs::write(dir.path().join(".gitattributes"), "tests/ -export-ignore\n").unwrap();
+ let patterns = parse_gitattributes(dir.path());
+ assert_eq!(patterns.len(), 1);
+ assert!(patterns[0].negate);
+ }
+
+ #[test]
+ fn test_parse_gitattributes_comment() {
+ let dir = tempdir().unwrap();
+ fs::write(
+ dir.path().join(".gitattributes"),
+ "# comment\ntests/ export-ignore\n",
+ )
+ .unwrap();
+ let patterns = parse_gitattributes(dir.path());
+ assert_eq!(patterns.len(), 1);
+ }
+
+ #[test]
+ fn test_parse_gitattributes_non_export() {
+ let dir = tempdir().unwrap();
+ fs::write(dir.path().join(".gitattributes"), "*.php text\n").unwrap();
+ let patterns = parse_gitattributes(dir.path());
+ assert!(patterns.is_empty());
+ }
+
+ #[test]
+ fn test_parse_gitattributes_missing_file() {
+ let dir = tempdir().unwrap();
+ let patterns = parse_gitattributes(dir.path());
+ assert!(patterns.is_empty());
+ }
+
+ // ── collect_archivable_files ──────────────────────────────────────────────
+
+ #[test]
+ fn test_collect_files_basic() {
+ let dir = tempdir().unwrap();
+ fs::write(dir.path().join("a.php"), b"<?php").unwrap();
+ fs::write(dir.path().join("b.php"), b"<?php").unwrap();
+ fs::create_dir(dir.path().join("src")).unwrap();
+ fs::write(dir.path().join("src").join("c.php"), b"<?php").unwrap();
+
+ let files = collect_archivable_files(dir.path(), &[]).unwrap();
+ let strs: Vec<String> = files
+ .iter()
+ .map(|p| p.to_string_lossy().to_string())
+ .collect();
+ assert!(strs.contains(&"a.php".to_string()));
+ assert!(strs.contains(&"b.php".to_string()));
+ assert!(strs.contains(&"src/c.php".to_string()));
+ }
+
+ #[test]
+ fn test_collect_files_excludes() {
+ let dir = tempdir().unwrap();
+ fs::write(dir.path().join("main.php"), b"<?php").unwrap();
+ fs::create_dir(dir.path().join("tests")).unwrap();
+ fs::write(dir.path().join("tests").join("test.php"), b"<?php").unwrap();
+
+ let patterns = vec![parse_gitignore_pattern("tests/").unwrap()];
+ let files = collect_archivable_files(dir.path(), &patterns).unwrap();
+ let strs: Vec<String> = files
+ .iter()
+ .map(|p| p.to_string_lossy().to_string())
+ .collect();
+ assert!(strs.contains(&"main.php".to_string()));
+ assert!(!strs.iter().any(|s| s.starts_with("tests")));
+ }
+
+ #[test]
+ fn test_collect_files_skips_vcs() {
+ let dir = tempdir().unwrap();
+ fs::write(dir.path().join("main.php"), b"<?php").unwrap();
+ fs::create_dir(dir.path().join(".git")).unwrap();
+ fs::write(
+ dir.path().join(".git").join("HEAD"),
+ b"ref: refs/heads/main",
+ )
+ .unwrap();
+
+ let files = collect_archivable_files(dir.path(), &[]).unwrap();
+ let strs: Vec<String> = files
+ .iter()
+ .map(|p| p.to_string_lossy().to_string())
+ .collect();
+ assert!(strs.contains(&"main.php".to_string()));
+ assert!(!strs.iter().any(|s| s.starts_with(".git")));
+ }
+
+ #[test]
+ fn test_collect_files_empty_dir() {
+ let dir = tempdir().unwrap();
+ fs::write(dir.path().join("main.php"), b"<?php").unwrap();
+ fs::create_dir(dir.path().join("empty_dir")).unwrap();
+
+ let files = collect_archivable_files(dir.path(), &[]).unwrap();
+ let strs: Vec<String> = files
+ .iter()
+ .map(|p| p.to_string_lossy().to_string())
+ .collect();
+ assert!(strs.contains(&"main.php".to_string()));
+ assert!(strs.contains(&"empty_dir".to_string()));
+ }
+
+ // ── create_archive ────────────────────────────────────────────────────────
+
+ fn make_source_tree(dir: &Path) {
+ fs::write(dir.join("main.php"), b"<?php echo 'hello';").unwrap();
+ fs::create_dir(dir.join("src")).unwrap();
+ fs::write(dir.join("src").join("Foo.php"), b"<?php class Foo {}").unwrap();
+ }
+
+ #[test]
+ fn test_create_zip_archive() {
+ let src = tempdir().unwrap();
+ make_source_tree(src.path());
+ let out = tempdir().unwrap();
+ let target = out.path().join("test.zip");
+
+ let files = collect_archivable_files(src.path(), &[]).unwrap();
+ create_archive(src.path(), &files, &target, &ArchiveFormat::Zip).unwrap();
+ assert!(target.exists());
+
+ // Verify contents
+ let zip_data = fs::read(&target).unwrap();
+ let cursor = std::io::Cursor::new(zip_data);
+ let mut archive = zip::ZipArchive::new(cursor).unwrap();
+ let names: Vec<String> = (0..archive.len())
+ .map(|i| archive.by_index(i).unwrap().name().to_string())
+ .collect();
+ assert!(names.contains(&"main.php".to_string()));
+ assert!(names.contains(&"src/Foo.php".to_string()));
+ }
+
+ #[test]
+ fn test_create_tar_archive() {
+ let src = tempdir().unwrap();
+ make_source_tree(src.path());
+ let out = tempdir().unwrap();
+ let target = out.path().join("test.tar");
+
+ let files = collect_archivable_files(src.path(), &[]).unwrap();
+ create_archive(src.path(), &files, &target, &ArchiveFormat::Tar).unwrap();
+ assert!(target.exists());
+
+ // Verify contents
+ let tar_data = fs::read(&target).unwrap();
+ let cursor = std::io::Cursor::new(tar_data);
+ let mut archive = tar::Archive::new(cursor);
+ let names: Vec<String> = archive
+ .entries()
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
+ .collect();
+ assert!(names.contains(&"main.php".to_string()));
+ assert!(names.contains(&"src/Foo.php".to_string()));
+ }
+
+ #[test]
+ fn test_create_tar_gz_archive() {
+ let src = tempdir().unwrap();
+ make_source_tree(src.path());
+ let out = tempdir().unwrap();
+ let target = out.path().join("test.tar.gz");
+
+ let files = collect_archivable_files(src.path(), &[]).unwrap();
+ create_archive(src.path(), &files, &target, &ArchiveFormat::TarGz).unwrap();
+ assert!(target.exists());
+
+ let gz_data = fs::read(&target).unwrap();
+ let cursor = std::io::Cursor::new(gz_data);
+ let decoder = flate2::read::GzDecoder::new(cursor);
+ let mut archive = tar::Archive::new(decoder);
+ let names: Vec<String> = archive
+ .entries()
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
+ .collect();
+ assert!(names.contains(&"main.php".to_string()));
+ }
+
+ #[test]
+ fn test_create_tar_bz2_archive() {
+ let src = tempdir().unwrap();
+ make_source_tree(src.path());
+ let out = tempdir().unwrap();
+ let target = out.path().join("test.tar.bz2");
+
+ let files = collect_archivable_files(src.path(), &[]).unwrap();
+ create_archive(src.path(), &files, &target, &ArchiveFormat::TarBz2).unwrap();
+ assert!(target.exists());
+
+ let bz_data = fs::read(&target).unwrap();
+ let cursor = std::io::Cursor::new(bz_data);
+ let decoder = bzip2::read::BzDecoder::new(cursor);
+ let mut archive = tar::Archive::new(decoder);
+ let names: Vec<String> = archive
+ .entries()
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
+ .collect();
+ assert!(names.contains(&"main.php".to_string()));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn test_zip_preserves_permissions() {
+ use std::os::unix::fs::PermissionsExt;
+
+ let src = tempdir().unwrap();
+ let script = src.path().join("run.sh");
+ fs::write(&script, b"#!/bin/sh\necho hello").unwrap();
+ fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap();
+
+ let out = tempdir().unwrap();
+ let target = out.path().join("test.zip");
+ let files = collect_archivable_files(src.path(), &[]).unwrap();
+ create_archive(src.path(), &files, &target, &ArchiveFormat::Zip).unwrap();
+
+ let zip_data = fs::read(&target).unwrap();
+ let cursor = std::io::Cursor::new(zip_data);
+ let mut archive = zip::ZipArchive::new(cursor).unwrap();
+ let entry = archive.by_name("run.sh").unwrap();
+ let mode = entry.unix_mode().unwrap_or(0);
+ // Lower 9 bits should be 0o755
+ assert_eq!(mode & 0o777, 0o755);
+ }
+
+ // ── generate_archive_filename ─────────────────────────────────────────────
+
+ #[test]
+ fn test_filename_simple_package() {
+ let name = generate_archive_filename("vendor/pkg", None, Some("1.2.3"), None, None, None);
+ assert_eq!(name, "vendor-pkg-1.2.3");
+ }
+
+ #[test]
+ fn test_filename_with_archive_name() {
+ let name = generate_archive_filename(
+ "vendor/pkg",
+ Some("my-package"),
+ Some("1.0.0"),
+ None,
+ None,
+ None,
+ );
+ assert_eq!(name, "my-package-1.0.0");
+ }
+
+ #[test]
+ fn test_filename_with_sha_dist_ref() {
+ let sha = "a".repeat(40);
+ let name = generate_archive_filename(
+ "vendor/pkg",
+ None,
+ Some("1.0.0"),
+ Some(&sha),
+ Some("zip"),
+ None,
+ );
+ // 40-char hex → append dist_ref and dist_type, not version
+ assert_eq!(name, format!("vendor-pkg-{}-zip", sha));
+ }
+
+ #[test]
+ fn test_filename_with_source_ref() {
+ let name = generate_archive_filename(
+ "vendor/pkg",
+ None,
+ Some("1.0.0"),
+ None,
+ None,
+ Some("abc123"),
+ );
+ // Appends first 6 chars of SHA-1 of "abc123"
+ let mut hasher = Sha1::new();
+ hasher.update(b"abc123");
+ let hash = format!("{:x}", hasher.finalize());
+ let expected = format!("vendor-pkg-1.0.0-{}", &hash[..6]);
+ assert_eq!(name, expected);
+ }
+
+ #[test]
+ fn test_filename_slashes_replaced() {
+ let name =
+ generate_archive_filename("vendor/my-pkg", None, Some("1.0/beta"), None, None, None);
+ assert_eq!(name, "vendor-my-pkg-1.0-beta");
+ }
+
+ // ── self_exclusion_patterns ───────────────────────────────────────────────
+
+ #[test]
+ fn test_self_exclusion_patterns_with_extra_parts() {
+ let patterns = self_exclusion_patterns("vendor-pkg", true);
+ assert!(patterns.contains(&"/vendor-pkg-*.zip".to_string()));
+ assert!(patterns.contains(&"/vendor-pkg-*.tar".to_string()));
+ assert!(patterns.contains(&"/vendor-pkg-*.tar.gz".to_string()));
+ assert!(patterns.contains(&"/vendor-pkg-*.tar.bz2".to_string()));
+ }
+
+ #[test]
+ fn test_self_exclusion_patterns_no_extra_parts() {
+ let patterns = self_exclusion_patterns("vendor-pkg", false);
+ assert!(patterns.contains(&"/vendor-pkg.zip".to_string()));
+ assert!(patterns.contains(&"/vendor-pkg.tar".to_string()));
+ }
+
+ // ── Integration tests ─────────────────────────────────────────────────────
+
+ #[test]
+ fn test_archive_root_package_tar() {
+ use crate::commands::Cli;
+ use crate::commands::archive::{ArchiveArgs, execute};
+
+ let src = tempdir().unwrap();
+ let out = tempdir().unwrap();
+
+ std::fs::write(
+ src.path().join("composer.json"),
+ r#"{"name": "test/project", "require": {}}"#,
+ )
+ .unwrap();
+ std::fs::write(src.path().join("main.php"), b"<?php").unwrap();
+
+ let args = ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("tar".to_string()),
+ dir: Some(out.path().to_string_lossy().to_string()),
+ file: Some("test-archive".to_string()),
+ ignore_filters: false,
+ };
+
+ let cli = Cli {
+ command: crate::commands::Commands::Archive(ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("tar".to_string()),
+ dir: Some(out.path().to_string_lossy().to_string()),
+ file: Some("test-archive".to_string()),
+ ignore_filters: false,
+ }),
+ verbose: 0,
+ profile: false,
+ no_plugins: false,
+ no_scripts: false,
+ working_dir: Some(src.path().to_string_lossy().to_string()),
+ no_cache: false,
+ no_interaction: false,
+ quiet: false,
+ ansi: false,
+ no_ansi: false,
+ };
+
+ execute(&args, &cli).unwrap();
+
+ let archive_path = out.path().join("test-archive.tar");
+ assert!(archive_path.exists(), "tar archive was not created");
+
+ // Verify contents
+ let tar_data = std::fs::read(&archive_path).unwrap();
+ let cursor = std::io::Cursor::new(tar_data);
+ let mut archive = tar::Archive::new(cursor);
+ let names: Vec<String> = archive
+ .entries()
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
+ .collect();
+ assert!(names.contains(&"main.php".to_string()));
+ assert!(names.contains(&"composer.json".to_string()));
+ }
+
+ #[test]
+ fn test_archive_root_package_zip() {
+ use crate::commands::Cli;
+ use crate::commands::archive::{ArchiveArgs, execute};
+
+ let src = tempdir().unwrap();
+ let out = tempdir().unwrap();
+
+ std::fs::write(
+ src.path().join("composer.json"),
+ r#"{"name": "test/project", "require": {}}"#,
+ )
+ .unwrap();
+ std::fs::write(src.path().join("main.php"), b"<?php").unwrap();
+
+ let args = ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("zip".to_string()),
+ dir: Some(out.path().to_string_lossy().to_string()),
+ file: Some("test-archive".to_string()),
+ ignore_filters: false,
+ };
+
+ let cli = Cli {
+ command: crate::commands::Commands::Archive(ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("zip".to_string()),
+ dir: Some(out.path().to_string_lossy().to_string()),
+ file: Some("test-archive".to_string()),
+ ignore_filters: false,
+ }),
+ verbose: 0,
+ profile: false,
+ no_plugins: false,
+ no_scripts: false,
+ working_dir: Some(src.path().to_string_lossy().to_string()),
+ no_cache: false,
+ no_interaction: false,
+ quiet: false,
+ ansi: false,
+ no_ansi: false,
+ };
+
+ execute(&args, &cli).unwrap();
+
+ let archive_path = out.path().join("test-archive.zip");
+ assert!(archive_path.exists(), "zip archive was not created");
+ }
+
+ #[test]
+ fn test_archive_custom_dir() {
+ use crate::commands::Cli;
+ use crate::commands::archive::{ArchiveArgs, execute};
+
+ let src = tempdir().unwrap();
+ let custom_out = tempdir().unwrap();
+
+ std::fs::write(
+ src.path().join("composer.json"),
+ r#"{"name": "test/project", "require": {}}"#,
+ )
+ .unwrap();
+
+ let args = ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("tar".to_string()),
+ dir: Some(custom_out.path().to_string_lossy().to_string()),
+ file: Some("custom".to_string()),
+ ignore_filters: false,
+ };
+
+ let cli = Cli {
+ command: crate::commands::Commands::Archive(ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("tar".to_string()),
+ dir: Some(custom_out.path().to_string_lossy().to_string()),
+ file: Some("custom".to_string()),
+ ignore_filters: false,
+ }),
+ verbose: 0,
+ profile: false,
+ no_plugins: false,
+ no_scripts: false,
+ working_dir: Some(src.path().to_string_lossy().to_string()),
+ no_cache: false,
+ no_interaction: false,
+ quiet: false,
+ ansi: false,
+ no_ansi: false,
+ };
+
+ execute(&args, &cli).unwrap();
+
+ assert!(custom_out.path().join("custom.tar").exists());
+ }
+
+ #[test]
+ fn test_archive_custom_filename() {
+ use crate::commands::Cli;
+ use crate::commands::archive::{ArchiveArgs, execute};
+
+ let src = tempdir().unwrap();
+ let out = tempdir().unwrap();
+
+ std::fs::write(
+ src.path().join("composer.json"),
+ r#"{"name": "test/project", "require": {}}"#,
+ )
+ .unwrap();
+
+ let args = ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("tar".to_string()),
+ dir: Some(out.path().to_string_lossy().to_string()),
+ file: Some("my-custom-name".to_string()),
+ ignore_filters: false,
+ };
+
+ let cli = Cli {
+ command: crate::commands::Commands::Archive(ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("tar".to_string()),
+ dir: Some(out.path().to_string_lossy().to_string()),
+ file: Some("my-custom-name".to_string()),
+ ignore_filters: false,
+ }),
+ verbose: 0,
+ profile: false,
+ no_plugins: false,
+ no_scripts: false,
+ working_dir: Some(src.path().to_string_lossy().to_string()),
+ no_cache: false,
+ no_interaction: false,
+ quiet: false,
+ ansi: false,
+ no_ansi: false,
+ };
+
+ execute(&args, &cli).unwrap();
+
+ assert!(out.path().join("my-custom-name.tar").exists());
+ }
+
+ #[test]
+ fn test_archive_gitattributes_filter() {
+ use crate::commands::Cli;
+ use crate::commands::archive::{ArchiveArgs, execute};
+
+ let src = tempdir().unwrap();
+ let out = tempdir().unwrap();
+
+ std::fs::write(
+ src.path().join("composer.json"),
+ r#"{"name": "test/project", "require": {}}"#,
+ )
+ .unwrap();
+ std::fs::write(src.path().join("main.php"), b"<?php").unwrap();
+ std::fs::create_dir(src.path().join("tests")).unwrap();
+ std::fs::write(src.path().join("tests").join("FooTest.php"), b"<?php").unwrap();
+ std::fs::write(src.path().join(".gitattributes"), "tests/ export-ignore\n").unwrap();
+
+ let args = ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("tar".to_string()),
+ dir: Some(out.path().to_string_lossy().to_string()),
+ file: Some("filtered".to_string()),
+ ignore_filters: false,
+ };
+
+ let cli = Cli {
+ command: crate::commands::Commands::Archive(ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("tar".to_string()),
+ dir: Some(out.path().to_string_lossy().to_string()),
+ file: Some("filtered".to_string()),
+ ignore_filters: false,
+ }),
+ verbose: 0,
+ profile: false,
+ no_plugins: false,
+ no_scripts: false,
+ working_dir: Some(src.path().to_string_lossy().to_string()),
+ no_cache: false,
+ no_interaction: false,
+ quiet: false,
+ ansi: false,
+ no_ansi: false,
+ };
+
+ execute(&args, &cli).unwrap();
+
+ let tar_path = out.path().join("filtered.tar");
+ assert!(tar_path.exists());
+
+ let tar_data = std::fs::read(&tar_path).unwrap();
+ let cursor = std::io::Cursor::new(tar_data);
+ let mut archive = tar::Archive::new(cursor);
+ let names: Vec<String> = archive
+ .entries()
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
+ .collect();
+ assert!(names.contains(&"main.php".to_string()));
+ assert!(!names.iter().any(|n| n.starts_with("tests")));
+ }
+
+ #[test]
+ fn test_archive_composer_excludes() {
+ use crate::commands::Cli;
+ use crate::commands::archive::{ArchiveArgs, execute};
+
+ let src = tempdir().unwrap();
+ let out = tempdir().unwrap();
+
+ std::fs::write(
+ src.path().join("composer.json"),
+ r#"{"name": "test/project", "require": {}, "archive": {"exclude": ["/docs"]}}"#,
+ )
+ .unwrap();
+ std::fs::write(src.path().join("main.php"), b"<?php").unwrap();
+ std::fs::create_dir(src.path().join("docs")).unwrap();
+ std::fs::write(src.path().join("docs").join("README.md"), b"# Docs").unwrap();
+
+ let args = ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("tar".to_string()),
+ dir: Some(out.path().to_string_lossy().to_string()),
+ file: Some("with-excludes".to_string()),
+ ignore_filters: false,
+ };
+
+ let cli = Cli {
+ command: crate::commands::Commands::Archive(ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("tar".to_string()),
+ dir: Some(out.path().to_string_lossy().to_string()),
+ file: Some("with-excludes".to_string()),
+ ignore_filters: false,
+ }),
+ verbose: 0,
+ profile: false,
+ no_plugins: false,
+ no_scripts: false,
+ working_dir: Some(src.path().to_string_lossy().to_string()),
+ no_cache: false,
+ no_interaction: false,
+ quiet: false,
+ ansi: false,
+ no_ansi: false,
+ };
+
+ execute(&args, &cli).unwrap();
+
+ let tar_path = out.path().join("with-excludes.tar");
+ assert!(tar_path.exists());
+
+ let tar_data = std::fs::read(&tar_path).unwrap();
+ let cursor = std::io::Cursor::new(tar_data);
+ let mut archive = tar::Archive::new(cursor);
+ let names: Vec<String> = archive
+ .entries()
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
+ .collect();
+ assert!(names.contains(&"main.php".to_string()));
+ assert!(!names.iter().any(|n| n.starts_with("docs")));
+ }
+
+ #[test]
+ fn test_archive_ignore_filters() {
+ use crate::commands::Cli;
+ use crate::commands::archive::{ArchiveArgs, execute};
+
+ let src = tempdir().unwrap();
+ let out = tempdir().unwrap();
+
+ std::fs::write(
+ src.path().join("composer.json"),
+ r#"{"name": "test/project", "require": {}}"#,
+ )
+ .unwrap();
+ std::fs::write(src.path().join("main.php"), b"<?php").unwrap();
+ std::fs::create_dir(src.path().join("tests")).unwrap();
+ std::fs::write(src.path().join("tests").join("FooTest.php"), b"<?php").unwrap();
+ std::fs::write(src.path().join(".gitattributes"), "tests/ export-ignore\n").unwrap();
+
+ let args = ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("tar".to_string()),
+ dir: Some(out.path().to_string_lossy().to_string()),
+ file: Some("unfiltered".to_string()),
+ ignore_filters: true, // All filters ignored
+ };
+
+ let cli = Cli {
+ command: crate::commands::Commands::Archive(ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("tar".to_string()),
+ dir: Some(out.path().to_string_lossy().to_string()),
+ file: Some("unfiltered".to_string()),
+ ignore_filters: true,
+ }),
+ verbose: 0,
+ profile: false,
+ no_plugins: false,
+ no_scripts: false,
+ working_dir: Some(src.path().to_string_lossy().to_string()),
+ no_cache: false,
+ no_interaction: false,
+ quiet: false,
+ ansi: false,
+ no_ansi: false,
+ };
+
+ execute(&args, &cli).unwrap();
+
+ let tar_path = out.path().join("unfiltered.tar");
+ assert!(tar_path.exists());
+
+ let tar_data = std::fs::read(&tar_path).unwrap();
+ let cursor = std::io::Cursor::new(tar_data);
+ let mut archive = tar::Archive::new(cursor);
+ let names: Vec<String> = archive
+ .entries()
+ .unwrap()
+ .filter_map(|e| e.ok())
+ .filter_map(|e| e.path().ok().map(|p| p.to_string_lossy().to_string()))
+ .collect();
+ // With --ignore-filters, tests/ should be included (VCS is still skipped)
+ assert!(names.iter().any(|n| n.starts_with("tests")));
+ }
+
+ #[test]
+ fn test_archive_invalid_format() {
+ use crate::commands::Cli;
+ use crate::commands::archive::{ArchiveArgs, execute};
+
+ let src = tempdir().unwrap();
+ let out = tempdir().unwrap();
+
+ std::fs::write(
+ src.path().join("composer.json"),
+ r#"{"name": "test/project", "require": {}}"#,
+ )
+ .unwrap();
+
+ let args = ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("rar".to_string()),
+ dir: Some(out.path().to_string_lossy().to_string()),
+ file: None,
+ ignore_filters: false,
+ };
+
+ let cli = Cli {
+ command: crate::commands::Commands::Archive(ArchiveArgs {
+ package: None,
+ version: None,
+ format: Some("rar".to_string()),
+ dir: Some(out.path().to_string_lossy().to_string()),
+ file: None,
+ ignore_filters: false,
+ }),
+ verbose: 0,
+ profile: false,
+ no_plugins: false,
+ no_scripts: false,
+ working_dir: Some(src.path().to_string_lossy().to_string()),
+ no_cache: false,
+ no_interaction: false,
+ quiet: false,
+ ansi: false,
+ no_ansi: false,
+ };
+
+ let result = execute(&args, &cli);
+ assert!(result.is_err());
+ assert!(result.unwrap_err().to_string().contains("rar"));
+ }
+}
diff --git a/crates/mozart/src/commands/archive.rs b/crates/mozart/src/commands/archive.rs
index f5e6f60..93a7558 100644
--- a/crates/mozart/src/commands/archive.rs
+++ b/crates/mozart/src/commands/archive.rs
@@ -1,4 +1,5 @@
use clap::Args;
+use std::path::PathBuf;
#[derive(Args)]
pub struct ArchiveArgs {
@@ -25,6 +26,299 @@ pub struct ArchiveArgs {
pub ignore_filters: bool,
}
-pub fn execute(_args: &ArchiveArgs, _cli: &super::Cli) -> anyhow::Result<()> {
- todo!()
+// ─── Archive config helpers ───────────────────────────────────────────────────
+
+/// Read `archive.name` and `archive.exclude` from a composer.json file.
+fn read_archive_config(
+ composer_json_path: &std::path::Path,
+) -> anyhow::Result<(Option<String>, Vec<String>)> {
+ let content = std::fs::read_to_string(composer_json_path)?;
+ let value: serde_json::Value = serde_json::from_str(&content)?;
+
+ let name = value
+ .get("archive")
+ .and_then(|a| a.get("name"))
+ .and_then(|n| n.as_str())
+ .map(|s| s.to_string());
+
+ let excludes = value
+ .get("archive")
+ .and_then(|a| a.get("exclude"))
+ .and_then(|e| e.as_array())
+ .map(|arr| {
+ arr.iter()
+ .filter_map(|v| v.as_str())
+ .map(|s| s.to_string())
+ .collect()
+ })
+ .unwrap_or_default();
+
+ Ok((name, excludes))
+}
+
+// ─── Metadata for a resolved package ─────────────────────────────────────────
+
+struct PackageMeta {
+ source_dir: PathBuf,
+ package_name: String,
+ archive_name: Option<String>,
+ archive_excludes: Vec<String>,
+ version: Option<String>,
+ dist_reference: Option<String>,
+ dist_type: Option<String>,
+ source_reference: Option<String>,
+ /// Holds an optional temp directory that must outlive `source_dir`.
+ _temp_dir: Option<PathBuf>,
+}
+
+impl Drop for PackageMeta {
+ fn drop(&mut self) {
+ // Clean up temporary directory used for remote packages
+ if let Some(ref dir) = self._temp_dir {
+ let _ = std::fs::remove_dir_all(dir);
+ }
+ }
+}
+
+// ─── Main entry point ─────────────────────────────────────────────────────────
+
+pub fn execute(args: &ArchiveArgs, cli: &super::Cli) -> anyhow::Result<()> {
+ use crate::archiver::{
+ ArchiveFormat, collect_archivable_files, create_archive, generate_archive_filename,
+ parse_composer_excludes, parse_gitattributes, parse_gitignore_pattern,
+ self_exclusion_patterns,
+ };
+
+ // 1. Determine working directory
+ let working_dir = match &cli.working_dir {
+ Some(dir) => PathBuf::from(dir),
+ None => std::env::current_dir()?,
+ };
+
+ // 2. Load config for format/dir defaults from composer.json's "config" section
+ let composer_json_path = working_dir.join("composer.json");
+ let (config_archive_format, config_archive_dir) = if composer_json_path.exists() {
+ let content = std::fs::read_to_string(&composer_json_path)?;
+ let value: serde_json::Value = serde_json::from_str(&content)?;
+ let fmt = value
+ .get("config")
+ .and_then(|c| c.get("archive-format"))
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+ let dir = value
+ .get("config")
+ .and_then(|c| c.get("archive-dir"))
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+ (fmt, dir)
+ } else {
+ (None, None)
+ };
+
+ // 3. Determine format: args -> config -> default "tar"
+ let format_str = args
+ .format
+ .as_deref()
+ .or(config_archive_format.as_deref())
+ .unwrap_or("tar");
+ let format = ArchiveFormat::parse(format_str).ok_or_else(|| {
+ anyhow::anyhow!(
+ "Unsupported archive format \"{}\". Supported formats: tar, tar.gz, tar.bz2, zip",
+ format_str
+ )
+ })?;
+
+ // 4. Determine output directory: args -> config -> default "."
+ let output_dir_str = args
+ .dir
+ .as_deref()
+ .or(config_archive_dir.as_deref())
+ .unwrap_or(".");
+ let output_dir = if std::path::Path::new(output_dir_str).is_absolute() {
+ PathBuf::from(output_dir_str)
+ } else {
+ working_dir.join(output_dir_str)
+ };
+ std::fs::create_dir_all(&output_dir)?;
+
+ // 5. Determine source directory and package metadata
+ let meta: PackageMeta = if let Some(ref pkg_name) = args.package {
+ // Remote package mode
+ resolve_remote_package(pkg_name, args.version.as_deref())?
+ } else {
+ // Root package mode
+ if !composer_json_path.exists() {
+ anyhow::bail!("No composer.json found in {}", working_dir.display());
+ }
+ let root = crate::package::read_from_file(&composer_json_path)?;
+ let (archive_name, archive_excludes) = read_archive_config(&composer_json_path)?;
+ let version = root
+ .extra_fields
+ .get("version")
+ .and_then(|v| v.as_str())
+ .map(|s| s.to_string());
+ PackageMeta {
+ source_dir: working_dir.clone(),
+ package_name: root.name.clone(),
+ archive_name,
+ archive_excludes,
+ version,
+ dist_reference: None,
+ dist_type: None,
+ source_reference: None,
+ _temp_dir: None,
+ }
+ };
+
+ // 6. Generate output filename
+ let filename_base = if let Some(ref f) = args.file {
+ f.clone()
+ } else {
+ generate_archive_filename(
+ &meta.package_name,
+ meta.archive_name.as_deref(),
+ meta.version.as_deref(),
+ meta.dist_reference.as_deref(),
+ meta.dist_type.as_deref(),
+ meta.source_reference.as_deref(),
+ )
+ };
+
+ // 7. Build exclude patterns
+ // Self-exclusion: prevent the archive from including itself
+ let has_extra_parts = args.file.is_none()
+ && (meta.version.is_some()
+ || meta.dist_reference.is_some()
+ || meta.source_reference.is_some());
+ let self_exclusion_strs = self_exclusion_patterns(&filename_base, has_extra_parts);
+
+ let mut all_patterns = Vec::new();
+
+ // Self-exclusion always applies
+ for rule in &self_exclusion_strs {
+ if let Some(p) = parse_gitignore_pattern(rule) {
+ all_patterns.push(p);
+ }
+ }
+
+ if !args.ignore_filters {
+ // Parse .gitattributes export-ignore rules
+ let git_patterns = parse_gitattributes(&meta.source_dir);
+ all_patterns.extend(git_patterns);
+
+ // Parse composer.json archive.exclude rules
+ let composer_patterns = parse_composer_excludes(&meta.archive_excludes);
+ all_patterns.extend(composer_patterns);
+ }
+
+ // 8. Collect files
+ let files = collect_archivable_files(&meta.source_dir, &all_patterns)?;
+
+ // 9. Create archive
+ let target_path = output_dir.join(format!("{}.{}", filename_base, format.extension()));
+ eprintln!("Creating the archive into \"{}\".", output_dir.display());
+ create_archive(&meta.source_dir, &files, &target_path, &format)?;
+
+ // Print relative path if possible
+ let display_path = if let Ok(rel) = target_path.strip_prefix(&working_dir) {
+ rel.display().to_string()
+ } else {
+ target_path.display().to_string()
+ };
+ println!("Created: {}", display_path);
+
+ Ok(())
+}
+
+// ─── Remote package resolution ────────────────────────────────────────────────
+
+fn resolve_remote_package(
+ package_name: &str,
+ version_constraint: Option<&str>,
+) -> anyhow::Result<PackageMeta> {
+ use crate::package::Stability;
+ use crate::version::find_best_candidate;
+
+ // Fetch versions from Packagist
+ let versions = crate::packagist::fetch_package_versions(package_name, None)?;
+ if versions.is_empty() {
+ anyhow::bail!("No versions found for package \"{}\"", package_name);
+ }
+
+ // Apply version constraint filtering if given
+ let candidate = if let Some(constraint) = version_constraint {
+ versions
+ .iter()
+ .find(|v| v.version == constraint || v.version_normalized.starts_with(constraint))
+ .ok_or_else(|| {
+ anyhow::anyhow!(
+ "Could not find version \"{}\" for package \"{}\"",
+ constraint,
+ package_name
+ )
+ })?
+ } else {
+ find_best_candidate(&versions, Stability::Stable)
+ .or_else(|| find_best_candidate(&versions, Stability::Dev))
+ .ok_or_else(|| {
+ anyhow::anyhow!("No suitable version found for package \"{}\"", package_name)
+ })?
+ };
+
+ let dist = candidate.dist.as_ref().ok_or_else(|| {
+ anyhow::anyhow!(
+ "Package \"{}\" version \"{}\" has no dist available",
+ package_name,
+ candidate.version
+ )
+ })?;
+
+ // Create a temp directory using std (not tempfile crate, which is dev-only)
+ let temp_base = std::env::temp_dir();
+ let unique = format!(
+ "mozart-archive-{}",
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_nanos())
+ .unwrap_or(0)
+ );
+ let temp_dir = temp_base.join(&unique);
+ std::fs::create_dir_all(&temp_dir)?;
+
+ let bytes = crate::downloader::download_dist(&dist.url, dist.shasum.as_deref(), None, None)?;
+
+ match dist.dist_type.as_str() {
+ "zip" => crate::downloader::extract_zip(&bytes, &temp_dir)?,
+ "tar" | "tar.gz" | "tgz" => crate::downloader::extract_tar_gz(&bytes, &temp_dir)?,
+ other => {
+ let _ = std::fs::remove_dir_all(&temp_dir);
+ anyhow::bail!("Unsupported dist type: {}", other);
+ }
+ }
+
+ // Try to read composer.json from the extracted source for archive.name / archive.exclude
+ let extracted_composer = temp_dir.join("composer.json");
+ let (archive_name, archive_excludes) = if extracted_composer.exists() {
+ read_archive_config(&extracted_composer).unwrap_or((None, vec![]))
+ } else {
+ (None, vec![])
+ };
+
+ let version: Option<String> = Some(candidate.version.clone());
+ let dist_reference: Option<String> = dist.reference.clone();
+ let dist_type: Option<String> = Some(dist.dist_type.clone());
+ let source_reference: Option<String> =
+ candidate.source.as_ref().and_then(|s| s.reference.clone());
+
+ Ok(PackageMeta {
+ source_dir: temp_dir.clone(),
+ package_name: package_name.to_string(),
+ archive_name,
+ archive_excludes,
+ version,
+ dist_reference,
+ dist_type,
+ source_reference,
+ _temp_dir: Some(temp_dir),
+ })
}
diff --git a/crates/mozart/src/lib.rs b/crates/mozart/src/lib.rs
index 2451a4a..d28ff9c 100644
--- a/crates/mozart/src/lib.rs
+++ b/crates/mozart/src/lib.rs
@@ -1,3 +1,4 @@
+pub mod archiver;
pub mod autoload;
pub mod cache;
pub mod commands;