shdo 0.1.0

A kanban board for the terminal: tasks are Markdown files in a git repository
//! Quick-add syntax: `Pay invoice P1 due:fr @work`.
//! Shared by the CLI and the TUI form.

use anyhow::{Result, bail};
use jiff::civil::Date;
use rust_i18n::t;

use crate::dates;

#[derive(Debug, PartialEq)]
pub struct QuickAdd {
    pub title: String,
    pub prio: Option<u8>,
    pub due: Option<Date>,
    /// Raw input after `@`; resolved against the buckets in `shdo.toml`.
    pub bucket: Option<String>,
}

pub fn parse(words: &[String], today: Date) -> Result<QuickAdd> {
    let mut title = Vec::new();
    let mut prio = None;
    let mut due = None;
    let mut bucket = None;
    for word in words.iter().flat_map(|w| w.split_whitespace()) {
        if let Some(p) = parse_prio(word) {
            prio = Some(p);
        } else if let Some(d) = word.strip_prefix("due:") {
            due = Some(dates::parse_date(d, today)?);
        } else if let Some(b) = word.strip_prefix('@').filter(|b| !b.is_empty()) {
            bucket = Some(b.to_string());
        } else {
            title.push(word);
        }
    }
    if title.is_empty() {
        bail!(t!("quickadd.title_missing"));
    }
    Ok(QuickAdd {
        title: title.join(" "),
        prio,
        due,
        bucket,
    })
}

pub fn parse_str(input: &str, today: Date) -> Result<QuickAdd> {
    parse(&[input.to_string()], today)
}

fn parse_prio(word: &str) -> Option<u8> {
    match word {
        "P1" | "p1" => Some(1),
        "P2" | "p2" => Some(2),
        "P3" | "p3" => Some(3),
        _ => None,
    }
}

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

    const TODAY: Date = date(2026, 10, 22);

    fn words(s: &str) -> Vec<String> {
        s.split(' ').map(String::from).collect()
    }

    #[test]
    fn plain_title() {
        let q = parse(&words("Rechnung bezahlen"), TODAY).unwrap();
        assert_eq!(
            q,
            QuickAdd {
                title: "Rechnung bezahlen".into(),
                prio: None,
                due: None,
                bucket: None
            }
        );
    }

    #[test]
    fn tokens_anywhere() {
        let q = parse(&words("P1 Rechnung due:fr bezahlen"), TODAY).unwrap();
        assert_eq!(q.title, "Rechnung bezahlen");
        assert_eq!(q.prio, Some(1));
        assert_eq!(q.due, Some(date(2026, 10, 23)));
        let q = parse(&words("Zahnarzt p3 due:+2w"), TODAY).unwrap();
        assert_eq!(q.prio, Some(3));
        assert_eq!(q.due, Some(date(2026, 11, 5)));
    }

    #[test]
    fn similar_words_stay_in_title() {
        let q = parse(&words("P4 P1-Team Meeting due"), TODAY).unwrap();
        assert_eq!(q.title, "P4 P1-Team Meeting due");
        assert_eq!(q.prio, None);
    }

    #[test]
    fn quoted_single_argument() {
        let q = parse(&["Rechnung bezahlen P2".to_string()], TODAY).unwrap();
        assert_eq!(q.title, "Rechnung bezahlen");
        assert_eq!(q.prio, Some(2));
    }

    #[test]
    fn bucket_token() {
        let q = parse(&words("Angebot @work schreiben"), TODAY).unwrap();
        assert_eq!(q.title, "Angebot schreiben");
        assert_eq!(q.bucket.as_deref(), Some("work"));
        let q = parse(&words("Mail an x @ y"), TODAY).unwrap();
        assert_eq!(q.title, "Mail an x @ y");
        assert_eq!(q.bucket, None);
    }

    #[test]
    fn errors() {
        assert!(parse(&words("P1 due:fr"), TODAY).is_err());
        assert!(parse(&words("x due:bald"), TODAY).is_err());
        assert!(parse_str("   ", TODAY).is_err());
    }
}