use serde::{Deserialize, Serialize};
use crate::core::errors::TgaError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AzureDevOpsConfig {
pub organization_url: String,
pub pat: String,
#[serde(default)]
pub project: Option<String>,
#[serde(default)]
pub projects: Vec<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,
#[serde(default)]
pub fetch_prs: bool,
}
fn default_ticket_regex() -> String {
r"(?i)\bAB#(\d+)\b".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.projects().is_empty() {
return Err(TgaError::ConfigError(
"pm.azure_devops.project (or .projects) must not be empty".into(),
));
}
Ok(())
}
pub fn projects(&self) -> Vec<&str> {
let mut out: Vec<&str> = Vec::new();
if let Some(p) = self.project.as_deref() {
let t = p.trim();
if !t.is_empty() {
out.push(t);
}
}
for p in &self.projects {
let t = p.trim();
if !t.is_empty() && !out.contains(&t) {
out.push(t);
}
}
out
}
}
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: if project.is_empty() {
None
} else {
Some(project.to_string())
},
projects: vec![],
ticket_regex: default_ticket_regex(),
team_keys: vec![],
fetch_on_reference: true,
fetch_prs: false,
}
}
#[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"(?i)\bAB#(\d+)\b");
}
#[test]
fn default_ticket_regex_matches_lowercase_ab() {
let re = regex::Regex::new(&default_ticket_regex()).expect("default compiles");
for sample in ["AB#42", "ab#42", "Ab#42", "aB#42"] {
assert!(re.is_match(sample), "default regex should match {sample:?}");
}
assert!(!re.is_match("xAB#42y"), "word boundaries must still apply");
}
#[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.as_deref(), Some("MyProject"));
assert_eq!(parsed.team_keys, vec!["ENG", "PLATFORM"]);
assert_eq!(parsed.ticket_regex, r"(?i)\bAB#(\d+)\b");
assert!(parsed.fetch_on_reference);
}
#[test]
fn projects_back_compat_single_project_field() {
let yaml = r#"
organization_url: "https://dev.azure.com/myorg"
pat: "secret-pat"
project: "BottomLineSystems"
"#;
let parsed: AzureDevOpsConfig =
serde_yaml::from_str(yaml).expect("should deserialize cleanly");
assert_eq!(parsed.projects(), vec!["BottomLineSystems"]);
parsed.validate().expect("single project should validate");
}
#[test]
fn projects_new_list_form() {
let yaml = r#"
organization_url: "https://dev.azure.com/myorg"
pat: "secret-pat"
projects: ["A", "B"]
"#;
let parsed: AzureDevOpsConfig =
serde_yaml::from_str(yaml).expect("should deserialize cleanly");
assert_eq!(parsed.projects(), vec!["A", "B"]);
parsed.validate().expect("projects list should validate");
}
#[test]
fn projects_both_fields_set_single_first_then_list() {
let yaml = r#"
organization_url: "https://dev.azure.com/myorg"
pat: "secret-pat"
project: "A"
projects: ["B", "C"]
"#;
let parsed: AzureDevOpsConfig =
serde_yaml::from_str(yaml).expect("should deserialize cleanly");
assert_eq!(parsed.projects(), vec!["A", "B", "C"]);
}
#[test]
fn projects_dedup_across_single_and_list() {
let yaml = r#"
organization_url: "https://dev.azure.com/myorg"
pat: "secret-pat"
project: "A"
projects: ["A", "B"]
"#;
let parsed: AzureDevOpsConfig =
serde_yaml::from_str(yaml).expect("should deserialize cleanly");
assert_eq!(parsed.projects(), vec!["A", "B"]);
}
#[test]
fn projects_filters_blank_strings() {
let yaml = r#"
organization_url: "https://dev.azure.com/myorg"
pat: "secret-pat"
project: ""
projects: ["", "B"]
"#;
let parsed: AzureDevOpsConfig =
serde_yaml::from_str(yaml).expect("should deserialize cleanly");
assert_eq!(parsed.projects(), vec!["B"]);
}
#[test]
fn projects_both_empty_fails_validate() {
let c = AzureDevOpsConfig {
organization_url: "https://dev.azure.com/myorg".to_string(),
pat: "secret".to_string(),
project: None,
projects: vec![],
ticket_regex: default_ticket_regex(),
team_keys: vec![],
fetch_on_reference: true,
fetch_prs: false,
};
let err = c
.validate()
.expect_err("empty project + empty projects must error");
let msg = format!("{err}");
assert!(
msg.contains("project"),
"error should mention project: {msg}"
);
}
#[test]
fn projects_order_preserved() {
let yaml = r#"
organization_url: "https://dev.azure.com/myorg"
pat: "secret-pat"
projects: ["Z", "A", "M"]
"#;
let parsed: AzureDevOpsConfig =
serde_yaml::from_str(yaml).expect("should deserialize cleanly");
assert_eq!(parsed.projects(), vec!["Z", "A", "M"]);
}
#[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");
}
}