ray-dashboard-sdk 0.4.0

A simple Rust client for the Ray Dashboard REST API.
Documentation
use ignore::WalkBuilder;
use sha1::{Digest, Sha1};
use std::{
    io::Write,
    path::{Path, PathBuf},
};
use tracing::debug;
use zip::{ZipWriter, write::SimpleFileOptions};

use crate::{schema::packages::PackageUri, utils::constants::RAY_PKG_PREFIX};

#[derive(Clone, Debug)]
pub struct DirectoryPackage {
    uri: PackageUri,
    data: Vec<u8>,
}

impl DirectoryPackage {
    pub fn new(directory: &Path) -> crate::Result<Self> {
        debug!("Creating package from {:?}", directory);

        let mut hasher = Sha1::new();
        let mut zip = ZipWriter::new(std::io::Cursor::new(Vec::new()));
        let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);

        for rel_path in Self::walk_files(directory)? {
            debug!("Adding file to package: {:?}", rel_path);

            let file_data = std::fs::read(directory.join(&rel_path))?;

            // Hash the relative path and file contents for content-addressing
            hasher.update(rel_path.to_string_lossy().as_bytes());
            hasher.update(&file_data);

            // Zip the file into the package
            zip.start_file(rel_path.to_string_lossy(), options)?;
            zip.write_all(&file_data)?;
        }

        let package_data = zip.finish()?.into_inner();

        let hash = format!("{:x}", hasher.finalize());
        let package_name = format!("{}{}.zip", RAY_PKG_PREFIX, hash);

        Ok(Self {
            uri: PackageUri::new(package_name),
            data: package_data,
        })
    }

    pub fn uri(&self) -> &PackageUri {
        &self.uri
    }

    pub fn data(&self) -> &[u8] {
        &self.data
    }

    /// Collect the sorted relative paths of all files in a directory, respecting .gitignore files.
    fn walk_files(directory: &Path) -> crate::Result<Vec<PathBuf>> {
        let walker = WalkBuilder::new(directory)
            .standard_filters(true)
            .git_ignore(true)
            .build();

        let mut rel_paths = Vec::new();
        for result in walker {
            let entry =
                result.map_err(|e| crate::Error::Generic(format!("Failed to walk directory: {e}")))?;
            if entry.file_type().is_some_and(|ft| ft.is_file()) {
                let rel_path = entry
                    .path()
                    .strip_prefix(directory)
                    .map_err(|e| crate::Error::Generic(format!("Failed to compute relative path: {e}")))?;
                rel_paths.push(rel_path.to_path_buf());
            }
        }

        // Sort for deterministic ordering
        rel_paths.sort();
        Ok(rel_paths)
    }
}

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

    #[test]
    fn test_hash_directory_deterministic() {
        let temp_dir = tempfile::tempdir().unwrap();

        // Create some test files
        fs::write(temp_dir.path().join("file1.txt"), "content1").unwrap();
        fs::write(temp_dir.path().join("file2.txt"), "content2").unwrap();

        let pkg1 = DirectoryPackage::new(temp_dir.path()).unwrap();
        let pkg2 = DirectoryPackage::new(temp_dir.path()).unwrap();

        assert_eq!(pkg1.uri().name, pkg2.uri().name);
    }

    #[test]
    fn test_create_package() {
        let temp_dir = tempfile::tempdir().unwrap();

        // Create some test files
        fs::write(temp_dir.path().join("file1.txt"), "content1").unwrap();
        fs::write(temp_dir.path().join("file2.txt"), "content2").unwrap();

        let package = DirectoryPackage::new(temp_dir.path()).unwrap();

        // Verify the in-memory zip contains the expected files
        let mut archive = zip::ZipArchive::new(std::io::Cursor::new(package.data)).unwrap();
        let mut names: Vec<String> = (0..archive.len())
            .map(|i| archive.by_index(i).unwrap().name().to_string())
            .collect();
        names.sort();
        assert_eq!(names, vec!["file1.txt", "file2.txt"]);
    }

    #[test]
    fn test_package_name_and_uri() {
        let temp_dir = tempfile::tempdir().unwrap();

        fs::write(temp_dir.path().join("file.txt"), "content").unwrap();

        let pkg = DirectoryPackage::new(temp_dir.path()).unwrap();

        assert!(pkg.uri().name.starts_with(RAY_PKG_PREFIX));
        assert!(pkg.uri().name.ends_with(".zip"));
    }
}