1use crate::tasks::manifest::{TaskManifest, TaskManifestError};
5
6use std::collections::HashMap;
7use std::fs;
8use std::io;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use thiserror::Error;
12use tokio::sync::RwLock;
13
14#[derive(Debug, Error)]
16pub enum TaskCacheError {
17 #[error("Task not found: {0}@{1}")]
18 TaskNotFound(String, String),
19
20 #[error("Failed to download task: {0}")]
21 DownloadError(String),
22
23 #[error("IO error: {0}")]
24 IoError(#[from] io::Error),
25
26 #[error("Manifest error: {0}")]
27 ManifestError(#[from] TaskManifestError),
28
29 #[error("Invalid task reference: {0}")]
30 InvalidTaskReference(String),
31
32 #[error("HTTP error: {0}")]
33 HttpError(String),
34
35 #[error("Archive error: {0}")]
36 ArchiveError(String),
37}
38
39#[derive(Debug, Clone)]
41pub struct TaskCacheConfig {
42 pub cache_dir: PathBuf,
44
45 pub allow_download: bool,
47
48 pub task_sources: Vec<TaskSource>,
50}
51
52impl Default for TaskCacheConfig {
53 fn default() -> Self {
54 let cache_dir = dirs::home_dir()
55 .unwrap_or_else(|| PathBuf::from("."))
56 .join(".roxid")
57 .join("tasks");
58
59 Self {
60 cache_dir,
61 allow_download: true,
62 task_sources: vec![TaskSource::AzureDevOps],
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
69pub enum TaskSource {
70 AzureDevOps,
72 LocalDir(PathBuf),
74 CustomUrl(String),
76}
77
78#[derive(Debug, Clone)]
80pub struct CachedTask {
81 pub name: String,
83
84 pub version: String,
86
87 pub path: PathBuf,
89
90 pub manifest: TaskManifest,
92}
93
94impl CachedTask {
95 pub fn execution_target(&self) -> Option<PathBuf> {
97 let exec = self.manifest.primary_execution()?;
98 Some(self.path.join(&exec.target))
99 }
100}
101
102pub struct TaskCache {
104 config: TaskCacheConfig,
105 cache: Arc<RwLock<HashMap<String, CachedTask>>>,
107}
108
109impl TaskCache {
110 pub fn new() -> Self {
112 Self::with_config(TaskCacheConfig::default())
113 }
114
115 pub fn with_config(config: TaskCacheConfig) -> Self {
117 Self {
118 config,
119 cache: Arc::new(RwLock::new(HashMap::new())),
120 }
121 }
122
123 pub fn with_cache_dir(cache_dir: impl AsRef<Path>) -> Self {
125 let config = TaskCacheConfig {
126 cache_dir: cache_dir.as_ref().to_path_buf(),
127 ..Default::default()
128 };
129 Self::with_config(config)
130 }
131
132 pub fn cache_dir(&self) -> &Path {
134 &self.config.cache_dir
135 }
136
137 pub fn parse_task_reference(task_ref: &str) -> Result<(String, String), TaskCacheError> {
139 let parts: Vec<&str> = task_ref.split('@').collect();
140 if parts.len() != 2 {
141 return Err(TaskCacheError::InvalidTaskReference(task_ref.to_string()));
142 }
143
144 let name = parts[0].to_string();
145 let version = parts[1].to_string();
146
147 if version.is_empty() || name.is_empty() {
149 return Err(TaskCacheError::InvalidTaskReference(task_ref.to_string()));
150 }
151
152 Ok((name, version))
153 }
154
155 pub async fn get_task(&self, task_ref: &str) -> Result<CachedTask, TaskCacheError> {
157 let (name, version) = Self::parse_task_reference(task_ref)?;
158 self.get_task_by_name_version(&name, &version).await
159 }
160
161 pub async fn get_task_by_name_version(
163 &self,
164 name: &str,
165 version: &str,
166 ) -> Result<CachedTask, TaskCacheError> {
167 let cache_key = format!("{}@{}", name, version);
168
169 {
171 let cache = self.cache.read().await;
172 if let Some(task) = cache.get(&cache_key) {
173 return Ok(task.clone());
174 }
175 }
176
177 let task_path = self.task_path(name, version);
179 if task_path.exists() {
180 let task = self.load_cached_task(name, version, &task_path)?;
181
182 let mut cache = self.cache.write().await;
184 cache.insert(cache_key, task.clone());
185
186 return Ok(task);
187 }
188
189 if self.config.allow_download {
191 let task = self.download_task(name, version).await?;
192
193 let mut cache = self.cache.write().await;
195 cache.insert(cache_key, task.clone());
196
197 return Ok(task);
198 }
199
200 Err(TaskCacheError::TaskNotFound(
201 name.to_string(),
202 version.to_string(),
203 ))
204 }
205
206 fn task_path(&self, name: &str, version: &str) -> PathBuf {
208 self.config.cache_dir.join(name).join(version)
209 }
210
211 fn load_cached_task(
213 &self,
214 name: &str,
215 version: &str,
216 path: &Path,
217 ) -> Result<CachedTask, TaskCacheError> {
218 let manifest_path = path.join("task.json");
219 let manifest = TaskManifest::from_file(&manifest_path)?;
220
221 Ok(CachedTask {
222 name: name.to_string(),
223 version: version.to_string(),
224 path: path.to_path_buf(),
225 manifest,
226 })
227 }
228
229 async fn download_task(&self, name: &str, version: &str) -> Result<CachedTask, TaskCacheError> {
231 for source in &self.config.task_sources {
232 match self.download_from_source(source, name, version).await {
233 Ok(task) => return Ok(task),
234 Err(_) => continue,
235 }
236 }
237
238 Err(TaskCacheError::TaskNotFound(
239 name.to_string(),
240 version.to_string(),
241 ))
242 }
243
244 async fn download_from_source(
246 &self,
247 source: &TaskSource,
248 name: &str,
249 version: &str,
250 ) -> Result<CachedTask, TaskCacheError> {
251 match source {
252 TaskSource::AzureDevOps => self.download_from_azure_devops(name, version).await,
253 TaskSource::LocalDir(dir) => self.load_from_local_dir(dir, name, version).await,
254 TaskSource::CustomUrl(pattern) => {
255 self.download_from_custom_url(pattern, name, version).await
256 }
257 }
258 }
259
260 async fn download_from_azure_devops(
262 &self,
263 name: &str,
264 version: &str,
265 ) -> Result<CachedTask, TaskCacheError> {
266 let task_path = self.task_path(name, version);
274 fs::create_dir_all(&task_path)?;
275
276 if let Some(manifest) = create_builtin_task_stub(name, version) {
282 let manifest_path = task_path.join("task.json");
283 let manifest_json = serde_json::to_string_pretty(&manifest)
284 .map_err(|e| TaskCacheError::DownloadError(e.to_string()))?;
285 fs::write(&manifest_path, manifest_json)?;
286
287 return Ok(CachedTask {
288 name: name.to_string(),
289 version: version.to_string(),
290 path: task_path,
291 manifest,
292 });
293 }
294
295 Err(TaskCacheError::TaskNotFound(
296 name.to_string(),
297 version.to_string(),
298 ))
299 }
300
301 async fn load_from_local_dir(
303 &self,
304 dir: &Path,
305 name: &str,
306 version: &str,
307 ) -> Result<CachedTask, TaskCacheError> {
308 let task_path = dir.join(name).join(version);
310 if task_path.exists() {
311 return self.load_cached_task(name, version, &task_path);
312 }
313
314 let task_path = dir.join(name);
316 if task_path.exists() {
317 return self.load_cached_task(name, version, &task_path);
318 }
319
320 Err(TaskCacheError::TaskNotFound(
321 name.to_string(),
322 version.to_string(),
323 ))
324 }
325
326 async fn download_from_custom_url(
328 &self,
329 pattern: &str,
330 name: &str,
331 version: &str,
332 ) -> Result<CachedTask, TaskCacheError> {
333 let url = pattern
334 .replace("{name}", name)
335 .replace("{version}", version);
336
337 Err(TaskCacheError::DownloadError(format!(
339 "Custom URL download not yet implemented: {}",
340 url
341 )))
342 }
343
344 pub fn list_cached_tasks(&self) -> io::Result<Vec<(String, String)>> {
346 let mut tasks = Vec::new();
347
348 if !self.config.cache_dir.exists() {
349 return Ok(tasks);
350 }
351
352 for entry in fs::read_dir(&self.config.cache_dir)? {
353 let entry = entry?;
354 let task_name = entry.file_name().to_string_lossy().to_string();
355
356 if entry.file_type()?.is_dir() {
357 for version_entry in fs::read_dir(entry.path())? {
358 let version_entry = version_entry?;
359 if version_entry.file_type()?.is_dir() {
360 let version = version_entry.file_name().to_string_lossy().to_string();
361 tasks.push((task_name.clone(), version));
362 }
363 }
364 }
365 }
366
367 Ok(tasks)
368 }
369
370 pub fn clear_cache(&self) -> io::Result<()> {
372 if self.config.cache_dir.exists() {
373 fs::remove_dir_all(&self.config.cache_dir)?;
374 }
375 Ok(())
376 }
377
378 pub fn clear_task(&self, name: &str, version: &str) -> io::Result<()> {
380 let task_path = self.task_path(name, version);
381 if task_path.exists() {
382 fs::remove_dir_all(task_path)?;
383 }
384 Ok(())
385 }
386}
387
388impl Default for TaskCache {
389 fn default() -> Self {
390 Self::new()
391 }
392}
393
394fn create_builtin_task_stub(name: &str, version: &str) -> Option<TaskManifest> {
396 let major: u32 = version.split('.').next()?.parse().ok()?;
398
399 match name {
400 "Bash" => Some(TaskManifest {
401 id: "6c731c3c-3c68-459a-a5c9-bde6e6595b5b".to_string(),
402 name: "Bash".to_string(),
403 friendly_name: Some("Bash".to_string()),
404 description: Some("Run a Bash script".to_string()),
405 help_url: None,
406 help_mark_down: None,
407 category: Some("Utility".to_string()),
408 visibility: Some(vec!["Build".to_string(), "Release".to_string()]),
409 runs_on: Some(vec!["Agent".to_string()]),
410 author: Some("Microsoft Corporation".to_string()),
411 version: crate::tasks::manifest::TaskVersion {
412 major,
413 minor: 0,
414 patch: 0,
415 },
416 minimum_agent_version: None,
417 instance_name_format: Some("Bash Script".to_string()),
418 groups: None,
419 inputs: vec![
420 crate::tasks::manifest::TaskInput {
421 name: "targetType".to_string(),
422 input_type: Some("radio".to_string()),
423 label: Some("Type".to_string()),
424 default_value: Some("inline".to_string()),
425 required: Some(false),
426 help_mark_down: None,
427 group_name: None,
428 visible_rule: None,
429 options: None,
430 properties: None,
431 validation: None,
432 aliases: None,
433 },
434 crate::tasks::manifest::TaskInput {
435 name: "script".to_string(),
436 input_type: Some("multiLine".to_string()),
437 label: Some("Script".to_string()),
438 default_value: None,
439 required: Some(true),
440 help_mark_down: None,
441 group_name: None,
442 visible_rule: Some("targetType = inline".to_string()),
443 options: None,
444 properties: None,
445 validation: None,
446 aliases: None,
447 },
448 crate::tasks::manifest::TaskInput {
449 name: "workingDirectory".to_string(),
450 input_type: Some("filePath".to_string()),
451 label: Some("Working Directory".to_string()),
452 default_value: None,
453 required: Some(false),
454 help_mark_down: None,
455 group_name: None,
456 visible_rule: None,
457 options: None,
458 properties: None,
459 validation: None,
460 aliases: None,
461 },
462 ],
463 output_variables: None,
464 execution: None, pre_job_execution: None,
466 post_job_execution: None,
467 data_source_bindings: None,
468 messages: None,
469 restrictions: None,
470 demands: None,
471 }),
472
473 "PowerShell" => Some(TaskManifest {
474 id: "e213ff0f-5d5c-4791-802d-52ea3e7be1f1".to_string(),
475 name: "PowerShell".to_string(),
476 friendly_name: Some("PowerShell".to_string()),
477 description: Some("Run a PowerShell script".to_string()),
478 help_url: None,
479 help_mark_down: None,
480 category: Some("Utility".to_string()),
481 visibility: Some(vec!["Build".to_string(), "Release".to_string()]),
482 runs_on: Some(vec!["Agent".to_string()]),
483 author: Some("Microsoft Corporation".to_string()),
484 version: crate::tasks::manifest::TaskVersion {
485 major,
486 minor: 0,
487 patch: 0,
488 },
489 minimum_agent_version: None,
490 instance_name_format: Some("PowerShell Script".to_string()),
491 groups: None,
492 inputs: vec![
493 crate::tasks::manifest::TaskInput {
494 name: "targetType".to_string(),
495 input_type: Some("radio".to_string()),
496 label: Some("Type".to_string()),
497 default_value: Some("inline".to_string()),
498 required: Some(false),
499 help_mark_down: None,
500 group_name: None,
501 visible_rule: None,
502 options: None,
503 properties: None,
504 validation: None,
505 aliases: None,
506 },
507 crate::tasks::manifest::TaskInput {
508 name: "script".to_string(),
509 input_type: Some("multiLine".to_string()),
510 label: Some("Script".to_string()),
511 default_value: None,
512 required: Some(true),
513 help_mark_down: None,
514 group_name: None,
515 visible_rule: Some("targetType = inline".to_string()),
516 options: None,
517 properties: None,
518 validation: None,
519 aliases: None,
520 },
521 crate::tasks::manifest::TaskInput {
522 name: "workingDirectory".to_string(),
523 input_type: Some("filePath".to_string()),
524 label: Some("Working Directory".to_string()),
525 default_value: None,
526 required: Some(false),
527 help_mark_down: None,
528 group_name: None,
529 visible_rule: None,
530 options: None,
531 properties: None,
532 validation: None,
533 aliases: None,
534 },
535 crate::tasks::manifest::TaskInput {
536 name: "pwsh".to_string(),
537 input_type: Some("boolean".to_string()),
538 label: Some("Use PowerShell Core".to_string()),
539 default_value: Some("false".to_string()),
540 required: Some(false),
541 help_mark_down: None,
542 group_name: None,
543 visible_rule: None,
544 options: None,
545 properties: None,
546 validation: None,
547 aliases: None,
548 },
549 ],
550 output_variables: None,
551 execution: None,
552 pre_job_execution: None,
553 post_job_execution: None,
554 data_source_bindings: None,
555 messages: None,
556 restrictions: None,
557 demands: None,
558 }),
559
560 "CmdLine" => Some(TaskManifest {
561 id: "d9bafed4-0b18-4f58-968d-86655b4d2ce9".to_string(),
562 name: "CmdLine".to_string(),
563 friendly_name: Some("Command line".to_string()),
564 description: Some("Run a command line script".to_string()),
565 help_url: None,
566 help_mark_down: None,
567 category: Some("Utility".to_string()),
568 visibility: Some(vec!["Build".to_string(), "Release".to_string()]),
569 runs_on: Some(vec!["Agent".to_string()]),
570 author: Some("Microsoft Corporation".to_string()),
571 version: crate::tasks::manifest::TaskVersion {
572 major,
573 minor: 0,
574 patch: 0,
575 },
576 minimum_agent_version: None,
577 instance_name_format: Some("Command Line Script".to_string()),
578 groups: None,
579 inputs: vec![
580 crate::tasks::manifest::TaskInput {
581 name: "script".to_string(),
582 input_type: Some("multiLine".to_string()),
583 label: Some("Script".to_string()),
584 default_value: None,
585 required: Some(true),
586 help_mark_down: None,
587 group_name: None,
588 visible_rule: None,
589 options: None,
590 properties: None,
591 validation: None,
592 aliases: None,
593 },
594 crate::tasks::manifest::TaskInput {
595 name: "workingDirectory".to_string(),
596 input_type: Some("filePath".to_string()),
597 label: Some("Working Directory".to_string()),
598 default_value: None,
599 required: Some(false),
600 help_mark_down: None,
601 group_name: None,
602 visible_rule: None,
603 options: None,
604 properties: None,
605 validation: None,
606 aliases: None,
607 },
608 ],
609 output_variables: None,
610 execution: None,
611 pre_job_execution: None,
612 post_job_execution: None,
613 data_source_bindings: None,
614 messages: None,
615 restrictions: None,
616 demands: None,
617 }),
618
619 _ => None,
620 }
621}
622
623#[cfg(test)]
624mod tests {
625 use super::*;
626
627 #[test]
628 fn test_parse_task_reference() {
629 let (name, version) = TaskCache::parse_task_reference("Bash@3").unwrap();
630 assert_eq!(name, "Bash");
631 assert_eq!(version, "3");
632
633 let (name, version) = TaskCache::parse_task_reference("DotNetCoreCLI@2.123.4").unwrap();
634 assert_eq!(name, "DotNetCoreCLI");
635 assert_eq!(version, "2.123.4");
636 }
637
638 #[test]
639 fn test_parse_invalid_task_reference() {
640 assert!(TaskCache::parse_task_reference("Bash").is_err());
641 assert!(TaskCache::parse_task_reference("Bash@").is_err());
642 assert!(TaskCache::parse_task_reference("@3").is_err());
643 }
644
645 #[test]
646 fn test_builtin_task_stub_bash() {
647 let manifest = create_builtin_task_stub("Bash", "3").unwrap();
648 assert_eq!(manifest.name, "Bash");
649 assert_eq!(manifest.version.major, 3);
650 }
651
652 #[test]
653 fn test_builtin_task_stub_powershell() {
654 let manifest = create_builtin_task_stub("PowerShell", "2").unwrap();
655 assert_eq!(manifest.name, "PowerShell");
656 assert_eq!(manifest.version.major, 2);
657 }
658
659 #[test]
660 fn test_builtin_task_stub_unknown() {
661 let manifest = create_builtin_task_stub("UnknownTask", "1");
662 assert!(manifest.is_none());
663 }
664
665 #[tokio::test]
666 async fn test_task_cache_config() {
667 let cache = TaskCache::new();
668 assert!(cache.config.allow_download);
669 assert!(!cache.config.task_sources.is_empty());
670 }
671}