tod 0.17.0

An unofficial Todoist command-line client
use crate::config::{SortKey, SortRule};
use serde::{Deserialize, Serialize};
use std::sync::Once;

static SORT_VALUE_WARNING: Once = Once::new();

#[derive(Clone, Serialize, Deserialize, Eq, PartialEq, Debug)]
#[serde(deny_unknown_fields)]
pub struct LegacySortValue {
    pub priority_none: Option<u8>,
    pub priority_low: Option<u8>,
    pub priority_medium: Option<u8>,
    pub priority_high: Option<u8>,
    pub no_due_date: Option<u8>,
    pub not_recurring: Option<u8>,
    pub today: Option<u8>,
    pub overdue: Option<u8>,
    pub now: Option<u8>,
    pub deadline_value: Option<u8>,
    pub deadline_days: Option<u8>,
}

pub(crate) fn detect_and_migrate_sort_value(
    sort_value: Option<&LegacySortValue>,
) -> Option<Vec<SortRule>> {
    let sort_value = sort_value?;
    SORT_VALUE_WARNING.call_once(|| {
        eprintln!(
            "Legacy sort_value config detected; migrating to sort_order. \
             Please update your config because sort_value will be removed in a future version."
        );
    });

    let priority = [
        sort_value.priority_none,
        sort_value.priority_low,
        sort_value.priority_medium,
        sort_value.priority_high,
    ]
    .into_iter()
    .flatten()
    .max()
    .unwrap_or_default();

    let mut weighted_keys = vec![
        (SortKey::Priority, priority),
        (SortKey::Overdue, sort_value.overdue.unwrap_or_default()),
        (SortKey::Today, sort_value.today.unwrap_or_default()),
        (SortKey::Now, sort_value.now.unwrap_or_default()),
        (
            SortKey::NoDueDate,
            sort_value.no_due_date.unwrap_or_default(),
        ),
        (
            SortKey::NotRecurring,
            sort_value.not_recurring.unwrap_or_default(),
        ),
        (
            SortKey::Deadline,
            sort_value.deadline_value.unwrap_or_default(),
        ),
    ];

    weighted_keys.sort_by(|(left_key, left_weight), (right_key, right_weight)| {
        right_weight.cmp(left_weight).then_with(|| {
            sort_key_default_index(*left_key).cmp(&sort_key_default_index(*right_key))
        })
    });

    let mut keys: Vec<SortKey> = weighted_keys.into_iter().map(|(key, _)| key).collect();
    for default_key in SortKey::default_order() {
        if !keys.contains(&default_key) {
            keys.push(default_key);
        }
    }

    Some(
        keys.into_iter()
            .map(SortRule::with_default_direction)
            .collect(),
    )
}

fn sort_key_default_index(key: SortKey) -> usize {
    SortKey::default_order()
        .iter()
        .position(|default_key| *default_key == key)
        .unwrap_or(usize::MAX)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detect_and_migrate_sort_value_returns_none_for_missing_input() {
        assert_eq!(detect_and_migrate_sort_value(None), None);
    }

    #[test]
    fn sort_key_default_index_returns_zero_for_first_key() {
        assert_eq!(sort_key_default_index(SortKey::Priority), 0);
    }

    #[test]
    fn sort_key_default_index_returns_last_for_last_key() {
        let keys = SortKey::default_order();
        let last = keys.last().expect("default order should not be empty");
        assert_eq!(sort_key_default_index(*last), keys.len() - 1);
    }

    #[test]
    fn sort_key_default_index_falls_back_to_usize_max_for_unknown_key() {
        // Sort keys are compared via sort_key_default_index as a tiebreaker;
        // if a SortKey variant is somehow not in default_order, it gets usize::MAX.
        // SortKey is an enum, so the only way to test this is to verify that
        // all current variants are found, implying the fallback exists for any
        // future variant accidentally omitted.
        for key in &[
            SortKey::Priority,
            SortKey::Overdue,
            SortKey::Today,
            SortKey::Now,
            SortKey::NoDueDate,
            SortKey::NotRecurring,
            SortKey::Deadline,
        ] {
            assert!(
                sort_key_default_index(*key) < usize::MAX,
                "{key:?} should be in default_order"
            );
        }
    }

    #[test]
    fn detect_and_migrate_sort_value_prioritizes_highest_weighted_key() {
        let legacy = LegacySortValue {
            priority_none: Some(0),
            priority_low: Some(0),
            priority_medium: Some(0),
            priority_high: Some(0),
            no_due_date: Some(0),
            not_recurring: Some(0),
            today: Some(0),
            overdue: Some(9),
            now: Some(0),
            deadline_value: Some(1),
            deadline_days: Some(0),
        };

        let migrated = detect_and_migrate_sort_value(Some(&legacy))
            .expect("legacy sort config should migrate");
        let first = migrated
            .first()
            .expect("migrated sort order should not be empty");
        assert_eq!(first.key, SortKey::Overdue);
    }
}