qctl 0.5.4

Control in-repo YAML work queues
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;

use crate::schema::Ledger;

pub(super) const MAX_TASK_NUMBER: u32 = 999_999;

#[derive(Clone, Copy, Eq, Ord, PartialEq, PartialOrd)]
enum Status {
    Queue,
    Archive,
    Horizon,
}

impl fmt::Display for Status {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter.write_str(match self {
            Self::Queue => "queue",
            Self::Archive => "archive",
            Self::Horizon => "horizon",
        })
    }
}

struct Task<'a> {
    id: &'a str,
    status: Status,
}

pub(super) fn errors(ledger: &Ledger) -> Vec<String> {
    let tasks = tasks(ledger);
    let mut errors = duplicate_errors(&tasks);
    let numbers = own_numbers(&tasks, &ledger.prefix, &mut errors);
    let missing = missing_ranges(&numbers);
    if !missing.is_empty() {
        errors.push(format!(
            "missing task ids: {}",
            missing
                .into_iter()
                .map(|(start, end)| format_range(&ledger.prefix, start, end))
                .collect::<Vec<_>>()
                .join(", ")
        ));
    }
    errors
}

pub(super) fn highest_number(ledger: &Ledger) -> u32 {
    tasks(ledger)
        .into_iter()
        .filter_map(|task| numeric_digits(task.id, &ledger.prefix))
        .filter_map(|digits| digits.parse::<u32>().ok())
        .filter(|number| *number <= MAX_TASK_NUMBER)
        .max()
        .unwrap_or(0)
}

fn tasks(ledger: &Ledger) -> Vec<Task<'_>> {
    ledger
        .queue
        .iter()
        .map(|task| Task {
            id: &task.id,
            status: Status::Queue,
        })
        .chain(ledger.archive.iter().map(|task| Task {
            id: &task.id,
            status: Status::Archive,
        }))
        .chain(ledger.horizon.iter().map(|task| Task {
            id: &task.id,
            status: Status::Horizon,
        }))
        .collect()
}

fn duplicate_errors(tasks: &[Task<'_>]) -> Vec<String> {
    let mut claims: BTreeMap<&str, Vec<Status>> = BTreeMap::new();
    for task in tasks {
        claims.entry(task.id).or_default().push(task.status);
    }
    claims
        .into_iter()
        .filter_map(|(id, mut statuses)| {
            if statuses.len() == 1 {
                return None;
            }
            statuses.sort_unstable();
            let distinct: BTreeSet<Status> = statuses.iter().copied().collect();
            if distinct.len() == 1 {
                Some(format!(
                    "duplicate id {id} appears {} times in {}",
                    statuses.len(),
                    statuses[0]
                ))
            } else {
                Some(format!(
                    "id {id} has multiple statuses: {}",
                    distinct
                        .into_iter()
                        .map(|status| status.to_string())
                        .collect::<Vec<_>>()
                        .join(", ")
                ))
            }
        })
        .collect()
}

fn own_numbers(tasks: &[Task<'_>], prefix: &str, errors: &mut Vec<String>) -> Vec<u32> {
    let mut numbers = BTreeSet::new();
    for task in tasks {
        let Some(digits) = numeric_digits(task.id, prefix) else {
            continue;
        };
        let Ok(number) = digits.parse::<u32>() else {
            errors.push(outside_space(task.id, prefix));
            continue;
        };
        if number == 0 {
            errors.push(format!(
                "{} uses zero; the task sequence starts at {prefix}-001",
                task.id
            ));
            continue;
        }
        if number > MAX_TASK_NUMBER {
            errors.push(outside_space(task.id, prefix));
            continue;
        }
        let canonical = format!("{prefix}-{number:03}");
        if task.id != canonical {
            errors.push(format!("{} is not canonical; use {canonical}", task.id));
            continue;
        }
        numbers.insert(number);
    }
    numbers.into_iter().collect()
}

fn outside_space(id: &str, prefix: &str) -> String {
    format!("{id} is outside the id space (max {prefix}-{MAX_TASK_NUMBER})")
}

fn format_range(prefix: &str, start: u32, end: u32) -> String {
    if start == end {
        format!("{prefix}-{start:03}")
    } else {
        format!("{prefix}-{start:03}..{prefix}-{end:03}")
    }
}

fn digits<'a>(id: &'a str, prefix: &str) -> Option<&'a str> {
    id.strip_prefix(prefix)?.strip_prefix('-')
}

fn numeric_digits<'a>(id: &'a str, prefix: &str) -> Option<&'a str> {
    let digits = digits(id, prefix)?;
    (!digits.is_empty() && digits.bytes().all(|byte| byte.is_ascii_digit())).then_some(digits)
}

fn missing_ranges(numbers: &[u32]) -> Vec<(u32, u32)> {
    let mut missing = Vec::new();
    let mut previous = 0;
    for number in numbers {
        if *number > previous + 1 {
            missing.push((previous + 1, number - 1));
        }
        previous = *number;
    }
    missing
}

#[cfg(test)]
mod tests {
    use super::{format_range, missing_ranges};

    #[test]
    fn missing_ids_are_complete_compact_ranges() {
        assert_eq!(missing_ranges(&[1, 3, 7, 8, 10]), [(2, 2), (4, 6), (9, 9)]);
        assert_eq!(format_range("QCTL", 4, 6), "QCTL-004..QCTL-006");
    }
}