Skip to main content

pipedash_core/domain/
pipeline_view.rs

1use std::collections::HashSet;
2use std::fmt;
3use std::str::FromStr;
4
5use chrono::{
6    DateTime,
7    Utc,
8};
9use serde::{
10    Deserialize,
11    Deserializer,
12    Serialize,
13};
14
15use super::error::{
16    DomainError,
17    DomainResult,
18};
19use super::pipeline::{
20    Pipeline,
21    PipelineStatus,
22};
23
24fn set_or_clear<'de, T, D>(deserializer: D) -> Result<Option<Option<T>>, D::Error>
25where
26    T: Deserialize<'de>,
27    D: Deserializer<'de>,
28{
29    Option::<T>::deserialize(deserializer).map(Some)
30}
31
32fn status_list<'de, D>(deserializer: D) -> Result<Vec<PipelineStatus>, D::Error>
33where
34    D: Deserializer<'de>,
35{
36    #[derive(Deserialize)]
37    #[serde(untagged)]
38    enum Raw {
39        List(Vec<PipelineStatus>),
40        Joined(String),
41    }
42
43    match Raw::deserialize(deserializer)? {
44        Raw::List(statuses) => Ok(statuses),
45        Raw::Joined(joined) => joined
46            .split(',')
47            .filter(|status| !status.is_empty())
48            .map(|status| {
49                PipelineStatus::deserialize(serde::de::value::StrDeserializer::<D::Error>::new(
50                    status,
51                ))
52            })
53            .collect(),
54    }
55}
56
57#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize, Deserialize)]
58#[serde(try_from = "String", into = "String")]
59pub enum ViewRef {
60    #[default]
61    All,
62    Pinned,
63    Custom(i64),
64    Config(String),
65}
66
67impl fmt::Display for ViewRef {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        match self {
70            ViewRef::All => f.write_str("all"),
71            ViewRef::Pinned => f.write_str("pinned"),
72            ViewRef::Custom(id) => write!(f, "{id}"),
73            ViewRef::Config(key) => write!(f, "config:{key}"),
74        }
75    }
76}
77
78impl FromStr for ViewRef {
79    type Err = DomainError;
80
81    fn from_str(s: &str) -> Result<Self, Self::Err> {
82        let unknown = || DomainError::InvalidConfig(format!("Unknown pipeline view id: {s}"));
83        match s {
84            "all" => Ok(ViewRef::All),
85            "pinned" => Ok(ViewRef::Pinned),
86            _ => {
87                if let Some(key) = s.strip_prefix("config:") {
88                    if key.is_empty() {
89                        return Err(unknown());
90                    }
91                    return Ok(ViewRef::Config(key.to_string()));
92                }
93                if !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit()) {
94                    return s.parse::<i64>().map(ViewRef::Custom).map_err(|_| unknown());
95                }
96                Err(unknown())
97            }
98        }
99    }
100}
101
102impl TryFrom<String> for ViewRef {
103    type Error = DomainError;
104
105    fn try_from(value: String) -> Result<Self, Self::Error> {
106        value.parse()
107    }
108}
109
110impl From<ViewRef> for String {
111    fn from(value: ViewRef) -> Self {
112        value.to_string()
113    }
114}
115
116#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
117#[serde(rename_all = "snake_case")]
118pub enum ViewKind {
119    All,
120    Pinned,
121    Hidden,
122    Custom,
123    Config,
124}
125
126impl ViewKind {
127    pub fn as_str(self) -> &'static str {
128        match self {
129            ViewKind::All => "all",
130            ViewKind::Pinned => "pinned",
131            ViewKind::Hidden => "hidden",
132            ViewKind::Custom => "custom",
133            ViewKind::Config => "config",
134        }
135    }
136}
137
138#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
139#[serde(rename_all = "snake_case")]
140pub enum SortKey {
141    #[default]
142    Name,
143    LastRun,
144    Status,
145    Organization,
146    Provider,
147    Manual,
148}
149
150#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
151#[serde(rename_all = "lowercase")]
152pub enum SortDirection {
153    #[default]
154    Asc,
155    Desc,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
159#[serde(rename_all = "snake_case")]
160pub enum SectionMode {
161    #[default]
162    Repository,
163    Organization,
164    Provider,
165    Status,
166    None,
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum SectionKind {
172    Repository,
173    Organization,
174    Provider,
175    Status,
176    None,
177    View,
178}
179
180impl From<SectionMode> for SectionKind {
181    fn from(mode: SectionMode) -> Self {
182        match mode {
183            SectionMode::Repository => SectionKind::Repository,
184            SectionMode::Organization => SectionKind::Organization,
185            SectionMode::Provider => SectionKind::Provider,
186            SectionMode::Status => SectionKind::Status,
187            SectionMode::None => SectionKind::None,
188        }
189    }
190}
191
192#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
193#[serde(rename_all = "lowercase")]
194pub enum ViewLayout {
195    #[default]
196    Table,
197    Cards,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
201#[serde(default)]
202pub struct ViewDisplay {
203    pub sort: SortKey,
204    pub direction: SortDirection,
205    pub sections: SectionMode,
206    pub layout: ViewLayout,
207}
208
209impl ViewDisplay {
210    pub fn pinned_default() -> Self {
211        Self {
212            sort: SortKey::Manual,
213            direction: SortDirection::Asc,
214            sections: SectionMode::None,
215            layout: ViewLayout::Cards,
216        }
217    }
218}
219
220#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
221#[serde(default)]
222pub struct ViewRules {
223    pub provider_ids: Vec<i64>,
224    pub repositories: Vec<String>,
225    pub name_contains: Option<String>,
226    pub statuses: Vec<PipelineStatus>,
227}
228
229impl ViewRules {
230    pub fn is_empty(&self) -> bool {
231        self.provider_ids.is_empty()
232            && self.repositories.is_empty()
233            && self.name_contains.as_deref().is_none_or(str::is_empty)
234            && self.statuses.is_empty()
235    }
236
237    pub fn validate(&self, known_provider_ids: &HashSet<i64>) -> DomainResult<ViewRules> {
238        let mut repositories = Vec::new();
239        for pattern in &self.repositories {
240            let pattern = pattern.trim();
241            if pattern.is_empty() || repositories.iter().any(|p| p == pattern) {
242                continue;
243            }
244            glob::Pattern::new(pattern).map_err(|err| {
245                DomainError::InvalidConfig(format!(
246                    "Invalid repository pattern \"{pattern}\": {err}"
247                ))
248            })?;
249            repositories.push(pattern.to_string());
250        }
251
252        let mut provider_ids = Vec::new();
253        for id in &self.provider_ids {
254            if !known_provider_ids.contains(id) {
255                return Err(DomainError::InvalidConfig(format!(
256                    "Unknown provider id {id}"
257                )));
258            }
259            if !provider_ids.contains(id) {
260                provider_ids.push(*id);
261            }
262        }
263
264        let mut statuses = Vec::new();
265        for status in &self.statuses {
266            if !statuses.contains(status) {
267                statuses.push(status.clone());
268            }
269        }
270
271        let name_contains = self
272            .name_contains
273            .as_deref()
274            .map(str::trim)
275            .filter(|s| !s.is_empty())
276            .map(str::to_string);
277
278        Ok(ViewRules {
279            provider_ids,
280            repositories,
281            name_contains,
282            statuses,
283        })
284    }
285
286    pub fn matcher(&self) -> RuleMatcher<'_> {
287        RuleMatcher {
288            rules: self,
289            patterns: self
290                .repositories
291                .iter()
292                .filter_map(|pattern| glob::Pattern::new(pattern).ok())
293                .collect(),
294            needle: self
295                .name_contains
296                .as_deref()
297                .filter(|s| !s.is_empty())
298                .map(str::to_lowercase),
299            empty: self.is_empty(),
300        }
301    }
302}
303
304pub struct RuleMatcher<'a> {
305    rules: &'a ViewRules,
306    patterns: Vec<glob::Pattern>,
307    needle: Option<String>,
308    empty: bool,
309}
310
311impl RuleMatcher<'_> {
312    pub fn matches(&self, pipeline: &Pipeline) -> bool {
313        if self.empty {
314            return false;
315        }
316        let rules = self.rules;
317        if !rules.provider_ids.is_empty() && !rules.provider_ids.contains(&pipeline.provider_id) {
318            return false;
319        }
320        if !rules.repositories.is_empty() {
321            let options = glob::MatchOptions {
322                case_sensitive: false,
323                require_literal_separator: false,
324                require_literal_leading_dot: false,
325            };
326            if !self
327                .patterns
328                .iter()
329                .any(|p| p.matches_with(&pipeline.repository, options))
330            {
331                return false;
332            }
333        }
334        if let Some(needle) = &self.needle {
335            if !pipeline.name.to_lowercase().contains(needle.as_str()) {
336                return false;
337            }
338        }
339        rules.statuses.is_empty() || rules.statuses.contains(&pipeline.status)
340    }
341}
342
343#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
344pub struct ViewCapabilities {
345    pub rename: bool,
346    pub delete: bool,
347    pub edit_rules: bool,
348    pub edit_members: bool,
349    pub edit_display: bool,
350    pub reorder: bool,
351}
352
353impl ViewCapabilities {
354    pub fn for_kind(kind: ViewKind) -> Self {
355        let custom = kind == ViewKind::Custom;
356        let pinned = kind == ViewKind::Pinned;
357        Self {
358            rename: custom,
359            delete: custom,
360            edit_rules: custom,
361            edit_members: custom || pinned,
362            edit_display: matches!(kind, ViewKind::All | ViewKind::Pinned | ViewKind::Custom),
363            reorder: custom || pinned,
364        }
365    }
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
369pub struct PipelineView {
370    pub id: ViewRef,
371    pub kind: ViewKind,
372    pub name: String,
373    pub rules: ViewRules,
374    pub display: ViewDisplay,
375    pub parent: Option<ViewRef>,
376    pub capabilities: ViewCapabilities,
377    pub count: usize,
378}
379
380#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
381#[serde(rename_all = "snake_case")]
382pub enum MembershipSource {
383    Manual,
384    Rule,
385    SubView,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
389pub struct ViewMembership {
390    pub view: ViewRef,
391    pub source: MembershipSource,
392}
393
394#[derive(Debug, Clone, Serialize, Deserialize)]
395pub struct ViewPipeline {
396    #[serde(flatten)]
397    pub pipeline: Pipeline,
398    pub organization: String,
399    pub pinned: bool,
400    pub hidden: bool,
401    pub memberships: Vec<ViewMembership>,
402}
403
404#[derive(Debug, Clone, Serialize, Deserialize)]
405pub struct ViewSection {
406    pub id: String,
407    pub kind: SectionKind,
408    pub title: String,
409    pub organization: Option<String>,
410    pub provider_ids: Vec<i64>,
411    pub status: Option<PipelineStatus>,
412    pub status_pipeline_id: Option<String>,
413    pub last_run: Option<DateTime<Utc>>,
414    pub pending_fetch: bool,
415    pub pipelines: Vec<ViewPipeline>,
416}
417
418#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
419#[serde(rename_all = "lowercase")]
420pub enum PinKind {
421    View,
422    Repository,
423}
424
425impl PinKind {
426    pub fn as_str(self) -> &'static str {
427        match self {
428            PinKind::View => "view",
429            PinKind::Repository => "repository",
430        }
431    }
432}
433
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435pub struct PinnedItem {
436    pub kind: PinKind,
437    #[serde(rename = "ref")]
438    pub reference: String,
439    pub name: String,
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
443pub struct PinnedBlock {
444    pub kind: PinKind,
445    #[serde(rename = "ref")]
446    pub reference: String,
447    pub name: String,
448    pub pipelines: Vec<ViewPipeline>,
449}
450
451#[derive(Debug, Clone, Default, Serialize, Deserialize)]
452pub struct ViewFacets {
453    pub organizations: Vec<String>,
454    pub provider_ids: Vec<i64>,
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct PipelineViewPage {
459    pub view: PipelineView,
460    pub pinned: Vec<ViewPipeline>,
461    pub pinned_blocks: Vec<PinnedBlock>,
462    pub sections: Vec<ViewSection>,
463    pub total: usize,
464    pub hidden_count: usize,
465    pub filters_active: bool,
466    pub facets: ViewFacets,
467}
468
469#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
470pub enum DateRange {
471    #[serde(rename = "today")]
472    Today,
473    #[serde(rename = "24h")]
474    Day,
475    #[serde(rename = "7d")]
476    Week,
477    #[serde(rename = "30d")]
478    Month,
479    #[serde(rename = "60d")]
480    TwoMonths,
481    #[serde(rename = "90d")]
482    Quarter,
483}
484
485#[derive(Debug, Clone, Default, Serialize, Deserialize)]
486pub struct PipelineViewQuery {
487    #[serde(default)]
488    pub view: ViewRef,
489    #[serde(default)]
490    pub provider_id: Option<i64>,
491    #[serde(default)]
492    pub search: Option<String>,
493    #[serde(default, deserialize_with = "status_list")]
494    pub statuses: Vec<PipelineStatus>,
495    #[serde(default)]
496    pub organization: Option<String>,
497    #[serde(default)]
498    pub date_range: Option<DateRange>,
499    #[serde(default)]
500    pub show_hidden: bool,
501    #[serde(default)]
502    pub utc_offset_minutes: i32,
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct CreateViewInput {
507    pub name: String,
508    #[serde(default)]
509    pub rules: ViewRules,
510    #[serde(default)]
511    pub display: ViewDisplay,
512    #[serde(default)]
513    pub members: Vec<String>,
514    #[serde(default)]
515    pub parent: Option<ViewRef>,
516}
517
518#[derive(Debug, Clone, Default, Serialize, Deserialize)]
519pub struct ViewPatch {
520    #[serde(default)]
521    pub name: Option<String>,
522    #[serde(default)]
523    pub rules: Option<ViewRules>,
524    #[serde(default)]
525    pub display: Option<ViewDisplay>,
526    #[serde(default)]
527    pub members: Option<Vec<String>>,
528    #[serde(
529        default,
530        deserialize_with = "set_or_clear",
531        skip_serializing_if = "Option::is_none"
532    )]
533    pub parent: Option<Option<ViewRef>>,
534}
535
536#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
537pub struct ViewMember {
538    pub pipeline_id: String,
539    pub name: Option<String>,
540    pub repository: Option<String>,
541    pub provider_id: i64,
542    pub available: bool,
543}
544
545#[derive(Debug, Clone, Default, Serialize, Deserialize)]
546pub struct LegacyMarks {
547    #[serde(default)]
548    pub pinned: Vec<String>,
549    #[serde(default)]
550    pub hidden: Vec<String>,
551}
552
553#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
554pub struct LegacyImportResult {
555    pub imported: usize,
556    pub skipped: usize,
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    #[test]
564    fn patch_parent_distinguishes_absent_null_and_value() {
565        let absent: ViewPatch = serde_json::from_str("{}").unwrap();
566        let top: ViewPatch = serde_json::from_str(r#"{"parent":null}"#).unwrap();
567        let nested: ViewPatch = serde_json::from_str(r#"{"parent":"12"}"#).unwrap();
568        assert_eq!(absent.parent, None);
569        assert_eq!(top.parent, Some(None));
570        assert_eq!(nested.parent, Some(Some(ViewRef::Custom(12))));
571    }
572
573    #[test]
574    fn query_statuses_accept_a_list_or_comma_separated_text() {
575        let listed: PipelineViewQuery =
576            serde_json::from_str(r#"{"statuses":["running","pending"]}"#).unwrap();
577        let joined: PipelineViewQuery =
578            serde_json::from_str(r#"{"statuses":"running,pending"}"#).unwrap();
579        let absent: PipelineViewQuery = serde_json::from_str("{}").unwrap();
580        let expected = vec![PipelineStatus::Running, PipelineStatus::Pending];
581        assert_eq!(listed.statuses, expected);
582        assert_eq!(joined.statuses, expected);
583        assert!(absent.statuses.is_empty());
584        assert!(
585            serde_json::from_str::<PipelineViewQuery>(r#"{"statuses":"running,bogus"}"#).is_err()
586        );
587    }
588}