Functional Weave
Code in Rust

todo.item@1.0.0

impl/rust/validate_draft.rs

7,775 bytes · the Rust implementation · view raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::dates_add_days::days_in_month;  ← from dates.add-days ^1.0.0 · built alongside by fune

pub const MAX_TITLE: usize = 200;
pub const MAX_NOTES: usize = 2000;
pub const MAX_TAGS: usize = 10;
pub const MAX_TAG_LENGTH: usize = 30;
pub const MAX_INTERVAL: i64 = 999;

const PRIORITIES: [&str; 4] = ["none", "low", "medium", "high"];
const FREQUENCIES: [&str; 5] = ["daily", "weekdays", "weekly", "monthly", "yearly"];

fn is_ascii_space(ch: char) -> bool {
    matches!(ch, ' ' | '\t' | '\n' | '\r' | '\u{0B}' | '\u{0C}')
}

/// Trim ASCII whitespace only, so "trimmed" means the same in every language.
pub fn trim_space(text: &str) -> &str {
    text.trim_matches(is_ascii_space)
}

pub fn code_point_length(text: &str) -> usize {
    text.chars().count()
}

/// A real calendar date written YYYY-MM-DD, years 0001 to 9999. Never panics.
pub fn is_iso_date(text: &str) -> bool {
    let b = text.as_bytes();
    if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
        return false;
    }
    let digits = |from: usize, to: usize| b[from..to].iter().all(|c| c.is_ascii_digit());
    if !digits(0, 4) || !digits(5, 7) || !digits(8, 10) {
        return false;
    }
    let num = |from: usize, to: usize| b[from..to].iter().fold(0i64, |n, c| n * 10 + (c - b'0') as i64);
    let (year, month, day) = (num(0, 4), num(5, 7), num(8, 10));
    year >= 1 && (1..=12).contains(&month) && day >= 1 && day <= days_in_month(year, month)
}

fn is_tag_form(tag: &str) -> bool {
    let b = tag.as_bytes();
    if b.is_empty() || b[0] == b'-' || b[b.len() - 1] == b'-' {
        return false;
    }
    for (i, &c) in b.iter().enumerate() {
        let ok = c.is_ascii_lowercase() || c.is_ascii_digit() || (c == b'-' && b[i - 1] != b'-');
        if !ok {
            return false;
        }
    }
    true
}

fn title_error(title: &str) -> Option<String> {
    let trimmed = trim_space(title);
    if trimmed.is_empty() {
        return Some("Enter a title.".to_string());
    }
    if trimmed.chars().any(|ch| (ch as u32) < 0x20 || ch as u32 == 0x7F) {
        return Some("Keep the title to one line, without control characters.".to_string());
    }
    if code_point_length(trimmed) > MAX_TITLE {
        return Some(format!("Use no more than {} characters for the title.", MAX_TITLE));
    }
    None
}

fn tags_error(tags: &[String]) -> Option<String> {
    if tags.len() > MAX_TAGS {
        return Some(format!("Use no more than {} tags.", MAX_TAGS));
    }
    let mut seen: Vec<&str> = Vec::new();
    for tag in tags {
        if !is_tag_form(tag) {
            return Some(format!("Tag \"{}\" can only use lower-case letters, digits and single hyphens.", tag));
        }
        if tag.len() > MAX_TAG_LENGTH {
            return Some(format!("Tag \"{}\" is longer than {} characters.", tag, MAX_TAG_LENGTH));
        }
        if seen.contains(&tag.as_str()) {
            return Some(format!("Tag \"{}\" is listed twice.", tag));
        }
        seen.push(tag);
    }
    None
}

fn recurrence_error(rule: Option<&Recurrence>, due: Option<&str>) -> Option<String> {
    let rule = rule?;
    if !FREQUENCIES.contains(&rule.frequency.as_str()) {
        return Some("Choose how it repeats: daily, weekdays, weekly, monthly or yearly.".to_string());
    }
    if rule.interval < 1 || rule.interval > MAX_INTERVAL {
        return Some(format!("Repeat every 1 to {} days, weeks, months or years.", MAX_INTERVAL));
    }
    if rule.frequency == "weekdays" && rule.interval != 1 {
        return Some("Weekday repeats cannot skip weeks; use an interval of 1.".to_string());
    }
    if !is_iso_date(&rule.anchor) {
        return Some("Enter the repeat start as a real date, YYYY-MM-DD.".to_string());
    }
    match due {
        None => Some("A repeating todo needs a due date.".to_string()),
        Some(d) if is_iso_date(d) && rule.anchor.as_str() > d => {
            Some("The repeat cannot start after the due date.".to_string())
        }
        _ => None,
    }
}

/// The edit rules as ordered (field, message) pairs, from the draft's fields,
/// so validate_todo can run them on a stored Todo too.
pub fn draft_errors(
    title: &str,
    notes: Option<&str>,
    priority: &str,
    due: Option<&str>,
    tags: &[String],
    recurrence: Option<&Recurrence>,
) -> Vec<(String, String)> {
    let mut errors: Vec<(String, String)> = Vec::new();
    if let Some(message) = title_error(title) {
        errors.push(("title".to_string(), message));
    }
    if let Some(n) = notes {
        if code_point_length(n) > MAX_NOTES {
            errors.push(("notes".to_string(), format!("Use no more than {} characters for the notes.", MAX_NOTES)));
        }
    }
    if !PRIORITIES.contains(&priority) {
        errors.push(("priority".to_string(), "Choose a priority: none, low, medium or high.".to_string()));
    }
    if let Some(d) = due {
        if !is_iso_date(d) {
            errors.push(("due".to_string(), "Enter the due date as a real date, YYYY-MM-DD.".to_string()));
        }
    }
    if let Some(message) = tags_error(tags) {
        errors.push(("tags".to_string(), message));
    }
    if let Some(message) = recurrence_error(recurrence, due) {
        errors.push(("recurrence".to_string(), message));
    }
    errors
}

/// Check what a person typed into the add or edit form; the title is judged trimmed.
pub fn validate_draft(draft: &TodoDraft) -> TodoValidation {
    let errors = draft_errors(
        &draft.title,
        draft.notes.as_deref(),
        &draft.priority,
        draft.due.as_deref(),
        &draft.tags,
        draft.recurrence.as_ref(),
    );
    TodoValidation { valid: errors.is_empty(), errors }
}

fn opt_text(v: &Value) -> Option<String> {
    if v.is_null() { None } else { Some(v.as_str().to_string()) }
}

fn opt_value(v: &Option<String>) -> Value {
    v.as_deref().map(Value::str).unwrap_or(Value::Null)
}

pub fn texts_from_value(v: &Value) -> Vec<String> {
    v.as_arr().iter().map(|t| t.as_str().to_string()).collect()
}

pub fn recurrence_from_value(v: &Value) -> Option<Recurrence> {
    if v.is_null() {
        return None;
    }
    Some(Recurrence {
        frequency: v.get("frequency").as_str().to_string(),
        interval: v.get("interval").as_i64(),
        anchor: v.get("anchor").as_str().to_string(),
    })
}

pub fn recurrence_to_value(rule: &Option<Recurrence>) -> Value {
    match rule {
        None => Value::Null,
        Some(r) => Value::obj(vec![
            ("frequency", Value::str(&r.frequency)),
            ("interval", Value::Int(r.interval)),
            ("anchor", Value::str(&r.anchor)),
        ]),
    }
}

pub fn todo_draft_from_value(v: &Value) -> TodoDraft {
    TodoDraft {
        title: v.get("title").as_str().to_string(),
        notes: opt_text(v.get("notes")),
        priority: v.get("priority").as_str().to_string(),
        due: opt_text(v.get("due")),
        tags: texts_from_value(v.get("tags")),
        recurrence: recurrence_from_value(v.get("recurrence")),
    }
}

pub fn todo_draft_to_value(draft: &TodoDraft) -> Value {
    Value::obj(vec![
        ("title", Value::str(&draft.title)),
        ("notes", opt_value(&draft.notes)),
        ("priority", Value::str(&draft.priority)),
        ("due", opt_value(&draft.due)),
        ("tags", Value::Arr(draft.tags.iter().map(|t| Value::str(t)).collect())),
        ("recurrence", recurrence_to_value(&draft.recurrence)),
    ])
}

pub fn todo_validation_to_value(result: &TodoValidation) -> Value {
    Value::obj(vec![
        ("valid", Value::Bool(result.valid)),
        ("errors", Value::Obj(result.errors.iter().map(|(k, m)| (k.clone(), Value::str(m))).collect())),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    todo_validation_to_value(&validate_draft(&todo_draft_from_value(&args[0])))
}