refactor(plugin)!: remove dedicated plugin fetch subsystem
Drop the entire plugin fetch module (HTTP, git, checksums, extraction). Simplify mail notification plugin to a bare stub. Remove outdated metadata tests. BREAKING CHANGE: Not a buildable version!
This commit is contained in:
@@ -1,91 +0,0 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use crate::finalize::fetch;
|
||||
use crate::finalize::plugin::{PluginError, RawPluginMap};
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
|
||||
mod checksum;
|
||||
mod extract;
|
||||
mod git;
|
||||
mod http;
|
||||
mod types;
|
||||
|
||||
pub use types::FetchArgument;
|
||||
|
||||
enum URLScheme {
|
||||
Unknown,
|
||||
Git,
|
||||
Http,
|
||||
Https,
|
||||
File,
|
||||
}
|
||||
|
||||
impl URLScheme {
|
||||
fn parse(url: &str) -> (Self, String) {
|
||||
match url.split_once("://") {
|
||||
Some((prefix, rest)) => {
|
||||
let scheme = match prefix {
|
||||
"git" => URLScheme::Git,
|
||||
"https" => URLScheme::Https,
|
||||
"http" => URLScheme::Http,
|
||||
"file" => URLScheme::File,
|
||||
_ => URLScheme::Unknown,
|
||||
};
|
||||
(scheme, rest.to_string())
|
||||
}
|
||||
None => (URLScheme::Unknown, url.to_string()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_plugin(
|
||||
url: &str,
|
||||
name: &str,
|
||||
basedir: &Path,
|
||||
_argument: FetchArgument,
|
||||
) -> Result<(), PluginError> {
|
||||
// Convert workspace:// to file:// if needed
|
||||
let resolved_url = if let Some(rel) = url.strip_prefix("workspace://") {
|
||||
format!("file://{}", std::env::current_dir().unwrap_or_default().join(rel).display())
|
||||
} else {
|
||||
url.to_string()
|
||||
};
|
||||
|
||||
crate::utils::fetch::fetch_resource(name, &resolved_url, basedir)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| PluginError::GeneralError(format!("Fetch failed: {}", e)))
|
||||
}
|
||||
|
||||
pub async fn fetch_plugins(
|
||||
plugin_path: &Path,
|
||||
plugin_config: &mut RawPluginMap,
|
||||
list: Option<&HashSet<String>>,
|
||||
) -> Result<(), PluginError> {
|
||||
log::info!("Preparing to download {} plugins", plugin_config.len());
|
||||
for (name, data) in plugin_config.iter_mut() {
|
||||
match list {
|
||||
Some(list) if !list.contains(name) => {
|
||||
log::debug!("Plugin {} not in list, skipping", name);
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
log::debug!("Downloading plugin {}", name);
|
||||
let (source, config) =
|
||||
data.iter_mut()
|
||||
.next()
|
||||
.ok_or_else(|| PluginError::PluginUnavailable {
|
||||
name: name.clone(),
|
||||
reason: "no source configured".to_string(),
|
||||
})?;
|
||||
let fetch_argument = FetchArgument {
|
||||
checksum: config.checksum.clone(),
|
||||
shallow: config.shallow,
|
||||
};
|
||||
fetch::fetch_plugin(source, name, plugin_path, fetch_argument).await?;
|
||||
log::debug!("Storing plugin {} to {}", name, plugin_path.display());
|
||||
config.runtime.fspath = Some(plugin_path.join(name));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
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),
|
||||
32 => Err(format!(
|
||||
"MD5 (first 8 chars: {}) is not supported for security reasons. Use SHA256 (64 chars) or SHA512 (128 chars)",
|
||||
&checksum[..8.min(checksum.len())]
|
||||
)),
|
||||
40 => Err(format!(
|
||||
"SHA1 (first 8 chars: {}) is deprecated and not supported. Use SHA256 (64 chars) or SHA512 (128 chars)",
|
||||
&checksum[..8.min(checksum.len())]
|
||||
)),
|
||||
64 => Ok(Some(ChecksumType::Sha256)),
|
||||
128 => Ok(Some(ChecksumType::Sha512)),
|
||||
_ => Err(format!(
|
||||
"Invalid checksum length: {}. Expected 0 (disabled), 64 (SHA256), or 128 (SHA512). First 8 chars: {}",
|
||||
checksum.len(),
|
||||
&checksum[..8.min(checksum.len())]
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
checksum_type: ChecksumType,
|
||||
) -> Result<(), String> {
|
||||
let actual = match checksum_type {
|
||||
ChecksumType::Sha256 => {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(data);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
ChecksumType::Sha512 => {
|
||||
let mut hasher = Sha512::new();
|
||||
hasher.update(data);
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
};
|
||||
|
||||
if actual.eq_ignore_ascii_case(expected) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"Checksum mismatch: expected {}..., got {}...",
|
||||
&expected[..8.min(expected.len())],
|
||||
&actual[..8.min(actual.len())]
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[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());
|
||||
}
|
||||
}
|
||||
@@ -1,169 +0,0 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tokio::task;
|
||||
|
||||
/// 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()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
if name.ends_with(".tar.gz") || name.ends_with(".tgz") {
|
||||
Ok(CompressionType::Gzip)
|
||||
} else if name.ends_with(".tar.zst")
|
||||
|| name.ends_with(".tar.zstd")
|
||||
|| name.ends_with(".tzst")
|
||||
{
|
||||
Ok(CompressionType::Zstd)
|
||||
} else if name.ends_with(".tar") {
|
||||
Ok(CompressionType::None)
|
||||
} else {
|
||||
Err(format!(
|
||||
"Unsupported archive format: '{}'. Supported formats: .tar, .tar.gz, .tgz, .tar.zst, .tar.zstd, .tzst",
|
||||
name
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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: &Path,
|
||||
destdir: &PathBuf,
|
||||
compression: CompressionType,
|
||||
) -> Result<(), PluginError> {
|
||||
if destdir.exists() {
|
||||
tokio::fs::remove_dir_all(destdir).await.map_err(|e| {
|
||||
PluginError::GeneralError(format!("Failed to remove existing directory: {}", e))
|
||||
})?;
|
||||
}
|
||||
tokio::fs::create_dir_all(destdir)
|
||||
.await
|
||||
.map_err(|e| PluginError::GeneralError(format!("Failed to create directory: {}", e)))?;
|
||||
|
||||
let archive = archive.to_path_buf();
|
||||
let destdir = destdir.clone();
|
||||
|
||||
task::spawn_blocking(move || {
|
||||
let file = std::fs::File::open(&archive)
|
||||
.map_err(|e| PluginError::GeneralError(format!("Failed to open archive: {}", e)))?;
|
||||
|
||||
match compression {
|
||||
CompressionType::Gzip => {
|
||||
let decoder = flate2::read::GzDecoder::new(file);
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(&destdir).map_err(|e| {
|
||||
PluginError::GeneralError(format!("Failed to unpack archive: {}", e))
|
||||
})?;
|
||||
}
|
||||
CompressionType::Zstd => {
|
||||
let decoder = zstd::stream::read::Decoder::new(file).map_err(|e| {
|
||||
PluginError::GeneralError(format!("Failed to create zstd decoder: {}", e))
|
||||
})?;
|
||||
let mut archive = tar::Archive::new(decoder);
|
||||
archive.unpack(&destdir).map_err(|e| {
|
||||
PluginError::GeneralError(format!("Failed to unpack archive: {}", e))
|
||||
})?;
|
||||
}
|
||||
CompressionType::None => {
|
||||
let mut archive = tar::Archive::new(file);
|
||||
archive.unpack(&destdir).map_err(|e| {
|
||||
PluginError::GeneralError(format!("Failed to unpack archive: {}", e))
|
||||
})?;
|
||||
}
|
||||
};
|
||||
|
||||
Ok::<(), PluginError>(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| PluginError::GeneralError(format!("Task join error: {}", e)))??;
|
||||
|
||||
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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use std::path::Path;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// 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.rsplit_once('@') {
|
||||
if version.contains(":") {
|
||||
let (tag, commit) = version.split_once(':').unwrap();
|
||||
return (
|
||||
base.to_string(),
|
||||
GitVersion::Mixed(tag.to_string(), commit.to_string()),
|
||||
);
|
||||
}
|
||||
return (base.to_string(), GitVersion::Single(version.to_string()));
|
||||
}
|
||||
(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,
|
||||
dest: &Path,
|
||||
name: &str,
|
||||
shallow: bool,
|
||||
) -> Result<(), PluginError> {
|
||||
if dest.exists() {
|
||||
tokio::fs::remove_dir_all(dest)
|
||||
.await
|
||||
.map_err(|e| PluginError::FetchError {
|
||||
name: name.to_string(),
|
||||
url: baseurl.to_string(),
|
||||
reason: format!("Failed to remove existing directory: {}", e),
|
||||
})?;
|
||||
}
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("clone");
|
||||
if shallow {
|
||||
cmd.arg("--depth").arg("1");
|
||||
}
|
||||
cmd.arg(baseurl).arg(dest);
|
||||
let output = cmd.output().await.map_err(|e| PluginError::FetchError {
|
||||
name: name.to_string(),
|
||||
url: baseurl.to_string(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(PluginError::FetchError {
|
||||
name: name.to_string(),
|
||||
url: baseurl.to_string(),
|
||||
reason: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
});
|
||||
}
|
||||
let mut cmd = Command::new("git");
|
||||
cmd.arg("checkout").arg(version).current_dir(dest);
|
||||
let output = cmd.output().await.map_err(|e| PluginError::FetchError {
|
||||
name: name.to_string(),
|
||||
url: baseurl.to_string(),
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
if !output.status.success() {
|
||||
return Err(PluginError::FetchError {
|
||||
name: name.to_string(),
|
||||
url: baseurl.to_string(),
|
||||
reason: String::from_utf8_lossy(&output.stderr).to_string(),
|
||||
});
|
||||
}
|
||||
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,
|
||||
basedir: &Path,
|
||||
shallow: Option<bool>,
|
||||
) -> Result<(), PluginError> {
|
||||
let (baseurl, version) = parse_git_url(path);
|
||||
match version {
|
||||
GitVersion::None => {
|
||||
log::error!("Version should be specified for git plugin URL: {}", path);
|
||||
Err(PluginError::FetchError {
|
||||
name: name.to_string(),
|
||||
url: path.to_string(),
|
||||
reason: "Version should be specified as either a tag or a commit hash.".to_string(),
|
||||
})
|
||||
}
|
||||
GitVersion::Single(version) => {
|
||||
log::debug!("Fetching git plugin: {}@{}", name, version);
|
||||
let shallow_flag = shallow.unwrap_or(false);
|
||||
git_clone_and_checkout(
|
||||
&baseurl,
|
||||
&version.to_string(),
|
||||
&basedir.join(name),
|
||||
name,
|
||||
shallow_flag,
|
||||
)
|
||||
.await
|
||||
}
|
||||
GitVersion::Mixed(tag, commit) => {
|
||||
log::debug!("Fetching git plugin: {}@{}/{}", name, tag, commit);
|
||||
let shallow_flag = shallow.unwrap_or(false);
|
||||
let result =
|
||||
git_clone_and_checkout(&baseurl, &tag, &basedir.join(name), name, shallow_flag)
|
||||
.await;
|
||||
if result.is_err() {
|
||||
log::warn!(
|
||||
"Failed to clone with tag, trying commit hash. URL: {}",
|
||||
path
|
||||
);
|
||||
return git_clone_and_checkout(&baseurl, &commit, &basedir.join(name), name, false)
|
||||
.await;
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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,110 +0,0 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use super::checksum::{detect_checksum_type, verify_checksum};
|
||||
use super::extract::{CompressionType, extract_tar};
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use std::path::Path;
|
||||
|
||||
pub async fn download_to_file(url: &str, dest: &Path, name: &str) -> Result<(), PluginError> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.map_err(|e| PluginError::GeneralError(format!("Failed to build HTTP client: {}", e)))?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| PluginError::FetchError {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
reason: format!("HTTP request failed: {}", e),
|
||||
})?;
|
||||
|
||||
let status = response.status();
|
||||
if !status.is_success() {
|
||||
return Err(PluginError::FetchError {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
reason: format!("HTTP error: {}", status),
|
||||
});
|
||||
}
|
||||
|
||||
let bytes = response
|
||||
.bytes()
|
||||
.await
|
||||
.map_err(|e| PluginError::FetchError {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
reason: format!("Failed to read response body: {}", e),
|
||||
})?;
|
||||
|
||||
tokio::fs::write(dest, &bytes)
|
||||
.await
|
||||
.map_err(|e| PluginError::FetchError {
|
||||
name: name.to_string(),
|
||||
url: url.to_string(),
|
||||
reason: format!("Failed to write file: {}", e),
|
||||
})?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn extract_archive_extension(url: &str) -> &str {
|
||||
let path_part = url
|
||||
.split('?')
|
||||
.next()
|
||||
.unwrap_or(url)
|
||||
.split('#')
|
||||
.next()
|
||||
.unwrap_or(url);
|
||||
let file_part = path_part.rsplit('/').next().unwrap_or("");
|
||||
let lower = file_part.to_lowercase();
|
||||
if lower.ends_with(".tar.gz") || lower.ends_with(".tgz") {
|
||||
".tar.gz"
|
||||
} else if lower.ends_with(".tar.zst")
|
||||
|| lower.ends_with(".tar.zstd")
|
||||
|| lower.ends_with(".tzst")
|
||||
{
|
||||
".tar.zst"
|
||||
} else if lower.ends_with(".tar") {
|
||||
".tar"
|
||||
} else {
|
||||
".unknown"
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch_http_plugin(
|
||||
url: &str,
|
||||
name: &str,
|
||||
basedir: &Path,
|
||||
checksum_str: Option<&str>,
|
||||
) -> Result<(), PluginError> {
|
||||
let temp_dir = std::env::temp_dir().join("workshop-plugins");
|
||||
tokio::fs::create_dir_all(&temp_dir).await.ok();
|
||||
let ext = extract_archive_extension(url);
|
||||
let temp_file = temp_dir.join(format!("{}-{}{}", name, uuid::Uuid::new_v4(), ext));
|
||||
|
||||
download_to_file(url, &temp_file, name).await?;
|
||||
|
||||
if let Some(checksum) = checksum_str
|
||||
&& !checksum.is_empty()
|
||||
{
|
||||
let bytes = tokio::fs::read(&temp_file).await.map_err(|e| {
|
||||
PluginError::GeneralError(format!("Failed to read downloaded file: {}", e))
|
||||
})?;
|
||||
|
||||
let checksum_type = detect_checksum_type(checksum).map_err(PluginError::GeneralError)?;
|
||||
|
||||
if let Some(ct) = checksum_type {
|
||||
verify_checksum(&bytes, checksum, ct).map_err(PluginError::GeneralError)?;
|
||||
}
|
||||
}
|
||||
|
||||
let compression = CompressionType::from_path(&temp_file).map_err(PluginError::GeneralError)?;
|
||||
let destdir = basedir.join(name);
|
||||
extract_tar(&temp_file, &destdir, compression).await?;
|
||||
|
||||
tokio::fs::remove_file(&temp_file).await.ok();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ impl AsyncPluginFn for Dummy {
|
||||
_ctx: &ExecutionContext,
|
||||
_event_tx: &EventSender,
|
||||
) -> Result<(), PluginError> {
|
||||
log::info!("[NOTIFY_DEBUG] dummy plugin called successfully");
|
||||
log::debug!("[NOTIFY_DEBUG] Successfully registered dummy plugin.");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,10 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use crate::finalize::plugin::PluginError;
|
||||
use crate::finalize::plugin::PluginMetadata;
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
use crate::{ finalize::plugin::AsyncPluginFn};
|
||||
use crate::{
|
||||
finalize::{error::PluginError, plugin::AsyncPluginFn},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use lettre::message::{MultiPart, SinglePart, header};
|
||||
use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
use workshop_engine::{EventSender, ExecutionContext};
|
||||
|
||||
mod config;
|
||||
pub use config::*;
|
||||
|
||||
/// Mail notification plugin — SSR only.
|
||||
///
|
||||
/// Expects `_rendered_content` in the argument (injected by
|
||||
/// `notification_handler`'s Server renderer). The subject must be
|
||||
/// provided via the notification method's config (`subject` field).
|
||||
///
|
||||
/// In standalone/bare mode the user MUST supply templates via
|
||||
/// `finalize.yml notification.templates`. In worker (c/d) mode the
|
||||
/// daemon may provide ws-base defaults.
|
||||
pub struct Mail;
|
||||
|
||||
#[async_trait]
|
||||
@@ -28,166 +12,10 @@ impl AsyncPluginFn for Mail {
|
||||
async fn call(
|
||||
&self,
|
||||
_metadata: &PluginMetadata,
|
||||
argument: serde_yaml::Value,
|
||||
_argument: serde_yaml::Value,
|
||||
_ctx: &ExecutionContext,
|
||||
_event_tx: &EventSender,
|
||||
) -> Result<(), PluginError> {
|
||||
let rendered_content = argument
|
||||
.get("_rendered_content")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let body_html = match rendered_content {
|
||||
Some(content) => content,
|
||||
None => {
|
||||
return Err(PluginError::GeneralError(
|
||||
"Mail plugin requires SSR mode: _rendered_content not found in argument. "
|
||||
.to_string()
|
||||
+ "Ensure notification.templates are configured in finalize.yml.",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let config: MailConfig = serde_yaml::from_value(argument)
|
||||
.map_err(|e| PluginError::GeneralError(format!("Invalid mail config: {}", e)))?;
|
||||
if let Err(errors) = config.validate() {
|
||||
return Err(PluginError::GeneralError(format!(
|
||||
"Mail config validation failed: {:?}",
|
||||
errors
|
||||
)));
|
||||
}
|
||||
|
||||
let subject = config.subject.clone().unwrap_or_else(|| "[HBW] {{ pipeline.name }}".to_string());
|
||||
let body_text = String::new(); // SSR provides HTML only; plaintext is optional
|
||||
|
||||
let email = build_email(&config, subject, body_html, body_text)?;
|
||||
send_email(&config, email).await
|
||||
todo!();
|
||||
}
|
||||
}
|
||||
|
||||
fn build_email(
|
||||
config: &MailConfig,
|
||||
subject: String,
|
||||
body_html: String,
|
||||
body_text: String,
|
||||
) -> Result<Message, PluginError> {
|
||||
let from_str = config.from.to_string();
|
||||
let from: lettre::message::Mailbox = from_str.parse().map_err(|e| {
|
||||
PluginError::GeneralError(format!("Invalid from address '{}': {}", from_str, e))
|
||||
})?;
|
||||
|
||||
let to_addrs: Vec<String> = config.to.to_vec();
|
||||
if to_addrs.is_empty() {
|
||||
return Err(PluginError::GeneralError(
|
||||
"No recipients specified".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut message_builder = Message::builder().from(from).subject(subject);
|
||||
|
||||
for addr in &to_addrs {
|
||||
let mailbox: lettre::message::Mailbox = addr.parse().map_err(|e| {
|
||||
PluginError::GeneralError(format!("Invalid to address '{}': {}", addr, e))
|
||||
})?;
|
||||
message_builder = message_builder.to(mailbox);
|
||||
}
|
||||
|
||||
if let Some(cc) = &config.cc {
|
||||
for addr in cc.to_vec() {
|
||||
let mailbox: lettre::message::Mailbox = addr.parse().map_err(|e| {
|
||||
PluginError::GeneralError(format!("Invalid cc address '{}': {}", addr, e))
|
||||
})?;
|
||||
message_builder = message_builder.cc(mailbox);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(bcc) = &config.bcc {
|
||||
for addr in bcc.to_vec() {
|
||||
let mailbox: lettre::message::Mailbox = addr.parse().map_err(|e| {
|
||||
PluginError::GeneralError(format!("Invalid bcc address '{}': {}", addr, e))
|
||||
})?;
|
||||
message_builder = message_builder.bcc(mailbox);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(reply_to) = &config.reply_to {
|
||||
let mailbox: lettre::message::Mailbox = reply_to.parse().map_err(|e| {
|
||||
PluginError::GeneralError(format!("Invalid reply-to address '{}': {}", reply_to, e))
|
||||
})?;
|
||||
message_builder = message_builder.reply_to(mailbox);
|
||||
}
|
||||
|
||||
let message = message_builder
|
||||
.multipart(
|
||||
MultiPart::alternative()
|
||||
.singlepart(
|
||||
SinglePart::builder()
|
||||
.header(header::ContentType::TEXT_PLAIN)
|
||||
.body(body_text),
|
||||
)
|
||||
.singlepart(
|
||||
SinglePart::builder()
|
||||
.header(header::ContentType::TEXT_HTML)
|
||||
.body(body_html),
|
||||
),
|
||||
)
|
||||
.map_err(|e| PluginError::GeneralError(format!("Failed to build email: {}", e)))?;
|
||||
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
async fn send_email(config: &MailConfig, email: Message) -> Result<(), PluginError> {
|
||||
let connect_timeout = if config.smtp.connect_timeout_ms == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(Duration::from_millis(config.smtp.connect_timeout_ms))
|
||||
};
|
||||
|
||||
let mut transport_builder = match config.smtp.encryption {
|
||||
Encryption::Tls => {
|
||||
log::debug!("Using TLS encryption");
|
||||
AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp.host)
|
||||
.map_err(|e| PluginError::GeneralError(format!("Invalid SMTP host: {}", e)))?
|
||||
}
|
||||
Encryption::Starttls => {
|
||||
log::debug!("Using StartTLS encryption");
|
||||
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.smtp.host)
|
||||
.map_err(|e| PluginError::GeneralError(format!("Invalid SMTP host: {}", e)))?
|
||||
}
|
||||
Encryption::None => {
|
||||
log::debug!("Using NO encryption");
|
||||
AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.smtp.host)
|
||||
}
|
||||
};
|
||||
|
||||
transport_builder = transport_builder.port(config.smtp.port);
|
||||
|
||||
match config.smtp.auth.auth_type {
|
||||
AuthType::Password => {
|
||||
log::debug!("Using password auth");
|
||||
transport_builder = transport_builder.credentials(
|
||||
(
|
||||
config.smtp.auth.username.clone(),
|
||||
config.smtp.auth.password.clone(),
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
AuthType::None => {
|
||||
log::debug!("Using no auth");
|
||||
}
|
||||
}
|
||||
|
||||
let transport = transport_builder.build();
|
||||
|
||||
let result = match connect_timeout {
|
||||
Some(t) => timeout(t, transport.send(email)).await,
|
||||
None => Ok(transport.send(email).await),
|
||||
};
|
||||
|
||||
result
|
||||
.map_err(|_| PluginError::GeneralError("SMTP connection timed out".to_string()))?
|
||||
.map_err(|e| PluginError::GeneralError(format!("SMTP error: {}", e)))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
// Code in this module PARTIALLY OR FULLY utilized AI Coding Agent.
|
||||
use serde::Deserialize;
|
||||
use workshop_engine::deserialize_duration_ms;
|
||||
|
||||
/// 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(),
|
||||
EmailAddress::Object { name, email } => {
|
||||
if name.is_empty() {
|
||||
email.clone()
|
||||
} else {
|
||||
format!("{} <{}>", name, email)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the raw email address without formatting.
|
||||
pub fn email(&self) -> &str {
|
||||
match self {
|
||||
EmailAddress::String(email) => email.as_str(),
|
||||
EmailAddress::Object { email, .. } => email.as_str(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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()],
|
||||
Recipients::Multiple(v) => v.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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"
|
||||
)]
|
||||
pub connect_timeout_ms: u64,
|
||||
}
|
||||
|
||||
fn default_connect_timeout_ms() -> u64 {
|
||||
30_000
|
||||
}
|
||||
|
||||
/// Complete mail notification configuration (SSR-only).
|
||||
/// The email body comes from `_rendered_content` (injected by notification_handler).
|
||||
/// Subject is optional — falls back to a minimal default.
|
||||
#[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>,
|
||||
/// Email subject (template string). Falls back to "[HBW] {{ pipeline.name }}".
|
||||
pub subject: Option<String>,
|
||||
/// SMTP server configuration.
|
||||
pub smtp: SmtpConfig,
|
||||
}
|
||||
|
||||
impl MailConfig {
|
||||
/// Validates the mail configuration.
|
||||
pub fn validate(&self) -> Result<(), Vec<String>> {
|
||||
let mut errors = Vec::new();
|
||||
if self.to.to_vec().is_empty() {
|
||||
errors.push("to: must have at least one recipient".to_string());
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[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_mail_config_validate_valid_config() {
|
||||
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,
|
||||
subject: Some("Test Subject".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_empty_recipients() {
|
||||
let config = MailConfig {
|
||||
to: Recipients::Multiple(vec![]),
|
||||
cc: None,
|
||||
bcc: None,
|
||||
from: EmailAddress::String("from@example.com".to_string()),
|
||||
reply_to: None,
|
||||
subject: 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_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_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));
|
||||
}
|
||||
}
|
||||
@@ -56,77 +56,3 @@ 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,
|
||||
renderer: None,
|
||||
templates: None,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user