use serde::{Deserialize, Serialize};
use crate::core::errors::TgaError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzureDevOpsConfig {
pub organization_url: String,
pub pat: String,
pub project: String,
#[serde(default = "default_ticket_regex")]
pub ticket_regex: String,
#[serde(default)]
pub team_keys: Vec<String>,
#[serde(default = "default_true")]
pub fetch_on_reference: bool,
}
fn default_ticket_regex() -> String {
"AB#(\\d+)".to_string()
}
fn default_true() -> bool {
true
}
impl AzureDevOpsConfig {
pub fn validate(&self) -> Result<(), TgaError> {
if self.organization_url.trim().is_empty() {
return Err(TgaError::ConfigError(
"pm.azure_devops.organization_url must not be empty".into(),
));
}
if !is_cloud_url(&self.organization_url) {
return Err(TgaError::ConfigError(format!(
"pm.azure_devops.organization_url {:?} is not an Azure DevOps cloud URL — \
on-premises ADO Server / TFS is not supported in Phase 1 \
(only dev.azure.com and *.visualstudio.com are accepted)",
self.organization_url
)));
}
if self.pat.trim().is_empty() {
return Err(TgaError::ConfigError(
"pm.azure_devops.pat must not be empty (Phase 1 uses PAT authentication)".into(),
));
}
if self.project.trim().is_empty() {
return Err(TgaError::ConfigError(
"pm.azure_devops.project must not be empty".into(),
));
}
Ok(())
}
}
fn is_cloud_url(url: &str) -> bool {
let lower = url.to_lowercase();
lower.contains("dev.azure.com") || lower.contains(".visualstudio.com")
}
#[cfg(test)]
mod tests {
use super::*;
fn cfg(url: &str, pat: &str, project: &str) -> AzureDevOpsConfig {
AzureDevOpsConfig {
organization_url: url.to_string(),
pat: pat.to_string(),
project: project.to_string(),
ticket_regex: default_ticket_regex(),
team_keys: vec![],
fetch_on_reference: true,
}
}
#[test]
fn cloud_url_accepted() {
let c = cfg("https://dev.azure.com/myorg", "secret-pat", "MyProject");
c.validate().expect("dev.azure.com URL should validate");
}
#[test]
fn visualstudio_url_accepted() {
let c = cfg("https://myorg.visualstudio.com", "secret-pat", "MyProject");
c.validate().expect("visualstudio.com URL should validate");
}
#[test]
fn on_prem_url_rejected() {
let c = cfg("https://tfs.mycompany.com/tfs", "secret-pat", "MyProject");
let err = c.validate().expect_err("on-prem URL must be rejected");
let msg = format!("{err}");
assert!(
msg.contains("on-premises") || msg.contains("not an Azure DevOps cloud URL"),
"unexpected error message: {msg}"
);
}
#[test]
fn empty_pat_rejected() {
let c = cfg("https://dev.azure.com/myorg", " ", "MyProject");
let err = c.validate().expect_err("whitespace PAT must be rejected");
assert!(format!("{err}").contains("pat"));
}
#[test]
fn empty_url_rejected() {
let c = cfg("", "secret", "MyProject");
c.validate().expect_err("empty url must be rejected");
}
#[test]
fn empty_project_rejected() {
let c = cfg("https://dev.azure.com/myorg", "secret", "");
c.validate().expect_err("empty project must be rejected");
}
#[test]
fn default_ticket_regex_is_ab_hash() {
assert_eq!(default_ticket_regex(), r"AB#(\d+)");
}
#[test]
fn yaml_deserialization() {
let yaml = r#"
organization_url: "https://dev.azure.com/myorg"
pat: "secret-pat"
project: "MyProject"
team_keys: ["ENG", "PLATFORM"]
"#;
let parsed: AzureDevOpsConfig =
serde_yaml::from_str(yaml).expect("should deserialize cleanly");
assert_eq!(parsed.organization_url, "https://dev.azure.com/myorg");
assert_eq!(parsed.pat, "secret-pat");
assert_eq!(parsed.project, "MyProject");
assert_eq!(parsed.team_keys, vec!["ENG", "PLATFORM"]);
assert_eq!(parsed.ticket_regex, r"AB#(\d+)");
assert!(parsed.fetch_on_reference);
}
#[test]
fn yaml_deserialization_in_pm_block() {
let yaml = r#"
pm:
azure_devops:
organization_url: "https://myorg.visualstudio.com"
pat: "x"
project: "Demo"
"#;
#[derive(Deserialize)]
struct Wrap {
pm: super::super::PmConfig,
}
let w: Wrap = serde_yaml::from_str(yaml).expect("pm.azure_devops should parse");
let adc = w.pm.azure_devops.expect("azure_devops present");
assert_eq!(adc.organization_url, "https://myorg.visualstudio.com");
adc.validate().expect("should validate");
}
}