use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::{SearchAttributeValue, WorkflowId, WorkflowStatus, WorkflowSummary};
pub const NAMESPACE_ATTRIBUTE: &str = "aion.namespace";
pub const DEFAULT_NAMESPACE: &str = "default";
#[must_use]
pub fn namespace_from_attributes<S: std::hash::BuildHasher>(
attributes: &std::collections::HashMap<String, SearchAttributeValue, S>,
) -> String {
match attributes.get(NAMESPACE_ATTRIBUTE) {
Some(SearchAttributeValue::String(namespace)) => namespace.clone(),
_ => String::from(DEFAULT_NAMESPACE),
}
}
pub const INTERNAL_WORKFLOW_TYPES: &[&str] = &["aion.schedule_coordinator"];
#[must_use]
pub fn is_internal_workflow_type(workflow_type: &str) -> bool {
INTERNAL_WORKFLOW_TYPES.contains(&workflow_type)
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowKind {
Workflow,
Workloop,
}
impl WorkflowKind {
#[must_use]
pub fn matches(self, recorded_kind: Option<&str>) -> bool {
match self {
Self::Workflow => recorded_kind != Some(crate::WORKLOOP_KIND),
Self::Workloop => recorded_kind == Some(crate::WORKLOOP_KIND),
}
}
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, Default, PartialEq, Eq)]
pub struct WorkflowListFilter {
#[serde(default)]
pub kind: Option<WorkflowKind>,
#[serde(default)]
pub workflow_types: Vec<String>,
#[serde(default)]
pub statuses: Vec<WorkflowStatus>,
#[serde(default)]
pub started_after: Option<DateTime<Utc>>,
#[serde(default)]
pub started_before: Option<DateTime<Utc>>,
#[serde(default)]
pub updated_after: Option<DateTime<Utc>>,
#[serde(default)]
pub updated_before: Option<DateTime<Utc>>,
#[serde(default)]
pub parent: Option<WorkflowId>,
#[serde(default)]
pub text: Option<String>,
}
impl WorkflowListFilter {
#[must_use]
pub fn matches(&self, summary: &WorkflowSummary) -> bool {
self.kind
.is_none_or(|kind| kind.matches(summary.kind.as_deref()))
&& self.matches_workflow_type(&summary.workflow_type)
&& (self.statuses.is_empty() || self.statuses.contains(&summary.status))
&& self
.started_after
.is_none_or(|bound| summary.started_at >= bound)
&& self
.started_before
.is_none_or(|bound| summary.started_at <= bound)
&& self
.updated_after
.is_none_or(|bound| summary.updated_at >= bound)
&& self
.updated_before
.is_none_or(|bound| summary.updated_at <= bound)
&& self
.parent
.as_ref()
.is_none_or(|parent| summary.parent.as_ref() == Some(parent))
&& self.matches_text(summary)
}
fn matches_workflow_type(&self, workflow_type: &str) -> bool {
if self.workflow_types.is_empty() {
!is_internal_workflow_type(workflow_type)
} else {
self.workflow_types
.iter()
.any(|named| named == workflow_type)
}
}
#[must_use]
pub fn text_needle(&self) -> Option<&str> {
self.text
.as_deref()
.map(str::trim)
.filter(|needle| !needle.is_empty())
}
fn matches_text(&self, summary: &WorkflowSummary) -> bool {
let Some(needle) = self.text_needle() else {
return true;
};
let lowered = needle.to_lowercase();
let by_name = summary
.display_name
.as_deref()
.is_some_and(|name| name.to_lowercase().contains(&lowered));
let by_id = summary
.workflow_id
.to_string()
.to_lowercase()
.starts_with(&lowered);
by_name || by_id
}
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum WorkflowSortField {
StartedAt,
UpdatedAt,
EndedAt,
WorkflowType,
Status,
DisplayName,
}
impl WorkflowSortField {
pub const ALL: [Self; 6] = [
Self::StartedAt,
Self::UpdatedAt,
Self::EndedAt,
Self::WorkflowType,
Self::Status,
Self::DisplayName,
];
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum SortDirection {
Asc,
Desc,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct WorkflowSort {
pub field: WorkflowSortField,
pub direction: SortDirection,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct WorkflowListRequest {
pub namespace: String,
#[serde(default)]
pub filter: WorkflowListFilter,
pub sort: WorkflowSort,
#[serde(default)]
pub cursor: Option<String>,
pub limit: u32,
}
#[derive(Serialize, Deserialize, ts_rs::TS, Clone, Debug, PartialEq, Eq)]
pub struct WorkflowListPage {
pub items: Vec<WorkflowSummary>,
pub next_cursor: Option<String>,
pub count: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provenance: Option<crate::ReadProvenance>,
}
#[cfg(test)]
#[path = "listing_text_cases.rs"]
mod listing_text_cases;
#[cfg(test)]
mod tests {
use chrono::{DateTime, Utc};
use super::{WorkflowKind, WorkflowListFilter};
use crate::{RunId, WorkflowId, WorkflowStatus, WorkflowSummary};
fn summary(display_name: Option<&str>, kind: Option<&str>) -> WorkflowSummary {
WorkflowSummary {
workflow_id: WorkflowId::new(uuid::Uuid::from_u128(
0xabc0_0000_0000_0000_0000_0000_0000_0001,
)),
run_id: RunId::new_v4(),
workflow_type: String::from("checkout"),
status: WorkflowStatus::Running,
started_at: DateTime::<Utc>::default(),
updated_at: DateTime::<Utc>::default() + chrono::Duration::seconds(5),
ended_at: None,
parent: None,
failed_step: None,
failure_reason: None,
display_name: display_name.map(str::to_owned),
kind: kind.map(str::to_owned),
current_worker: None,
package_version: None,
}
}
#[test]
fn empty_filter_matches_everything() {
assert!(WorkflowListFilter::default().matches(&summary(None, None)));
}
#[test]
fn kind_predicate_reads_the_recorded_attribute() {
assert!(WorkflowKind::Workflow.matches(None));
assert!(!WorkflowKind::Workflow.matches(Some("workloop")));
assert!(WorkflowKind::Workloop.matches(Some("workloop")));
assert!(!WorkflowKind::Workloop.matches(None));
}
#[test]
fn text_matches_display_name_substring_case_insensitively() {
let filter = WorkflowListFilter {
text: Some(String::from(" NIGHTLY ")),
..WorkflowListFilter::default()
};
assert!(filter.matches(&summary(Some("the nightly build"), None)));
assert!(!filter.matches(&summary(Some("weekly build"), None)));
assert!(!filter.matches(&summary(None, None)));
}
#[test]
fn text_matches_workflow_id_prefix() {
let filter = WorkflowListFilter {
text: Some(String::from("ABC00000")),
..WorkflowListFilter::default()
};
assert!(filter.matches(&summary(None, None)));
let miss = WorkflowListFilter {
text: Some(String::from("bc00000")),
..WorkflowListFilter::default()
};
assert!(
!miss.matches(&summary(None, None)),
"a prefix, not a substring"
);
}
#[test]
fn internal_types_hide_unless_named() {
let mut internal = summary(None, None);
internal.workflow_type = String::from("aion.schedule_coordinator");
assert!(!WorkflowListFilter::default().matches(&internal));
let named = WorkflowListFilter {
workflow_types: vec![String::from("aion.schedule_coordinator")],
..WorkflowListFilter::default()
};
assert!(named.matches(&internal));
assert!(!named.matches(&summary(None, None)));
}
#[test]
fn blank_text_is_absent() {
let filter = WorkflowListFilter {
text: Some(String::from(" ")),
..WorkflowListFilter::default()
};
assert_eq!(filter.text_needle(), None);
assert!(filter.matches(&summary(None, None)));
}
#[test]
fn list_predicates_are_any_of_and_bounds_are_inclusive() {
let row = summary(None, None);
let filter = WorkflowListFilter {
workflow_types: vec![String::from("other"), String::from("checkout")],
statuses: vec![WorkflowStatus::Completed, WorkflowStatus::Running],
started_after: Some(row.started_at),
started_before: Some(row.started_at),
updated_after: Some(row.updated_at),
updated_before: Some(row.updated_at),
..WorkflowListFilter::default()
};
assert!(filter.matches(&row));
let excluded = WorkflowListFilter {
statuses: vec![WorkflowStatus::Completed],
..WorkflowListFilter::default()
};
assert!(!excluded.matches(&row));
let too_late = WorkflowListFilter {
updated_after: Some(row.updated_at + chrono::Duration::seconds(1)),
..WorkflowListFilter::default()
};
assert!(!too_late.matches(&row));
}
}