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])))
}