commit eb9a3200616c854e859ed225a18efc49e87e3e26 Author: upstream Date: Wed Sep 2 09:42:40 2026 +0200 Upstream Nukesor/pueue state of the touched files before a1d47a62e4 diff --git a/pueue/src/client/commands/state/query/filters.rs b/pueue/src/client/commands/state/query/filters.rs new file mode 100644 index 0000000..30cce56 --- /dev/null +++ b/pueue/src/client/commands/state/query/filters.rs @@ -0,0 +1,381 @@ +#![allow(bindings_with_variant_name)] +use chrono::{DateTime, Local, NaiveDate, NaiveDateTime, NaiveTime, TimeDelta}; +use pest::iterators::Pair; +use pueue_lib::task::{Task, TaskResult, TaskStatus}; + +use super::{QueryResult, Rule}; +use crate::internal_prelude::*; + +enum DateOrDateTime { + DateTime(DateTime), + Date(NaiveDate), +} + +/// Parse a datetime/date/time filter. +/// Such a filter can be applied to either the `start`, `end` or `enqueue_at` field. +/// +/// This filter syntax looks like this is expected to be: +/// `[enqueue_at|start|end] [>|<|=|!=] [YYYY-MM-DD HH:mm:SS|HH:mm:SS|YYYY-MM-DD]` +/// +/// The data structure looks something like this: +/// Pair { +/// rule: datetime_filter, +/// span: Span { +/// str: "start=2022-09-01", +/// start: 0, +/// end: 16, +/// }, +/// inner: [ +/// Pair { +/// rule: column_start, +/// span: Span { +/// str: "start", +/// start: 0, +/// end: 5, +/// }, +/// inner: [], +/// }, +/// Pair { +/// rule: operator, +/// span: Span { +/// str: "=", +/// start: 5, +/// end: 6, +/// }, +/// inner: [ +/// Pair { +/// rule: eq, +/// span: Span { +/// str: "=", +/// start: 5, +/// end: 6, +/// }, +/// inner: [], +/// }, +/// ], +/// }, +/// Pair { +/// rule: date, +/// span: Span { +/// str: "2022-09-01", +/// start: 6, +/// end: 16, +/// }, +/// inner: [], +/// }, +/// ], +/// } +pub fn datetime(section: Pair<'_, Rule>, query_result: &mut QueryResult) -> Result<()> { + let mut filter = section.into_inner(); + // Get the column this filter should be applied to. + // Either of [Rule::column_enqueue_at | Rule::column_start | Rule::column_end] + let column = filter.next().unwrap(); + let column = column.as_rule(); + + // Get the operator that should be applied in this filter. + // Either of [Rule::eq | Rule::neq | Rule::lt | Rule::gt] + let operator = filter.next().unwrap().as_rule(); + + // Get the point in time which we should filter for. + // This can be either a Date or a DateTime. + let operand = filter.next().unwrap(); + let operand_rule = operand.as_rule(); + let operand = match operand_rule { + Rule::time => { + let time = NaiveTime::parse_from_str(operand.as_str(), "%X") + .context("Expected hh:mm:ss time format")?; + let today = Local::now().date_naive(); + let datetime = today.and_time(time).and_local_timezone(Local).unwrap(); + DateOrDateTime::DateTime(datetime) + } + Rule::datetime => { + let datetime = NaiveDateTime::parse_from_str(operand.as_str(), "%F %X") + .context("Expected YYYY-MM-SS hh:mm:ss date time format")?; + DateOrDateTime::DateTime(datetime.and_local_timezone(Local).unwrap()) + } + Rule::date => { + let date = NaiveDate::parse_from_str(operand.as_str(), "%F") + .context("Expected YYYY-MM-SS date format")?; + DateOrDateTime::Date(date) + } + _ => bail!("Expected either a date, datetime or time expression."), + }; + + let filter_function = Box::new(move |task: &Task| -> bool { + // Get the field we should apply the filter to. + let field = match column { + Rule::column_enqueue_at => { + let TaskStatus::Stashed { + enqueue_at: Some(enqueue_at), + } = task.status + else { + return false; + }; + + enqueue_at + } + Rule::column_start => { + let (start, _) = task.start_and_end(); + let Some(start) = start else { + return false; + }; + start + } + Rule::column_end => { + let (_, end) = task.start_and_end(); + let Some(end) = end else { + return false; + }; + end + } + _ => return true, + }; + + // Apply the operator to the operands. + // The operator might have a different meaning depending on the type of datetime/date + // we're dealing with. + // E.g. when working with dates, `>` should mean bigger than the end of that day. + // `<` however should mean before that day. + match operand { + DateOrDateTime::DateTime(datetime) => match operator { + Rule::eq => field == datetime, + Rule::neq => field != datetime, + Rule::lt => field < datetime, + Rule::gt => field > datetime, + _ => true, + }, + DateOrDateTime::Date(date) => { + // Get the start of the given day. + // Use the most inclusive datetime in case of ambiguity + let start_of_day = date + .and_hms_opt(0, 0, 0) + .expect("Couldn't determine start of day for given date.") + .and_local_timezone(Local); + let start_of_day = match start_of_day.latest() { + None => return false, + Some(datetime) => datetime, + }; + + // Get the end of the given day. + // Use the most inclusive datetime in case of ambiguity + let end_of_day = (date + TimeDelta::try_days(1).unwrap()) + .and_hms_opt(0, 0, 0) + .expect("Couldn't determine start of day for given date.") + .and_local_timezone(Local); + let end_of_day = match end_of_day.latest() { + None => return false, + Some(datetime) => datetime, + }; + + match operator { + Rule::eq => field > start_of_day && field < end_of_day, + Rule::neq => field < start_of_day && field > end_of_day, + Rule::lt => field < start_of_day, + Rule::gt => field > end_of_day, + _ => true, + } + } + } + }); + query_result.filters.push(filter_function); + + Ok(()) +} + +/// Parse a filter for the label field. +/// +/// This filter syntax looks like this: +/// `label [=|!=] string` +/// +/// The data structure looks something like this: +/// Pair { +/// rule: label_filter, +/// span: Span { +/// str: "label=test", +/// start: 0, +/// end: 10, +/// }, +/// inner: [ +/// Pair { +/// rule: column_label, +/// span: Span { +/// str: "label", +/// start: 0, +/// end: 5, +/// }, +/// inner: [], +/// }, +/// Pair { +/// rule: eq, +/// span: Span { +/// str: "=", +/// start: 5, +/// end: 6, +/// }, +/// inner: [], +/// }, +/// Pair { +/// rule: label, +/// span: Span { +/// str: "test", +/// start: 6, +/// end: 10, +/// }, +/// inner: [], +/// }, +/// ], +/// } +pub fn label(section: Pair<'_, Rule>, query_result: &mut QueryResult) -> Result<()> { + let mut filter = section.into_inner(); + // The first word should be the `label` keyword. + let _label = filter.next().unwrap(); + + // Get the operator that should be applied in this filter. + // Can be either of [Rule::eq | Rule::neq]. + let operator = filter.next().unwrap().as_rule(); + + // Get the name of the label we should filter for. + let operand = filter.next().unwrap().as_str().to_string(); + + // Build the label filter function. + let filter_function = Box::new(move |task: &Task| -> bool { + let Some(label) = &task.label else { + return operator == Rule::neq; + }; + + match operator { + Rule::eq => label == &operand, + Rule::neq => label != &operand, + Rule::contains => label.contains(&operand), + _ => false, + } + }); + query_result.filters.push(filter_function); + + Ok(()) +} + +/// Parse a filter for the command field. +/// +/// This filter syntax is exactly the same as the [label] filter. +/// Only the keyword changed from `label` to `command`. +pub fn command(section: Pair<'_, Rule>, query_result: &mut QueryResult) -> Result<()> { + let mut filter = section.into_inner(); + // The first word should be the `command` keyword. + let _command = filter.next().unwrap(); + + // Get the operator that should be applied in this filter. + // Can be either of [Rule::eq | Rule::neq]. + let operator = filter.next().unwrap().as_rule(); + + // Get the name of the command we should filter for. + let operand = filter.next().unwrap().as_str().to_string(); + + // Build the command filter function. + let filter_function = Box::new(move |task: &Task| -> bool { + let command = task.command.as_str(); + match operator { + Rule::eq => command == operand, + Rule::neq => command != operand, + Rule::contains => command.contains(&operand), + _ => false, + } + }); + query_result.filters.push(filter_function); + + Ok(()) +} + +/// Parse a filter for the status field. +/// +/// This filter syntax looks like this: +/// `status [=|!=] [status]` +/// +/// The data structure looks something like this: +/// Pair { +/// rule: status_filter, +/// span: Span { +/// str: "status=success", +/// start: 0, +/// end: 14, +/// }, +/// inner: [ +/// Pair { +/// rule: column_status, +/// span: Span { +/// str: "status", +/// start: 0, +/// end: 6, +/// }, +/// inner: [], +/// }, +/// Pair { +/// rule: eq, +/// span: Span { +/// str: "=", +/// start: 6, +/// end: 7, +/// }, +/// inner: [], +/// }, +/// Pair { +/// rule: status_success, +/// span: Span { +/// str: "success", +/// start: 7, +/// end: 14, +/// }, +/// inner: [], +/// }, +/// ], +/// } +pub fn status(section: Pair<'_, Rule>, query_result: &mut QueryResult) -> Result<()> { + let mut filter = section.into_inner(); + // The first word should be the `status` keyword. + let _status = filter.next().unwrap(); + + // Get the operator that should be applied in this filter. + // Can be either of [Rule::eq | Rule::neq] + let operator = filter.next().unwrap().as_rule(); + + // Get the status we should filter for. + let operand = filter.next().unwrap().as_rule(); + + // Build the filter function for the task's status. + let filter_function = Box::new(move |task: &Task| -> bool { + let matches = match operand { + Rule::status_queued => matches!(task.status, TaskStatus::Queued { .. }), + Rule::status_stashed => matches!(task.status, TaskStatus::Stashed { .. }), + Rule::status_running => matches!(task.status, TaskStatus::Running { .. }), + Rule::status_paused => matches!(task.status, TaskStatus::Paused { .. }), + Rule::status_success => { + matches!( + &task.status, + TaskStatus::Done { + result: TaskResult::Success, + .. + } + ) + } + Rule::status_failed => { + let mut matches = false; + if let TaskStatus::Done { result, .. } = &task.status + && !matches!(result, TaskResult::Success) + { + matches = true; + } + matches + } + _ => return false, + }; + + match operator { + Rule::eq => matches, + Rule::neq => !matches, + _ => false, + } + }); + query_result.filters.push(filter_function); + + Ok(()) +} diff --git a/pueue/src/client/commands/state/query/mod.rs b/pueue/src/client/commands/state/query/mod.rs new file mode 100644 index 0000000..ab6d8a7 --- /dev/null +++ b/pueue/src/client/commands/state/query/mod.rs @@ -0,0 +1,635 @@ +// Clippy generates a false-positive for an empty generated docstring in the query parser code. +#![allow(clippy::empty_docs)] +use chrono::prelude::*; +use pest::Parser; +use pest_derive::Parser; +use pueue_lib::task::{Task, TaskResult, TaskStatus}; + +use crate::internal_prelude::*; + +mod column_selection; +mod filters; +mod limit; +mod order_by; + +use limit::Limit; +use order_by::Direction; + +/// See the pest docs on how this derive macro works and how to use pest: +/// +#[derive(Parser)] +#[grammar = "./src/client/commands/state/query/syntax.pest"] +struct QueryParser; + +type FilterFunction = dyn Fn(&Task) -> bool; + +/// All applicable information that has been extracted from the query. +#[derive(Default)] +pub struct QueryResult { + /// Filter results for a single group. + group: Option, + + /// The list of selected columns based. + pub selected_columns: Vec, + + /// A list of filter functions that should be applied to the list of tasks. + filters: Vec>, + + /// A list of filter functions that should be applied to the list of tasks. + order_by: Option<(Rule, Direction)>, + + /// Limit + limit: Option<(Limit, usize)>, +} + +impl QueryResult { + /// Take a list of tasks and apply all filters to it. + pub fn apply_filters(&self, tasks: Vec) -> Vec { + let mut iter = tasks.into_iter(); + + // If requested, only look at tasks of a specific group. + if let Some(group) = &self.group { + iter = iter + .filter(|task| task.group == *group) + .collect::>() + .into_iter(); + } + + for filter in self.filters.iter() { + iter = iter.filter(filter).collect::>().into_iter(); + } + iter.collect() + } + + /// Take a list of tasks and apply all filters to it. + pub fn order_tasks(&self, mut tasks: Vec) -> Vec { + // Only apply ordering if it was requested. + let Some((column, direction)) = &self.order_by else { + return tasks; + }; + + // Sort the tasks by the specified column. + tasks.sort_by(|task1, task2| match column { + Rule::column_id => task1.id.cmp(&task2.id), + Rule::column_status => { + /// Rank a task status to allow ordering by status. + /// Returns a u8 based on the expected + fn rank_status(task: &Task) -> u8 { + match &task.status { + TaskStatus::Stashed { .. } => 0, + TaskStatus::Locked { .. } => 1, + TaskStatus::Queued { .. } => 2, + TaskStatus::Paused { .. } => 3, + TaskStatus::Running { .. } => 4, + TaskStatus::Done { result, .. } => match result { + TaskResult::Success => 6, + _ => 5, + }, + } + } + + rank_status(task1).cmp(&rank_status(task2)) + } + Rule::column_label => task1.label.cmp(&task2.label), + Rule::column_command => task1.command.cmp(&task2.command), + Rule::column_path => task1.path.cmp(&task2.path), + Rule::column_enqueue_at => { + fn enqueue_date(task: &Task) -> DateTime { + match &task.status { + TaskStatus::Queued { enqueued_at, .. } + | TaskStatus::Running { enqueued_at, .. } + | TaskStatus::Paused { enqueued_at, .. } + | TaskStatus::Done { enqueued_at, .. } + | TaskStatus::Stashed { + enqueue_at: Some(enqueued_at), + .. + } => *enqueued_at, + // considered far in the future when no explicit date: + _ => DateTime::::MAX_UTC.into(), + } + } + + enqueue_date(task1).cmp(&enqueue_date(task2)) + } + Rule::column_start => { + let (start1, _) = task1.start_and_end(); + let (start2, _) = task2.start_and_end(); + start1.cmp(&start2) + } + Rule::column_end => { + let (_, end1) = task1.start_and_end(); + let (_, end2) = task2.start_and_end(); + end1.cmp(&end2) + } + _ => std::cmp::Ordering::Less, + }); + + // Reverse the order, if we're in ordering by descending order. + if let Direction::Descending = direction { + tasks.reverse(); + } + + tasks + } + + /// Take a list of tasks and apply all filters to it. + pub fn limit_tasks(&self, tasks: Vec) -> Vec { + // Only apply limits if it was requested. + let Some((direction, count)) = &self.limit else { + return tasks; + }; + + // Don't do anything if: + // - we don't have to limit + // - the limit is invalid + if tasks.len() <= *count || *count == 0 { + return tasks; + } + + match direction { + Limit::First => tasks[0..*count].to_vec(), + Limit::Last => tasks[(tasks.len() - count)..].to_vec(), + } + } +} + +/// Take a given `pueue status QUERY` and apply it to all components that're involved in the +/// `pueue status` process: +/// +/// - TableBuilder: The component responsible for building the table and determining which columns +/// should or need to be displayed. A `columns [columns]` statement will define the set of visible +/// columns. +pub fn apply_query(query: &str, group: &Option) -> Result { + let mut parsed = QueryParser::parse(Rule::query, query).context("Failed to parse query")?; + + let mut query_result = QueryResult { + group: group.clone(), + ..Default::default() + }; + + // Expect there to be exactly one pair for the full query. + // Return early if we got an empty query. + let Some(parsed) = parsed.next() else { + return Ok(query_result); + }; + + // Make sure we really got a query. + if parsed.as_rule() != Rule::query { + bail!("Expected a valid query"); + } + + // Get the sections of the query + let sections = parsed.into_inner(); + // Go through each section and handle it accordingly + for section in sections { + // The `columns=[columns]` section + // E.g. `columns=id,status,start,end` + match section.as_rule() { + Rule::column_selection => column_selection::apply(section, &mut query_result)?, + Rule::datetime_filter => filters::datetime(section, &mut query_result)?, + Rule::label_filter => filters::label(section, &mut query_result)?, + Rule::command_filter => filters::command(section, &mut query_result)?, + Rule::status_filter => filters::status(section, &mut query_result)?, + Rule::order_by_condition => order_by::order_by(section, &mut query_result)?, + Rule::limit_condition => limit::limit(section, &mut query_result)?, + _ => (), + } + } + + Ok(query_result) +} + +#[cfg(test)] +mod test { + use std::{collections::HashMap, path::PathBuf}; + + use assert_matches::assert_matches; + use chrono::{Local, TimeZone}; + use pretty_assertions::assert_eq; + use pueue_lib::{ + state::PUEUE_DEFAULT_GROUP, + task::{Task, TaskResult, TaskStatus}, + }; + use rstest::rstest; + + use super::{Rule, apply_query}; + use crate::internal_prelude::*; + + const TEST_COMMAND_SLEEP: &str = "sleep 60"; + const TEST_COMMAND_HELLO: &str = "echo Hello Pueue"; + + /// A small helper function to reduce a bit of boilerplate. + pub fn build_task() -> Task { + Task::new( + TEST_COMMAND_SLEEP.to_owned(), + PathBuf::from("/tmp"), + HashMap::new(), + PUEUE_DEFAULT_GROUP.to_owned(), + TaskStatus::Queued { + enqueued_at: Local.with_ymd_and_hms(2022, 1, 10, 10, 0, 0).unwrap(), + }, + Vec::new(), + 0, + None, + ) + } + + /// Build a list of some pre-build tasks that are used to test. + pub fn test_tasks() -> Vec { + let mut tasks = Vec::new(); + + // Failed task + let mut failed = build_task(); + failed.id = 0; + failed.status = TaskStatus::Done { + result: TaskResult::Failed(255), + enqueued_at: Local.with_ymd_and_hms(2022, 1, 10, 10, 0, 0).unwrap(), + start: Local.with_ymd_and_hms(2022, 1, 10, 10, 5, 0).unwrap(), + end: Local.with_ymd_and_hms(2022, 1, 10, 10, 10, 0).unwrap(), + }; + failed.label = Some("label-10-0".to_string()); + tasks.insert(failed.id, failed); + + // Successful task + let mut successful = build_task(); + successful.id = 1; + successful.status = TaskStatus::Done { + result: TaskResult::Success, + enqueued_at: Local.with_ymd_and_hms(2022, 1, 8, 10, 0, 0).unwrap(), + start: Local.with_ymd_and_hms(2022, 1, 8, 10, 5, 0).unwrap(), + end: Local.with_ymd_and_hms(2022, 1, 8, 10, 10, 0).unwrap(), + }; + successful.label = Some("label-10-1".to_string()); + tasks.insert(successful.id, successful); + + // Stashed task + let mut stashed = build_task(); + stashed.status = TaskStatus::Stashed { enqueue_at: None }; + stashed.id = 2; + stashed.label = Some("label-10-2".to_string()); + tasks.insert(stashed.id, stashed); + + // Scheduled task + let mut scheduled = build_task(); + scheduled.status = TaskStatus::Stashed { + enqueue_at: Some(Local.with_ymd_and_hms(2022, 1, 10, 11, 0, 0).unwrap()), + }; + scheduled.id = 3; + scheduled.group = "testgroup".to_string(); + tasks.insert(scheduled.id, scheduled); + + // Running task + let mut running = build_task(); + running.status = TaskStatus::Running { + enqueued_at: Local.with_ymd_and_hms(2022, 1, 10, 10, 0, 0).unwrap(), + start: Local.with_ymd_and_hms(2022, 1, 2, 12, 0, 0).unwrap(), + }; + running.id = 4; + tasks.insert(running.id, running); + + // Add two queued tasks with different command + let mut queued = build_task(); + queued.id = 5; + queued.command = TEST_COMMAND_HELLO.to_string(); + tasks.insert(queued.id, queued.clone()); + + // Task 6 depends on task 5 + queued.id = 6; + queued.dependencies.push(5); + tasks.insert(queued.id, queued); + + tasks + } + + fn test_tasks_with_query(query: &str, group: &Option) -> Result> { + let mut tasks = test_tasks(); + + let query_result = apply_query(query, group)?; + tasks = query_result.apply_filters(tasks); + tasks = query_result.order_tasks(tasks); + tasks = query_result.limit_tasks(tasks); + + Ok(tasks) + } + + /// Select only specific columns for printing + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn column_selection() -> Result<()> { + let result = apply_query("columns=id,status,command", &None)?; + assert_eq!( + result.selected_columns, + [Rule::column_id, Rule::column_status, Rule::column_command] + ); + + Ok(()) + } + + /// Select the first few entries of the list + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn limit_first() -> Result<()> { + let tasks = test_tasks_with_query("first 4", &None)?; + + assert!(tasks.len() == 4); + assert_eq!(tasks[0].id, 0); + assert_eq!(tasks[3].id, 3); + + Ok(()) + } + + /// Select the last few entries of the list + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn limit_last() -> Result<()> { + let tasks = test_tasks_with_query("last 4", &None)?; + + assert!(tasks.len() == 4); + assert_eq!(tasks[0].id, 3); + assert_eq!(tasks[3].id, 6); + + Ok(()) + } + + /// Filter by start date + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn filter_start() -> Result<()> { + let tasks = test_tasks_with_query("start>2022-01-10 09:00:00", &None)?; + + assert!(tasks.len() == 1); + assert_eq!(tasks[0].id, 0); + + Ok(()) + } + + /// Filtering in combination with groups works as expected + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn filter_with_group() -> Result<()> { + let tasks = test_tasks_with_query("status=stashed", &Some("testgroup".to_string()))?; + + assert!(tasks.len() == 1); + assert_eq!(tasks[0].id, 3); + + Ok(()) + } + + /// Filter by end date with the current time as a time and a date. + #[rstest] + #[case("2022-01-10")] + #[case("2022-01-10 09:00:00")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn filter_end_with_time(#[case] format: &'static str) -> Result<()> { + let tasks = test_tasks_with_query(&format!("end<{format}"), &None)?; + + assert!(tasks.len() == 1); + assert_eq!(tasks[0].id, 1); + + Ok(()) + } + + /// Filter tasks by status + #[rstest] + #[case("queued", 2)] + #[case("running", 1)] + #[case("paused", 0)] + #[case("success", 1)] + #[case("failed", 1)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn filter_status(#[case] status_filter: &str, #[case] match_count: usize) -> Result<()> { + // Get the correct query keyword for the given status. + + let tasks = test_tasks_with_query(&format!("status={status_filter}"), &None)?; + + for task in tasks.iter() { + match status_filter { + "queued" => { + assert_matches!( + task.status, + TaskStatus::Queued { .. }, + "Only Queued tasks are allowed" + ); + } + "stashed" => assert_matches!( + task.status, + TaskStatus::Stashed { .. }, + "Only Stashed tasks are allowed" + ), + "running" => assert_matches!( + task.status, + TaskStatus::Running { .. }, + "Only Running tasks are allowed" + ), + "paused" => assert_matches!( + task.status, + TaskStatus::Paused { .. }, + "Only Paused tasks are allowed" + ), + "success" => assert_matches!( + task.status, + TaskStatus::Done { + result: TaskResult::Success, + .. + }, + "Only Succesful tasks are allowed" + ), + "failed" => assert_matches!( + task.status, + TaskStatus::Done { + result: TaskResult::Failed(_), + .. + }, + "Only Failed tasks are allowed" + ), + _ => bail!("Got unexpected TaskStatus in filter_status"), + }; + } + + assert_eq!( + tasks.len(), + match_count, + "Got a different amount of tasks than expected for the status filter {status_filter:?}." + ); + + Ok(()) + } + + /// Order the test state by task status. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn order_by_status() -> Result<()> { + let tasks = test_tasks_with_query("order_by status", &None)?; + + let expected = vec![ + TaskStatus::Stashed { enqueue_at: None }, + TaskStatus::Stashed { + enqueue_at: Some(Local.with_ymd_and_hms(2022, 1, 10, 11, 0, 0).unwrap()), + }, + TaskStatus::Queued { + enqueued_at: Local.with_ymd_and_hms(2022, 1, 10, 10, 0, 0).unwrap(), + }, + TaskStatus::Queued { + enqueued_at: Local.with_ymd_and_hms(2022, 1, 10, 10, 0, 0).unwrap(), + }, + TaskStatus::Running { + enqueued_at: Local.with_ymd_and_hms(2022, 1, 10, 10, 0, 0).unwrap(), + start: Local.with_ymd_and_hms(2022, 1, 2, 12, 0, 0).unwrap(), + }, + TaskStatus::Done { + enqueued_at: Local.with_ymd_and_hms(2022, 1, 10, 10, 0, 0).unwrap(), + start: Local.with_ymd_and_hms(2022, 1, 10, 10, 5, 0).unwrap(), + end: Local.with_ymd_and_hms(2022, 1, 10, 10, 10, 0).unwrap(), + result: TaskResult::Failed(255), + }, + TaskStatus::Done { + enqueued_at: Local.with_ymd_and_hms(2022, 1, 8, 10, 0, 0).unwrap(), + start: Local.with_ymd_and_hms(2022, 1, 8, 10, 5, 0).unwrap(), + end: Local.with_ymd_and_hms(2022, 1, 8, 10, 10, 0).unwrap(), + result: TaskResult::Success, + }, + ]; + + let actual: Vec = tasks.iter().map(|task| task.status.clone()).collect(); + assert_eq!(actual, expected); + + Ok(()) + } + + /// Order the tasks by enqueue(d) date. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn order_by_enqueue_at() -> Result<()> { + let tasks = test_tasks_with_query("order_by enqueue_at asc", &None)?; + + let expected = vec![ + TaskStatus::Done { + enqueued_at: Local.with_ymd_and_hms(2022, 1, 8, 10, 0, 0).unwrap(), + start: Local.with_ymd_and_hms(2022, 1, 8, 10, 5, 0).unwrap(), + end: Local.with_ymd_and_hms(2022, 1, 8, 10, 10, 0).unwrap(), + result: TaskResult::Success, + }, + TaskStatus::Done { + enqueued_at: Local.with_ymd_and_hms(2022, 1, 10, 10, 0, 0).unwrap(), + start: Local.with_ymd_and_hms(2022, 1, 10, 10, 5, 0).unwrap(), + end: Local.with_ymd_and_hms(2022, 1, 10, 10, 10, 0).unwrap(), + result: TaskResult::Failed(255), + }, + TaskStatus::Running { + enqueued_at: Local.with_ymd_and_hms(2022, 1, 10, 10, 0, 0).unwrap(), + start: Local.with_ymd_and_hms(2022, 1, 2, 12, 0, 0).unwrap(), + }, + TaskStatus::Queued { + enqueued_at: Local.with_ymd_and_hms(2022, 1, 10, 10, 0, 0).unwrap(), + }, + TaskStatus::Queued { + enqueued_at: Local.with_ymd_and_hms(2022, 1, 10, 10, 0, 0).unwrap(), + }, + TaskStatus::Stashed { + enqueue_at: Some(Local.with_ymd_and_hms(2022, 1, 10, 11, 0, 0).unwrap()), + }, + TaskStatus::Stashed { enqueue_at: None }, + ]; + + let actual: Vec = tasks.iter().map(|task| task.status.clone()).collect(); + assert_eq!(actual, expected); + + Ok(()) + } + + /// Filter tasks by label with the "eq" `=` "ne" `!=` and "contains" `%=`filter. + #[rstest] + #[case("%=", "label", 3)] + #[case("%=", "label-10", 3)] + #[case("%=", "label-10-1", 1)] + #[case("=", "label-10-1", 1)] + #[case("!=", "label-10-1", 6)] + #[case("!=", "label-10", 7)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn filter_label( + #[case] operator: &'static str, + #[case] label_filter: &'static str, + #[case] match_count: usize, + ) -> Result<()> { + let tasks = test_tasks_with_query(&format!("label{operator}{label_filter}"), &None)?; + + for task in tasks.iter() { + // Make sure the task either has no label or the label doesn't match the filter. + if operator == "!=" { + if let Some(label) = &task.label { + assert_ne!( + label, label_filter, + "Label '{label}' matched exact filter '{label_filter}'" + ); + } + continue; + } + + let label = task.label.as_ref().expect("Expected task to have a label"); + if operator == "%=" { + // Make sure the label contained our filter. + assert!( + label.contains(label_filter), + "Label '{label}' didn't contain filter '{label_filter}'" + ); + } else if operator == "=" { + // Make sure the label exactly matches the filter. + assert_eq!( + label, &label_filter, + "Label '{label}' didn't match exact filter '{label_filter}'" + ); + } + } + + assert_eq!( + tasks.len(), + match_count, + "Got a different amount of tasks than expected for the label filter: {label_filter}." + ); + + Ok(()) + } + + /// Filter tasks by command with the "eq" `=` "ne" `!=` and "contains" `%=`filter. + #[rstest] + #[case("=", TEST_COMMAND_SLEEP, 5)] + #[case("!=", TEST_COMMAND_SLEEP, 2)] + #[case("%=", &TEST_COMMAND_SLEEP[..4], 5)] + #[case("=", TEST_COMMAND_HELLO, 2)] + #[case("!=", TEST_COMMAND_HELLO, 5)] + #[case("%=", &TEST_COMMAND_HELLO[..4], 2)] + #[case("!=", "nonexist", 7)] + #[case("%=", "nonexist", 0)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn filter_command( + #[case] operator: &'static str, + #[case] command_filter: &'static str, + #[case] match_count: usize, + ) -> Result<()> { + let tasks = test_tasks_with_query(&format!("command{operator}{command_filter}"), &None)?; + + for task in tasks.iter() { + let command = task.command.as_str(); + if operator == "!=" { + // Make sure the task's command doesn't match the filter. + assert_ne!( + command, command_filter, + "Command '{command}' matched exact filter '{command_filter}'" + ); + } else if operator == "%=" { + // Make sure the command contained our filter. + assert!( + command.contains(command_filter), + "Command '{command}' didn't contain filter '{command_filter}'" + ); + } else if operator == "=" { + // Make sure the command exactly matches the filter. + assert_eq!( + command, command_filter, + "Command '{command}' didn't match exact filter '{command_filter}'" + ); + } + } + + assert_eq!( + tasks.len(), + match_count, + "Got a different amount of tasks than expected for the command filter: {command_filter}." + ); + + Ok(()) + } +}