use anyhow::{Context, Result};
use regex::Regex;
use serde::Deserialize;
use std::fs;
#[derive(Debug, Deserialize)]
pub struct VariableGroup {
pub group: Option<String>,
#[serde(flatten)]
pub variables: Option<std::collections::HashMap<String, String>>,
}
#[derive(Debug, Deserialize)]
pub struct Variable {
pub name: Option<String>,
pub value: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum VariableEntry {
Group { group: String },
Named { name: String, value: Option<String> },
Template { template: String },
Conditional(serde_yaml::Value),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum Variables {
List(Vec<VariableEntry>),
Map(std::collections::HashMap<String, serde_yaml::Value>),
}
impl Variables {
pub fn len(&self) -> usize {
match self {
Variables::List(entries) => entries.len(),
Variables::Map(map) => map.len(),
}
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
pub fn iter(&self) -> std::slice::Iter<'_, VariableEntry> {
match self {
Variables::List(entries) => entries.iter(),
Variables::Map(_) => [].iter(),
}
}
}
#[derive(Debug, Deserialize)]
pub struct Job {
#[serde(default)]
pub variables: Option<Variables>,
}
#[derive(Debug, Deserialize)]
pub struct Deployment {
#[serde(default)]
pub variables: Option<Variables>,
}
#[derive(Debug, Deserialize)]
pub struct Stage {
#[serde(default)]
pub stage: Option<String>,
#[serde(default)]
pub variables: Option<Variables>,
#[serde(default)]
pub jobs: Option<Vec<Job>>,
}
#[derive(Debug, Deserialize)]
pub struct Pipeline {
#[serde(default)]
pub variables: Option<Variables>,
#[serde(default)]
pub stages: Option<Vec<Stage>>,
}
impl Pipeline {
pub fn get_variable_groups(&self) -> Vec<String> {
let mut groups = Vec::new();
Self::collect_groups_from_variables(&self.variables, &mut groups);
if let Some(ref stages) = self.stages {
for stage in stages {
Self::collect_groups_from_variables(&stage.variables, &mut groups);
if let Some(ref jobs) = stage.jobs {
for job in jobs {
Self::collect_groups_from_variables(&job.variables, &mut groups);
}
}
}
}
groups
}
fn collect_groups_from_variables(variables: &Option<Variables>, groups: &mut Vec<String>) {
if let Some(ref vars) = variables {
match vars {
Variables::List(entries) => {
for entry in entries {
match entry {
VariableEntry::Group { group } => {
if !groups.contains(group) {
groups.push(group.clone());
}
}
VariableEntry::Conditional(value) => {
Self::extract_groups_from_value(value, groups);
}
_ => {}
}
}
}
Variables::Map(_) => {
}
}
}
}
pub fn get_inline_variable_names(&self) -> Vec<String> {
let mut names = Vec::new();
Self::collect_inline_variables(&self.variables, &mut names);
if let Some(ref stages) = self.stages {
for stage in stages {
Self::collect_inline_variables(&stage.variables, &mut names);
if let Some(ref jobs) = stage.jobs {
for job in jobs {
Self::collect_inline_variables(&job.variables, &mut names);
}
}
}
}
names
}
fn collect_inline_variables(variables: &Option<Variables>, names: &mut Vec<String>) {
if let Some(ref vars) = variables {
match vars {
Variables::List(entries) => {
for entry in entries {
match entry {
VariableEntry::Named { name, .. } => {
if !names.contains(name) {
names.push(name.clone());
}
}
VariableEntry::Conditional(value) => {
Self::extract_inline_variables_from_value(value, names);
}
_ => {}
}
}
}
Variables::Map(map) => {
for (key, value) in map {
if key.starts_with("${{") {
Self::extract_inline_variables_from_value(value, names);
} else if !names.contains(key) {
names.push(key.clone());
}
}
}
}
}
}
fn extract_groups_from_value(value: &serde_yaml::Value, groups: &mut Vec<String>) {
match value {
serde_yaml::Value::Mapping(map) => {
if let Some(serde_yaml::Value::String(group_name)) =
map.get(serde_yaml::Value::String("group".to_string()))
{
if !groups.contains(group_name) {
groups.push(group_name.clone());
}
}
for (_key, val) in map {
Self::extract_groups_from_value(val, groups);
}
}
serde_yaml::Value::Sequence(seq) => {
for item in seq {
Self::extract_groups_from_value(item, groups);
}
}
_ => {}
}
}
fn extract_inline_variables_from_value(value: &serde_yaml::Value, names: &mut Vec<String>) {
match value {
serde_yaml::Value::Mapping(map) => {
if let Some(serde_yaml::Value::String(var_name)) =
map.get(serde_yaml::Value::String("name".to_string()))
{
if !names.contains(var_name) {
names.push(var_name.clone());
}
}
for (key, val) in map {
if let serde_yaml::Value::String(key_str) = key {
if key_str.starts_with("${{") {
Self::extract_inline_variables_from_value(val, names);
} else if !is_special_yaml_key(key_str) && !names.contains(key_str) {
names.push(key_str.clone());
}
}
Self::extract_inline_variables_from_value(val, names);
}
}
serde_yaml::Value::Sequence(seq) => {
for item in seq {
Self::extract_inline_variables_from_value(item, names);
}
}
_ => {}
}
}
}
fn is_special_yaml_key(key: &str) -> bool {
const SPECIAL_KEYS: &[&str] = &[
"name", "value", "group", "template", "readonly", "isSecret",
];
SPECIAL_KEYS.contains(&key)
}
pub fn parse_pipeline_file(path: &str) -> Result<Pipeline> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read pipeline file: {path}"))?;
let pipeline: Pipeline = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse YAML in pipeline file: {path}"))?;
Ok(pipeline)
}
pub fn extract_variable_references(path: &str) -> Result<Vec<String>> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read pipeline file: {path}"))?;
extract_variable_references_from_content(&content)
}
const SYSTEM_VARIABLE_PREFIXES: &[&str] = &[
"Build.",
"System.",
"Agent.",
"Pipeline.",
"Environment.",
"Checks.",
"Release.",
"Task.",
"Resources.",
];
pub fn is_system_variable(name: &str) -> bool {
SYSTEM_VARIABLE_PREFIXES
.iter()
.any(|prefix| name.starts_with(prefix))
}
fn is_runtime_output_variable(name: &str) -> bool {
if !name.contains('.') {
return false;
}
let parts: Vec<&str> = name.split('.').collect();
if parts.len() < 2 {
return false;
}
if is_system_variable(name) {
return false;
}
true
}
const BUILD_NUMBER_FORMAT_PREFIXES: &[&str] = &["Date:", "Rev:"];
fn should_skip_variable(name: &str) -> bool {
if name.starts_with('$') {
return true;
}
if name.starts_with('[') {
return true;
}
if is_system_variable(name) {
return true;
}
if BUILD_NUMBER_FORMAT_PREFIXES
.iter()
.any(|prefix| name.starts_with(prefix))
{
return true;
}
if is_runtime_output_variable(name) {
return true;
}
if looks_like_shell_command(name) {
return true;
}
false
}
fn looks_like_shell_command(name: &str) -> bool {
name.contains(' ')
}
#[derive(Debug)]
pub struct TemplateInfo {
pub is_template: bool,
pub parameter_names: Vec<String>,
}
pub fn detect_template(path: &str) -> Result<TemplateInfo> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read pipeline file: {path}"))?;
let yaml: serde_yaml::Value = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse YAML in pipeline file: {path}"))?;
let mapping = match yaml.as_mapping() {
Some(m) => m,
None => {
return Ok(TemplateInfo {
is_template: false,
parameter_names: Vec::new(),
})
}
};
let has_parameters = mapping.contains_key(serde_yaml::Value::String("parameters".to_string()));
let has_trigger = mapping.contains_key(serde_yaml::Value::String("trigger".to_string()));
let has_pr = mapping.contains_key(serde_yaml::Value::String("pr".to_string()));
let is_template = has_parameters && !has_trigger && !has_pr;
let parameter_names = if is_template {
extract_parameter_names(&yaml)
} else {
Vec::new()
};
Ok(TemplateInfo {
is_template,
parameter_names,
})
}
fn extract_parameter_names(yaml: &serde_yaml::Value) -> Vec<String> {
let mut names = Vec::new();
if let Some(mapping) = yaml.as_mapping() {
if let Some(params) = mapping.get(serde_yaml::Value::String("parameters".to_string())) {
if let Some(params_seq) = params.as_sequence() {
for param in params_seq {
if let Some(param_map) = param.as_mapping() {
if let Some(serde_yaml::Value::String(name)) =
param_map.get(serde_yaml::Value::String("name".to_string()))
{
names.push(name.clone());
}
}
}
}
}
}
names
}
#[derive(Debug, Clone)]
pub struct TemplateReference {
pub template_path: String,
pub stage_name: Option<String>,
pub available_groups: Vec<String>,
pub available_inline_vars: Vec<String>,
}
pub fn extract_template_references(path: &str) -> Result<Vec<TemplateReference>> {
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read pipeline file: {path}"))?;
let yaml: serde_yaml::Value = serde_yaml::from_str(&content)
.with_context(|| format!("Failed to parse YAML in pipeline file: {path}"))?;
let mut references = Vec::new();
let mapping = match yaml.as_mapping() {
Some(m) => m,
None => return Ok(references),
};
let mut top_level_groups = Vec::new();
let mut top_level_inline_vars = Vec::new();
if let Some(vars) = mapping.get(serde_yaml::Value::String("variables".to_string())) {
collect_groups_from_yaml_value(vars, &mut top_level_groups);
collect_inline_vars_from_yaml_value(vars, &mut top_level_inline_vars);
}
if let Some(stages) = mapping.get(serde_yaml::Value::String("stages".to_string())) {
if let Some(stages_seq) = stages.as_sequence() {
for stage in stages_seq {
if let Some(stage_map) = stage.as_mapping() {
let stage_name = stage_map
.get(serde_yaml::Value::String("stage".to_string()))
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let mut stage_groups = top_level_groups.clone();
let mut stage_inline_vars = top_level_inline_vars.clone();
if let Some(vars) = stage_map.get(serde_yaml::Value::String("variables".to_string())) {
collect_groups_from_yaml_value(vars, &mut stage_groups);
collect_inline_vars_from_yaml_value(vars, &mut stage_inline_vars);
}
if let Some(jobs) = stage_map.get(serde_yaml::Value::String("jobs".to_string())) {
if let Some(jobs_seq) = jobs.as_sequence() {
for job in jobs_seq {
if let Some(job_map) = job.as_mapping() {
if let Some(template_val) = job_map.get(serde_yaml::Value::String("template".to_string())) {
if let Some(template_path) = template_val.as_str() {
references.push(TemplateReference {
template_path: template_path.to_string(),
stage_name: stage_name.clone(),
available_groups: stage_groups.clone(),
available_inline_vars: stage_inline_vars.clone(),
});
}
}
}
}
}
}
}
}
}
}
Ok(references)
}
fn collect_groups_from_yaml_value(value: &serde_yaml::Value, groups: &mut Vec<String>) {
if let Some(seq) = value.as_sequence() {
for item in seq {
if let Some(map) = item.as_mapping() {
if let Some(serde_yaml::Value::String(group_name)) =
map.get(serde_yaml::Value::String("group".to_string()))
{
if !groups.contains(group_name) {
groups.push(group_name.clone());
}
}
}
}
}
}
fn collect_inline_vars_from_yaml_value(value: &serde_yaml::Value, vars: &mut Vec<String>) {
if let Some(seq) = value.as_sequence() {
for item in seq {
if let Some(map) = item.as_mapping() {
if let Some(serde_yaml::Value::String(var_name)) =
map.get(serde_yaml::Value::String("name".to_string()))
{
if !vars.contains(var_name) {
vars.push(var_name.clone());
}
}
}
}
}
}
pub fn resolve_template_path(parent_path: &str, template_ref: &str) -> String {
use std::path::Path;
let parent = Path::new(parent_path);
let parent_dir = parent.parent().unwrap_or(Path::new("."));
parent_dir.join(template_ref).to_string_lossy().to_string()
}
pub fn extract_variable_references_from_content(content: &str) -> Result<Vec<String>> {
let re = Regex::new(r"\$\(([^)]+)\)")
.with_context(|| "Failed to compile variable reference regex")?;
let mut variables = Vec::new();
for cap in re.captures_iter(content) {
if let Some(var_name) = cap.get(1) {
let name = var_name.as_str();
if should_skip_variable(name) {
continue;
}
let name_string = name.to_string();
if !variables.contains(&name_string) {
variables.push(name_string);
}
}
}
Ok(variables)
}