use crate::tasks::manifest::{TaskManifest, TaskManifestError};
use std::collections::HashMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use thiserror::Error;
use tokio::sync::RwLock;
#[derive(Debug, Error)]
pub enum TaskCacheError {
#[error("Task not found: {0}@{1}")]
TaskNotFound(String, String),
#[error("Failed to download task: {0}")]
DownloadError(String),
#[error("IO error: {0}")]
IoError(#[from] io::Error),
#[error("Manifest error: {0}")]
ManifestError(#[from] TaskManifestError),
#[error("Invalid task reference: {0}")]
InvalidTaskReference(String),
#[error("HTTP error: {0}")]
HttpError(String),
#[error("Archive error: {0}")]
ArchiveError(String),
}
#[derive(Debug, Clone)]
pub struct TaskCacheConfig {
pub cache_dir: PathBuf,
pub allow_download: bool,
pub task_sources: Vec<TaskSource>,
}
impl Default for TaskCacheConfig {
fn default() -> Self {
let cache_dir = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".roxid")
.join("tasks");
Self {
cache_dir,
allow_download: true,
task_sources: vec![TaskSource::AzureDevOps],
}
}
}
#[derive(Debug, Clone)]
pub enum TaskSource {
AzureDevOps,
LocalDir(PathBuf),
CustomUrl(String),
}
#[derive(Debug, Clone)]
pub struct CachedTask {
pub name: String,
pub version: String,
pub path: PathBuf,
pub manifest: TaskManifest,
}
impl CachedTask {
pub fn execution_target(&self) -> Option<PathBuf> {
let exec = self.manifest.primary_execution()?;
Some(self.path.join(&exec.target))
}
}
pub struct TaskCache {
config: TaskCacheConfig,
cache: Arc<RwLock<HashMap<String, CachedTask>>>,
}
impl TaskCache {
pub fn new() -> Self {
Self::with_config(TaskCacheConfig::default())
}
pub fn with_config(config: TaskCacheConfig) -> Self {
Self {
config,
cache: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn with_cache_dir(cache_dir: impl AsRef<Path>) -> Self {
let config = TaskCacheConfig {
cache_dir: cache_dir.as_ref().to_path_buf(),
..Default::default()
};
Self::with_config(config)
}
pub fn cache_dir(&self) -> &Path {
&self.config.cache_dir
}
pub fn parse_task_reference(task_ref: &str) -> Result<(String, String), TaskCacheError> {
let parts: Vec<&str> = task_ref.split('@').collect();
if parts.len() != 2 {
return Err(TaskCacheError::InvalidTaskReference(task_ref.to_string()));
}
let name = parts[0].to_string();
let version = parts[1].to_string();
if version.is_empty() || name.is_empty() {
return Err(TaskCacheError::InvalidTaskReference(task_ref.to_string()));
}
Ok((name, version))
}
pub async fn get_task(&self, task_ref: &str) -> Result<CachedTask, TaskCacheError> {
let (name, version) = Self::parse_task_reference(task_ref)?;
self.get_task_by_name_version(&name, &version).await
}
pub async fn get_task_by_name_version(
&self,
name: &str,
version: &str,
) -> Result<CachedTask, TaskCacheError> {
let cache_key = format!("{}@{}", name, version);
{
let cache = self.cache.read().await;
if let Some(task) = cache.get(&cache_key) {
return Ok(task.clone());
}
}
let task_path = self.task_path(name, version);
if task_path.exists() {
let task = self.load_cached_task(name, version, &task_path)?;
let mut cache = self.cache.write().await;
cache.insert(cache_key, task.clone());
return Ok(task);
}
if self.config.allow_download {
let task = self.download_task(name, version).await?;
let mut cache = self.cache.write().await;
cache.insert(cache_key, task.clone());
return Ok(task);
}
Err(TaskCacheError::TaskNotFound(
name.to_string(),
version.to_string(),
))
}
fn task_path(&self, name: &str, version: &str) -> PathBuf {
self.config.cache_dir.join(name).join(version)
}
fn load_cached_task(
&self,
name: &str,
version: &str,
path: &Path,
) -> Result<CachedTask, TaskCacheError> {
let manifest_path = path.join("task.json");
let manifest = TaskManifest::from_file(&manifest_path)?;
Ok(CachedTask {
name: name.to_string(),
version: version.to_string(),
path: path.to_path_buf(),
manifest,
})
}
async fn download_task(&self, name: &str, version: &str) -> Result<CachedTask, TaskCacheError> {
for source in &self.config.task_sources {
match self.download_from_source(source, name, version).await {
Ok(task) => return Ok(task),
Err(_) => continue,
}
}
Err(TaskCacheError::TaskNotFound(
name.to_string(),
version.to_string(),
))
}
async fn download_from_source(
&self,
source: &TaskSource,
name: &str,
version: &str,
) -> Result<CachedTask, TaskCacheError> {
match source {
TaskSource::AzureDevOps => self.download_from_azure_devops(name, version).await,
TaskSource::LocalDir(dir) => self.load_from_local_dir(dir, name, version).await,
TaskSource::CustomUrl(pattern) => {
self.download_from_custom_url(pattern, name, version).await
}
}
}
async fn download_from_azure_devops(
&self,
name: &str,
version: &str,
) -> Result<CachedTask, TaskCacheError> {
let task_path = self.task_path(name, version);
fs::create_dir_all(&task_path)?;
if let Some(manifest) = create_builtin_task_stub(name, version) {
let manifest_path = task_path.join("task.json");
let manifest_json = serde_json::to_string_pretty(&manifest)
.map_err(|e| TaskCacheError::DownloadError(e.to_string()))?;
fs::write(&manifest_path, manifest_json)?;
return Ok(CachedTask {
name: name.to_string(),
version: version.to_string(),
path: task_path,
manifest,
});
}
Err(TaskCacheError::TaskNotFound(
name.to_string(),
version.to_string(),
))
}
async fn load_from_local_dir(
&self,
dir: &Path,
name: &str,
version: &str,
) -> Result<CachedTask, TaskCacheError> {
let task_path = dir.join(name).join(version);
if task_path.exists() {
return self.load_cached_task(name, version, &task_path);
}
let task_path = dir.join(name);
if task_path.exists() {
return self.load_cached_task(name, version, &task_path);
}
Err(TaskCacheError::TaskNotFound(
name.to_string(),
version.to_string(),
))
}
async fn download_from_custom_url(
&self,
pattern: &str,
name: &str,
version: &str,
) -> Result<CachedTask, TaskCacheError> {
let url = pattern
.replace("{name}", name)
.replace("{version}", version);
Err(TaskCacheError::DownloadError(format!(
"Custom URL download not yet implemented: {}",
url
)))
}
pub fn list_cached_tasks(&self) -> io::Result<Vec<(String, String)>> {
let mut tasks = Vec::new();
if !self.config.cache_dir.exists() {
return Ok(tasks);
}
for entry in fs::read_dir(&self.config.cache_dir)? {
let entry = entry?;
let task_name = entry.file_name().to_string_lossy().to_string();
if entry.file_type()?.is_dir() {
for version_entry in fs::read_dir(entry.path())? {
let version_entry = version_entry?;
if version_entry.file_type()?.is_dir() {
let version = version_entry.file_name().to_string_lossy().to_string();
tasks.push((task_name.clone(), version));
}
}
}
}
Ok(tasks)
}
pub fn clear_cache(&self) -> io::Result<()> {
if self.config.cache_dir.exists() {
fs::remove_dir_all(&self.config.cache_dir)?;
}
Ok(())
}
pub fn clear_task(&self, name: &str, version: &str) -> io::Result<()> {
let task_path = self.task_path(name, version);
if task_path.exists() {
fs::remove_dir_all(task_path)?;
}
Ok(())
}
}
impl Default for TaskCache {
fn default() -> Self {
Self::new()
}
}
fn create_builtin_task_stub(name: &str, version: &str) -> Option<TaskManifest> {
let major: u32 = version.split('.').next()?.parse().ok()?;
match name {
"Bash" => Some(TaskManifest {
id: "6c731c3c-3c68-459a-a5c9-bde6e6595b5b".to_string(),
name: "Bash".to_string(),
friendly_name: Some("Bash".to_string()),
description: Some("Run a Bash script".to_string()),
help_url: None,
help_mark_down: None,
category: Some("Utility".to_string()),
visibility: Some(vec!["Build".to_string(), "Release".to_string()]),
runs_on: Some(vec!["Agent".to_string()]),
author: Some("Microsoft Corporation".to_string()),
version: crate::tasks::manifest::TaskVersion {
major,
minor: 0,
patch: 0,
},
minimum_agent_version: None,
instance_name_format: Some("Bash Script".to_string()),
groups: None,
inputs: vec![
crate::tasks::manifest::TaskInput {
name: "targetType".to_string(),
input_type: Some("radio".to_string()),
label: Some("Type".to_string()),
default_value: Some("inline".to_string()),
required: Some(false),
help_mark_down: None,
group_name: None,
visible_rule: None,
options: None,
properties: None,
validation: None,
aliases: None,
},
crate::tasks::manifest::TaskInput {
name: "script".to_string(),
input_type: Some("multiLine".to_string()),
label: Some("Script".to_string()),
default_value: None,
required: Some(true),
help_mark_down: None,
group_name: None,
visible_rule: Some("targetType = inline".to_string()),
options: None,
properties: None,
validation: None,
aliases: None,
},
crate::tasks::manifest::TaskInput {
name: "workingDirectory".to_string(),
input_type: Some("filePath".to_string()),
label: Some("Working Directory".to_string()),
default_value: None,
required: Some(false),
help_mark_down: None,
group_name: None,
visible_rule: None,
options: None,
properties: None,
validation: None,
aliases: None,
},
],
output_variables: None,
execution: None, pre_job_execution: None,
post_job_execution: None,
data_source_bindings: None,
messages: None,
restrictions: None,
demands: None,
}),
"PowerShell" => Some(TaskManifest {
id: "e213ff0f-5d5c-4791-802d-52ea3e7be1f1".to_string(),
name: "PowerShell".to_string(),
friendly_name: Some("PowerShell".to_string()),
description: Some("Run a PowerShell script".to_string()),
help_url: None,
help_mark_down: None,
category: Some("Utility".to_string()),
visibility: Some(vec!["Build".to_string(), "Release".to_string()]),
runs_on: Some(vec!["Agent".to_string()]),
author: Some("Microsoft Corporation".to_string()),
version: crate::tasks::manifest::TaskVersion {
major,
minor: 0,
patch: 0,
},
minimum_agent_version: None,
instance_name_format: Some("PowerShell Script".to_string()),
groups: None,
inputs: vec![
crate::tasks::manifest::TaskInput {
name: "targetType".to_string(),
input_type: Some("radio".to_string()),
label: Some("Type".to_string()),
default_value: Some("inline".to_string()),
required: Some(false),
help_mark_down: None,
group_name: None,
visible_rule: None,
options: None,
properties: None,
validation: None,
aliases: None,
},
crate::tasks::manifest::TaskInput {
name: "script".to_string(),
input_type: Some("multiLine".to_string()),
label: Some("Script".to_string()),
default_value: None,
required: Some(true),
help_mark_down: None,
group_name: None,
visible_rule: Some("targetType = inline".to_string()),
options: None,
properties: None,
validation: None,
aliases: None,
},
crate::tasks::manifest::TaskInput {
name: "workingDirectory".to_string(),
input_type: Some("filePath".to_string()),
label: Some("Working Directory".to_string()),
default_value: None,
required: Some(false),
help_mark_down: None,
group_name: None,
visible_rule: None,
options: None,
properties: None,
validation: None,
aliases: None,
},
crate::tasks::manifest::TaskInput {
name: "pwsh".to_string(),
input_type: Some("boolean".to_string()),
label: Some("Use PowerShell Core".to_string()),
default_value: Some("false".to_string()),
required: Some(false),
help_mark_down: None,
group_name: None,
visible_rule: None,
options: None,
properties: None,
validation: None,
aliases: None,
},
],
output_variables: None,
execution: None,
pre_job_execution: None,
post_job_execution: None,
data_source_bindings: None,
messages: None,
restrictions: None,
demands: None,
}),
"CmdLine" => Some(TaskManifest {
id: "d9bafed4-0b18-4f58-968d-86655b4d2ce9".to_string(),
name: "CmdLine".to_string(),
friendly_name: Some("Command line".to_string()),
description: Some("Run a command line script".to_string()),
help_url: None,
help_mark_down: None,
category: Some("Utility".to_string()),
visibility: Some(vec!["Build".to_string(), "Release".to_string()]),
runs_on: Some(vec!["Agent".to_string()]),
author: Some("Microsoft Corporation".to_string()),
version: crate::tasks::manifest::TaskVersion {
major,
minor: 0,
patch: 0,
},
minimum_agent_version: None,
instance_name_format: Some("Command Line Script".to_string()),
groups: None,
inputs: vec![
crate::tasks::manifest::TaskInput {
name: "script".to_string(),
input_type: Some("multiLine".to_string()),
label: Some("Script".to_string()),
default_value: None,
required: Some(true),
help_mark_down: None,
group_name: None,
visible_rule: None,
options: None,
properties: None,
validation: None,
aliases: None,
},
crate::tasks::manifest::TaskInput {
name: "workingDirectory".to_string(),
input_type: Some("filePath".to_string()),
label: Some("Working Directory".to_string()),
default_value: None,
required: Some(false),
help_mark_down: None,
group_name: None,
visible_rule: None,
options: None,
properties: None,
validation: None,
aliases: None,
},
],
output_variables: None,
execution: None,
pre_job_execution: None,
post_job_execution: None,
data_source_bindings: None,
messages: None,
restrictions: None,
demands: None,
}),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_task_reference() {
let (name, version) = TaskCache::parse_task_reference("Bash@3").unwrap();
assert_eq!(name, "Bash");
assert_eq!(version, "3");
let (name, version) = TaskCache::parse_task_reference("DotNetCoreCLI@2.123.4").unwrap();
assert_eq!(name, "DotNetCoreCLI");
assert_eq!(version, "2.123.4");
}
#[test]
fn test_parse_invalid_task_reference() {
assert!(TaskCache::parse_task_reference("Bash").is_err());
assert!(TaskCache::parse_task_reference("Bash@").is_err());
assert!(TaskCache::parse_task_reference("@3").is_err());
}
#[test]
fn test_builtin_task_stub_bash() {
let manifest = create_builtin_task_stub("Bash", "3").unwrap();
assert_eq!(manifest.name, "Bash");
assert_eq!(manifest.version.major, 3);
}
#[test]
fn test_builtin_task_stub_powershell() {
let manifest = create_builtin_task_stub("PowerShell", "2").unwrap();
assert_eq!(manifest.name, "PowerShell");
assert_eq!(manifest.version.major, 2);
}
#[test]
fn test_builtin_task_stub_unknown() {
let manifest = create_builtin_task_stub("UnknownTask", "1");
assert!(manifest.is_none());
}
#[tokio::test]
async fn test_task_cache_config() {
let cache = TaskCache::new();
assert!(cache.config.allow_download);
assert!(!cache.config.task_sources.is_empty());
}
}