use anyhow::{Context, Result};
use serde::Deserialize;
use std::collections::HashMap;
use std::process::Command;
#[derive(Debug, Deserialize)]
pub struct VariableGroupData {
pub id: i32,
pub name: String,
#[serde(default)]
pub variables: HashMap<String, VariableValue>,
}
#[derive(Debug, Deserialize)]
pub struct VariableValue {
pub value: Option<String>,
#[serde(rename = "isSecret")]
pub is_secret: Option<bool>,
}
pub struct AzureDevOpsClient {
pub organization: String,
pub project: String,
}
impl AzureDevOpsClient {
pub fn new(organization: String, project: String) -> Self {
let organization_url = if organization.starts_with("https://") || organization.starts_with("http://") {
organization
} else {
format!("https://dev.azure.com/{organization}")
};
Self {
organization: organization_url,
project,
}
}
pub fn check_cli_available(&self) -> Result<()> {
let output = Command::new("az")
.arg("--version")
.output()
.with_context(|| "Failed to execute 'az --version'. Is Azure CLI installed?")?;
if output.status.success() {
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("Azure CLI check failed: {stderr}")
}
}
pub fn get_variable_group(&self, group_name: &str) -> Result<VariableGroupData> {
let output = Command::new("az")
.args([
"pipelines",
"variable-group",
"list",
"--organization",
&self.organization,
"--project",
&self.project,
"--query",
&format!("[?name=='{group_name}'] | [0]"),
"--output",
"json",
])
.output()
.with_context(|| {
format!(
"Failed to execute 'az pipelines variable-group list' for group '{group_name}'"
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"Azure CLI command failed for variable group '{group_name}': {stderr}"
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim();
if trimmed.is_empty() || trimmed == "null" {
anyhow::bail!("Variable group '{group_name}' not found");
}
let group_data: VariableGroupData = serde_json::from_str(trimmed).with_context(|| {
format!(
"Failed to parse Azure CLI response for variable group '{group_name}'"
)
})?;
Ok(group_data)
}
pub fn get_variables_in_group(&self, group_id: i32) -> Result<Vec<String>> {
let output = Command::new("az")
.args([
"pipelines",
"variable-group",
"show",
"--id",
&group_id.to_string(),
"--organization",
&self.organization,
"--project",
&self.project,
"--output",
"json",
])
.output()
.with_context(|| {
format!(
"Failed to execute 'az pipelines variable-group show' for group ID {group_id}"
)
})?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!(
"Azure CLI command failed for variable group ID {group_id}: {stderr}"
);
}
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim();
if trimmed.is_empty() || trimmed == "null" {
anyhow::bail!("Variable group with ID {group_id} not found");
}
let group_data: VariableGroupData = serde_json::from_str(trimmed).with_context(|| {
format!(
"Failed to parse Azure CLI response for variable group ID {group_id}"
)
})?;
let variable_names: Vec<String> = group_data.variables.keys().cloned().collect();
Ok(variable_names)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_client_creation() {
let client = AzureDevOpsClient::new(
"https://dev.azure.com/myorg".to_string(),
"myproject".to_string(),
);
assert_eq!(client.organization, "https://dev.azure.com/myorg");
assert_eq!(client.project, "myproject");
}
#[test]
fn test_client_creation_with_org_name() {
let client = AzureDevOpsClient::new("myorg".to_string(), "myproject".to_string());
assert_eq!(client.organization, "https://dev.azure.com/myorg");
assert_eq!(client.project, "myproject");
}
#[test]
fn test_client_creation_preserves_full_url() {
let client = AzureDevOpsClient::new(
"https://dev.azure.com/customorg".to_string(),
"myproject".to_string(),
);
assert_eq!(client.organization, "https://dev.azure.com/customorg");
assert_eq!(client.project, "myproject");
}
#[test]
fn test_parse_variable_group_response() {
let json_response = r#"{
"id": 123,
"name": "ProductionSecrets",
"variables": {
"ConnectionString": {
"value": "Server=prod.db;",
"isSecret": false
},
"ApiKey": {
"value": null,
"isSecret": true
}
}
}"#;
let group_data: VariableGroupData =
serde_json::from_str(json_response).expect("Failed to parse JSON");
assert_eq!(group_data.id, 123);
assert_eq!(group_data.name, "ProductionSecrets");
assert_eq!(group_data.variables.len(), 2);
let conn_string = group_data
.variables
.get("ConnectionString")
.expect("ConnectionString not found");
assert_eq!(conn_string.value, Some("Server=prod.db;".to_string()));
assert_eq!(conn_string.is_secret, Some(false));
let api_key = group_data.variables.get("ApiKey").expect("ApiKey not found");
assert!(api_key.value.is_none());
assert_eq!(api_key.is_secret, Some(true));
}
#[test]
fn test_parse_variable_group_empty_variables() {
let json_response = r#"{
"id": 456,
"name": "EmptyGroup"
}"#;
let group_data: VariableGroupData =
serde_json::from_str(json_response).expect("Failed to parse JSON");
assert_eq!(group_data.id, 456);
assert_eq!(group_data.name, "EmptyGroup");
assert!(group_data.variables.is_empty());
}
#[test]
fn test_parse_variable_group_with_missing_optional_fields() {
let json_response = r#"{
"id": 789,
"name": "TestGroup",
"variables": {
"SimpleVar": {
"value": "hello"
}
}
}"#;
let group_data: VariableGroupData =
serde_json::from_str(json_response).expect("Failed to parse JSON");
let simple_var = group_data
.variables
.get("SimpleVar")
.expect("SimpleVar not found");
assert_eq!(simple_var.value, Some("hello".to_string()));
assert_eq!(simple_var.is_secret, None); }
#[test]
fn test_extract_variable_names_from_group_data() {
let json_response = r#"{
"id": 100,
"name": "MyGroup",
"variables": {
"Var1": {"value": "a"},
"Var2": {"value": "b"},
"Var3": {"value": "c"}
}
}"#;
let group_data: VariableGroupData =
serde_json::from_str(json_response).expect("Failed to parse JSON");
let mut variable_names: Vec<String> = group_data.variables.keys().cloned().collect();
variable_names.sort();
assert_eq!(variable_names.len(), 3);
assert_eq!(variable_names, vec!["Var1", "Var2", "Var3"]);
}
#[test]
fn test_variable_value_deserialization_with_null_value() {
let json_response = r#"{
"value": null,
"isSecret": true
}"#;
let var_value: VariableValue =
serde_json::from_str(json_response).expect("Failed to parse JSON");
assert!(var_value.value.is_none());
assert_eq!(var_value.is_secret, Some(true));
}
#[test]
fn test_variable_value_deserialization_minimal() {
let json_response = r#"{"value": "test-value"}"#;
let var_value: VariableValue =
serde_json::from_str(json_response).expect("Failed to parse JSON");
assert_eq!(var_value.value, Some("test-value".to_string()));
assert_eq!(var_value.is_secret, None); }
#[test]
fn test_variable_value_deserialization_with_null_is_secret() {
let json_response = r#"{"value": "test-value", "isSecret": null}"#;
let var_value: VariableValue =
serde_json::from_str(json_response).expect("Failed to parse JSON");
assert_eq!(var_value.value, Some("test-value".to_string()));
assert_eq!(var_value.is_secret, None); }
}