Functional Weave
Code in Python

todo.import-csv@1.0.0

impl/rust.rs

6,748 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 std::collections::HashSet;

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::todo_item::{todos_to_value, validate_todo, Recurrence, Todo};  ← from todo.item ^1.0.0 · built alongside by fune
use super::todo_normalise_tags::normalise_tags;  ← from todo.normalise-tags ^1.0.0 · built alongside by fune

const COLUMNS: [&str; 13] = [
    "id", "title", "notes", "done", "priority", "due", "tags",
    "recurrenceFrequency", "recurrenceInterval", "recurrenceAnchor", "createdAt", "completedAt", "order",
];

fn header_rule() -> String {
    format!("the header must have the columns {}", COLUMNS.join(", "))
}

fn fail(row: usize, message: &str) -> ! {
    panic!("row {}: {}", row, message)
}

/// One RFC 4180 record from `pos`: its fields and where the next record
/// starts. Only ASCII bytes are looked at, so every slice is valid UTF-8.
fn read_record(text: &str, mut pos: usize, row: usize) -> (Vec<String>, usize) {
    let b = text.as_bytes();
    let size = b.len();
    let mut fields: Vec<String> = Vec::new();
    loop {
        let value: String;
        if pos < size && b[pos] == b'"' {
            pos += 1;
            let mut out = String::new();
            loop {
                let quote = match text[pos..].find('"') {
                    Some(q) => pos + q,
                    None => fail(row, "a quoted field is not closed"),
                };
                out.push_str(&text[pos..quote]);
                if quote + 1 < size && b[quote + 1] == b'"' {
                    out.push('"');
                    pos = quote + 2;
                } else {
                    pos = quote + 1;
                    break;
                }
            }
            if pos < size && !matches!(b[pos], b',' | b'\r' | b'\n') {
                fail(row, "text after the closing quote of a field");
            }
            value = out;
        } else {
            let mut end = pos;
            while end < size && !matches!(b[end], b',' | b'\r' | b'\n') {
                if b[end] == b'"' {
                    fail(row, "a field with a double quote in it must be quoted");
                }
                end += 1;
            }
            value = text[pos..end].to_string();
            pos = end;
        }
        fields.push(value);
        if pos >= size {
            return (fields, pos);
        }
        if b[pos] == b',' {
            pos += 1;
        } else if b[pos] == b'\n' {
            return (fields, pos + 1);
        } else if pos + 1 < size && b[pos + 1] == b'\n' {
            return (fields, pos + 2);
        } else {
            fail(row, "a CR outside quotes must be followed by LF");
        }
    }
}

/// Where each of COLUMNS sits in the file's header.
fn read_header(names: &[String]) -> Vec<usize> {
    let mut at: Vec<(&str, usize)> = Vec::new();
    for (i, name) in names.iter().enumerate() {
        if !COLUMNS.contains(&name.as_str()) {
            panic!("{}: unknown column \"{}\"", header_rule(), name);
        }
        if at.iter().any(|(n, _)| n == name) {
            panic!("{}: column \"{}\" appears twice", header_rule(), name);
        }
        at.push((name.as_str(), i));
    }
    COLUMNS
        .iter()
        .map(|column| match at.iter().find(|(n, _)| n == column) {
            Some((_, i)) => *i,
            None => panic!("{}: missing column \"{}\"", header_rule(), column),
        })
        .collect()
}

fn whole(row: usize, column: &str, text: &str) -> i64 {
    let digits = text.strip_prefix('-').unwrap_or(text);
    if digits.is_empty() || digits.len() > 15 || !digits.bytes().all(|c| c.is_ascii_digit()) {
        fail(row, &format!("{} must be a whole number, found \"{}\"", column, text));
    }
    text.parse::<i64>().unwrap()
}

fn opt(text: &str) -> Option<String> {
    if text.is_empty() { None } else { Some(text.to_string()) }
}

fn read_todo(fields: &[String], at: &[usize], row: usize) -> Todo {
    if fields.len() != COLUMNS.len() {
        fail(row, &format!("expected {} fields, found {}", COLUMNS.len(), fields.len()));
    }
    let get = |i: usize| fields[at[i]].as_str();
    let done = get(3);
    if done != "true" && done != "false" {
        fail(row, &format!("done must be true or false, found \"{}\"", done));
    }
    let (frequency, interval, anchor) = (get(7), get(8), get(9));
    let filled = [frequency, interval, anchor].iter().filter(|t| !t.is_empty()).count();
    let recurrence = match filled {
        0 => None,
        3 => Some(Recurrence {
            frequency: frequency.to_string(),
            interval: whole(row, "recurrenceInterval", interval),
            anchor: anchor.to_string(),
        }),
        _ => fail(row, "fill in all three recurrence columns or leave them all empty"),
    };
    let tags: Vec<String> = get(6).split(' ').map(|t| t.to_string()).collect();
    Todo {
        id: get(0).to_string(),
        title: get(1).to_string(),
        notes: opt(get(2)),
        done: done == "true",
        priority: get(4).to_string(),
        due: opt(get(5)),
        tags: normalise_tags(&tags),
        recurrence,
        created_at: get(10).to_string(),
        completed_at: opt(get(11)),
        order: whole(row, "order", get(12)),
    }
}

/// Todos from CSV: the 13 columns in any order, CRLF or LF, an optional
/// byte order mark. Every row is checked with validate_todo.
///
/// # Panics
/// With "row N: ..." (the header is row 1) on the first malformed or invalid
/// row, or on a header without exactly the 13 columns.
pub fn import_csv(csv: &str) -> Vec<Todo> {
    let text = csv.strip_prefix('\u{feff}').unwrap_or(csv);
    let b = text.as_bytes();
    let mut todos: Vec<Todo> = Vec::new();
    let mut ids: HashSet<String> = HashSet::new();
    let mut at: Option<Vec<usize>> = None;
    let mut row = 0;
    let mut pos = 0;
    while pos < b.len() {
        if b[pos] == b'\n' {
            pos += 1;
            continue;
        }
        if b[pos] == b'\r' && pos + 1 < b.len() && b[pos + 1] == b'\n' {
            pos += 2;
            continue;
        }
        row += 1;
        let (fields, next) = read_record(text, pos, row);
        pos = next;
        let columns = match &at {
            None => {
                at = Some(read_header(&fields));
                continue;
            }
            Some(columns) => columns,
        };
        let todo = read_todo(&fields, columns, row);
        if let Some((field, message)) = validate_todo(&todo).errors.first() {
            fail(row, &format!("{}: {}", field, message));
        }
        if !ids.insert(todo.id.clone()) {
            fail(row, &format!("duplicate id \"{}\"", todo.id));
        }
        todos.push(todo);
    }
    if at.is_none() {
        panic!("the CSV has no header row");
    }
    todos
}

pub fn fune_vector(args: &[Value]) -> Value {
    todos_to_value(&import_csv(args[0].as_str()))
}