mecha10-deploy 0.6.2

Mecha10 deploy services — Docker management, binary distribution, remote deployment
Documentation
//! Collection functions for package service
//!
//! Functions for collecting binaries, configurations, and assets from the project.

use super::types::{AssetInfo, BinaryInfo, ConfigInfo, PackageConfig};
use super::utils;
use anyhow::Result;
use std::fs;
use std::path::{Path, PathBuf};

/// Collect built binaries from target directory
pub(super) fn collect_binaries(project_root: &Path, config: &PackageConfig) -> Result<Vec<BinaryInfo>> {
    let target_dir = project_root
        .join("target")
        .join(config.target_arch.as_str())
        .join(&config.build_profile);

    if !target_dir.exists() {
        return Err(anyhow::anyhow!(
            "Target directory not found: {}. Did the build succeed?",
            target_dir.display()
        ));
    }

    let mut binaries = Vec::new();

    for entry in fs::read_dir(&target_dir)? {
        let entry = entry?;
        let path = entry.path();

        if path.is_file() && utils::is_executable(&path)? {
            if let Some(filename) = path.file_name() {
                let filename_str = filename.to_string_lossy();

                // Skip build artifacts
                if filename_str.starts_with('.') || filename_str.contains(".d") || filename_str.ends_with(".rlib") {
                    continue;
                }

                let metadata = path.metadata()?;
                let checksum = utils::calculate_checksum(&path)?;

                binaries.push(BinaryInfo {
                    name: filename_str.to_string(),
                    path: format!("bin/{}", filename_str),
                    size_bytes: metadata.len(),
                    checksum,
                });
            }
        }
    }

    if binaries.is_empty() {
        return Err(anyhow::anyhow!("No binaries found in target directory"));
    }

    Ok(binaries)
}

/// Collect configuration files from project
///
/// Both `config/` and `configs/` are recognized as source roots. Each file's recorded
/// manifest path is prefixed with the actual root directory it was found under (not a
/// hardcoded literal), so files sourced from `configs/` are correctly labeled as such.
pub(super) fn collect_configs(project_root: &Path) -> Result<Vec<ConfigInfo>> {
    let files = collect_files_from_roots(project_root, &["config", "configs"], |path| {
        let extension = path.extension().and_then(|e| e.to_str());
        matches!(extension, Some("json") | Some("yaml") | Some("yml") | Some("toml"))
    })?;

    files
        .into_iter()
        .map(|(manifest_path, path)| {
            let config_type = utils::determine_config_type(&path);
            let checksum = utils::calculate_checksum(&path)?;

            Ok(ConfigInfo {
                name: path.file_name().unwrap().to_string_lossy().to_string(),
                path: manifest_path,
                config_type,
                checksum,
            })
        })
        .collect()
}

/// Collect asset files from project
///
/// `assets/`, `models/`, `data/`, and `calibration/` are all recognized as source roots.
/// Each file's recorded manifest path is prefixed with the actual root directory it was
/// found under (not a hardcoded literal), so two files with the same relative name under
/// different roots (e.g. `models/foo.bin` and `data/foo.bin`) don't collide in the manifest.
pub(super) fn collect_assets(project_root: &Path) -> Result<Vec<AssetInfo>> {
    let files = collect_files_from_roots(project_root, &["assets", "models", "data", "calibration"], |_| true)?;

    files
        .into_iter()
        .map(|(manifest_path, path)| {
            let asset_type = utils::determine_asset_type(&path);
            let metadata = path.metadata()?;
            let checksum = utils::calculate_checksum(&path)?;

            Ok(AssetInfo {
                name: path.file_name().unwrap().to_string_lossy().to_string(),
                path: manifest_path,
                asset_type,
                size_bytes: metadata.len(),
                checksum,
            })
        })
        .collect()
}

/// Walk multiple candidate root directories under `project_root`, collecting every file
/// matching `matches`.
///
/// Returns `(manifest_path, filesystem_path)` pairs. `manifest_path` is prefixed with the
/// name of the real root directory the file was found under (e.g. `"models"`, `"data"`),
/// rather than a single hardcoded prefix — this is what keeps files with identical relative
/// paths under different roots from colliding in the manifest.
fn collect_files_from_roots(
    project_root: &Path,
    roots: &[&str],
    matches: impl Fn(&Path) -> bool,
) -> Result<Vec<(String, PathBuf)>> {
    let mut results = Vec::new();

    for root_name in roots {
        let root_dir = project_root.join(root_name);
        if root_dir.exists() {
            collect_files_recursive(&root_dir, &root_dir, root_name, &matches, &mut results)?;
        }
    }

    Ok(results)
}

/// Recursively walk `current_dir` (rooted at `base_dir`, under the named `root_name`
/// source directory), collecting every file matching `matches`.
fn collect_files_recursive(
    base_dir: &Path,
    current_dir: &Path,
    root_name: &str,
    matches: &impl Fn(&Path) -> bool,
    results: &mut Vec<(String, PathBuf)>,
) -> Result<()> {
    for entry in fs::read_dir(current_dir)? {
        let entry = entry?;
        let path = entry.path();

        if path.is_dir() {
            collect_files_recursive(base_dir, &path, root_name, matches, results)?;
        } else if path.is_file() && matches(&path) {
            let relative_path = path.strip_prefix(base_dir)?;
            results.push((format!("{}/{}", root_name, relative_path.display()), path));
        }
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::fs;

    /// Regression test for the manifest-path collision bug: files with the same relative
    /// name under two different asset roots (`models/` and `data/`) used to both get
    /// recorded under a single hardcoded `assets/` prefix, silently colliding in the
    /// manifest. Each file's manifest path must now reflect its real source root.
    #[test]
    fn collect_assets_preserves_distinct_root_in_manifest_path() {
        let project_root = tempfile::tempdir().unwrap();

        let models_dir = project_root.path().join("models");
        let data_dir = project_root.path().join("data");
        fs::create_dir_all(&models_dir).unwrap();
        fs::create_dir_all(&data_dir).unwrap();

        fs::write(models_dir.join("foo.bin"), b"model bytes").unwrap();
        fs::write(data_dir.join("foo.bin"), b"data bytes").unwrap();

        let assets = collect_assets(project_root.path()).unwrap();

        let paths: Vec<&str> = assets.iter().map(|a| a.path.as_str()).collect();

        assert_eq!(assets.len(), 2, "expected one asset per source root, got: {:?}", paths);
        assert!(
            paths.contains(&"models/foo.bin"),
            "expected models/foo.bin in manifest paths, got: {:?}",
            paths
        );
        assert!(
            paths.contains(&"data/foo.bin"),
            "expected data/foo.bin in manifest paths, got: {:?}",
            paths
        );
        // The old hardcoded-prefix behavior collapsed both into "assets/foo.bin".
        assert!(
            !paths.contains(&"assets/foo.bin"),
            "manifest paths must not collapse distinct roots into a single prefix: {:?}",
            paths
        );
    }

    /// Same collision bug, on the config side: a file under `config/` and a
    /// same-named file under `configs/` must not both be labeled `config/...`.
    #[test]
    fn collect_configs_preserves_distinct_root_in_manifest_path() {
        let project_root = tempfile::tempdir().unwrap();

        let config_dir = project_root.path().join("config");
        let configs_dir = project_root.path().join("configs");
        fs::create_dir_all(&config_dir).unwrap();
        fs::create_dir_all(&configs_dir).unwrap();

        fs::write(config_dir.join("settings.json"), b"{}").unwrap();
        fs::write(configs_dir.join("settings.json"), b"{}").unwrap();

        let configs = collect_configs(project_root.path()).unwrap();

        let paths: Vec<&str> = configs.iter().map(|c| c.path.as_str()).collect();

        assert_eq!(
            configs.len(),
            2,
            "expected one config per source root, got: {:?}",
            paths
        );
        assert!(paths.contains(&"config/settings.json"), "got: {:?}", paths);
        assert!(paths.contains(&"configs/settings.json"), "got: {:?}", paths);
    }
}