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}