use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fs;
use std::path::Path;
use std::str::FromStr;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum TaskManifestError {
#[error("Failed to read task manifest: {0}")]
IoError(#[from] std::io::Error),
#[error("Failed to parse task manifest: {0}")]
ParseError(#[from] serde_json::Error),
#[error("Task manifest not found at: {0}")]
NotFound(String),
#[error("Invalid task manifest: {0}")]
Invalid(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskManifest {
pub id: String,
pub name: String,
pub friendly_name: Option<String>,
pub description: Option<String>,
pub help_url: Option<String>,
pub help_mark_down: Option<String>,
pub category: Option<String>,
pub visibility: Option<Vec<String>>,
pub runs_on: Option<Vec<String>>,
pub author: Option<String>,
pub version: TaskVersion,
pub minimum_agent_version: Option<String>,
pub instance_name_format: Option<String>,
pub groups: Option<Vec<TaskGroup>>,
#[serde(default)]
pub inputs: Vec<TaskInput>,
#[serde(default)]
pub output_variables: Option<Vec<TaskOutputVariable>>,
pub execution: Option<TaskExecutionSection>,
pub pre_job_execution: Option<TaskExecutionSection>,
pub post_job_execution: Option<TaskExecutionSection>,
pub data_source_bindings: Option<Vec<DataSourceBinding>>,
pub messages: Option<HashMap<String, String>>,
pub restrictions: Option<TaskRestrictions>,
pub demands: Option<Vec<String>>,
}
impl FromStr for TaskManifest {
type Err = TaskManifestError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let manifest: TaskManifest = serde_json::from_str(s)?;
Ok(manifest)
}
}
impl TaskManifest {
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, TaskManifestError> {
let path = path.as_ref();
if !path.exists() {
return Err(TaskManifestError::NotFound(path.display().to_string()));
}
let content = fs::read_to_string(path)?;
Self::parse_str(&content)
}
pub fn parse_str(content: &str) -> Result<Self, TaskManifestError> {
content.parse()
}
pub fn version_string(&self) -> String {
format!(
"{}.{}.{}",
self.version.major, self.version.minor, self.version.patch
)
}
pub fn primary_execution(&self) -> Option<&TaskExecution> {
self.execution.as_ref().and_then(|e| {
e.node
.as_ref()
.or(e.node10.as_ref())
.or(e.node16.as_ref())
.or(e.node20.as_ref())
.or(e.powershell3.as_ref())
.or(e.powershell.as_ref())
})
}
pub fn is_node_task(&self) -> bool {
self.execution.as_ref().is_some_and(|e| {
e.node.is_some() || e.node10.is_some() || e.node16.is_some() || e.node20.is_some()
})
}
pub fn is_powershell_task(&self) -> bool {
self.execution
.as_ref()
.is_some_and(|e| e.powershell.is_some() || e.powershell3.is_some())
}
pub fn required_inputs(&self) -> Vec<&str> {
self.inputs
.iter()
.filter(|i| i.required.unwrap_or(false))
.map(|i| i.name.as_str())
.collect()
}
pub fn default_values(&self) -> HashMap<String, String> {
self.inputs
.iter()
.filter_map(|i| {
i.default_value
.as_ref()
.map(|v| (i.name.clone(), v.clone()))
})
.collect()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct TaskVersion {
pub major: u32,
pub minor: u32,
pub patch: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskGroup {
pub name: String,
pub display_name: Option<String>,
pub is_expanded: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskInput {
pub name: String,
#[serde(rename = "type")]
pub input_type: Option<String>,
pub label: Option<String>,
pub default_value: Option<String>,
pub required: Option<bool>,
pub help_mark_down: Option<String>,
pub group_name: Option<String>,
pub visible_rule: Option<String>,
pub options: Option<HashMap<String, String>>,
pub properties: Option<InputProperties>,
pub validation: Option<InputValidation>,
pub aliases: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct InputProperties {
pub editable_options: Option<String>,
pub multi_select: Option<String>,
pub multi_select_flatlist: Option<String>,
pub disable_manage_link: Option<String>,
pub is_search_required: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputValidation {
pub expression: Option<String>,
pub message: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskOutputVariable {
pub name: String,
pub description: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub struct TaskExecutionSection {
pub node: Option<TaskExecution>,
pub node10: Option<TaskExecution>,
pub node16: Option<TaskExecution>,
pub node20: Option<TaskExecution>,
pub powershell: Option<TaskExecution>,
#[serde(rename = "PowerShell3")]
pub powershell3: Option<TaskExecution>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct TaskExecution {
pub target: String,
pub working_directory: Option<String>,
pub platforms: Option<Vec<String>>,
pub argument_format: Option<String>,
}
impl TaskExecution {
pub fn execution_type(&self) -> &'static str {
"Node"
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DataSourceBinding {
pub target: String,
pub endpoint_id: Option<String>,
pub data_source_name: Option<String>,
pub parameters: Option<HashMap<String, String>>,
pub result_template: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskRestrictions {
pub commands: Option<TaskCommandRestrictions>,
#[serde(rename = "settableVariables")]
pub settable_variables: Option<TaskSettableVariables>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskCommandRestrictions {
pub mode: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskSettableVariables {
pub allowed: Option<Vec<String>>,
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_TASK_JSON: &str = r#"{
"id": "6c731c3c-3c68-459a-a5c9-bde6e6595b5b",
"name": "Bash",
"friendlyName": "Bash",
"description": "Run a Bash script on macOS, Linux, or Windows",
"helpUrl": "https://docs.microsoft.com/azure/devops/pipelines/tasks/utility/bash",
"category": "Utility",
"visibility": ["Build", "Release"],
"runsOn": ["Agent", "DeploymentGroup"],
"author": "Microsoft Corporation",
"version": {
"Major": 3,
"Minor": 231,
"Patch": 0
},
"instanceNameFormat": "Bash Script",
"groups": [
{
"name": "advanced",
"displayName": "Advanced",
"isExpanded": false
}
],
"inputs": [
{
"name": "targetType",
"type": "radio",
"label": "Type",
"required": false,
"defaultValue": "filePath",
"options": {
"filePath": "File Path",
"inline": "Inline"
}
},
{
"name": "filePath",
"type": "filePath",
"label": "Script Path",
"required": true,
"visibleRule": "targetType = filePath"
},
{
"name": "script",
"type": "multiLine",
"label": "Script",
"required": true,
"defaultValue": "echo Hello world",
"visibleRule": "targetType = inline"
},
{
"name": "workingDirectory",
"type": "filePath",
"label": "Working Directory",
"groupName": "advanced"
},
{
"name": "failOnStderr",
"type": "boolean",
"label": "Fail on Standard Error",
"defaultValue": "false",
"groupName": "advanced"
}
],
"execution": {
"Node10": {
"target": "bash.js"
},
"Node16": {
"target": "bash.js"
}
}
}"#;
#[test]
fn test_parse_task_manifest() {
let manifest = TaskManifest::from_str(SAMPLE_TASK_JSON).unwrap();
assert_eq!(manifest.name, "Bash");
assert_eq!(manifest.friendly_name, Some("Bash".to_string()));
assert_eq!(manifest.version.major, 3);
assert_eq!(manifest.version.minor, 231);
assert_eq!(manifest.version.patch, 0);
assert_eq!(manifest.version_string(), "3.231.0");
}
#[test]
fn test_task_inputs() {
let manifest = TaskManifest::from_str(SAMPLE_TASK_JSON).unwrap();
assert_eq!(manifest.inputs.len(), 5);
assert_eq!(manifest.inputs[0].name, "targetType");
assert_eq!(
manifest.inputs[0].default_value,
Some("filePath".to_string())
);
}
#[test]
fn test_required_inputs() {
let manifest = TaskManifest::from_str(SAMPLE_TASK_JSON).unwrap();
let required = manifest.required_inputs();
assert!(required.contains(&"filePath"));
assert!(required.contains(&"script"));
}
#[test]
fn test_default_values() {
let manifest = TaskManifest::from_str(SAMPLE_TASK_JSON).unwrap();
let defaults = manifest.default_values();
assert_eq!(defaults.get("targetType"), Some(&"filePath".to_string()));
assert_eq!(defaults.get("failOnStderr"), Some(&"false".to_string()));
}
#[test]
fn test_is_node_task() {
let manifest = TaskManifest::from_str(SAMPLE_TASK_JSON).unwrap();
assert!(manifest.is_node_task());
assert!(!manifest.is_powershell_task());
}
#[test]
fn test_primary_execution() {
let manifest = TaskManifest::from_str(SAMPLE_TASK_JSON).unwrap();
let exec = manifest.primary_execution().unwrap();
assert_eq!(exec.target, "bash.js");
}
}