use anyhow::{Context, Result};
use globset::{Glob, GlobSet, GlobSetBuilder};
use std::io::Write as _;
use std::path::Path;
pub const BUILD_SOURCE_UPLOAD_FIELD: &str = "source";
pub const BUILD_SOURCE_UPLOAD_CONTENT_TYPE: &str = "application/zip";
pub const MAX_BUILD_SOURCE_UPLOAD_BYTES: u64 = 1024 * 1024 * 1024;
pub const DEFAULT_EXCLUDED_DIRS: &[&str] = &["models/", "docker/", "logs/"];
pub const IGNORE_FILE_NAME: &str = ".mecha10ignore";
pub fn build_ignore_set(project_root: &Path) -> Result<GlobSet> {
let mut patterns: Vec<String> = DEFAULT_EXCLUDED_DIRS.iter().map(|s| s.to_string()).collect();
patterns.extend(read_ignore_file_patterns(project_root)?);
compile_ignore_patterns(&patterns)
}
fn read_ignore_file_patterns(project_root: &Path) -> Result<Vec<String>> {
let ignore_path = project_root.join(IGNORE_FILE_NAME);
if !ignore_path.exists() {
return Ok(Vec::new());
}
let content =
std::fs::read_to_string(&ignore_path).with_context(|| format!("Failed to read {}", ignore_path.display()))?;
Ok(parse_ignore_file_content(&content))
}
fn parse_ignore_file_content(content: &str) -> Vec<String> {
content
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(str::to_string)
.collect()
}
fn compile_ignore_patterns(patterns: &[String]) -> Result<GlobSet> {
let mut builder = GlobSetBuilder::new();
for pattern in patterns {
for expanded in expand_gitignore_pattern(pattern) {
let glob = Glob::new(&expanded).with_context(|| format!("Invalid .mecha10ignore pattern: {pattern}"))?;
builder.add(glob);
}
}
builder.build().context("Failed to build .mecha10ignore glob set")
}
fn expand_gitignore_pattern(pattern: &str) -> Vec<String> {
let rooted = pattern.starts_with('/');
let trimmed = pattern.trim_start_matches('/').trim_end_matches('/');
let anchored = if rooted {
trimmed.to_string()
} else {
format!("**/{trimmed}")
};
vec![anchored.clone(), format!("{anchored}/**")]
}
fn normalize_relative_path(path: &Path) -> String {
path.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("/")
}
pub fn write_project_zip<W: std::io::Write + std::io::Seek>(
project_root: &Path,
ignore_set: &GlobSet,
writer: W,
) -> Result<W> {
let options = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
let mut zip = zip::ZipWriter::new(writer);
let walker = walkdir::WalkDir::new(project_root).into_iter().filter_entry(|entry| {
let relative = entry.path().strip_prefix(project_root).unwrap_or(entry.path());
if relative.as_os_str().is_empty() {
return true;
}
!ignore_set.is_match(normalize_relative_path(relative))
});
for entry in walker {
let entry = entry.context("Failed to walk project directory")?;
let path = entry.path();
let relative = path.strip_prefix(project_root).unwrap_or(path);
if relative.as_os_str().is_empty() {
continue;
}
let relative_str = normalize_relative_path(relative);
if entry.file_type().is_dir() {
zip.add_directory(format!("{relative_str}/"), options)
.with_context(|| format!("Failed to add directory {relative_str} to zip"))?;
} else if entry.file_type().is_file() {
let bytes = std::fs::read(path).with_context(|| format!("Failed to read {}", path.display()))?;
zip.start_file(relative_str.clone(), options)
.with_context(|| format!("Failed to add file {relative_str} to zip"))?;
zip.write_all(&bytes)
.with_context(|| format!("Failed to write {relative_str} into zip"))?;
}
}
zip.finish().context("Failed to finalize zip archive")
}
pub fn check_zip_size(zip_size_bytes: u64) -> Result<()> {
if zip_size_bytes > MAX_BUILD_SOURCE_UPLOAD_BYTES {
anyhow::bail!(
"Project source zip is too large to upload: {} bytes (limit is {} bytes / 1 GiB).\n\
Check your {} file - large directories like build artifacts, datasets, or logs \
should usually be excluded.",
zip_size_bytes,
MAX_BUILD_SOURCE_UPLOAD_BYTES,
IGNORE_FILE_NAME
);
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
use tempfile::tempdir;
#[test]
fn default_excluded_dirs_matches_the_server_contract() {
assert_eq!(DEFAULT_EXCLUDED_DIRS, &["models/", "docker/", "logs/"]);
}
#[test]
fn max_build_source_upload_bytes_is_exactly_one_gibibyte() {
assert_eq!(MAX_BUILD_SOURCE_UPLOAD_BYTES, 1024 * 1024 * 1024);
}
#[test]
fn build_source_upload_field_and_content_type_match_the_server_contract() {
assert_eq!(BUILD_SOURCE_UPLOAD_FIELD, "source");
assert_eq!(BUILD_SOURCE_UPLOAD_CONTENT_TYPE, "application/zip");
}
#[test]
fn parse_ignore_file_content_skips_blank_and_comment_lines() {
let content = "# a comment\n\ntarget/\n *.log \n# another\nsecrets.json\n";
assert_eq!(
parse_ignore_file_content(content),
vec!["target/".to_string(), "*.log".to_string(), "secrets.json".to_string()]
);
}
#[test]
fn read_ignore_file_patterns_is_empty_when_file_missing() {
let dir = tempdir().unwrap();
assert!(read_ignore_file_patterns(dir.path()).unwrap().is_empty());
}
#[test]
fn read_ignore_file_patterns_reads_existing_file() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join(".mecha10ignore"), "target/\n*.secret\n").unwrap();
assert_eq!(
read_ignore_file_patterns(dir.path()).unwrap(),
vec!["target/".to_string(), "*.secret".to_string()]
);
}
#[test]
fn expand_gitignore_pattern_matches_nested_directory_at_any_depth_when_unrooted() {
let globs = expand_gitignore_pattern("models/");
let set = compile_ignore_patterns(&["models/".to_string()]).unwrap();
assert!(!globs.is_empty());
assert!(set.is_match("models"));
assert!(set.is_match("models/weights.bin"));
assert!(set.is_match("nested/models/weights.bin"));
assert!(!set.is_match("my-models/weights.bin"));
}
#[test]
fn expand_gitignore_pattern_respects_a_rooted_leading_slash() {
let set = compile_ignore_patterns(&["/configs".to_string()]).unwrap();
assert!(set.is_match("configs/one.json"));
assert!(!set.is_match("nested/configs/one.json"));
}
#[test]
fn build_ignore_set_applies_defaults_even_without_an_ignore_file() {
let dir = tempdir().unwrap();
let set = build_ignore_set(dir.path()).unwrap();
assert!(set.is_match("models/weights.bin"));
assert!(set.is_match("docker/Dockerfile"));
assert!(set.is_match("logs/latest.log"));
assert!(!set.is_match("src/main.rs"));
}
#[test]
fn build_ignore_set_includes_user_authored_patterns() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join(".mecha10ignore"), "*.secret\n").unwrap();
let set = build_ignore_set(dir.path()).unwrap();
assert!(set.is_match("api.secret"));
assert!(set.is_match("nested/api.secret"));
assert!(!set.is_match("src/main.rs"));
}
#[test]
fn check_zip_size_accepts_a_zip_under_the_cap() {
assert!(check_zip_size(1024).is_ok());
}
#[test]
fn check_zip_size_rejects_a_zip_over_the_cap() {
let err = check_zip_size(MAX_BUILD_SOURCE_UPLOAD_BYTES + 1).unwrap_err();
assert!(err.to_string().contains("too large"));
assert!(err.to_string().contains(IGNORE_FILE_NAME));
}
#[test]
fn write_project_zip_excludes_default_dirs_and_includes_the_rest() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::write(dir.path().join("src/main.rs"), "fn main() {}").unwrap();
std::fs::create_dir_all(dir.path().join("models")).unwrap();
std::fs::write(dir.path().join("models/weights.bin"), b"big").unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
let ignore_set = build_ignore_set(dir.path()).unwrap();
let out_path = dir.path().join("out.zip");
let file = std::fs::File::create(&out_path).unwrap();
write_project_zip(dir.path(), &ignore_set, file).unwrap();
let mut archive = zip::ZipArchive::new(std::fs::File::open(&out_path).unwrap()).unwrap();
let names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
assert!(names.iter().any(|n| n == "src/main.rs"));
assert!(names.iter().any(|n| n == "Cargo.toml"));
assert!(!names.iter().any(|n| n.starts_with("models")));
}
#[test]
fn write_project_zip_respects_custom_mecha10ignore_patterns() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join(".mecha10ignore"), "scratch/\n").unwrap();
std::fs::create_dir_all(dir.path().join("scratch")).unwrap();
std::fs::write(dir.path().join("scratch/notes.txt"), "wip").unwrap();
std::fs::write(dir.path().join("keep.txt"), "keep me").unwrap();
let ignore_set = build_ignore_set(dir.path()).unwrap();
let out_path = dir.path().join("out.zip");
let file = std::fs::File::create(&out_path).unwrap();
write_project_zip(dir.path(), &ignore_set, file).unwrap();
let mut archive = zip::ZipArchive::new(std::fs::File::open(&out_path).unwrap()).unwrap();
let names: Vec<String> = (0..archive.len())
.map(|i| archive.by_index(i).unwrap().name().to_string())
.collect();
assert!(names.iter().any(|n| n == "keep.txt"));
assert!(!names.iter().any(|n| n.starts_with("scratch")));
}
#[test]
fn write_project_zip_round_trips_file_contents() {
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("hello.txt"), "hello world").unwrap();
let ignore_set = build_ignore_set(dir.path()).unwrap();
let out_path = dir.path().join("out.zip");
let file = std::fs::File::create(&out_path).unwrap();
write_project_zip(dir.path(), &ignore_set, file).unwrap();
let mut archive = zip::ZipArchive::new(std::fs::File::open(&out_path).unwrap()).unwrap();
let mut entry = archive.by_name("hello.txt").unwrap();
let mut contents = String::new();
entry.read_to_string(&mut contents).unwrap();
assert_eq!(contents, "hello world");
}
}