Skip to main content

pipeline_service/tasks/
cache.rs

1// Task Cache
2// Downloads and caches Azure DevOps tasks from the marketplace
3
4use 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/// Errors that can occur with task caching
15#[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/// Configuration for the task cache
40#[derive(Debug, Clone)]
41pub struct TaskCacheConfig {
42    /// Cache directory (default: ~/.roxid/tasks/)
43    pub cache_dir: PathBuf,
44
45    /// Whether to allow downloading tasks
46    pub allow_download: bool,
47
48    /// Custom task sources (for testing)
49    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/// Task source for downloading tasks
68#[derive(Debug, Clone)]
69pub enum TaskSource {
70    /// Azure DevOps built-in tasks from GitHub
71    AzureDevOps,
72    /// Local directory containing tasks
73    LocalDir(PathBuf),
74    /// Custom URL pattern (task name and version substituted)
75    CustomUrl(String),
76}
77
78/// A cached task
79#[derive(Debug, Clone)]
80pub struct CachedTask {
81    /// Task name
82    pub name: String,
83
84    /// Task version
85    pub version: String,
86
87    /// Path to the task directory
88    pub path: PathBuf,
89
90    /// Parsed task manifest
91    pub manifest: TaskManifest,
92}
93
94impl CachedTask {
95    /// Get the path to the task's execution target
96    pub fn execution_target(&self) -> Option<PathBuf> {
97        let exec = self.manifest.primary_execution()?;
98        Some(self.path.join(&exec.target))
99    }
100}
101
102/// Task cache for managing Azure DevOps tasks
103pub struct TaskCache {
104    config: TaskCacheConfig,
105    /// In-memory cache of loaded manifests
106    cache: Arc<RwLock<HashMap<String, CachedTask>>>,
107}
108
109impl TaskCache {
110    /// Create a new task cache with default configuration
111    pub fn new() -> Self {
112        Self::with_config(TaskCacheConfig::default())
113    }
114
115    /// Create a task cache with custom configuration
116    pub fn with_config(config: TaskCacheConfig) -> Self {
117        Self {
118            config,
119            cache: Arc::new(RwLock::new(HashMap::new())),
120        }
121    }
122
123    /// Create a task cache with a specific cache directory
124    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    /// Get the cache directory
133    pub fn cache_dir(&self) -> &Path {
134        &self.config.cache_dir
135    }
136
137    /// Parse a task reference (e.g., "Bash@3" or "DotNetCoreCLI@2.123.4")
138    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        // Version can be just major (e.g., "3") or full (e.g., "3.231.0")
148        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    /// Get a task from the cache, downloading if necessary
156    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    /// Get a task by name and version
162    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        // Check in-memory cache first
170        {
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        // Check disk cache
178        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            // Store in memory cache
183            let mut cache = self.cache.write().await;
184            cache.insert(cache_key, task.clone());
185
186            return Ok(task);
187        }
188
189        // Download if allowed
190        if self.config.allow_download {
191            let task = self.download_task(name, version).await?;
192
193            // Store in memory cache
194            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    /// Get the path where a task would be cached
207    fn task_path(&self, name: &str, version: &str) -> PathBuf {
208        self.config.cache_dir.join(name).join(version)
209    }
210
211    /// Load a cached task from disk
212    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    /// Download a task from available sources
230    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    /// Download a task from a specific source
245    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    /// Download from Azure DevOps built-in tasks (GitHub)
261    async fn download_from_azure_devops(
262        &self,
263        name: &str,
264        version: &str,
265    ) -> Result<CachedTask, TaskCacheError> {
266        // Azure DevOps built-in tasks are on GitHub:
267        // https://github.com/microsoft/azure-pipelines-tasks
268        //
269        // For now, we'll create a placeholder since downloading from GitHub
270        // requires more complex handling (finding the right version, extracting, etc.)
271
272        // Create task directory
273        let task_path = self.task_path(name, version);
274        fs::create_dir_all(&task_path)?;
275
276        // For built-in tasks, we can use common patterns
277        // This is a simplified implementation - real implementation would
278        // download from GitHub releases or npm packages
279
280        // Check if this is a known built-in task and create a stub
281        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    /// Load from a local directory
302    async fn load_from_local_dir(
303        &self,
304        dir: &Path,
305        name: &str,
306        version: &str,
307    ) -> Result<CachedTask, TaskCacheError> {
308        // Look for task in local directory
309        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        // Try just the task name (for single-version tasks)
315        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    /// Download from a custom URL
327    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        // For now, this is a stub - real implementation would use reqwest to download
338        Err(TaskCacheError::DownloadError(format!(
339            "Custom URL download not yet implemented: {}",
340            url
341        )))
342    }
343
344    /// List all cached tasks
345    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    /// Clear all cached tasks
371    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    /// Clear a specific cached task
379    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
394/// Create a stub manifest for built-in Azure DevOps tasks
395fn create_builtin_task_stub(name: &str, version: &str) -> Option<TaskManifest> {
396    // Parse major version
397    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, // Will be handled specially by the runner
465            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}