diff options
| -rw-r--r-- | Cargo.lock | 26 | ||||
| -rw-r--r-- | crates/mozart/Cargo.toml | 1 | ||||
| -rw-r--r-- | crates/mozart/src/archiver.rs | 1390 | ||||
| -rw-r--r-- | crates/mozart/src/commands/archive.rs | 298 | ||||
| -rw-r--r-- | crates/mozart/src/lib.rs | 1 |
5 files changed, 1714 insertions, 2 deletions
@@ -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; |
