mecha10-cli 4.2.14

Mecha10 CLI tool
Documentation
//! `.mecha10ignore`-filtered project source zip construction for the CLI's remote/dashboard
//! build upload flow (LAB-5442, part of the "build via mecha10 dashboard" feature, LAB-2153).
//!
//! Mirrors the CLI<->API contract pinned by mecha10-api's `jobs::build_jobs` module
//! (`services/api/src/jobs/build_jobs.rs`, LAB-5467): the multipart field name/content-type
//! the zip must be sent under, the server's hard size cap, and the directories excluded by
//! default even with no `.mecha10ignore` file present. Those constants are duplicated here
//! (not shared via a crate, since mecha10-api is a separate repo) - both sides' unit tests
//! pin the exact same values so a future drift fails CI on whichever side changes first.

use anyhow::{Context, Result};
use globset::{Glob, GlobSet, GlobSetBuilder};
use std::io::Write as _;
use std::path::Path;

/// Multipart field name the zip must be sent under - matches mecha10-api's
/// `BUILD_SOURCE_UPLOAD_FIELD`.
pub const BUILD_SOURCE_UPLOAD_FIELD: &str = "source";

/// Content-type the `source` part must be sent as - matches mecha10-api's
/// `BUILD_SOURCE_UPLOAD_CONTENT_TYPE`.
pub const BUILD_SOURCE_UPLOAD_CONTENT_TYPE: &str = "application/zip";

/// Hard cap on the constructed zip - matches mecha10-api's `MAX_BUILD_SOURCE_UPLOAD_BYTES`
/// (1 GiB). Enforced here *in addition to* the server's own enforcement - LAB-5440 decision:
/// fail fast client-side for quick feedback, since the server can't be relied on as the only
/// gate (the upload itself would otherwise have to complete first).
pub const MAX_BUILD_SOURCE_UPLOAD_BYTES: u64 = 1024 * 1024 * 1024;

/// Directories auto-excluded from the uploaded source archive even if no `.mecha10ignore`
/// file exists - matches mecha10-api's `DEFAULT_BUILD_SOURCE_EXCLUDED_DIRS`.
pub const DEFAULT_EXCLUDED_DIRS: &[&str] = &["models/", "docker/", "logs/"];

/// `.gitignore`-style ignore file read from the project root (LAB-5440 decision #2).
pub const IGNORE_FILE_NAME: &str = ".mecha10ignore";

/// Build the glob set a project's source zip should exclude: the default excluded
/// directories, plus any additional patterns in `<project_root>/.mecha10ignore` (one
/// pattern per line; blank lines and `#`-prefixed comment lines are skipped). Applied even
/// when no `.mecha10ignore` file exists at all.
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)
}

/// Read the additional user-authored patterns out of `<project_root>/.mecha10ignore`, or an
/// empty list if the file doesn't exist. Split out from [`build_ignore_set`] so the pure
/// parsing step (comment/blank-line handling) is unit-testable without a glob engine.
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))
}

/// Pure parse of `.mecha10ignore` file content into pattern lines - skips blank lines and
/// `#`-prefixed comments, same convention as `.gitignore`.
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()
}

/// Compile a list of `.gitignore`-style pattern lines into a [`GlobSet`] via `globset`
/// (already a workspace dependency - see `packages/core/src/aggregated.rs`), rather than
/// hand-rolling glob matching. Each pattern expands to two globs: an anchored form (relative
/// to the project root unless the pattern starts with `/`) and a `/**`-suffixed form so a
/// directory pattern also matches everything underneath it - approximating `.gitignore`
/// semantics closely enough for v0 (no negation (`!pattern`) support).
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")
}

/// Expand one `.gitignore`-style pattern line into the globset patterns that approximate it.
/// A leading `/` anchors the pattern to the project root; otherwise it matches at any depth
/// (`**/<pattern>`). Trailing `/` is stripped before expansion since it is only meaningful to
/// real `.gitignore` as a "directory only" marker - treating it identically to a file pattern
/// here is a deliberate v0 simplification.
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}/**")]
}

/// `/`-separated, platform-independent form of a path relative to the project root, suitable
/// for matching against the glob set built by [`build_ignore_set`] and for zip entry names
/// (zip entries are always `/`-separated regardless of host OS).
fn normalize_relative_path(path: &Path) -> String {
    path.components()
        .map(|c| c.as_os_str().to_string_lossy().into_owned())
        .collect::<Vec<_>>()
        .join("/")
}

/// Walk `project_root`, skipping anything matched by `ignore_set`, and write every remaining
/// file into a zip archive via `writer`. Returns the same writer (so callers can inspect the
/// resulting file size without a second open) once the archive is finalized.
///
/// Directories are walked (not skipped outright) even when a file inside them survives the
/// ignore set but the directory entry itself happens to match some unrelated pattern; what
/// actually gets excluded is governed entirely by `ignore_set` against each entry's relative
/// path, not by any additional hardcoded logic here.
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")
}

/// Fail fast (before ever attempting the upload) if `zip_size_bytes` exceeds
/// [`MAX_BUILD_SOURCE_UPLOAD_BYTES`], per LAB-5440 decision #1.
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");
    }
}