pipedash-plugin-azure-devops 0.9.0

Azure DevOps Pipelines plugin for Pipedash
Documentation
use std::collections::HashMap;

use pipedash_plugin_api::{
    PluginError,
    PluginResult,
};
use reqwest::Url;

pub(crate) const PROVIDER_TYPE: &str = "azure-devops";

pub(crate) fn get_server_url(config: &HashMap<String, String>) -> PluginResult<Url> {
    let raw = config
        .get("server_url")
        .map(|url| url.trim())
        .filter(|url| !url.is_empty())
        .ok_or_else(|| PluginError::InvalidConfig("Missing Azure DevOps server URL".to_string()))?;
    let mut url = Url::parse(raw)
        .map_err(|e| PluginError::InvalidConfig(format!("Invalid Azure DevOps server URL: {e}")))?;
    if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
        return Err(PluginError::InvalidConfig(
            "Azure DevOps server URL must start with http:// or https://".to_string(),
        ));
    }
    url.set_query(None);
    url.set_fragment(None);
    Ok(url)
}

pub(crate) fn organization_of(server_url: &Url) -> String {
    server_url
        .path_segments()
        .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty()))
        .or_else(|| server_url.host_str())
        .unwrap_or_default()
        .to_string()
}

pub(crate) fn get_projects(config: &HashMap<String, String>) -> Vec<String> {
    config
        .get("selected_items")
        .map(|items| {
            items
                .split(',')
                .map(str::trim)
                .filter(|s| !s.is_empty())
                .map(str::to_string)
                .collect()
        })
        .unwrap_or_default()
}

#[derive(Debug, PartialEq, Eq)]
pub(crate) struct PipelineId<'a> {
    pub project: &'a str,
    pub definition_id: i64,
}

pub(crate) fn build_pipeline_id(provider_id: i64, project: &str, definition_id: i64) -> String {
    format!("{PROVIDER_TYPE}__{provider_id}__{project}/{definition_id}")
}

pub(crate) fn parse_pipeline_id(id: &str) -> PluginResult<PipelineId<'_>> {
    let invalid = || PluginError::InvalidConfig(format!("Invalid pipeline ID format: {id}"));

    let mut head = id.splitn(3, "__");
    let (Some(PROVIDER_TYPE), Some(provider_id), Some(rest)) =
        (head.next(), head.next(), head.next())
    else {
        return Err(invalid());
    };
    provider_id.parse::<i64>().map_err(|_| invalid())?;

    let (project, definition_id) = rest.rsplit_once('/').ok_or_else(invalid)?;
    if project.is_empty() {
        return Err(invalid());
    }

    Ok(PipelineId {
        project,
        definition_id: definition_id.parse().map_err(|_| invalid())?,
    })
}

pub(crate) fn build_web_url(server_url: &Url, project: &str, build_id: i64) -> String {
    let mut url = server_url.clone();
    if let Ok(mut segments) = url.path_segments_mut() {
        segments
            .pop_if_empty()
            .push(project)
            .push("_build")
            .push("results");
    }
    url.query_pairs_mut()
        .append_pair("buildId", &build_id.to_string());
    url.to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    fn config_with(url: &str) -> HashMap<String, String> {
        HashMap::from([("server_url".to_string(), url.to_string())])
    }

    #[test]
    fn pipeline_id_round_trips_project_names_with_spaces_and_double_underscores() {
        let id = build_pipeline_id(7, "My__Project Name", 42);
        assert_eq!(id, "azure-devops__7__My__Project Name/42");
        assert_eq!(
            parse_pipeline_id(&id).unwrap(),
            PipelineId {
                project: "My__Project Name",
                definition_id: 42,
            }
        );
    }

    #[test]
    fn pipeline_id_rejects_malformed_input() {
        for id in [
            "azuredevops__1__proj/5",
            "github__1__proj/5",
            "azure-devops__x__proj/5",
            "azure-devops__1__proj",
            "azure-devops__1__proj/five",
            "azure-devops__1__/5",
            "azure-devops__1",
        ] {
            assert!(parse_pipeline_id(id).is_err(), "{id}");
        }
    }

    #[test]
    fn server_url_is_required_trimmed_and_stripped_of_query_and_fragment() {
        let url = get_server_url(&config_with(" https://dev.azure.com/acme/?a=1#top ")).unwrap();
        assert_eq!(url.as_str(), "https://dev.azure.com/acme/");
        assert!(get_server_url(&HashMap::new()).is_err());
        assert!(get_server_url(&config_with("   ")).is_err());
    }

    #[test]
    fn server_url_must_be_http_or_https() {
        for url in ["dev.azure.com/acme", "ftp://dev.azure.com/acme", "https://"] {
            let error = get_server_url(&config_with(url)).unwrap_err();
            assert!(matches!(error, PluginError::InvalidConfig(_)), "{url}");
        }
    }

    #[test]
    fn organization_is_the_last_path_segment_of_the_server_url() {
        for (url, organization) in [
            ("https://dev.azure.com/acme", "acme"),
            ("https://dev.azure.com/acme/", "acme"),
            (
                "https://tfs.example.com/tfs/DefaultCollection/",
                "DefaultCollection",
            ),
            ("https://tfs.example.com", "tfs.example.com"),
        ] {
            let url = get_server_url(&config_with(url)).unwrap();
            assert_eq!(organization_of(&url), organization, "{url}");
        }
    }

    #[test]
    fn selected_projects_are_split_and_trimmed() {
        let config = HashMap::from([(
            "selected_items".to_string(),
            " Web App, ,Mobile ".to_string(),
        )]);
        assert_eq!(get_projects(&config), ["Web App", "Mobile"]);
        assert!(get_projects(&HashMap::new()).is_empty());
    }

    #[test]
    fn web_url_encodes_the_project_and_never_doubles_the_slash() {
        let server = get_server_url(&config_with("https://dev.azure.com/acme/")).unwrap();
        assert_eq!(
            build_web_url(&server, "My Project", 9),
            "https://dev.azure.com/acme/My%20Project/_build/results?buildId=9"
        );
    }
}