docs: add comprehensive documentation and tests to workshop-baker
This commit is contained in:
@@ -14,6 +14,8 @@ use crate::error::BakeError;
|
||||
/// Template for use_template=false, which just executes the main body without any wrapping.
|
||||
const TRIVIAL_TEMPLATE: &str = "{{ main }}";
|
||||
|
||||
/// Renders a `Function` into an executable shell script using a minijinja template.
|
||||
/// If `use_template` is true, wraps the body in `bake_base`; otherwise uses a trivial template.
|
||||
pub fn build_script(
|
||||
function: &Function,
|
||||
bake_base: &Path,
|
||||
|
||||
@@ -15,20 +15,34 @@ lazy_static! {
|
||||
static ref INVALID_ARGUMENT: &'static str = "invalid argument";
|
||||
}
|
||||
|
||||
/// Pipeline decorator representing shell script annotations in `# @name(args)` syntax.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum Decorator {
|
||||
Unknown, // for general error reporting when the decorator name is not recognized
|
||||
/// Unrecognized decorator name (used for error reporting).
|
||||
Unknown,
|
||||
/// Marks a function as a main pipeline step.
|
||||
Pipeline,
|
||||
/// Conditional execution with shell condition.
|
||||
If(String),
|
||||
/// Allows the step to fail without halting the pipeline.
|
||||
Fallible,
|
||||
/// Repeat the function body N times.
|
||||
Loop(usize),
|
||||
/// Run after the specified function completes.
|
||||
After(String),
|
||||
/// Execution timeout in seconds.
|
||||
Timeout(u32),
|
||||
/// Retry N times with M second delay between attempts.
|
||||
Retry(usize, u32),
|
||||
/// Export environment variables to subsequent steps.
|
||||
Export,
|
||||
/// Chain functions via pipe with comma-separated names.
|
||||
Pipe(Vec<String>),
|
||||
/// Run concurrently with other parallel functions.
|
||||
Parallel,
|
||||
/// Run as a background daemon process.
|
||||
Daemon,
|
||||
/// Health check endpoint for daemon verification.
|
||||
Health(String),
|
||||
}
|
||||
|
||||
@@ -52,6 +66,8 @@ impl Display for Decorator {
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses a line for `# @name(args)` decorator syntax.
|
||||
/// Returns `Ok(None)` if the line is not a decorator line, `Ok(Some(Decorator))` on match.
|
||||
pub fn parse_decorator(line: &str, line_num: usize) -> Result<Option<Decorator>, BakeError> {
|
||||
let caps = match DECORATOR_REGEX.captures(line) {
|
||||
Some(c) => c,
|
||||
|
||||
@@ -12,14 +12,19 @@ lazy_static! {
|
||||
static ref DECORATOR_HEADER_REGEX: Regex = Regex::new(r"\s*#\s+@").unwrap();
|
||||
}
|
||||
|
||||
/// A parsed shell function with decorator annotations and body content.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Function {
|
||||
/// Function name (alphanumeric with underscore/hyphen).
|
||||
pub name: String,
|
||||
/// Decorators applied to this function.
|
||||
pub decorators: Vec<Decorator>,
|
||||
/// Raw function body including `function name() { ... }` syntax.
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
impl Function {
|
||||
/// Finds the first decorator matching the given predicate.
|
||||
pub fn find_decorator<T>(&self, matcher: impl Fn(&Decorator) -> Option<T>) -> Option<T> {
|
||||
self.decorators.iter().find_map(matcher)
|
||||
}
|
||||
@@ -37,12 +42,17 @@ enum ParserState {
|
||||
},
|
||||
}
|
||||
|
||||
/// Result of parsing a shell script with decorator annotations.
|
||||
#[derive(Debug)]
|
||||
pub struct ParsedScript {
|
||||
/// Functions marked with `@pipeline` decorator.
|
||||
pub pipeline_functions: Vec<Function>,
|
||||
/// Code not belonging to any pipeline function (comments, helpers, etc.).
|
||||
pub remaining_code: String,
|
||||
}
|
||||
|
||||
/// Parses shell scripts with `# @decorator(args)` annotations into a `Function` list.
|
||||
/// Lines prefixed with `# @` before a function definition become decorators for that function.
|
||||
pub fn parse_script(text: &str) -> Result<ParsedScript, BakeError> {
|
||||
let mut pipeline_functions = Vec::new();
|
||||
let mut remaining_code = String::new();
|
||||
|
||||
@@ -3,6 +3,9 @@ use crate::error::BakeError;
|
||||
use std::collections::HashMap;
|
||||
use topological_sort::TopologicalSort;
|
||||
|
||||
/// Topologically sorts functions by explicit `@after` dependencies and implicit ordering
|
||||
/// for consecutive `@pipeline` functions (each pipeline function implicitly depends on
|
||||
/// the previous one unless it has an explicit `@after`).
|
||||
pub fn sort_function(functions: Vec<Function>) -> Result<Vec<Function>, BakeError> {
|
||||
let function_mapped = functions
|
||||
.iter()
|
||||
|
||||
@@ -2,11 +2,16 @@ use std::fs;
|
||||
use std::io::{self, Write};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Linux cgroup resource manager for ws-executor.
|
||||
///
|
||||
/// Manages cgroup v2 hierarchy under `/sys/fs/cgroup/ws-executor/` for
|
||||
/// CPU, memory, and process isolation.
|
||||
pub struct CgroupManager {
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl CgroupManager {
|
||||
/// Creates a new cgroup for the given build ID.
|
||||
pub fn new(build_id: &str) -> io::Result<Self> {
|
||||
let path = PathBuf::from(format!("/sys/fs/cgroup/ws-executor/{}", build_id));
|
||||
|
||||
@@ -15,6 +20,7 @@ impl CgroupManager {
|
||||
Ok(Self { path })
|
||||
}
|
||||
|
||||
/// Sets memory limit in bytes. Use 0 for unlimited.
|
||||
pub fn set_memory_limit(&self, limit_bytes: u64) -> io::Result<()> {
|
||||
let mem_max_path = self.path.join("memory.max");
|
||||
let content = if limit_bytes == 0 {
|
||||
@@ -30,6 +36,7 @@ impl CgroupManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets CPU weight (1-10000, higher = more CPU time).
|
||||
pub fn set_cpu_weight(&self, weight: u64) -> io::Result<()> {
|
||||
let cpu_weight_path = self.path.join("cpu.weight");
|
||||
let weight = weight.clamp(1, 10000);
|
||||
@@ -37,6 +44,7 @@ impl CgroupManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sets CPU max burst in microseconds with optional period.
|
||||
pub fn set_cpu_max(&self, max_us: u64, period_us: u64) -> io::Result<()> {
|
||||
let cpu_max_path = self.path.join("cpu.max");
|
||||
let content = if max_us == 0 {
|
||||
@@ -48,6 +56,7 @@ impl CgroupManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Adds a process to this cgroup.
|
||||
pub fn add_process(&self, pid: u32) -> io::Result<()> {
|
||||
let cgroup_procs_path = self.path.join("cgroup.procs");
|
||||
let mut file = fs::OpenOptions::new()
|
||||
@@ -59,6 +68,7 @@ impl CgroupManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Destroys the cgroup directory.
|
||||
pub fn destroy(&self) -> io::Result<()> {
|
||||
if self.path.exists() {
|
||||
fs::remove_dir(&self.path)?;
|
||||
@@ -66,6 +76,7 @@ impl CgroupManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the cgroup path.
|
||||
pub fn path(&self) -> &PathBuf {
|
||||
&self.path
|
||||
}
|
||||
|
||||
@@ -5,20 +5,24 @@ use tokio::process::Command;
|
||||
// mod apt;
|
||||
mod pacman;
|
||||
|
||||
/// Trait for package manager implementations.
|
||||
///
|
||||
/// Provides a unified interface for generating commands to interact with
|
||||
/// system package managers across different Linux distributions.
|
||||
pub trait PackageManager {
|
||||
/// Name of package manager
|
||||
/// Returns the name of the package manager.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Adopt for a distro
|
||||
/// Determines whether this package manager supports the given distribution.
|
||||
fn adopt(&self, distro: &Info) -> bool;
|
||||
|
||||
/// Binary name
|
||||
/// Returns the binary name of the package manager.
|
||||
fn binary(&self) -> &'static str;
|
||||
|
||||
/// The command of updating
|
||||
/// Generates commands to update the package database.
|
||||
fn update(&self) -> Vec<Command>;
|
||||
|
||||
/// The command of installing packages
|
||||
/// Generates commands to install the specified packages.
|
||||
fn install(&self, package: &[String]) -> Vec<Command>;
|
||||
|
||||
// Check whether a package is installed
|
||||
@@ -28,13 +32,14 @@ pub trait PackageManager {
|
||||
// Also the PM module should generate command, not execute them
|
||||
// fn is_installed(&self, _package: &str) -> Option<bool> {unimplemented!()}
|
||||
|
||||
/// Change major mirror of package manager
|
||||
/// Generates commands to change the primary mirror repository.
|
||||
fn change_mirror(&self, repo: &str, osinfo: Option<&Info>) -> Vec<Command>;
|
||||
|
||||
/// add a custom repository
|
||||
/// Generates commands to add a custom repository configuration.
|
||||
fn add_repository(&self, repo: &Value, osinfo: Option<&Info>) -> Vec<Command>;
|
||||
}
|
||||
|
||||
/// Returns a list of all available package managers.
|
||||
pub fn all_managers() -> Vec<Box<dyn PackageManager>> {
|
||||
vec![
|
||||
// Box::new(apt::Apt),
|
||||
@@ -43,13 +48,39 @@ pub fn all_managers() -> Vec<Box<dyn PackageManager>> {
|
||||
]
|
||||
}
|
||||
|
||||
/// Detects the appropriate package manager for the given distribution.
|
||||
pub fn detect(distro: &os_info::Info) -> Option<Box<dyn PackageManager>> {
|
||||
all_managers().into_iter().find(|pm| pm.adopt(distro))
|
||||
}
|
||||
|
||||
/// Selects a package manager by name.
|
||||
pub fn select(manager: &str) -> Option<Box<dyn PackageManager>> {
|
||||
// TODO: allow specify PM
|
||||
// log::warn!("Manually select package manager is not recommended.");
|
||||
// log::warn!("Use at your own risk.");
|
||||
all_managers().into_iter().find(|pm| pm.name() == manager)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_all_managers_returns_non_empty() {
|
||||
let managers = all_managers();
|
||||
assert!(!managers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_known_manager() {
|
||||
let pm = select("pacman");
|
||||
assert!(pm.is_some());
|
||||
assert_eq!(pm.unwrap().name(), "pacman");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_unknown_manager() {
|
||||
let pm = select("nonexistent_pm");
|
||||
assert!(pm.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,11 @@ use serde::Deserialize;
|
||||
use serde_yaml::Value;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Arch Linux package manager implementation.
|
||||
#[derive(Clone)]
|
||||
pub struct Pacman;
|
||||
|
||||
/// Repository configuration for pacman.
|
||||
#[derive(Deserialize)]
|
||||
struct PacmanRepo {
|
||||
name: String,
|
||||
@@ -17,6 +19,7 @@ struct PacmanRepo {
|
||||
keypackage: Option<String>,
|
||||
}
|
||||
|
||||
/// Pacman signature level configuration.
|
||||
struct SigLevel {
|
||||
raw: String,
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@ lazy_static::lazy_static! {
|
||||
};
|
||||
}
|
||||
|
||||
/// Resolves package names to the target package manager.
|
||||
///
|
||||
/// Looks up each package in the internal mapping and returns the corresponding
|
||||
/// package names for the specified package manager.
|
||||
pub async fn resolve(packages: &[String], target_pm: &str) -> Vec<String> {
|
||||
let mut result = Vec::new();
|
||||
for pkg in packages {
|
||||
@@ -94,3 +98,57 @@ pub async fn resolve(packages: &[String], target_pm: &str) -> Vec<String> {
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_rustup_to_pacman() {
|
||||
let packages = vec!["rustup".to_string()];
|
||||
let resolved = resolve(&packages, "pacman").await;
|
||||
assert_eq!(resolved, vec!["rustup"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_go_to_apt() {
|
||||
let packages = vec!["go".to_string()];
|
||||
let resolved = resolve(&packages, "apt").await;
|
||||
assert_eq!(resolved, vec!["golang-go"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_go_to_pacman() {
|
||||
let packages = vec!["go".to_string()];
|
||||
let resolved = resolve(&packages, "pacman").await;
|
||||
assert_eq!(resolved, vec!["go"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_python_to_dnf() {
|
||||
let packages = vec!["python".to_string()];
|
||||
let resolved = resolve(&packages, "dnf").await;
|
||||
assert_eq!(resolved, vec!["python3"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_multiple_packages() {
|
||||
let packages = vec!["rustup".to_string(), "cargo".to_string()];
|
||||
let resolved = resolve(&packages, "pacman").await;
|
||||
assert_eq!(resolved, vec!["rustup", "cargo"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_unknown_package() {
|
||||
let packages = vec!["nonexistent_package".to_string()];
|
||||
let resolved = resolve(&packages, "pacman").await;
|
||||
assert!(resolved.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_unknown_package_manager() {
|
||||
let packages = vec!["rustup".to_string()];
|
||||
let resolved = resolve(&packages, "unknown_pm").await;
|
||||
assert!(resolved.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,26 +5,34 @@ mod custom;
|
||||
|
||||
use crate::{ExecutionContext, error::DependencyError};
|
||||
|
||||
/// System dependencies required by a user package manager.
|
||||
pub struct UPMSysDeps {
|
||||
/// List of package names to install via the system package manager.
|
||||
pub packages: Vec<String>,
|
||||
/// Whether package names have been mapped to the target package manager.
|
||||
pub mapped: bool,
|
||||
}
|
||||
|
||||
impl UPMSysDeps {
|
||||
/// Creates a new UPMSysDeps instance.
|
||||
pub fn new(packages: Vec<String>, mapped: bool) -> Self {
|
||||
Self { packages, mapped }
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for user package manager implementations.
|
||||
///
|
||||
/// User package managers (Nix, Guix, etc.) operate at the user level
|
||||
/// and may require system-level dependencies to be installed first.
|
||||
#[async_trait]
|
||||
pub trait UserPackageManager {
|
||||
/// Name of package manager
|
||||
/// Returns the name of the user package manager.
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Indicates that DepsSystem needs to install these packages
|
||||
/// Returns system dependencies required by this user package manager.
|
||||
fn system_dependency(&self, config: &Value) -> UPMSysDeps;
|
||||
|
||||
/// Main function of User Package Manager
|
||||
/// Main function of User Package Manager.
|
||||
async fn main(
|
||||
&self,
|
||||
config: &Value,
|
||||
@@ -33,10 +41,36 @@ pub trait UserPackageManager {
|
||||
) -> Result<(), DependencyError>;
|
||||
}
|
||||
|
||||
/// Returns a list of all available user package managers.
|
||||
pub fn all_managers() -> Vec<Box<dyn UserPackageManager>> {
|
||||
vec![Box::new(custom::Custom)]
|
||||
}
|
||||
|
||||
/// Selects a user package manager by name.
|
||||
pub fn select(manager: &str) -> Option<Box<dyn UserPackageManager>> {
|
||||
all_managers().into_iter().find(|pm| pm.name() == manager)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_all_managers_returns_non_empty() {
|
||||
let managers = all_managers();
|
||||
assert!(!managers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_known_manager() {
|
||||
let pm = select("custom");
|
||||
assert!(pm.is_some());
|
||||
assert_eq!(pm.unwrap().name(), "custom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_select_unknown_manager() {
|
||||
let pm = select("nonexistent_upm");
|
||||
assert!(pm.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
use crate::engine::{ExecutionEvent, StreamType};
|
||||
|
||||
/// Receives and processes execution events for the finalize stage.
|
||||
/// Logs task lifecycle events (started, completed, failed) and streaming output (stdout/stderr)
|
||||
/// for the pipeline build identified by the given pipeline_name and build_id.
|
||||
pub async fn event_receiver(
|
||||
mut rx: tokio::sync::mpsc::Receiver<ExecutionEvent>,
|
||||
pipeline_name: String,
|
||||
@@ -47,4 +50,4 @@ pub async fn event_receiver(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,11 @@
|
||||
use super::types::ChecksumType;
|
||||
use sha2::{Digest, Sha256, Sha512};
|
||||
|
||||
/// Detects the checksum type based on the length of the checksum string.
|
||||
///
|
||||
/// Returns `Ok(None)` for empty string (checksum disabled).
|
||||
/// Returns `Ok(Some(ChecksumType))` for valid SHA256 (64 chars) or SHA512 (128 chars).
|
||||
/// Returns `Err` for invalid lengths (32=MD5, 40=SHA1) or unsupported lengths.
|
||||
pub fn detect_checksum_type(checksum: &str) -> Result<Option<ChecksumType>, String> {
|
||||
match checksum.len() {
|
||||
0 => Ok(None),
|
||||
@@ -23,6 +28,10 @@ pub fn detect_checksum_type(checksum: &str) -> Result<Option<ChecksumType>, Stri
|
||||
}
|
||||
}
|
||||
|
||||
/// Verifies that the data matches the expected checksum.
|
||||
///
|
||||
/// Compares the computed hash of `data` against `expected` using the specified `checksum_type`.
|
||||
/// Returns `Ok(())` if the checksums match, `Err` otherwise.
|
||||
pub fn verify_checksum(
|
||||
data: &[u8],
|
||||
expected: &str,
|
||||
@@ -51,3 +60,72 @@ pub fn verify_checksum(
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_empty() {
|
||||
assert_eq!(detect_checksum_type("").unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_md5_rejected() {
|
||||
let result = detect_checksum_type("a".repeat(32).as_str());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("MD5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_sha1_rejected() {
|
||||
let result = detect_checksum_type("a".repeat(40).as_str());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("SHA1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_sha256() {
|
||||
assert_eq!(detect_checksum_type("a".repeat(64).as_str()).unwrap(), Some(ChecksumType::Sha256));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_sha512() {
|
||||
assert_eq!(detect_checksum_type("a".repeat(128).as_str()).unwrap(), Some(ChecksumType::Sha512));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_checksum_type_invalid_length() {
|
||||
let result = detect_checksum_type("a".repeat(50).as_str());
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid checksum length"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_checksum_sha256_valid() {
|
||||
let data = b"hello";
|
||||
let expected = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
||||
assert!(verify_checksum(data, expected, ChecksumType::Sha256).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_checksum_sha256_invalid() {
|
||||
let data = b"hello";
|
||||
let expected = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
assert!(verify_checksum(data, expected, ChecksumType::Sha256).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_checksum_sha512_valid() {
|
||||
let data = b"hello";
|
||||
let expected = "9b71d224bd62f3785d96d46ad3ea3d73319bfbc2890caadae2dff72519673ca72323c3d99ba5c11d7c7acc6e14b8c5da0c4663475c2e5c3adef46f73bcdec043";
|
||||
assert!(verify_checksum(data, expected, ChecksumType::Sha512).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_verify_checksum_sha512_invalid() {
|
||||
let data = b"hello";
|
||||
let expected = "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000";
|
||||
assert!(verify_checksum(data, expected, ChecksumType::Sha512).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,14 +3,21 @@ use crate::finalize::plugin::PluginError;
|
||||
use std::path::PathBuf;
|
||||
use tokio::task;
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
/// Compression type for tar archives.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub enum CompressionType {
|
||||
/// Gzip compressed (.tar.gz, .tgz)
|
||||
Gzip,
|
||||
/// Zstd compressed (.tar.zst, .tar.zstd, .tzst)
|
||||
Zstd,
|
||||
/// Uncompressed tar (.tar)
|
||||
None,
|
||||
}
|
||||
|
||||
impl CompressionType {
|
||||
/// Detects compression type from file path extension.
|
||||
///
|
||||
/// Supports: .tar.gz, .tgz (Gzip), .tar.zst, .tar.zstd, .tzst (Zstd), .tar (None).
|
||||
pub fn from_path(path: &std::path::Path) -> Result<Self, String> {
|
||||
let name = path
|
||||
.file_name()
|
||||
@@ -36,6 +43,10 @@ impl CompressionType {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts a tar archive to the destination directory.
|
||||
///
|
||||
/// Removes existing destination directory if present, creates a fresh directory,
|
||||
/// then unpacks the archive using the appropriate decompression based on `compression`.
|
||||
pub async fn extract_tar(
|
||||
archive: &PathBuf,
|
||||
destdir: &PathBuf,
|
||||
@@ -89,3 +100,70 @@ pub async fn extract_tar(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tar_gz() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tar.gz")).unwrap(),
|
||||
CompressionType::Gzip
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tgz() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tgz")).unwrap(),
|
||||
CompressionType::Gzip
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tar_zst() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tar.zst")).unwrap(),
|
||||
CompressionType::Zstd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tar_zstd() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tar.zstd")).unwrap(),
|
||||
CompressionType::Zstd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tzst() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tzst")).unwrap(),
|
||||
CompressionType::Zstd
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_tar() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.tar")).unwrap(),
|
||||
CompressionType::None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_unsupported() {
|
||||
assert!(CompressionType::from_path(Path::new("archive.zip")).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_path_case_insensitive() {
|
||||
assert_eq!(
|
||||
CompressionType::from_path(Path::new("archive.TAR.GZ")).unwrap(),
|
||||
CompressionType::Gzip
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,13 +3,21 @@ use crate::finalize::plugin::PluginError;
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
/// Version specification for a git repository.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum GitVersion {
|
||||
/// No version specified (invalid for fetch operations)
|
||||
None,
|
||||
/// Single version string (tag or commit hash)
|
||||
Single(String),
|
||||
/// Mixed: try tag first, fallback to commit hash
|
||||
Mixed(String, String),
|
||||
}
|
||||
|
||||
/// Parses a git URL with optional version specification.
|
||||
///
|
||||
/// Format: `url` (no version), `url@tag` (single version), `url@tag:commit` (mixed version).
|
||||
/// Returns (base_url, GitVersion).
|
||||
pub fn parse_git_url(url: &str) -> (String, GitVersion) {
|
||||
if let Some((base, version)) = url.split_once('@') {
|
||||
if version.contains(":") {
|
||||
@@ -24,6 +32,10 @@ pub fn parse_git_url(url: &str) -> (String, GitVersion) {
|
||||
(url.to_string(), GitVersion::None)
|
||||
}
|
||||
|
||||
/// Clones a git repository and checks out the specified version.
|
||||
///
|
||||
/// Removes existing destination directory, performs shallow or full clone,
|
||||
/// then checks out the given version (tag or commit).
|
||||
pub async fn git_clone_and_checkout(
|
||||
baseurl: &str,
|
||||
version: &str,
|
||||
@@ -75,6 +87,10 @@ pub async fn git_clone_and_checkout(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Fetches a plugin from a git repository.
|
||||
///
|
||||
/// Parses the URL for version info, then clones and checks out the appropriate ref.
|
||||
/// For Mixed version, tries the tag first and falls back to the commit hash.
|
||||
pub async fn fetch_git_plugin(
|
||||
path: &str,
|
||||
name: &str,
|
||||
@@ -121,3 +137,43 @@ pub async fn fetch_git_plugin(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_url_no_version() {
|
||||
let (url, version) = parse_git_url("https://github.com/user/repo");
|
||||
assert_eq!(url, "https://github.com/user/repo");
|
||||
assert_eq!(version, GitVersion::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_url_single_tag() {
|
||||
let (url, version) = parse_git_url("https://github.com/user/repo@v1.0.0");
|
||||
assert_eq!(url, "https://github.com/user/repo");
|
||||
assert_eq!(version, GitVersion::Single("v1.0.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_url_mixed_version() {
|
||||
let (url, version) = parse_git_url("https://github.com/user/repo@main:abc123");
|
||||
assert_eq!(url, "https://github.com/user/repo");
|
||||
assert_eq!(version, GitVersion::Mixed("main".to_string(), "abc123".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_url_complex_url() {
|
||||
let (url, version) = parse_git_url("git@github.com:user/repo.git@tag:commit");
|
||||
assert_eq!(url, "git@github.com:user/repo.git");
|
||||
assert_eq!(version, GitVersion::Mixed("tag".to_string(), "commit".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_git_url_single_with_at_in_path() {
|
||||
let (url, version) = parse_git_url("https://github.com/user/repo@my-repo@v1.0");
|
||||
assert_eq!(url, "https://github.com/user/repo@my-repo");
|
||||
assert_eq!(version, GitVersion::Single("v1.0".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +1,84 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
/// Supported checksum types for verifying downloaded artifacts.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ChecksumType {
|
||||
/// SHA-256 hash (64 hex characters)
|
||||
Sha256,
|
||||
/// SHA-512 hash (128 hex characters)
|
||||
Sha512,
|
||||
}
|
||||
|
||||
/// Arguments for fetching a plugin artifact.
|
||||
///
|
||||
/// Contains optional checksum verification and shallow clone settings.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
pub struct FetchArgument {
|
||||
/// SHA256 or SHA512 hex string for integrity verification.
|
||||
#[serde(default)]
|
||||
pub checksum: Option<String>,
|
||||
/// Perform a shallow clone (single revision only).
|
||||
#[serde(default)]
|
||||
pub shallow: Option<bool>,
|
||||
}
|
||||
|
||||
impl FetchArgument {
|
||||
/// Returns the checksum if present and non-empty.
|
||||
pub fn get_checksum(&self) -> Option<&str> {
|
||||
self.checksum.as_deref().filter(|s| !s.is_empty())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_checksum_some_non_empty() {
|
||||
let arg = FetchArgument {
|
||||
checksum: Some("abc123".to_string()),
|
||||
shallow: None,
|
||||
};
|
||||
assert_eq!(arg.get_checksum(), Some("abc123"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_checksum_empty_string() {
|
||||
let arg = FetchArgument {
|
||||
checksum: Some("".to_string()),
|
||||
shallow: None,
|
||||
};
|
||||
assert_eq!(arg.get_checksum(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_checksum_none() {
|
||||
let arg = FetchArgument {
|
||||
checksum: None,
|
||||
shallow: None,
|
||||
};
|
||||
assert_eq!(arg.get_checksum(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip() {
|
||||
let arg = FetchArgument {
|
||||
checksum: Some("deadbeef".to_string()),
|
||||
shallow: Some(true),
|
||||
};
|
||||
let json = serde_json::to_string(&arg).unwrap();
|
||||
let parsed: FetchArgument = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.checksum, arg.checksum);
|
||||
assert_eq!(parsed.shallow, arg.shallow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_empty() {
|
||||
let arg = FetchArgument::default();
|
||||
let json = serde_json::to_string(&arg).unwrap();
|
||||
let parsed: FetchArgument = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed.checksum, arg.checksum);
|
||||
assert_eq!(parsed.shallow, arg.shallow);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,18 @@
|
||||
use crate::types::time::deserialize_duration_ms;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Email address representation supporting plain string or structured object.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum EmailAddress {
|
||||
/// Plain email address string.
|
||||
String(String),
|
||||
/// Structured email with display name.
|
||||
Object { name: String, email: String },
|
||||
}
|
||||
|
||||
impl EmailAddress {
|
||||
/// Returns the formatted address string (e.g., "user@example.com" or "Name <user@example.com>").
|
||||
pub fn to_string(&self) -> String {
|
||||
match self {
|
||||
EmailAddress::String(email) => email.clone(),
|
||||
@@ -23,6 +27,7 @@ impl EmailAddress {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the raw email address without formatting.
|
||||
pub fn email(&self) -> &str {
|
||||
match self {
|
||||
EmailAddress::String(email) => email.as_str(),
|
||||
@@ -31,14 +36,18 @@ impl EmailAddress {
|
||||
}
|
||||
}
|
||||
|
||||
/// Recipients container supporting single or multiple addresses.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum Recipients {
|
||||
/// Single recipient.
|
||||
Single(String),
|
||||
/// Multiple recipients.
|
||||
Multiple(Vec<String>),
|
||||
}
|
||||
|
||||
impl Recipients {
|
||||
/// Converts recipients to a flat vector of email strings.
|
||||
pub fn to_vec(&self) -> Vec<String> {
|
||||
match self {
|
||||
Recipients::Single(s) => vec![s.clone()],
|
||||
@@ -47,36 +56,52 @@ impl Recipients {
|
||||
}
|
||||
}
|
||||
|
||||
/// SMTP authentication mechanism.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum AuthType {
|
||||
/// No authentication.
|
||||
None,
|
||||
/// Password-based authentication.
|
||||
Password,
|
||||
}
|
||||
|
||||
/// SMTP authentication configuration.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct AuthConfig {
|
||||
/// Authentication mechanism type.
|
||||
#[serde(rename = "type")]
|
||||
pub auth_type: AuthType,
|
||||
/// Username for authentication.
|
||||
pub username: String,
|
||||
/// Password for authentication.
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// SMTP connection encryption mode.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Encryption {
|
||||
/// Implicit TLS/SSL on connection.
|
||||
Tls,
|
||||
/// STARTTLS upgrade after initial plaintext connection.
|
||||
Starttls,
|
||||
/// No encryption.
|
||||
None,
|
||||
}
|
||||
|
||||
/// SMTP server connection configuration.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct SmtpConfig {
|
||||
/// SMTP server hostname.
|
||||
pub host: String,
|
||||
/// SMTP server port.
|
||||
pub port: u16,
|
||||
/// Authentication configuration.
|
||||
pub auth: AuthConfig,
|
||||
/// Encryption mode.
|
||||
pub encryption: Encryption,
|
||||
|
||||
/// Connection timeout in milliseconds.
|
||||
#[serde(
|
||||
default = "default_connect_timeout_ms",
|
||||
deserialize_with = "deserialize_duration_ms"
|
||||
@@ -88,10 +113,13 @@ fn default_connect_timeout_ms() -> u64 {
|
||||
30_000
|
||||
}
|
||||
|
||||
/// Mail schema type determining required fields.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Schema {
|
||||
/// Default schema with minimal required fields.
|
||||
Default,
|
||||
/// Custom schema requiring explicit subject and body.
|
||||
Custom,
|
||||
}
|
||||
|
||||
@@ -99,32 +127,36 @@ fn default_schema() -> Schema {
|
||||
Schema::Default
|
||||
}
|
||||
|
||||
/// Complete mail notification configuration.
|
||||
#[derive(Debug, Deserialize, Clone)]
|
||||
pub struct MailConfig {
|
||||
/// Primary recipients.
|
||||
pub to: Recipients,
|
||||
|
||||
/// Carbon copy recipients.
|
||||
#[serde(default)]
|
||||
pub cc: Option<Recipients>,
|
||||
|
||||
/// Blind carbon copy recipients.
|
||||
#[serde(default)]
|
||||
pub bcc: Option<Recipients>,
|
||||
|
||||
/// Sender address.
|
||||
pub from: EmailAddress,
|
||||
|
||||
/// Reply-to address.
|
||||
#[serde(default)]
|
||||
pub reply_to: Option<String>,
|
||||
|
||||
/// Schema type determining validation rules.
|
||||
#[serde(default = "default_schema")]
|
||||
pub schema: Schema,
|
||||
|
||||
/// Email subject (required for custom schema).
|
||||
pub subject: Option<String>,
|
||||
|
||||
/// Email body content (required for custom schema).
|
||||
pub body: Option<String>,
|
||||
|
||||
/// SMTP server configuration.
|
||||
pub smtp: SmtpConfig,
|
||||
}
|
||||
|
||||
impl MailConfig {
|
||||
/// Validates the mail configuration according to schema rules.
|
||||
/// Returns Ok(()) if valid, or Err(vec) with error messages if invalid.
|
||||
pub fn validate(&self) -> Result<(), Vec<String>> {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
@@ -149,12 +181,15 @@ impl MailConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/// Built-in email notification templates.
|
||||
pub struct DefaultTemplates;
|
||||
|
||||
impl DefaultTemplates {
|
||||
/// Default subject template with build status.
|
||||
pub const SUBJECT: &'static str =
|
||||
"[{{ build.status | upper }}] {{ pipeline.name }} #{{ build.id }}";
|
||||
|
||||
/// Default HTML email body template.
|
||||
pub const BODY_HTML: &'static str = r#"<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="font-family: Arial, sans-serif;">
|
||||
@@ -177,6 +212,7 @@ impl DefaultTemplates {
|
||||
</body>
|
||||
</html>"#;
|
||||
|
||||
/// Default plain text email body template.
|
||||
pub const BODY_TEXT: &'static str = r#"Build {{ build.status | title }}
|
||||
==============================
|
||||
Pipeline: {{ pipeline.name }}
|
||||
@@ -189,3 +225,262 @@ Message: {{ commit.message }}
|
||||
|
||||
View details: {{ build.url }}"#;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_email_address_string_to_string() {
|
||||
let addr = EmailAddress::String("test@example.com".to_string());
|
||||
assert_eq!(addr.to_string(), "test@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_address_object_with_name_to_string() {
|
||||
let addr = EmailAddress::Object {
|
||||
name: "Test User".to_string(),
|
||||
email: "test@example.com".to_string(),
|
||||
};
|
||||
assert_eq!(addr.to_string(), "Test User <test@example.com>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_address_object_without_name_to_string() {
|
||||
let addr = EmailAddress::Object {
|
||||
name: "".to_string(),
|
||||
email: "test@example.com".to_string(),
|
||||
};
|
||||
assert_eq!(addr.to_string(), "test@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_address_string_email() {
|
||||
let addr = EmailAddress::String("test@example.com".to_string());
|
||||
assert_eq!(addr.email(), "test@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_address_object_email() {
|
||||
let addr = EmailAddress::Object {
|
||||
name: "Test User".to_string(),
|
||||
email: "test@example.com".to_string(),
|
||||
};
|
||||
assert_eq!(addr.email(), "test@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recipients_single_to_vec() {
|
||||
let recipients = Recipients::Single("test@example.com".to_string());
|
||||
let vec = recipients.to_vec();
|
||||
assert_eq!(vec, vec!["test@example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recipients_multiple_to_vec() {
|
||||
let recipients = Recipients::Multiple(vec![
|
||||
"a@example.com".to_string(),
|
||||
"b@example.com".to_string(),
|
||||
]);
|
||||
let vec = recipients.to_vec();
|
||||
assert_eq!(vec, vec!["a@example.com", "b@example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_config_validate_valid_default_schema() {
|
||||
let config = MailConfig {
|
||||
to: Recipients::Single("to@example.com".to_string()),
|
||||
cc: None,
|
||||
bcc: None,
|
||||
from: EmailAddress::String("from@example.com".to_string()),
|
||||
reply_to: None,
|
||||
schema: Schema::Default,
|
||||
subject: None,
|
||||
body: None,
|
||||
smtp: SmtpConfig {
|
||||
host: "smtp.example.com".to_string(),
|
||||
port: 587,
|
||||
auth: AuthConfig {
|
||||
auth_type: AuthType::None,
|
||||
username: "".to_string(),
|
||||
password: "".to_string(),
|
||||
},
|
||||
encryption: Encryption::Tls,
|
||||
connect_timeout_ms: 30_000,
|
||||
},
|
||||
};
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_config_validate_valid_custom_schema() {
|
||||
let config = MailConfig {
|
||||
to: Recipients::Single("to@example.com".to_string()),
|
||||
cc: None,
|
||||
bcc: None,
|
||||
from: EmailAddress::String("from@example.com".to_string()),
|
||||
reply_to: None,
|
||||
schema: Schema::Custom,
|
||||
subject: Some("Test Subject".to_string()),
|
||||
body: Some("Test Body".to_string()),
|
||||
smtp: SmtpConfig {
|
||||
host: "smtp.example.com".to_string(),
|
||||
port: 587,
|
||||
auth: AuthConfig {
|
||||
auth_type: AuthType::None,
|
||||
username: "".to_string(),
|
||||
password: "".to_string(),
|
||||
},
|
||||
encryption: Encryption::Tls,
|
||||
connect_timeout_ms: 30_000,
|
||||
},
|
||||
};
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_config_validate_custom_schema_missing_subject() {
|
||||
let config = MailConfig {
|
||||
to: Recipients::Single("to@example.com".to_string()),
|
||||
cc: None,
|
||||
bcc: None,
|
||||
from: EmailAddress::String("from@example.com".to_string()),
|
||||
reply_to: None,
|
||||
schema: Schema::Custom,
|
||||
subject: None,
|
||||
body: Some("Test Body".to_string()),
|
||||
smtp: SmtpConfig {
|
||||
host: "smtp.example.com".to_string(),
|
||||
port: 587,
|
||||
auth: AuthConfig {
|
||||
auth_type: AuthType::None,
|
||||
username: "".to_string(),
|
||||
password: "".to_string(),
|
||||
},
|
||||
encryption: Encryption::Tls,
|
||||
connect_timeout_ms: 30_000,
|
||||
},
|
||||
};
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().iter().any(|e| e.contains("subject")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_config_validate_custom_schema_missing_body() {
|
||||
let config = MailConfig {
|
||||
to: Recipients::Single("to@example.com".to_string()),
|
||||
cc: None,
|
||||
bcc: None,
|
||||
from: EmailAddress::String("from@example.com".to_string()),
|
||||
reply_to: None,
|
||||
schema: Schema::Custom,
|
||||
subject: Some("Test Subject".to_string()),
|
||||
body: None,
|
||||
smtp: SmtpConfig {
|
||||
host: "smtp.example.com".to_string(),
|
||||
port: 587,
|
||||
auth: AuthConfig {
|
||||
auth_type: AuthType::None,
|
||||
username: "".to_string(),
|
||||
password: "".to_string(),
|
||||
},
|
||||
encryption: Encryption::Tls,
|
||||
connect_timeout_ms: 30_000,
|
||||
},
|
||||
};
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().iter().any(|e| e.contains("body")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mail_config_validate_empty_recipients() {
|
||||
let config = MailConfig {
|
||||
to: Recipients::Multiple(vec![]),
|
||||
cc: None,
|
||||
bcc: None,
|
||||
from: EmailAddress::String("from@example.com".to_string()),
|
||||
reply_to: None,
|
||||
schema: Schema::Default,
|
||||
subject: None,
|
||||
body: None,
|
||||
smtp: SmtpConfig {
|
||||
host: "smtp.example.com".to_string(),
|
||||
port: 587,
|
||||
auth: AuthConfig {
|
||||
auth_type: AuthType::None,
|
||||
username: "".to_string(),
|
||||
password: "".to_string(),
|
||||
},
|
||||
encryption: Encryption::Tls,
|
||||
connect_timeout_ms: 30_000,
|
||||
},
|
||||
};
|
||||
let result = config.validate();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().iter().any(|e| e.contains("recipient")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_address_serde_string() {
|
||||
let json = r#""test@example.com""#;
|
||||
let addr: EmailAddress = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(addr, EmailAddress::String(s) if s == "test@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_email_address_serde_object() {
|
||||
let json = r#"{"name":"Test User","email":"test@example.com"}"#;
|
||||
let addr: EmailAddress = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(addr, EmailAddress::Object { name, email } if name == "Test User" && email == "test@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recipients_serde_single() {
|
||||
let json = r#""single@example.com""#;
|
||||
let recipients: Recipients = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(recipients, Recipients::Single(s) if s == "single@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recipients_serde_multiple() {
|
||||
let json = r#"["a@example.com","b@example.com"]"#;
|
||||
let recipients: Recipients = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(recipients, Recipients::Multiple(v) if v == vec!["a@example.com".to_string(), "b@example.com".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auth_type_serde() {
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
struct Test {
|
||||
#[serde(rename = "type")]
|
||||
auth_type: AuthType,
|
||||
}
|
||||
let json = r#"{"type":"password"}"#;
|
||||
let t: Test = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(t.auth_type, AuthType::Password));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encryption_serde() {
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
struct Test {
|
||||
encryption: Encryption,
|
||||
}
|
||||
let json = r#"{"encryption":"tls"}"#;
|
||||
let t: Test = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(t.encryption, Encryption::Tls));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_schema_serde() {
|
||||
#[derive(serde::Deserialize, Debug)]
|
||||
struct Test {
|
||||
schema: Schema,
|
||||
}
|
||||
let json = r#"{"schema":"custom"}"#;
|
||||
let t: Test = serde_json::from_str(json).unwrap();
|
||||
assert!(matches!(t.schema, Schema::Custom));
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ use crate::ExecutionContext;
|
||||
use minijinja::Environment;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Builds template context data from execution context.
|
||||
/// Populates pipeline, build, and commit information for template rendering.
|
||||
pub fn prepare_template_data(ctx: &ExecutionContext) -> HashMap<String, serde_json::Value> {
|
||||
let mut data = HashMap::new();
|
||||
|
||||
@@ -38,6 +40,8 @@ pub fn prepare_template_data(ctx: &ExecutionContext) -> HashMap<String, serde_js
|
||||
data
|
||||
}
|
||||
|
||||
/// Renders a minijinja template string with the provided data map.
|
||||
/// Returns the rendered string on success, or a String error message on failure.
|
||||
pub fn render_template(
|
||||
template: &str,
|
||||
data: &HashMap<String, serde_json::Value>,
|
||||
@@ -54,3 +58,44 @@ pub fn render_template(
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_render_template_simple() {
|
||||
let template = "Hello {{ name }}!";
|
||||
let mut data = HashMap::new();
|
||||
data.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String("World".to_string()),
|
||||
);
|
||||
let result = render_template(template, &data).unwrap();
|
||||
assert_eq!(result, "Hello World!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_template_with_build_status() {
|
||||
let template = "Build {{ build.status }} for pipeline {{ pipeline.name }}";
|
||||
let mut data = HashMap::new();
|
||||
data.insert(
|
||||
"build".to_string(),
|
||||
serde_json::json!({ "status": "success" }),
|
||||
);
|
||||
data.insert(
|
||||
"pipeline".to_string(),
|
||||
serde_json::json!({ "name": "test-pipeline" }),
|
||||
);
|
||||
let result = render_template(template, &data).unwrap();
|
||||
assert_eq!(result, "Build success for pipeline test-pipeline");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_render_template_invalid_syntax() {
|
||||
let template = "{{ Unterminated";
|
||||
let data = HashMap::new();
|
||||
let result = render_template(template, &data);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,22 @@
|
||||
use serde::Deserialize;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Plugin manifest metadata containing identification and configuration.
|
||||
#[derive(Deserialize, Clone)]
|
||||
pub struct PluginMetadata {
|
||||
/// Plugin name identifier.
|
||||
pub name: String,
|
||||
/// Plugin version string.
|
||||
pub version: String,
|
||||
|
||||
/// Path or command to invoke the plugin.
|
||||
pub entrypoint: String,
|
||||
|
||||
/// Plugin type classification.
|
||||
#[serde(default)]
|
||||
pub plugin_type: PluginType,
|
||||
|
||||
/// Additional YAML fields not captured by struct.
|
||||
#[serde(flatten)]
|
||||
pub value: serde_yaml::Value,
|
||||
|
||||
/// Filesystem path to plugin definition.
|
||||
#[serde(skip)]
|
||||
pub fspath: PathBuf,
|
||||
}
|
||||
@@ -31,7 +34,8 @@ impl Default for PluginMetadata {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default, PartialEq, Eq, Clone)]
|
||||
/// Plugin execution model.
|
||||
#[derive(Deserialize, Default, PartialEq, Eq, Clone, Debug)]
|
||||
pub enum PluginType {
|
||||
#[default]
|
||||
Unknown,
|
||||
@@ -39,3 +43,75 @@ pub enum PluginType {
|
||||
Rhai,
|
||||
Dylib,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_plugin_metadata_default() {
|
||||
let meta = PluginMetadata::default();
|
||||
assert_eq!(meta.name, "");
|
||||
assert_eq!(meta.version, "");
|
||||
assert_eq!(meta.entrypoint, "");
|
||||
assert_eq!(meta.plugin_type, PluginType::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_type_default() {
|
||||
let pt = PluginType::default();
|
||||
assert_eq!(pt, PluginType::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_type_equality() {
|
||||
assert_eq!(PluginType::Shell, PluginType::Shell);
|
||||
assert_eq!(PluginType::Dylib, PluginType::Dylib);
|
||||
assert_ne!(PluginType::Shell, PluginType::Dylib);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_metadata_clone() {
|
||||
let meta = PluginMetadata {
|
||||
name: "test".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
entrypoint: "/bin/test".to_string(),
|
||||
plugin_type: PluginType::Shell,
|
||||
value: serde_yaml::Value::Null,
|
||||
fspath: PathBuf::from("/path/to/plugin"),
|
||||
};
|
||||
let cloned = meta.clone();
|
||||
assert_eq!(cloned.name, meta.name);
|
||||
assert_eq!(cloned.version, meta.version);
|
||||
assert_eq!(cloned.plugin_type, meta.plugin_type);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_type_serde_unknown() {
|
||||
let yaml = "Unknown";
|
||||
let pt: PluginType = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(pt, PluginType::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_type_serde_shell() {
|
||||
let yaml = "Shell";
|
||||
let pt: PluginType = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(pt, PluginType::Shell);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_plugin_metadata_serde() {
|
||||
let yaml = r#"
|
||||
name: test-plugin
|
||||
version: "2.0"
|
||||
entrypoint: ./bin/test
|
||||
plugin_type: Shell
|
||||
"#;
|
||||
let meta: PluginMetadata = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(meta.name, "test-plugin");
|
||||
assert_eq!(meta.version, "2.0");
|
||||
assert_eq!(meta.entrypoint, "./bin/test");
|
||||
assert_eq!(meta.plugin_type, PluginType::Shell);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,11 @@ use crate::finalize::plugin::{PluginMap, call_named_plugin};
|
||||
use crate::types::command::CustomCommand;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Executes post-build finalize hooks, supporting both plugin and shell command hooks.
|
||||
///
|
||||
/// Each hook can be a plugin call (prefixed with "plugin:") or a direct shell command.
|
||||
///
|
||||
/// Plugins receive a modified context with custom timeout and environment variables.
|
||||
pub async fn hook(
|
||||
hook: Option<&Vec<CustomCommand>>,
|
||||
ctx: &ExecutionContext,
|
||||
@@ -21,7 +26,6 @@ pub async fn hook(
|
||||
let engine = Engine::new();
|
||||
for (index, hook_cmd) in hook.iter().enumerate() {
|
||||
if hook_cmd.command.starts_with("plugin:") {
|
||||
// Treat as a plugin
|
||||
let mut plugin_ctx = ctx.clone();
|
||||
plugin_ctx.timeout = Some(std::time::Duration::from_millis(hook_cmd.timeout_ms));
|
||||
for i in hook_cmd.environment.clone().unwrap_or_default() {
|
||||
|
||||
@@ -59,6 +59,7 @@ pub async fn hook(
|
||||
ctx: &ExecutionContext,
|
||||
event_tx: EventSender,
|
||||
) -> Result<(), ExecutionError> {
|
||||
dbg!(ctx);
|
||||
let hook = match hook {
|
||||
Some(h) => h,
|
||||
None => {
|
||||
@@ -80,6 +81,7 @@ pub async fn hook(
|
||||
let result = engine
|
||||
.execute_command_with_delta(&hook_cmd.command, ctx, &event_tx, &deltactx)
|
||||
.await;
|
||||
dbg!(&result);
|
||||
if let Err(e) = result {
|
||||
log::error!("Hook {} failed: {:?}", index, e);
|
||||
return Err(e);
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
/// Socket type for executor connections.
|
||||
pub enum ExecutorSocket {
|
||||
/// Unix domain socket at the given path.
|
||||
Unix(String),
|
||||
/// TCP socket at the given host:port address.
|
||||
Tcp(String),
|
||||
/// Dry-run mode (discards all output).
|
||||
DryRun,
|
||||
}
|
||||
|
||||
/// Parses a socket address string and returns the appropriate ExecutorSocket variant.
|
||||
///
|
||||
/// Supports the following formats:
|
||||
/// - `unix://<path>` - Unix domain socket
|
||||
/// - `/<absolute/path>` - Unix domain socket (shorthand)
|
||||
/// - `tcp://<host>:<port>` - TCP socket
|
||||
/// - `<host>:<port>` - TCP socket (shorthand, no tcp:// prefix)
|
||||
/// - `dryrun` - Dry-run mode
|
||||
pub fn get_socket_addr(socket_addr: String) -> anyhow::Result<ExecutorSocket> {
|
||||
let socket = if socket_addr.starts_with("unix://") {
|
||||
ExecutorSocket::Unix(socket_addr.strip_prefix("unix://").unwrap().to_string())
|
||||
@@ -19,6 +32,56 @@ pub fn get_socket_addr(socket_addr: String) -> anyhow::Result<ExecutorSocket> {
|
||||
Ok(socket)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_unix_protocol_prefix() {
|
||||
let socket = get_socket_addr("unix:///var/run/executor.sock".to_string()).unwrap();
|
||||
match socket {
|
||||
ExecutorSocket::Unix(path) => assert_eq!(path, "/var/run/executor.sock"),
|
||||
_ => panic!("Expected Unix socket"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unix_absolute_path() {
|
||||
let socket = get_socket_addr("/var/run/executor.sock".to_string()).unwrap();
|
||||
match socket {
|
||||
ExecutorSocket::Unix(path) => assert_eq!(path, "/var/run/executor.sock"),
|
||||
_ => panic!("Expected Unix socket"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tcp_protocol_prefix() {
|
||||
let socket = get_socket_addr("tcp://localhost:8080".to_string()).unwrap();
|
||||
match socket {
|
||||
ExecutorSocket::Tcp(addr) => assert_eq!(addr, "localhost:8080"),
|
||||
_ => panic!("Expected Tcp socket"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_dryrun() {
|
||||
let socket = get_socket_addr("dryrun".to_string()).unwrap();
|
||||
match socket {
|
||||
ExecutorSocket::DryRun => {}
|
||||
_ => panic!("Expected DryRun socket"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tcp_fallback() {
|
||||
let socket = get_socket_addr("localhost:8080".to_string()).unwrap();
|
||||
match socket {
|
||||
ExecutorSocket::Tcp(addr) => assert_eq!(addr, "localhost:8080"),
|
||||
_ => panic!("Expected Tcp socket (fallback)"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn establish_connection(
|
||||
socket_addr: &ExecutorSocket,
|
||||
) -> anyhow::Result<Box<dyn tokio::io::AsyncWrite + Unpin>> {
|
||||
|
||||
@@ -1,19 +1,37 @@
|
||||
use std::str::FromStr;
|
||||
|
||||
/// Supported build architectures for cross-compilation.
|
||||
///
|
||||
/// Variants like `Amd64`/`Aarch64`/`Loong64` are normalized to their
|
||||
/// canonical forms (`X86_64`/`Arm64`/`Loongarch64`) during processing.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Default)]
|
||||
pub enum Architecture {
|
||||
/// No architecture specified (used as default/placeholder).
|
||||
#[default]
|
||||
NoArch,
|
||||
/// 64-bit x86 architecture.
|
||||
X86_64,
|
||||
Amd64, // Same as X86_64, normalized when processing
|
||||
/// Alias for X86_64, normalized during processing.
|
||||
Amd64,
|
||||
/// 64-bit ARM architecture.
|
||||
Arm64,
|
||||
Aarch64, // Same as Arm64, normalized when processing
|
||||
/// Alias for Arm64, normalized during processing.
|
||||
Aarch64,
|
||||
/// 64-bit RISC-V architecture.
|
||||
Riscv64,
|
||||
/// Loongson 64-bit architecture (canonical form).
|
||||
Loongarch64,
|
||||
Loong64, // Same as Loongarch64, normalized when processing, discarding old world Loongarch for simplicity
|
||||
/// Alias for Loongarch64, normalized during processing.
|
||||
Loong64,
|
||||
}
|
||||
|
||||
impl Architecture {
|
||||
/// Returns the normalized canonical form of this architecture.
|
||||
///
|
||||
/// - `Amd64` → `X86_64`
|
||||
/// - `Aarch64` → `Arm64`
|
||||
/// - `Loong64` → `Loongarch64`
|
||||
/// - All other variants are returned unchanged.
|
||||
pub fn normalized(&self) -> Architecture {
|
||||
match self {
|
||||
Architecture::Amd64 => Architecture::X86_64,
|
||||
@@ -23,6 +41,7 @@ impl Architecture {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a static string representation of this architecture variant.
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Architecture::NoArch => "noarch",
|
||||
@@ -40,6 +59,26 @@ impl Architecture {
|
||||
impl FromStr for Architecture {
|
||||
type Err = String;
|
||||
|
||||
/// Parses an architecture string case-insensitively.
|
||||
///
|
||||
/// Accepts both canonical and alias forms (e.g., "amd64" and "x86_64").
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for unknown architecture strings.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// # use workshop_baker::types::Architecture;
|
||||
/// use std::str::FromStr;
|
||||
///
|
||||
/// let arch = Architecture::from_str("amd64").unwrap();
|
||||
/// assert_eq!(arch, Architecture::Amd64);
|
||||
///
|
||||
/// let unknown = Architecture::from_str("unknown").unwrap_err();
|
||||
/// assert!(unknown.contains("Unknown architecture"));
|
||||
/// ```
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"noarch" => Ok(Architecture::NoArch),
|
||||
@@ -51,3 +90,98 @@ impl FromStr for Architecture {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// normalized() tests
|
||||
|
||||
#[test]
|
||||
fn test_normalized_amd64_to_x86_64() {
|
||||
assert_eq!(Architecture::Amd64.normalized(), Architecture::X86_64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalized_aarch64_to_arm64() {
|
||||
assert_eq!(Architecture::Aarch64.normalized(), Architecture::Arm64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalized_loong64_to_loongarch64() {
|
||||
assert_eq!(Architecture::Loong64.normalized(), Architecture::Loongarch64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalized_noarch_unchanged() {
|
||||
assert_eq!(Architecture::NoArch.normalized(), Architecture::NoArch);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalized_x86_64_unchanged() {
|
||||
assert_eq!(Architecture::X86_64.normalized(), Architecture::X86_64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalized_arm64_unchanged() {
|
||||
assert_eq!(Architecture::Arm64.normalized(), Architecture::Arm64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalized_riscv64_unchanged() {
|
||||
assert_eq!(Architecture::Riscv64.normalized(), Architecture::Riscv64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_normalized_loongarch64_unchanged() {
|
||||
assert_eq!(Architecture::Loongarch64.normalized(), Architecture::Loongarch64);
|
||||
}
|
||||
|
||||
// as_str() tests
|
||||
|
||||
#[test]
|
||||
fn test_as_str_all_variants() {
|
||||
assert_eq!(Architecture::NoArch.as_str(), "noarch");
|
||||
assert_eq!(Architecture::X86_64.as_str(), "x86_64");
|
||||
assert_eq!(Architecture::Amd64.as_str(), "amd64");
|
||||
assert_eq!(Architecture::Arm64.as_str(), "arm64");
|
||||
assert_eq!(Architecture::Aarch64.as_str(), "aarch64");
|
||||
assert_eq!(Architecture::Riscv64.as_str(), "riscv64");
|
||||
assert_eq!(Architecture::Loongarch64.as_str(), "loongarch64");
|
||||
assert_eq!(Architecture::Loong64.as_str(), "loong64");
|
||||
}
|
||||
|
||||
// FromStr tests
|
||||
|
||||
#[test]
|
||||
fn test_from_str_valid_canonical() {
|
||||
assert_eq!("noarch".parse::<Architecture>().unwrap(), Architecture::NoArch);
|
||||
assert_eq!("x86_64".parse::<Architecture>().unwrap(), Architecture::Amd64);
|
||||
assert_eq!("arm64".parse::<Architecture>().unwrap(), Architecture::Aarch64);
|
||||
assert_eq!("riscv64".parse::<Architecture>().unwrap(), Architecture::Riscv64);
|
||||
assert_eq!("loongarch64".parse::<Architecture>().unwrap(), Architecture::Loong64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_str_valid_aliases() {
|
||||
assert_eq!("amd64".parse::<Architecture>().unwrap(), Architecture::Amd64);
|
||||
assert_eq!("aarch64".parse::<Architecture>().unwrap(), Architecture::Aarch64);
|
||||
assert_eq!("loong64".parse::<Architecture>().unwrap(), Architecture::Loong64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_str_case_insensitive() {
|
||||
assert_eq!("X86_64".parse::<Architecture>().unwrap(), Architecture::Amd64);
|
||||
assert_eq!("ARM64".parse::<Architecture>().unwrap(), Architecture::Aarch64);
|
||||
assert_eq!("RISCV64".parse::<Architecture>().unwrap(), Architecture::Riscv64);
|
||||
assert_eq!("NoArch".parse::<Architecture>().unwrap(), Architecture::NoArch);
|
||||
assert_eq!("LoongArch64".parse::<Architecture>().unwrap(), Architecture::Loong64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_str_unknown_error() {
|
||||
let result = "unknown_arch".parse::<Architecture>();
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Unknown architecture"));
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,125 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Build environment type.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BuilderType {
|
||||
/// Docker-based build environment.
|
||||
Docker,
|
||||
/// Firecracker microVM-based build environment.
|
||||
Firecracker,
|
||||
/// Custom build environment with user-defined scripts.
|
||||
Custom,
|
||||
/// Bare metal build environment.
|
||||
Baremetal,
|
||||
}
|
||||
|
||||
/// Configuration for build environments.
|
||||
///
|
||||
/// Uses untagged representation to accept any variant without a type key.
|
||||
/// Automatically detects the appropriate variant based on YAML structure.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
#[serde(untagged)]
|
||||
pub enum BuilderConfig {
|
||||
/// Docker-specific build configuration.
|
||||
Docker(DockerConfig),
|
||||
/// Firecracker-specific build configuration.
|
||||
Firecracker(FirecrackerConfig),
|
||||
/// Custom build environment with setup/cleanup scripts.
|
||||
Custom(CustomConfig),
|
||||
/// Bare metal build configuration.
|
||||
Baremetal(BaremetalConfig),
|
||||
}
|
||||
|
||||
/// Docker build environment configuration.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct DockerConfig {
|
||||
/// Docker image to use for the build.
|
||||
#[serde(default)]
|
||||
pub image: Option<String>,
|
||||
|
||||
/// Path to Dockerfile for the build.
|
||||
#[serde(default)]
|
||||
pub dockerfile: Option<String>,
|
||||
|
||||
/// Build arguments passed to Docker.
|
||||
#[serde(default)]
|
||||
pub build_args: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
/// Firecracker microVM build configuration (empty placeholder).
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct FirecrackerConfig {}
|
||||
|
||||
/// Custom build environment with user-defined setup and cleanup scripts.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct CustomConfig {
|
||||
/// Identifier for the custom builder.
|
||||
pub name: String,
|
||||
|
||||
/// Script to run during environment setup.
|
||||
pub setup_script: String,
|
||||
|
||||
/// Script to run during environment cleanup.
|
||||
pub cleanup_script: String,
|
||||
}
|
||||
|
||||
/// Bare metal build configuration (empty placeholder).
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct BaremetalConfig {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_builder_type_serde_roundtrip() {
|
||||
for builder_type in [
|
||||
BuilderType::Docker,
|
||||
BuilderType::Firecracker,
|
||||
BuilderType::Custom,
|
||||
BuilderType::Baremetal,
|
||||
] {
|
||||
let yaml = serde_yaml::to_string(&builder_type).unwrap();
|
||||
let deserialized: BuilderType = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(deserialized, builder_type);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_docker_config_serde() {
|
||||
let config = DockerConfig {
|
||||
image: Some("rust:latest".to_string()),
|
||||
dockerfile: Some("Dockerfile".to_string()),
|
||||
build_args: Some(HashMap::from([("RUST_BACKTRACE".to_string(), "1".to_string())])),
|
||||
};
|
||||
let yaml = serde_yaml::to_string(&config).unwrap();
|
||||
let deserialized: DockerConfig = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(deserialized.image, Some("rust:latest".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builder_config_untagged_docker() {
|
||||
let yaml = "image: rust:latest";
|
||||
let config: BuilderConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
match config {
|
||||
BuilderConfig::Docker(dc) => assert_eq!(dc.image, Some("rust:latest".to_string())),
|
||||
_ => panic!("Expected Docker variant"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builder_config_untagged_docker_first() {
|
||||
let yaml = "image: rust:latest\nname: my-builder";
|
||||
let config: BuilderConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert!(matches!(config, BuilderConfig::Docker(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builder_config_untagged_empty_map() {
|
||||
let yaml = "{}";
|
||||
let config: BuilderConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
assert!(matches!(config, BuilderConfig::Docker(_)));
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,14 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Pipeline execution phase.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum StagePhase {
|
||||
/// Prebake phase: environment setup and dependency installation.
|
||||
Prebake,
|
||||
/// Bake phase: main build and compilation.
|
||||
Bake,
|
||||
/// Finalize phase: packaging and deployment.
|
||||
Finalize,
|
||||
}
|
||||
|
||||
@@ -18,11 +22,16 @@ impl std::fmt::Display for StagePhase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Outcome of a pipeline stage execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum StageResult {
|
||||
/// Stage completed successfully.
|
||||
Success,
|
||||
/// Stage failed during execution.
|
||||
Failure,
|
||||
/// Stage was canceled before completion.
|
||||
Canceled,
|
||||
/// Stage was skipped.
|
||||
Skipped,
|
||||
}
|
||||
|
||||
@@ -37,30 +46,60 @@ impl std::fmt::Display for StageResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// Information about a single pipeline stage execution.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StageInfo {
|
||||
/// Stage name identifier.
|
||||
pub name: String,
|
||||
/// Execution phase.
|
||||
pub phase: StagePhase,
|
||||
/// Substage identifier within the phase.
|
||||
pub substage: String,
|
||||
/// Stage outcome.
|
||||
pub result: StageResult,
|
||||
/// Execution duration in milliseconds.
|
||||
pub duration_ms: u64,
|
||||
/// Stage start timestamp.
|
||||
pub started_at: DateTime<Utc>,
|
||||
/// Stage completion timestamp.
|
||||
pub finished_at: DateTime<Utc>,
|
||||
/// Error message if the stage failed.
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
impl StageInfo {
|
||||
/// Returns the full stage name in `phase.substage` format.
|
||||
///
|
||||
/// # Example
|
||||
/// ```
|
||||
/// # use workshop_baker::types::buildstatus::{StageInfo, StagePhase, StageResult};
|
||||
/// # use chrono::Utc;
|
||||
/// let info = StageInfo {
|
||||
/// name: "bootstrap".to_string(),
|
||||
/// phase: StagePhase::Prebake,
|
||||
/// substage: "bootstrap".to_string(),
|
||||
/// result: StageResult::Success,
|
||||
/// duration_ms: 1000,
|
||||
/// started_at: Utc::now(),
|
||||
/// finished_at: Utc::now(),
|
||||
/// error_message: None,
|
||||
/// };
|
||||
/// assert_eq!(info.full_name(), "prebake.bootstrap");
|
||||
/// ```
|
||||
pub fn full_name(&self) -> String {
|
||||
format!("{}.{}", self.phase, self.substage)
|
||||
}
|
||||
}
|
||||
|
||||
/// Aggregate build status containing all stage information.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct BuildStatus {
|
||||
/// List of stage execution records.
|
||||
pub stages: Vec<StageInfo>,
|
||||
}
|
||||
|
||||
impl BuildStatus {
|
||||
/// Returns the result of the final stage, or `Success` if no stages recorded.
|
||||
pub fn final_result(&self) -> StageResult {
|
||||
self.stages
|
||||
.last()
|
||||
@@ -68,12 +107,14 @@ impl BuildStatus {
|
||||
.unwrap_or(StageResult::Success)
|
||||
}
|
||||
|
||||
/// Returns `true` if any stage failed.
|
||||
pub fn has_failures(&self) -> bool {
|
||||
self.stages
|
||||
.iter()
|
||||
.any(|s| matches!(s.result, StageResult::Failure))
|
||||
}
|
||||
|
||||
/// Returns the full name of the last stage, or `None` if no stages recorded.
|
||||
pub fn last_stage_name(&self) -> Option<String> {
|
||||
self.stages.last().map(|s| s.full_name())
|
||||
}
|
||||
@@ -82,6 +123,10 @@ impl BuildStatus {
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
|
||||
/// Appends a stage record to the status file in `status_dir`.
|
||||
///
|
||||
/// Each line in the file contains a JSON-encoded `StageInfo` entry.
|
||||
/// Creates the file if it does not exist, appends otherwise.
|
||||
pub fn write_stage_status<P: AsRef<Path>>(status_dir: P, stage: StageInfo) -> io::Result<()> {
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
@@ -105,6 +150,11 @@ pub fn write_stage_status<P: AsRef<Path>>(status_dir: P, stage: StageInfo) -> io
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads and parses the build status from `status_dir`.
|
||||
///
|
||||
/// Reads the status file, parsing each line as a JSON-encoded `StageInfo`.
|
||||
/// Returns an empty `BuildStatus` if the file does not exist.
|
||||
/// Empty lines are ignored during parsing.
|
||||
pub fn read_build_status<P: AsRef<Path>>(status_dir: P) -> io::Result<BuildStatus> {
|
||||
use std::fs;
|
||||
|
||||
@@ -185,4 +235,4 @@ mod tests {
|
||||
assert!(status.stages.is_empty());
|
||||
assert_eq!(status.final_result(), StageResult::Success);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Cache directory configuration with strategy and compression mode.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct CacheDirectory {
|
||||
/// Path to the cache directory.
|
||||
pub path: String,
|
||||
/// Cache strategy type. Defaults to `Always`.
|
||||
#[serde(default = "default_strategy")]
|
||||
pub strategy: CacheStrategyType,
|
||||
/// Compression mode for cached artifacts. Defaults to `Zstd`.
|
||||
#[serde(default = "default_mode")]
|
||||
pub mode: CacheMode,
|
||||
}
|
||||
@@ -15,30 +20,44 @@ fn default_mode() -> CacheMode {
|
||||
CacheMode::Zstd
|
||||
}
|
||||
|
||||
/// Type of cache strategy.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CacheStrategyType {
|
||||
/// Always cache, read and write on every operation.
|
||||
Always,
|
||||
/// Read-only cache, never writes new entries.
|
||||
Readonly,
|
||||
/// Clean the cache according to cleanup policy.
|
||||
Clean,
|
||||
/// Never use cache.
|
||||
Never,
|
||||
}
|
||||
|
||||
/// Compression mode for cached artifacts.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CacheMode {
|
||||
/// Gzip compression.
|
||||
Gzip,
|
||||
/// Zstd compression.
|
||||
Zstd,
|
||||
/// No compression.
|
||||
None,
|
||||
/// Directory mode.
|
||||
Dir,
|
||||
}
|
||||
|
||||
/// Cache strategy with size and TTL limits.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct CacheStrategy {
|
||||
/// Time-to-live in days. Defaults to 30.
|
||||
#[serde(default = "default_ttl_days", rename = "ttl_days")]
|
||||
pub ttl_days: u32,
|
||||
/// Maximum cache size in gigabytes. Defaults to 20.
|
||||
#[serde(default = "default_max_size_gb", rename = "max_size_gb")]
|
||||
pub max_size_gb: u32,
|
||||
/// Cleanup policy when cache exceeds limits. Defaults to `Lru`.
|
||||
#[serde(default = "default_cleanup_policy", rename = "cleanup_policy")]
|
||||
pub cleanup_policy: CleanupPolicy,
|
||||
}
|
||||
@@ -53,10 +72,163 @@ fn default_cleanup_policy() -> CleanupPolicy {
|
||||
CleanupPolicy::Lru
|
||||
}
|
||||
|
||||
/// Policy for cleaning up cache when limits are exceeded.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CleanupPolicy {
|
||||
/// Least Recently Used eviction.
|
||||
Lru,
|
||||
/// First In First Out eviction.
|
||||
Fifo,
|
||||
/// Evict by size.
|
||||
Size,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// CacheDirectory default tests
|
||||
|
||||
#[test]
|
||||
fn test_cache_directory_default_strategy() {
|
||||
let json = r#"{"path": "/tmp/cache"}"#;
|
||||
let cache: CacheDirectory = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(cache.strategy, CacheStrategyType::Always);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_directory_default_mode() {
|
||||
let json = r#"{"path": "/tmp/cache"}"#;
|
||||
let cache: CacheDirectory = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(cache.mode, CacheMode::Zstd);
|
||||
}
|
||||
|
||||
// CacheStrategy default tests
|
||||
|
||||
#[test]
|
||||
fn test_cache_strategy_default_ttl_days() {
|
||||
let strategy_json = r#"{}"#;
|
||||
let strategy: CacheStrategy = serde_json::from_str(strategy_json).unwrap();
|
||||
assert_eq!(strategy.ttl_days, 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_strategy_default_max_size_gb() {
|
||||
let strategy_json = r#"{}"#;
|
||||
let strategy: CacheStrategy = serde_json::from_str(strategy_json).unwrap();
|
||||
assert_eq!(strategy.max_size_gb, 20);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_strategy_default_cleanup_policy() {
|
||||
let strategy_json = r#"{}"#;
|
||||
let strategy: CacheStrategy = serde_json::from_str(strategy_json).unwrap();
|
||||
assert_eq!(strategy.cleanup_policy, CleanupPolicy::Lru);
|
||||
}
|
||||
|
||||
// Serde round-trip tests for CacheStrategyType
|
||||
|
||||
#[test]
|
||||
fn test_cache_strategy_type_always_roundtrip() {
|
||||
let val = CacheStrategyType::Always;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"always\"");
|
||||
let parsed: CacheStrategyType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_strategy_type_readonly_roundtrip() {
|
||||
let val = CacheStrategyType::Readonly;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"readonly\"");
|
||||
let parsed: CacheStrategyType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_strategy_type_clean_roundtrip() {
|
||||
let val = CacheStrategyType::Clean;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"clean\"");
|
||||
let parsed: CacheStrategyType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_strategy_type_never_roundtrip() {
|
||||
let val = CacheStrategyType::Never;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"never\"");
|
||||
let parsed: CacheStrategyType = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
// Serde round-trip tests for CacheMode
|
||||
|
||||
#[test]
|
||||
fn test_cache_mode_gzip_roundtrip() {
|
||||
let val = CacheMode::Gzip;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"gzip\"");
|
||||
let parsed: CacheMode = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_mode_zstd_roundtrip() {
|
||||
let val = CacheMode::Zstd;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"zstd\"");
|
||||
let parsed: CacheMode = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_mode_none_roundtrip() {
|
||||
let val = CacheMode::None;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"none\"");
|
||||
let parsed: CacheMode = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_mode_dir_roundtrip() {
|
||||
let val = CacheMode::Dir;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"dir\"");
|
||||
let parsed: CacheMode = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
// Serde round-trip tests for CleanupPolicy
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_policy_lru_roundtrip() {
|
||||
let val = CleanupPolicy::Lru;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"lru\"");
|
||||
let parsed: CleanupPolicy = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_policy_fifo_roundtrip() {
|
||||
let val = CleanupPolicy::Fifo;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"fifo\"");
|
||||
let parsed: CleanupPolicy = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cleanup_policy_size_roundtrip() {
|
||||
let val = CleanupPolicy::Size;
|
||||
let json = serde_json::to_string(&val).unwrap();
|
||||
assert_eq!(json, "\"size\"");
|
||||
let parsed: CleanupPolicy = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(parsed, val);
|
||||
}
|
||||
}
|
||||
@@ -2,21 +2,42 @@ use crate::types::time::deserialize_duration_ms;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A customizable command with environment, working directory, and timeout settings.
|
||||
///
|
||||
/// # Example
|
||||
/// ```yaml
|
||||
/// name: build-script
|
||||
/// command: ./build.sh
|
||||
/// timeout: 60000
|
||||
/// ```
|
||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||
pub struct CustomCommand {
|
||||
/// Command identifier. Defaults to `"(unnamed)"`.
|
||||
#[serde(default = "default_name")]
|
||||
pub name: String,
|
||||
|
||||
/// The shell command to execute.
|
||||
pub command: String,
|
||||
|
||||
/// Environment variables as key-value pairs. A `None` value means the variable
|
||||
/// should be unset.
|
||||
#[serde(default)]
|
||||
pub environment: Option<HashMap<String, Option<String>>>,
|
||||
|
||||
/// Working directory for command execution. Defaults to current directory.
|
||||
#[serde(default)]
|
||||
pub working_dir: Option<String>,
|
||||
|
||||
/// Execution timeout in milliseconds. Defaults to `300_000` (5 minutes).
|
||||
/// Accepts human-readable duration strings (e.g., `"30s"`, `"5m"`).
|
||||
#[serde(
|
||||
default = "default_timeout_ms",
|
||||
deserialize_with = "deserialize_duration_ms",
|
||||
rename = "timeout"
|
||||
)]
|
||||
pub timeout_ms: u64,
|
||||
|
||||
/// Additional YAML value for flexible argument passing.
|
||||
#[serde(default)]
|
||||
pub argument: serde_yaml::Value,
|
||||
}
|
||||
@@ -28,3 +49,47 @@ fn default_timeout_ms() -> u64 {
|
||||
fn default_name() -> String {
|
||||
"(unnamed)".to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_values() {
|
||||
let yaml = "command: echo hello";
|
||||
let cmd: CustomCommand = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(cmd.name, "(unnamed)");
|
||||
assert_eq!(cmd.timeout_ms, 300_000);
|
||||
assert!(cmd.environment.is_none());
|
||||
assert!(cmd.working_dir.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_full_serde_roundtrip() {
|
||||
let cmd = CustomCommand {
|
||||
name: "test-cmd".to_string(),
|
||||
command: "./build.sh".to_string(),
|
||||
environment: Some(HashMap::from([
|
||||
("FOO".to_string(), Some("bar".to_string())),
|
||||
("BAZ".to_string(), None),
|
||||
])),
|
||||
working_dir: Some("/tmp/build".to_string()),
|
||||
timeout_ms: 60000,
|
||||
argument: serde_yaml::Value::Null,
|
||||
};
|
||||
let yaml = serde_yaml::to_string(&cmd).unwrap();
|
||||
let deserialized: CustomCommand = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(deserialized.name, "test-cmd");
|
||||
assert_eq!(deserialized.command, "./build.sh");
|
||||
assert_eq!(deserialized.timeout_ms, 60_000_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_minimal_yaml() {
|
||||
let yaml = "command: ./build.sh";
|
||||
let cmd: CustomCommand = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(cmd.command, "./build.sh");
|
||||
assert_eq!(cmd.name, "(unnamed)");
|
||||
assert_eq!(cmd.timeout_ms, 300_000);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,94 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Compression method for build artifacts.
|
||||
///
|
||||
/// Defaults to `Zstd` when used in configuration.
|
||||
#[derive(Debug, Deserialize, Serialize, Clone, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CompressionMethod {
|
||||
/// Zstandard compression (default).
|
||||
#[default]
|
||||
Zstd,
|
||||
/// Gzip compression.
|
||||
Gzip,
|
||||
/// No compression (store as-is).
|
||||
None,
|
||||
/// Directory mode (no compression, store as directory).
|
||||
Dir,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_is_zstd() {
|
||||
let method = CompressionMethod::default();
|
||||
match method {
|
||||
CompressionMethod::Zstd => {}
|
||||
other => panic!("expected Zstd, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_zstd() {
|
||||
let method = CompressionMethod::Zstd;
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
assert_eq!(json, "\"zstd\"");
|
||||
let parsed: CompressionMethod = serde_json::from_str(&json).unwrap();
|
||||
match parsed {
|
||||
CompressionMethod::Zstd => {}
|
||||
other => panic!("expected Zstd, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_gzip() {
|
||||
let method = CompressionMethod::Gzip;
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
assert_eq!(json, "\"gzip\"");
|
||||
let parsed: CompressionMethod = serde_json::from_str(&json).unwrap();
|
||||
match parsed {
|
||||
CompressionMethod::Gzip => {}
|
||||
other => panic!("expected Gzip, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_none() {
|
||||
let method = CompressionMethod::None;
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
assert_eq!(json, "\"none\"");
|
||||
let parsed: CompressionMethod = serde_json::from_str(&json).unwrap();
|
||||
match parsed {
|
||||
CompressionMethod::None => {}
|
||||
other => panic!("expected None, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_roundtrip_dir() {
|
||||
let method = CompressionMethod::Dir;
|
||||
let json = serde_json::to_string(&method).unwrap();
|
||||
assert_eq!(json, "\"dir\"");
|
||||
let parsed: CompressionMethod = serde_json::from_str(&json).unwrap();
|
||||
match parsed {
|
||||
CompressionMethod::Dir => {}
|
||||
other => panic!("expected Dir, got {:?}", other),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serde_deserialize_lowercase() {
|
||||
let parsed: CompressionMethod = serde_json::from_str("\"zstd\"").unwrap();
|
||||
match parsed {
|
||||
CompressionMethod::Zstd => {}
|
||||
other => panic!("expected Zstd, got {:?}", other),
|
||||
}
|
||||
let parsed: CompressionMethod = serde_json::from_str("\"gzip\"").unwrap();
|
||||
match parsed {
|
||||
CompressionMethod::Gzip => {}
|
||||
other => panic!("expected Gzip, got {:?}", other),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,27 @@
|
||||
/// Repology endpoint configuration for package name resolution.
|
||||
///
|
||||
/// # Deserialize
|
||||
///
|
||||
/// Accepts: `disabled`, `none`, `local`, `remote`, `server`, `default`, or empty string.
|
||||
/// Empty string and `default` both map to `RepologyEndpoint::Default`.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns a `serde` error for unknown variant names.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub enum RepologyEndpoint {
|
||||
/// Default Repology endpoint.
|
||||
#[default]
|
||||
Default,
|
||||
/// Repology integration disabled.
|
||||
Disabled,
|
||||
/// No Repology endpoint configured.
|
||||
None,
|
||||
/// Local Repology instance.
|
||||
Local,
|
||||
/// Remote Repology service.
|
||||
Remote,
|
||||
/// Server-based Repology endpoint.
|
||||
Server,
|
||||
}
|
||||
|
||||
@@ -64,3 +80,72 @@ impl serde::Serialize for RepologyEndpoint {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_disabled() {
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("disabled").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::Disabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_none() {
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("none").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_local() {
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("local").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_remote() {
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("remote").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::Remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_server() {
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("server").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::Server);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_default() {
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("default").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_default_string() {
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("default").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::Default);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unknown_variant_error() {
|
||||
let result: Result<RepologyEndpoint, _> = serde_yaml::from_str("unknown");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_serialize_roundtrip() {
|
||||
for ep in [
|
||||
RepologyEndpoint::Default,
|
||||
RepologyEndpoint::Disabled,
|
||||
RepologyEndpoint::None,
|
||||
RepologyEndpoint::Local,
|
||||
RepologyEndpoint::Remote,
|
||||
RepologyEndpoint::Server,
|
||||
] {
|
||||
let yaml = serde_yaml::to_string(&ep).unwrap();
|
||||
let deserialized: RepologyEndpoint = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(deserialized, ep);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,26 @@
|
||||
use serde::{Deserializer, de::Visitor};
|
||||
|
||||
/// Maximum duration in milliseconds that can be represented as i32::MAX.
|
||||
pub const INT_MAX_MS: u64 = i32::MAX as u64;
|
||||
|
||||
/// Parses a duration string to milliseconds.
|
||||
///
|
||||
/// Accepts formats like "30s", "5m", "1h", "1d", etc.
|
||||
/// Warns when year ("y") or month ("mon") units are used, treating them
|
||||
/// as 365 days and 30 days respectively.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the input is invalid or exceeds [`INT_MAX_MS`].
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```
|
||||
/// # use workshop_baker::types::time::parse_duration_to_ms;
|
||||
/// assert_eq!(parse_duration_to_ms("30s").unwrap(), 30_000);
|
||||
/// assert_eq!(parse_duration_to_ms("5m").unwrap(), 300_000);
|
||||
/// assert_eq!(parse_duration_to_ms("1h").unwrap(), 3_600_000);
|
||||
/// ```
|
||||
pub fn parse_duration_to_ms(input: &str) -> Result<u64, String> {
|
||||
let lowered = input.to_lowercase();
|
||||
if lowered.contains('y') || lowered.contains("mon") {
|
||||
@@ -27,6 +46,15 @@ pub fn parse_duration_to_ms(input: &str) -> Result<u64, String> {
|
||||
Ok(ms)
|
||||
}
|
||||
|
||||
/// Serde visitor for deserializing a duration in milliseconds.
|
||||
///
|
||||
/// Accepts either:
|
||||
/// - A number (treated as seconds and converted to milliseconds)
|
||||
/// - A duration string like "30s", "5m", "1h"
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for negative values, invalid strings, or values exceeding [`INT_MAX_MS`].
|
||||
pub fn deserialize_duration_ms<'de, D>(deserializer: D) -> Result<u64, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
@@ -76,6 +104,14 @@ where
|
||||
deserializer.deserialize_any(DurationMsVisitor)
|
||||
}
|
||||
|
||||
/// Serde visitor for deserializing an optional duration in milliseconds.
|
||||
///
|
||||
/// Accepts `null`, a number (seconds), or a duration string.
|
||||
/// Returns `None` for null or unit values.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error for negative values, invalid strings, or values exceeding [`INT_MAX_MS`].
|
||||
pub fn deserialize_option_duration_ms<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
|
||||
where
|
||||
D: Deserializer<'de>,
|
||||
@@ -138,3 +174,138 @@ where
|
||||
|
||||
deserializer.deserialize_option(OptionDurationMsVisitor)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::Deserialize;
|
||||
|
||||
// parse_duration_to_ms tests
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_seconds() {
|
||||
assert_eq!(parse_duration_to_ms("30s").unwrap(), 30_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_minutes() {
|
||||
assert_eq!(parse_duration_to_ms("5m").unwrap(), 300_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_hours() {
|
||||
assert_eq!(parse_duration_to_ms("1h").unwrap(), 3_600_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_days() {
|
||||
assert_eq!(parse_duration_to_ms("2d").unwrap(), 172_800_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_milliseconds() {
|
||||
assert_eq!(parse_duration_to_ms("500ms").unwrap(), 500);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_invalid_error() {
|
||||
let result = parse_duration_to_ms("invalid");
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("Invalid duration"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_overflow_error() {
|
||||
// Value exceeding INT_MAX_MS
|
||||
let result = parse_duration_to_ms("2147483648s"); // > i32::MAX ms
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("exceeds maximum"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_duration_negative_error() {
|
||||
let result = parse_duration_to_ms("-30s");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// Serde deserialization tests for deserialize_duration_ms
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct DurationStruct {
|
||||
#[serde(deserialize_with = "deserialize_duration_ms")]
|
||||
duration_ms: u64,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_duration_from_number() {
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct Test {
|
||||
#[serde(deserialize_with = "deserialize_duration_ms")]
|
||||
val: u64,
|
||||
}
|
||||
let json = r#"{"val": 30}"#;
|
||||
let t: Test = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(t.val, 30_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_duration_from_string() {
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct Test {
|
||||
#[serde(deserialize_with = "deserialize_duration_ms")]
|
||||
val: u64,
|
||||
}
|
||||
let json = r#"{"val": "30s"}"#;
|
||||
let t: Test = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(t.val, 30_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_duration_negative_error() {
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Test {
|
||||
#[serde(deserialize_with = "deserialize_duration_ms")]
|
||||
val: u64,
|
||||
}
|
||||
let json = r#"{"val": -30}"#;
|
||||
let result = serde_json::from_str::<Test>(json);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// Serde deserialization tests for deserialize_option_duration_ms
|
||||
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct OptionalDurationStruct {
|
||||
#[serde(deserialize_with = "deserialize_option_duration_ms")]
|
||||
duration_ms: Option<u64>,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_option_duration_null() {
|
||||
let json = r#"{"duration_ms": null}"#;
|
||||
let t: OptionalDurationStruct = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(t.duration_ms, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_option_duration_from_number() {
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct DirectOpt {
|
||||
duration_ms: Option<u64>,
|
||||
}
|
||||
let json = r#"{"duration_ms": 60}"#;
|
||||
let t: DirectOpt = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(t.duration_ms, Some(60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_deserialize_option_duration_from_string() {
|
||||
#[derive(Debug, Deserialize, PartialEq)]
|
||||
struct DirectOptStr {
|
||||
duration_ms: Option<String>,
|
||||
}
|
||||
let json = r#"{"duration_ms": "2m"}"#;
|
||||
let t: DirectOptStr = serde_json::from_str(json).unwrap();
|
||||
assert_eq!(t.duration_ms, Some("2m".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use workshop_baker::bake::parser::parse_script;
|
||||
|
||||
#[test]
|
||||
fn test_full_pipeline_parse() {
|
||||
let script = r#"
|
||||
# @pipeline
|
||||
func1() { echo 1; }
|
||||
|
||||
# @pipeline
|
||||
func2() { echo 2; }
|
||||
"#;
|
||||
let parsed = parse_script(script).unwrap();
|
||||
assert_eq!(parsed.pipeline_functions.len(), 2);
|
||||
assert_eq!(parsed.pipeline_functions[0].name, "func1");
|
||||
assert_eq!(parsed.pipeline_functions[1].name, "func2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pipeline_with_after_decorator() {
|
||||
let script = r#"
|
||||
# @pipeline
|
||||
build() { echo building; }
|
||||
|
||||
# @pipeline
|
||||
# @after(build)
|
||||
test() { echo testing; }
|
||||
"#;
|
||||
let parsed = parse_script(script).unwrap();
|
||||
assert_eq!(parsed.pipeline_functions.len(), 2);
|
||||
assert_eq!(parsed.pipeline_functions[0].name, "build");
|
||||
assert_eq!(parsed.pipeline_functions[1].name, "test");
|
||||
assert_eq!(parsed.pipeline_functions[1].decorators.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pipeline_with_mixed_decorators() {
|
||||
let script = r#"
|
||||
# @pipeline
|
||||
# @timeout(60)
|
||||
step1() { echo step1; }
|
||||
|
||||
# @pipeline
|
||||
# @retry(3, 10)
|
||||
# @after(step1)
|
||||
step2() { echo step2; }
|
||||
"#;
|
||||
let parsed = parse_script(script).unwrap();
|
||||
assert_eq!(parsed.pipeline_functions.len(), 2);
|
||||
assert_eq!(parsed.pipeline_functions[0].name, "step1");
|
||||
assert_eq!(parsed.pipeline_functions[1].name, "step2");
|
||||
assert_eq!(parsed.pipeline_functions[0].decorators.len(), 2);
|
||||
assert_eq!(parsed.pipeline_functions[1].decorators.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pipeline_remaining_code_excludes_pipeline_functions() {
|
||||
let script = r#"#!/bin/bash
|
||||
# comment
|
||||
helper() { echo helper; }
|
||||
|
||||
# @pipeline
|
||||
build() { echo build; }
|
||||
"#;
|
||||
let parsed = parse_script(script).unwrap();
|
||||
assert_eq!(parsed.pipeline_functions.len(), 1);
|
||||
assert_eq!(parsed.pipeline_functions[0].name, "build");
|
||||
assert!(parsed.remaining_code.contains("helper"));
|
||||
assert!(!parsed.remaining_code.contains("# @pipeline"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pipeline_multiline_function() {
|
||||
let script = r#"
|
||||
# @pipeline
|
||||
deploy() {
|
||||
echo "deploying"
|
||||
echo "done"
|
||||
}
|
||||
"#;
|
||||
let parsed = parse_script(script).unwrap();
|
||||
assert_eq!(parsed.pipeline_functions.len(), 1);
|
||||
assert_eq!(parsed.pipeline_functions[0].name, "deploy");
|
||||
assert!(parsed.pipeline_functions[0].body.contains("deploying"));
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
use workshop_baker::prebake::parse;
|
||||
use workshop_baker::types::builderconfig::BuilderType;
|
||||
|
||||
#[test]
|
||||
fn test_prebake_config_minimal_yaml() {
|
||||
let yaml = r#"
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: docker
|
||||
"#;
|
||||
let config = parse(yaml).unwrap();
|
||||
assert_eq!(config.version, "1.0");
|
||||
assert_eq!(config.environment.builder, BuilderType::Docker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prebake_config_with_builder_type_baremetal() {
|
||||
let yaml = r#"
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: baremetal
|
||||
"#;
|
||||
let config = parse(yaml).unwrap();
|
||||
assert_eq!(config.environment.builder, BuilderType::Baremetal);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prebake_config_with_envvars() {
|
||||
let yaml = r#"
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: baremetal
|
||||
envvars:
|
||||
prebake:
|
||||
RUST_LOG: debug
|
||||
"#;
|
||||
let config = parse(yaml).unwrap();
|
||||
assert_eq!(config.environment.builder, BuilderType::Baremetal);
|
||||
let prebake_vars = config.envvars.unwrap().prebake.unwrap();
|
||||
assert_eq!(prebake_vars.get("RUST_LOG").unwrap(), "debug");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prebake_config_with_bootstrap_workspace() {
|
||||
let yaml = r#"
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: docker
|
||||
bootstrap:
|
||||
workspace:
|
||||
path: "/workspace"
|
||||
"#;
|
||||
let config = parse(yaml).unwrap();
|
||||
let bootstrap = config.bootstrap.unwrap();
|
||||
let workspace = bootstrap.workspace.unwrap();
|
||||
assert_eq!(workspace.path, "/workspace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prebake_config_validation_passes() {
|
||||
let yaml = r#"
|
||||
version: "1.0"
|
||||
environment:
|
||||
builder: docker
|
||||
"#;
|
||||
let config = parse(yaml).unwrap();
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prebake_config_validation_fails_bad_version() {
|
||||
let yaml = r#"
|
||||
version: "99.0"
|
||||
environment:
|
||||
builder: docker
|
||||
"#;
|
||||
let config = parse(yaml).unwrap();
|
||||
assert!(config.validate().is_err());
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
use workshop_baker::types::architecture::Architecture;
|
||||
use workshop_baker::types::builderconfig::BuilderConfig;
|
||||
use workshop_baker::types::cache::{CacheDirectory, CacheMode, CacheStrategyType};
|
||||
use workshop_baker::types::compression::CompressionMethod;
|
||||
use workshop_baker::types::command::CustomCommand;
|
||||
use workshop_baker::types::repology::RepologyEndpoint;
|
||||
|
||||
#[test]
|
||||
fn test_architecture_from_str() {
|
||||
let arch: Architecture = "x86_64".parse().unwrap();
|
||||
assert_eq!(arch, Architecture::Amd64);
|
||||
let arch: Architecture = "arm64".parse().unwrap();
|
||||
assert_eq!(arch, Architecture::Aarch64);
|
||||
let arch: Architecture = "riscv64".parse().unwrap();
|
||||
assert_eq!(arch, Architecture::Riscv64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_architecture_normalized() {
|
||||
assert_eq!(Architecture::Amd64.normalized(), Architecture::X86_64);
|
||||
assert_eq!(Architecture::Aarch64.normalized(), Architecture::Arm64);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_builder_config_docker_yaml() {
|
||||
let yaml = r#"
|
||||
image: "rust:latest"
|
||||
build_args:
|
||||
TARGET: x86_64
|
||||
"#;
|
||||
let config: BuilderConfig = serde_yaml::from_str(yaml).unwrap();
|
||||
match config {
|
||||
BuilderConfig::Docker(docker) => {
|
||||
assert_eq!(docker.image, Some("rust:latest".to_string()));
|
||||
}
|
||||
_ => panic!("Expected Docker config"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_directory_yaml_roundtrip() {
|
||||
let cache = CacheDirectory {
|
||||
path: "/tmp/cache".to_string(),
|
||||
strategy: CacheStrategyType::Readonly,
|
||||
mode: CacheMode::Gzip,
|
||||
};
|
||||
let yaml = serde_yaml::to_string(&cache).unwrap();
|
||||
let deserialized: CacheDirectory = serde_yaml::from_str(&yaml).unwrap();
|
||||
assert_eq!(cache.path, deserialized.path);
|
||||
assert_eq!(cache.strategy, deserialized.strategy);
|
||||
assert_eq!(cache.mode, deserialized.mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_repology_endpoint_yaml() {
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("disabled").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::Disabled);
|
||||
let ep: RepologyEndpoint = serde_yaml::from_str("local").unwrap();
|
||||
assert_eq!(ep, RepologyEndpoint::Local);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compression_method_yaml() {
|
||||
let cm: CompressionMethod = serde_yaml::from_str("gzip").unwrap();
|
||||
assert!(matches!(cm, CompressionMethod::Gzip));
|
||||
let cm: CompressionMethod = serde_yaml::from_str("zstd").unwrap();
|
||||
assert!(matches!(cm, CompressionMethod::Zstd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_command_yaml() {
|
||||
let yaml = r#"
|
||||
name: build
|
||||
timeout: 300
|
||||
command: cargo build
|
||||
"#;
|
||||
let cmd: CustomCommand = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(cmd.name, "build");
|
||||
assert_eq!(cmd.timeout_ms, 300_000);
|
||||
assert_eq!(cmd.command, "cargo build");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_custom_command_default_timeout() {
|
||||
let yaml = "command: echo hello";
|
||||
let cmd: CustomCommand = serde_yaml::from_str(yaml).unwrap();
|
||||
assert_eq!(cmd.name, "(unnamed)");
|
||||
assert_eq!(cmd.timeout_ms, 300_000);
|
||||
}
|
||||
Reference in New Issue
Block a user