Skip to main content

pipedash_plugin_api/
utils.rs

1use std::collections::HashMap;
2use std::future::Future;
3use std::sync::OnceLock;
4use std::time::Duration;
5
6use chrono::{
7    DateTime,
8    Utc,
9};
10use futures::StreamExt;
11use reqwest::Url;
12
13use crate::config::comma_separated;
14use crate::http::map_send_error;
15use crate::{
16    PipelineStatus,
17    PluginError,
18    PluginResult,
19};
20
21pub struct RetryPolicy {
22    pub max_retries: usize,
23    pub initial_delay: Duration,
24    pub exponential_backoff: bool,
25}
26
27impl Default for RetryPolicy {
28    fn default() -> Self {
29        Self {
30            max_retries: 3,
31            initial_delay: Duration::from_millis(100),
32            exponential_backoff: true,
33        }
34    }
35}
36
37impl RetryPolicy {
38    pub fn new(max_retries: usize, initial_delay: Duration, exponential_backoff: bool) -> Self {
39        Self {
40            max_retries,
41            initial_delay,
42            exponential_backoff,
43        }
44    }
45
46    pub async fn retry<F, Fut, T>(&self, operation: F) -> PluginResult<T>
47    where
48        F: Fn() -> Fut,
49        Fut: Future<Output = PluginResult<T>>,
50    {
51        let mut delay = self.initial_delay;
52        let mut attempt = 1;
53
54        loop {
55            match operation().await {
56                Ok(result) => return Ok(result),
57                Err(e) if attempt < self.max_retries && Self::should_retry(&e, delay) => {
58                    tokio::time::sleep(delay).await;
59                    delay = self.next_delay(delay);
60                    attempt += 1;
61                }
62                Err(e) => return Err(e),
63            }
64        }
65    }
66
67    pub async fn retry_unsent<F, Fut>(&self, url: &Url, send: F) -> PluginResult<reqwest::Response>
68    where
69        F: Fn() -> Fut,
70        Fut: Future<Output = Result<reqwest::Response, reqwest::Error>>,
71    {
72        let mut delay = self.initial_delay;
73        let mut attempt = 1;
74
75        loop {
76            match send().await {
77                Ok(response) => return Ok(response),
78                Err(e) if attempt < self.max_retries && e.is_connect() && !e.is_timeout() => {
79                    tokio::time::sleep(delay).await;
80                    delay = self.next_delay(delay);
81                    attempt += 1;
82                }
83                Err(e) => return Err(map_send_error(e, url)),
84            }
85        }
86    }
87
88    fn should_retry(error: &PluginError, delay: Duration) -> bool {
89        error.is_transient() && error.retry_after().is_none_or(|hint| hint <= delay)
90    }
91
92    fn next_delay(&self, delay: Duration) -> Duration {
93        if self.exponential_backoff {
94            delay.saturating_mul(2)
95        } else {
96            delay
97        }
98    }
99}
100
101#[derive(Debug)]
102pub struct Cached<T>(OnceLock<T>);
103
104impl<T> Default for Cached<T> {
105    fn default() -> Self {
106        Self(OnceLock::new())
107    }
108}
109
110impl<T: Clone> Cached<T> {
111    pub async fn get_or_fetch<F, Fut>(&self, fetch: F) -> PluginResult<T>
112    where
113        F: FnOnce() -> Fut,
114        Fut: Future<Output = PluginResult<T>>,
115    {
116        if let Some(value) = self.0.get() {
117            return Ok(value.clone());
118        }
119        let value = fetch().await?;
120        let _ = self.0.set(value.clone());
121        Ok(value)
122    }
123}
124
125pub async fn retain_with_ci<F, Fut>(ids: Vec<String>, has_ci: F) -> Vec<String>
126where
127    F: Fn(String) -> Fut,
128    Fut: Future<Output = PluginResult<bool>>,
129{
130    futures::stream::iter(ids)
131        .map(|id| {
132            let check = has_ci(id.clone());
133            async move { check.await.unwrap_or(true).then_some(id) }
134        })
135        .buffered(8)
136        .filter_map(std::future::ready)
137        .collect()
138        .await
139}
140
141pub fn status_from_conclusion(
142    status: &str, conclusion: Option<&str>, successful: &[&str], cancelled: &[&str],
143) -> PipelineStatus {
144    match (status, conclusion) {
145        ("completed", Some(c)) if successful.contains(&c) => PipelineStatus::Success,
146        ("completed", Some(c)) if cancelled.contains(&c) => PipelineStatus::Cancelled,
147        ("completed", Some("skipped")) => PipelineStatus::Skipped,
148        ("completed", _) => PipelineStatus::Failed,
149        ("in_progress", _) | ("queued", _) => PipelineStatus::Running,
150        _ => PipelineStatus::Pending,
151    }
152}
153
154pub fn repository_name(repo_url: &str) -> String {
155    let cleaned = repo_url
156        .trim_end_matches('/')
157        .trim_end_matches(".git")
158        .trim_end_matches('/');
159
160    if !cleaned.contains("://") {
161        if let Some((_, path)) = cleaned.rsplit_once(':') {
162            if path.contains('/') {
163                return path.to_string();
164            }
165        }
166    }
167
168    let mut segments = cleaned.rsplit('/');
169    match (segments.next(), segments.next()) {
170        (Some(repo), Some(org)) => format!("{org}/{repo}"),
171        _ => repo_url.to_string(),
172    }
173}
174
175pub fn parse_timestamp(value: &str) -> Option<DateTime<Utc>> {
176    DateTime::parse_from_rfc3339(value)
177        .ok()
178        .map(|parsed| parsed.with_timezone(&Utc))
179}
180
181pub fn duration_seconds(
182    started: Option<DateTime<Utc>>, finished: Option<DateTime<Utc>>,
183) -> Option<i64> {
184    Some((finished? - started?).num_seconds())
185}
186
187pub fn collect_partial<T>(
188    results: impl IntoIterator<Item = PluginResult<T>>, mut skipped: impl FnMut(&PluginError),
189) -> PluginResult<Vec<T>> {
190    let mut items = Vec::new();
191    for result in results {
192        match result {
193            Ok(item) => items.push(item),
194            Err(error) if error.is_transient() => return Err(error),
195            Err(error) => skipped(&error),
196        }
197    }
198    Ok(items)
199}
200
201pub fn selected_items(config: &HashMap<String, String>) -> impl Iterator<Item = &str> {
202    config
203        .get("selected_items")
204        .into_iter()
205        .flat_map(|items| comma_separated(items))
206}
207
208pub const fn provider_type(package_name: &'static str) -> &'static str {
209    const PREFIX: &[u8] = b"pipedash-plugin-";
210    let name = package_name.as_bytes();
211    if name.len() < PREFIX.len() {
212        return package_name;
213    }
214    let (head, rest) = name.split_at(PREFIX.len());
215    let mut index = 0;
216    while index < PREFIX.len() {
217        if head[index] != PREFIX[index] {
218            return package_name;
219        }
220        index += 1;
221    }
222    match core::str::from_utf8(rest) {
223        Ok(stripped) => stripped,
224        Err(_) => package_name,
225    }
226}
227
228pub fn svg_data_url(svg: &str) -> String {
229    const PREFIX: &str = "data:image/svg+xml,";
230    const HEX: &[u8; 16] = b"0123456789ABCDEF";
231    let mut url = String::with_capacity(PREFIX.len() + svg.len());
232    url.push_str(PREFIX);
233    for byte in svg.bytes() {
234        if byte.is_ascii_alphanumeric() || b"-._~/:;,=!*'()".contains(&byte) {
235            url.push(char::from(byte));
236        } else {
237            url.push('%');
238            url.push(char::from(HEX[usize::from(byte >> 4)]));
239            url.push(char::from(HEX[usize::from(byte & 0x0F)]));
240        }
241    }
242    url
243}
244
245#[cfg(test)]
246mod tests;