pipedash-plugin-api 0.13.0

Plugin API for Pipedash - CI/CD pipeline dashboard
Documentation
use std::io::{
    Read,
    Write,
};
use std::net::TcpListener;
use std::sync::atomic::{
    AtomicUsize,
    Ordering,
};
use std::sync::Arc;

use super::*;

fn fast_policy() -> RetryPolicy {
    RetryPolicy::new(3, Duration::from_millis(5), false)
}

fn client(timeout: Duration) -> reqwest::Client {
    let _ = rustls::crypto::ring::default_provider().install_default();
    reqwest::Client::builder().timeout(timeout).build().unwrap()
}

async fn attempts_for(error: fn() -> PluginError) -> usize {
    let attempts = AtomicUsize::new(0);
    let result: PluginResult<()> = fast_policy()
        .retry(|| async {
            attempts.fetch_add(1, Ordering::SeqCst);
            Err(error())
        })
        .await;
    assert!(result.is_err());
    attempts.load(Ordering::SeqCst)
}

#[tokio::test]
async fn test_retry_success() {
    let policy = RetryPolicy::default();
    let result = policy.retry(|| async { Ok::<_, PluginError>(42) }).await;
    assert_eq!(result.unwrap(), 42);
}

#[tokio::test]
async fn test_retry_eventual_success() {
    let policy = RetryPolicy::new(3, Duration::from_millis(10), false);
    let attempts = std::cell::Cell::new(0);

    let result = policy
        .retry(|| async {
            let count = attempts.get() + 1;
            attempts.set(count);
            if count < 2 {
                Err(PluginError::NetworkError("Temporary failure".to_string()))
            } else {
                Ok(42)
            }
        })
        .await;

    assert_eq!(result.unwrap(), 42);
}

#[tokio::test]
async fn retries_network_and_short_hint_unavailable() {
    assert_eq!(
        attempts_for(|| PluginError::NetworkError("x".into())).await,
        3
    );
    assert_eq!(
        attempts_for(|| PluginError::ServiceUnavailable {
            message: "x".into(),
            retry_after: None,
        })
        .await,
        3
    );
    assert_eq!(
        attempts_for(|| PluginError::ServiceUnavailable {
            message: "x".into(),
            retry_after: Some(Duration::from_millis(1)),
        })
        .await,
        3
    );
}

#[tokio::test]
async fn does_not_retry_permanent_errors_or_long_hints() {
    assert_eq!(
        attempts_for(|| PluginError::AuthenticationFailed("x".into())).await,
        1
    );
    assert_eq!(
        attempts_for(|| PluginError::PipelineNotFound("x".into())).await,
        1
    );
    assert_eq!(attempts_for(|| PluginError::ApiError("x".into())).await, 1);
    assert_eq!(
        attempts_for(|| PluginError::RateLimited {
            message: "x".into(),
            retry_after: Some(Duration::from_secs(120)),
        })
        .await,
        1
    );
    assert_eq!(
        attempts_for(|| PluginError::ServiceUnavailable {
            message: "x".into(),
            retry_after: Some(Duration::from_secs(120)),
        })
        .await,
        1
    );
}

#[tokio::test]
async fn retry_unsent_retries_connect_errors() {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let addr = listener.local_addr().unwrap();
    drop(listener);
    let url = Url::parse(&format!("http://{addr}/trigger")).unwrap();
    let client = client(Duration::from_secs(5));
    let attempts = AtomicUsize::new(0);

    let result = fast_policy()
        .retry_unsent(&url, || {
            attempts.fetch_add(1, Ordering::SeqCst);
            client.post(url.clone()).send()
        })
        .await;

    assert!(matches!(result, Err(PluginError::NetworkError(_))));
    assert_eq!(attempts.load(Ordering::SeqCst), 3);
}

#[tokio::test]
async fn retry_unsent_never_resends_after_timeout() {
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
    let addr = listener.local_addr().unwrap();
    let received = Arc::new(AtomicUsize::new(0));
    let counter = received.clone();
    std::thread::spawn(move || {
        for mut stream in listener.incoming().flatten() {
            let mut buf = [0u8; 1024];
            if stream.read(&mut buf).unwrap_or(0) > 0 {
                counter.fetch_add(1, Ordering::SeqCst);
            }
            std::thread::sleep(Duration::from_millis(300));
            let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n");
        }
    });
    let url = Url::parse(&format!("http://{addr}/trigger")).unwrap();
    let client = client(Duration::from_millis(50));

    let result = fast_policy()
        .retry_unsent(&url, || client.post(url.clone()).send())
        .await;

    assert!(matches!(result, Err(PluginError::NetworkError(_))));
    tokio::time::sleep(Duration::from_millis(100)).await;
    assert_eq!(received.load(Ordering::SeqCst), 1);
}

#[tokio::test]
async fn ids_without_ci_are_dropped_and_unchecked_ones_kept_in_order() {
    let ids = ["empty", "ci", "unreachable", "ci-too"]
        .map(String::from)
        .to_vec();

    let kept = retain_with_ci(ids, |id| async move {
        match id.as_str() {
            "empty" => Ok(false),
            "unreachable" => Err(PluginError::NetworkError("reset".into())),
            _ => Ok(true),
        }
    })
    .await;

    assert_eq!(kept, ["ci", "unreachable", "ci-too"]);
}

#[test]
fn conclusion_mapping_uses_supplied_lists() {
    let map = |c| {
        status_from_conclusion(
            "completed",
            Some(c),
            &["success", "neutral"],
            &["cancelled"],
        )
    };
    assert_eq!(map("neutral"), PipelineStatus::Success);
    assert_eq!(map("cancelled"), PipelineStatus::Cancelled);
    assert_eq!(map("skipped"), PipelineStatus::Skipped);
    assert_eq!(map("boom"), PipelineStatus::Failed);
    assert_eq!(
        status_from_conclusion("queued", None, &[], &[]),
        PipelineStatus::Running
    );
    assert_eq!(
        status_from_conclusion("x", None, &[], &[]),
        PipelineStatus::Pending
    );
}

#[test]
fn durations_are_signed_and_optional() {
    let a = parse_timestamp("2026-01-01T00:00:10Z");
    let b = parse_timestamp("2026-01-01T00:00:00Z");
    assert_eq!(duration_seconds(b, a), Some(10));
    assert_eq!(duration_seconds(a, b), Some(-10));
    assert_eq!(duration_seconds(None, a), None);
    assert!(parse_timestamp("nope").is_none());
}

#[test]
fn collect_partial_skips_permanent_and_stops_on_transient() {
    let mut skipped = 0;
    let ok = collect_partial(
        [Ok(1), Err(PluginError::ApiError("x".into())), Ok(2)],
        |_| skipped += 1,
    )
    .unwrap();
    assert_eq!((ok, skipped), (vec![1, 2], 1));
    let transient = PluginError::ServiceUnavailable {
        message: "x".into(),
        retry_after: None,
    };
    assert!(collect_partial([Ok(1), Err(transient)], |_| {}).is_err());
}

#[test]
fn repository_name_ignores_hosts_ports_and_suffixes() {
    for url in [
        "https://git.example.com/acme/web.git",
        "https://git.example.com/acme/web.git/",
        "http://git:3000/acme/web",
        "ssh://git@git.example.com:2222/acme/web",
        "git@git.example.com:acme/web.git",
        "git@git.example.com:acme/web/",
    ] {
        assert_eq!(repository_name(url), "acme/web", "{url}");
    }
    assert_eq!(repository_name("web"), "web");
}

#[tokio::test]
async fn cached_fetches_once_and_keeps_errors_uncached() {
    let cache = Cached::<u32>::default();
    let calls = AtomicUsize::new(0);
    let failed = cache
        .get_or_fetch(|| async { Err(PluginError::NetworkError("down".into())) })
        .await;
    assert!(failed.is_err());
    for _ in 0..2 {
        let value = cache
            .get_or_fetch(|| async {
                calls.fetch_add(1, Ordering::SeqCst);
                Ok(7)
            })
            .await;
        assert_eq!(value.unwrap(), 7);
    }
    assert_eq!(calls.load(Ordering::SeqCst), 1);
}

#[test]
fn svg_data_url_keeps_unreserved_and_safe_bytes() {
    let safe = "AZaz09-._~/:;,=!*'()";
    assert_eq!(svg_data_url(safe), format!("data:image/svg+xml,{safe}"));
    assert_eq!(svg_data_url(""), "data:image/svg+xml,");
}

#[test]
fn svg_data_url_encodes_reserved_characters_and_markup() {
    assert_eq!(
        svg_data_url("<svg viewBox=\"0 0 1 1\"/>"),
        "data:image/svg+xml,%3Csvg%20viewBox=%220%200%201%201%22/%3E"
    );
    assert_eq!(
        svg_data_url("?&@+[]{}|\\^`$"),
        "data:image/svg+xml,%3F%26%40%2B%5B%5D%7B%7D%7C%5C%5E%60%24"
    );
}

#[test]
fn svg_data_url_encodes_hash_and_percent_so_fragments_and_escapes_survive() {
    assert_eq!(
        svg_data_url("fill=\"#fff\" 100%"),
        "data:image/svg+xml,fill=%22%23fff%22%20100%25"
    );
}

#[test]
fn svg_data_url_encodes_every_utf8_byte_of_non_ascii_text() {
    assert_eq!(svg_data_url("é✓"), "data:image/svg+xml,%C3%A9%E2%9C%93");
    assert_eq!(svg_data_url("a\nb\tc"), "data:image/svg+xml,a%0Ab%09c");
}

#[test]
fn selected_items_trims_and_skips_blank_entries() {
    let config = HashMap::from([("selected_items".to_string(), " a ,, b,".to_string())]);
    assert_eq!(selected_items(&config).collect::<Vec<_>>(), ["a", "b"]);
    assert_eq!(selected_items(&HashMap::new()).count(), 0);
}

#[test]
fn provider_type_strips_the_plugin_crate_prefix() {
    assert_eq!(provider_type("pipedash-plugin-acme"), "acme");
    assert_eq!(provider_type("pipedash-plugin-acme-cloud"), "acme-cloud");
}

#[test]
fn provider_type_returns_other_names_unchanged() {
    assert_eq!(provider_type("pipedash-plugins"), "pipedash-plugins");
    assert_eq!(provider_type("pipedash-plugin-"), "");
    assert_eq!(provider_type("pipedash-plugin"), "pipedash-plugin");
    assert_eq!(provider_type("acme"), "acme");
    assert_eq!(provider_type(""), "");
}

#[test]
fn provider_type_is_usable_in_const_context() {
    const TYPE: &str = provider_type("pipedash-plugin-acme");
    assert_eq!(TYPE, "acme");
}