use std::collections::{HashMap, HashSet};
use crate::job::{array_base_id, parse_array_id, pending_range_count, ArrayTask, Job, JobStatus};
#[derive(Debug, Clone, PartialEq)]
pub enum JobRow {
Single { job_index: usize },
Group {
base_id: String,
task_indices: Vec<usize>,
expanded: bool,
},
Task { job_index: usize, last: bool },
}
pub fn build_rows(jobs: &[Job], group_arrays: bool, expanded: &HashSet<String>) -> Vec<JobRow> {
if !group_arrays {
return (0..jobs.len())
.map(|job_index| JobRow::Single { job_index })
.collect();
}
let mut tasks_by_base: HashMap<&str, Vec<usize>> = HashMap::new();
for (index, job) in jobs.iter().enumerate() {
if let Some(base) = array_base_id(&job.id) {
tasks_by_base.entry(base).or_default().push(index);
}
}
let mut emitted: HashSet<&str> = HashSet::new();
let mut rows = Vec::with_capacity(jobs.len());
for (job_index, job) in jobs.iter().enumerate() {
let base = array_base_id(&job.id);
let group = base.and_then(|base| {
let task_indices = tasks_by_base.get(base)?;
(task_indices.len() >= 2).then_some((base, task_indices))
});
let Some((base, task_indices)) = group else {
rows.push(JobRow::Single { job_index });
continue;
};
if !emitted.insert(base) {
continue;
}
let is_expanded = expanded.contains(base);
rows.push(JobRow::Group {
base_id: base.to_string(),
task_indices: task_indices.clone(),
expanded: is_expanded,
});
if is_expanded {
let last_pos = task_indices.len() - 1;
rows.extend(
task_indices
.iter()
.enumerate()
.map(|(pos, &index)| JobRow::Task {
job_index: index,
last: pos == last_pos,
}),
);
}
}
rows
}
pub fn filter_rows<F>(rows: Vec<JobRow>, jobs: &[Job], matches: F) -> Vec<JobRow>
where
F: Fn(&Job) -> bool,
{
let mut filtered = Vec::with_capacity(rows.len());
for row in rows {
match row {
JobRow::Single { job_index } => {
if matches(&jobs[job_index]) {
filtered.push(JobRow::Single { job_index });
}
}
JobRow::Group {
base_id,
task_indices,
expanded,
} => {
let matching: Vec<usize> = task_indices
.into_iter()
.filter(|&index| matches(&jobs[index]))
.collect();
if matching.is_empty() {
continue;
}
let task_rows = expanded.then(|| {
let last_pos = matching.len() - 1;
matching
.iter()
.enumerate()
.map(|(pos, &job_index)| JobRow::Task {
job_index,
last: pos == last_pos,
})
.collect::<Vec<JobRow>>()
});
filtered.push(JobRow::Group {
base_id,
task_indices: matching,
expanded,
});
if let Some(task_rows) = task_rows {
filtered.extend(task_rows);
}
}
JobRow::Task { .. } => {}
}
}
filtered
}
pub fn status_counts(tasks: &[&Job]) -> String {
const ORDER: [JobStatus; 8] = [
JobStatus::Running,
JobStatus::Pending,
JobStatus::Completing,
JobStatus::Completed,
JobStatus::Failed,
JobStatus::Timeout,
JobStatus::Cancelled,
JobStatus::Unknown,
];
let mut counts = [0u64; ORDER.len()];
for job in tasks {
let weight = match parse_array_id(&job.id) {
Some((_, ArrayTask::Range(range))) => pending_range_count(&range),
_ => 1,
};
if let Some(position) = ORDER.iter().position(|status| status == &job.status) {
counts[position] += weight;
}
}
ORDER
.iter()
.zip(counts)
.filter(|(_, count)| *count > 0)
.map(|(status, count)| format!("{}{}", count, status.abbrev()))
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
fn job(id: &str, status: JobStatus) -> Job {
Job::new(
id,
&format!("job_{}", id),
status,
"00:10:00",
"main",
1,
"/work",
"cmd",
None,
)
}
fn mixed_jobs() -> Vec<Job> {
vec![
job("42", JobStatus::Running),
job("100_1", JobStatus::Running),
job("100_2", JobStatus::Pending),
job("7", JobStatus::Completed),
job("200_0", JobStatus::Running),
job("200_[5-9]", JobStatus::Pending),
]
}
#[test]
fn grouping_disabled_yields_only_singles() {
let jobs = mixed_jobs();
let rows = build_rows(&jobs, false, &HashSet::new());
assert_eq!(rows.len(), jobs.len());
assert!(rows.iter().all(|row| matches!(row, JobRow::Single { .. })));
}
#[test]
fn mixed_singles_and_two_arrays_are_grouped() {
let jobs = mixed_jobs();
let rows = build_rows(&jobs, true, &HashSet::new());
assert_eq!(
rows,
vec![
JobRow::Single { job_index: 0 },
JobRow::Group {
base_id: "100".to_string(),
task_indices: vec![1, 2],
expanded: false,
},
JobRow::Single { job_index: 3 },
JobRow::Group {
base_id: "200".to_string(),
task_indices: vec![4, 5],
expanded: false,
},
]
);
}
#[test]
fn lone_array_task_stays_a_single_row() {
let jobs = vec![
job("42", JobStatus::Running),
job("100_1", JobStatus::Running),
];
let rows = build_rows(&jobs, true, &HashSet::new());
assert_eq!(
rows,
vec![
JobRow::Single { job_index: 0 },
JobRow::Single { job_index: 1 },
]
);
}
#[test]
fn expanded_group_lists_its_tasks_below_the_header() {
let jobs = mixed_jobs();
let expanded: HashSet<String> = ["100".to_string()].into();
let rows = build_rows(&jobs, true, &expanded);
assert_eq!(
rows,
vec![
JobRow::Single { job_index: 0 },
JobRow::Group {
base_id: "100".to_string(),
task_indices: vec![1, 2],
expanded: true,
},
JobRow::Task {
job_index: 1,
last: false,
},
JobRow::Task {
job_index: 2,
last: true,
},
JobRow::Single { job_index: 3 },
JobRow::Group {
base_id: "200".to_string(),
task_indices: vec![4, 5],
expanded: false,
},
]
);
}
#[test]
fn scattered_tasks_form_a_contiguous_group_at_the_first_task() {
let jobs = vec![
job("100_1", JobStatus::Running),
job("42", JobStatus::Running),
job("100_2", JobStatus::Pending),
];
let rows = build_rows(&jobs, true, &HashSet::new());
assert_eq!(
rows,
vec![
JobRow::Group {
base_id: "100".to_string(),
task_indices: vec![0, 2],
expanded: false,
},
JobRow::Single { job_index: 1 },
]
);
}
#[test]
fn filter_keeps_matching_singles_and_narrows_groups() {
let jobs = mixed_jobs();
let rows = build_rows(&jobs, true, &HashSet::new());
let rows = filter_rows(rows, &jobs, |job| job.status == JobStatus::Pending);
assert_eq!(
rows,
vec![
JobRow::Group {
base_id: "100".to_string(),
task_indices: vec![2],
expanded: false,
},
JobRow::Group {
base_id: "200".to_string(),
task_indices: vec![5],
expanded: false,
},
]
);
let tasks: Vec<&Job> = vec![&jobs[2]];
assert_eq!(status_counts(&tasks), "1PD");
}
#[test]
fn filter_drops_groups_without_matching_tasks() {
let jobs = mixed_jobs();
let rows = build_rows(&jobs, true, &HashSet::new());
let rows = filter_rows(rows, &jobs, |job| job.status == JobStatus::Completed);
assert_eq!(rows, vec![JobRow::Single { job_index: 3 }]);
let rows = build_rows(&jobs, true, &HashSet::new());
let rows = filter_rows(rows, &jobs, |_| false);
assert!(rows.is_empty());
}
#[test]
fn filter_rebuilds_expanded_task_rows_with_tree_glyphs() {
let jobs = vec![
job("100_1", JobStatus::Running),
job("100_2", JobStatus::Pending),
job("100_3", JobStatus::Running),
];
let expanded: HashSet<String> = ["100".to_string()].into();
let rows = build_rows(&jobs, true, &expanded);
let rows = filter_rows(rows, &jobs, |job| job.status == JobStatus::Running);
assert_eq!(
rows,
vec![
JobRow::Group {
base_id: "100".to_string(),
task_indices: vec![0, 2],
expanded: true,
},
JobRow::Task {
job_index: 0,
last: false,
},
JobRow::Task {
job_index: 2,
last: true,
},
]
);
}
#[test]
fn status_counts_aggregates_in_fixed_order() {
let jobs = [
job("100_1", JobStatus::Pending),
job("100_2", JobStatus::Running),
job("100_3", JobStatus::Completed),
job("100_4", JobStatus::Running),
job("100_5", JobStatus::Failed),
];
let tasks: Vec<&Job> = jobs.iter().collect();
assert_eq!(status_counts(&tasks), "2R 1PD 1CD 1F");
}
#[test]
fn status_counts_expands_pending_range_placeholders() {
let jobs = [
job("100_1", JobStatus::Running),
job("100_[8-99]", JobStatus::Pending),
];
let tasks: Vec<&Job> = jobs.iter().collect();
assert_eq!(status_counts(&tasks), "1R 92PD");
}
}