2992. A checklist item is a real Checkbox with its text beside it, so a box can be
ticked while looking at the note — which is what M304 left undone. It changed where
a checklist is STORED and never changed what the editor draws.
The body is split into blocks and joined back on every edit, so the note underneath
is the same markdown string it was this morning. Nothing below the editor can tell
this exists: no migration, no protocol change, no new shape on the wire.
A run of prose lines is ONE block, not one per line. Typing a paragraph has to feel
like typing a paragraph, and a separate field under every sentence would break the
caret mid-sentence. Only a checklist item earns a block, because only a checklist
item needs a widget.
Two things that look like detail and are not:
* A block carries its own TextFieldValue, and an ID that survives insertion.
Compose keys fields by position unless told otherwise, so adding an item would
otherwise move every caret below it up a row. Content cannot be that key —
two empty items are identical and neither is the other.
* Focus is hoisted to the screen rather than kept inside BlockBody, because the
toolbar's checklist button also asks for one. Two owners of one cursor is one
too many.
Return on an item makes the next item and puts the caret in it; on an EMPTY item
the block becomes prose, which is how a list ends and how you get a paragraph after
one — the same rule the plain text field used, now with somewhere to land. It
appends rather than splitting at the caret: splitting an item in two is a rarity,
and the caret is at the end for every ordinary use of that key.
The core gains `render_item` and `DerivedItem.line`; `item_lines` and
`checklist_lines` are gone, subsumed. Every renderer that walks a body line by line
needs the text, the state and the position TOGETHER — asking for them separately is
how two calls come to disagree about a body that changed between them. The card now
reads its items from the body for the same reason, instead of from note.items,
which is the same list by a longer route and one save behind.
WANTS A DEVICE PASS, and the focus behaviours are what to look at: return making a
row and landing in it, return twice at the end of a list getting you a paragraph,
and rotation restoring the right field. CI can only prove this compiles.
555 lines
20 KiB
Rust
555 lines
20 KiB
Rust
//! Deriving structure from a note's body — the local mirror of what the server
|
|
//! computes on save. Pure string scanning (no regex dependency), kept in lockstep
|
|
//! with the frontend's inline rules (see frontend notes/markdown.ts):
|
|
//!
|
|
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
|
|
//! On save these become labels attached with `via_tag = true`.
|
|
//! - `- [ ] item`: a checklist item. The body IS the checklist (M304) — there is no
|
|
//! table of items beside it, so a list can sit between two paragraphs instead of
|
|
//! only after them.
|
|
//!
|
|
//! The two are the same idea at different strengths. Tags MATERIALISE into label
|
|
//! rows, because the board queries by label. Items materialise into nothing,
|
|
//! because nothing queries them: their only readers are the card, the editor and
|
|
//! `display_title`. So `extract_items` is the whole storage layer for a checklist,
|
|
//! and the rewriters below are how one is edited.
|
|
//!
|
|
//! Dedupes case-insensitively, preserving first-seen order.
|
|
//!
|
|
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
|
|
//! capture-and-recall surface, and a linking system is organization.
|
|
|
|
/// Extract every `#tag` name (without the leading `#`) from `body`.
|
|
pub fn extract_tags(body: &str) -> Vec<String> {
|
|
let chars: Vec<char> = body.chars().collect();
|
|
let mut out: Vec<String> = Vec::new();
|
|
let mut i = 0;
|
|
while i < chars.len() {
|
|
if chars[i] == '#' {
|
|
let boundary = i == 0 || (!is_tag_char(chars[i - 1]) && chars[i - 1] != '#');
|
|
// A tag must start with a letter (so "#1" or a bare "#" is not a tag).
|
|
if boundary && i + 1 < chars.len() && chars[i + 1].is_alphabetic() {
|
|
let mut j = i + 1;
|
|
while j < chars.len() && is_tag_char(chars[j]) {
|
|
j += 1;
|
|
}
|
|
let tag: String = chars[i + 1..j].iter().collect();
|
|
push_unique(&mut out, &tag);
|
|
i = j;
|
|
continue;
|
|
}
|
|
}
|
|
i += 1;
|
|
}
|
|
out
|
|
}
|
|
|
|
fn is_tag_char(c: char) -> bool {
|
|
c.is_alphanumeric() || c == '_' || c == '-'
|
|
}
|
|
|
|
fn push_unique(out: &mut Vec<String>, candidate: &str) {
|
|
if !out.iter().any(|x| x.eq_ignore_ascii_case(candidate)) {
|
|
out.push(candidate.to_string());
|
|
}
|
|
}
|
|
|
|
// ── checklist items ─────────────────────────────────────────────────────────
|
|
//
|
|
// The grammar, in one place, because three languages implement it (here,
|
|
// `notes/checklist.py`, `notes/markdown.ts`) and a difference between any two of
|
|
// them is a checklist that changes shape when it syncs:
|
|
//
|
|
// optional indent, `-` or `*`, one-or-more spaces, `[ ]`/`[x]`/`[X]`,
|
|
// then either end-of-line or one-or-more spaces and the text.
|
|
//
|
|
// `*` is accepted because markdown.ts already accepts it for a plain bullet, and a
|
|
// grammar that takes `* item` but not `* [ ] item` would be a rule with no reason
|
|
// anyone could guess. `- [ ]` with nothing after it IS an item with empty text:
|
|
// that is exactly what pressing Enter on a list leaves behind, and refusing to
|
|
// parse it would make a half-typed list stop being a list.
|
|
|
|
/// A checklist item, as found in the body. Its position in the returned vector is
|
|
/// its identity — the same thing `position` meant when these were rows, and all the
|
|
/// wire ever carried (`push.rs` sent text and checked, never an id).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DerivedItem {
|
|
pub text: String,
|
|
pub checked: bool,
|
|
/// Which body line it sits on.
|
|
///
|
|
/// Carried here rather than offered as a second function, because every renderer
|
|
/// that walks a body line by line — the Android card, the block editor — needs the
|
|
/// text, the state AND the position together, and asking for them separately is
|
|
/// how two calls come to disagree about a body that changed between them.
|
|
pub line: u32,
|
|
}
|
|
|
|
/// One parsed task line, holding enough to put it back exactly as it was found.
|
|
struct TaskLine<'a> {
|
|
indent: &'a str,
|
|
/// Preserved rather than normalised to `-`: rewriting someone's `*` bullets
|
|
/// because they ticked a box would be an edit they did not ask for.
|
|
bullet: char,
|
|
checked: bool,
|
|
text: &'a str,
|
|
}
|
|
|
|
fn parse_task_line(line: &str) -> Option<TaskLine<'_>> {
|
|
let indent_len = line.len() - line.trim_start().len();
|
|
let (indent, rest) = line.split_at(indent_len);
|
|
|
|
let bullet = rest.chars().next()?;
|
|
if bullet != '-' && bullet != '*' {
|
|
return None;
|
|
}
|
|
// At least one space after the bullet. `-[ ] x` is not a list item in any
|
|
// markdown either, so it stays prose here too.
|
|
let rest = &rest[bullet.len_utf8()..];
|
|
let gap = rest.len() - rest.trim_start_matches(' ').len();
|
|
if gap == 0 {
|
|
return None;
|
|
}
|
|
let rest = &rest[gap..];
|
|
|
|
let mut chars = rest.chars();
|
|
if chars.next()? != '[' {
|
|
return None;
|
|
}
|
|
let mark = chars.next()?;
|
|
if chars.next()? != ']' {
|
|
return None;
|
|
}
|
|
// Decided BEFORE the slice below, which is what guarantees `mark` is one byte
|
|
// and `[?]` is exactly three.
|
|
let checked = match mark {
|
|
' ' => false,
|
|
'x' | 'X' => true,
|
|
_ => return None,
|
|
};
|
|
let rest = &rest[3..];
|
|
|
|
let text = if rest.is_empty() {
|
|
// "- [ ]" — an empty item, which is what an unfinished list line is.
|
|
rest
|
|
} else {
|
|
let gap = rest.len() - rest.trim_start_matches(' ').len();
|
|
// "- [ ]x" is prose: without the space this is not a marker, it is a
|
|
// sentence that happens to start with brackets.
|
|
if gap == 0 {
|
|
return None;
|
|
}
|
|
&rest[gap..]
|
|
};
|
|
|
|
Some(TaskLine {
|
|
indent,
|
|
bullet,
|
|
checked,
|
|
text,
|
|
})
|
|
}
|
|
|
|
/// One item as the line that stores it, in canonical form.
|
|
///
|
|
/// Public because a block editor has to write a line back after someone edits it in a
|
|
/// widget that never showed them the marker. Rendering is trivial where PARSING is
|
|
/// not, but it still belongs here: this is the file that decides what canonical looks
|
|
/// like, and a caller inventing its own `- [x] ` would be a fourth opinion on it.
|
|
pub fn render_item(text: &str, checked: bool) -> String {
|
|
render_task_line("", '-', checked, text)
|
|
}
|
|
|
|
fn render_task_line(indent: &str, bullet: char, checked: bool, text: &str) -> String {
|
|
// Always lowercase `x`, whatever was parsed: one canonical output is what makes
|
|
// a round trip stable, so `- [X]` normalises the first time it is touched and
|
|
// never again.
|
|
let mark = if checked { 'x' } else { ' ' };
|
|
if text.is_empty() {
|
|
format!("{indent}{bullet} [{mark}]")
|
|
} else {
|
|
format!("{indent}{bullet} [{mark}] {text}")
|
|
}
|
|
}
|
|
|
|
/// The text of a line with its task marker removed, or the line as it was.
|
|
///
|
|
/// For naming a note: a list-only note is named by its first item, and calling one
|
|
/// "- [ ] milk" would be showing someone the storage instead of the note.
|
|
pub fn strip_marker(line: &str) -> &str {
|
|
match parse_task_line(line) {
|
|
Some(t) => t.text,
|
|
None => line,
|
|
}
|
|
}
|
|
|
|
/// Every checklist item in `body`, in the order they appear.
|
|
pub fn extract_items(body: &str) -> Vec<DerivedItem> {
|
|
let mut out = Vec::new();
|
|
for (n, line) in body.split('\n').enumerate() {
|
|
if let Some(t) = parse_task_line(line) {
|
|
out.push(DerivedItem {
|
|
text: t.text.to_string(),
|
|
checked: t.checked,
|
|
line: n as u32,
|
|
});
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Rewrite the `index`-th task line, or drop it when `f` returns None.
|
|
///
|
|
/// A body with fewer task lines than that is returned UNCHANGED rather than
|
|
/// panicking: the index comes from a UI that may be a moment behind the store, and
|
|
/// a stale tap should do nothing rather than take the app down.
|
|
fn map_task_line<F>(body: &str, index: usize, f: F) -> String
|
|
where
|
|
F: FnOnce(&TaskLine<'_>) -> Option<String>,
|
|
{
|
|
let lines: Vec<&str> = body.split('\n').collect();
|
|
let mut target: Option<usize> = None;
|
|
let mut seen = 0usize;
|
|
for (n, line) in lines.iter().enumerate() {
|
|
if parse_task_line(line).is_some() {
|
|
if seen == index {
|
|
target = Some(n);
|
|
break;
|
|
}
|
|
seen += 1;
|
|
}
|
|
}
|
|
let target = match target {
|
|
Some(n) => n,
|
|
None => return body.to_string(),
|
|
};
|
|
let replacement = match parse_task_line(lines[target]) {
|
|
Some(parsed) => f(&parsed),
|
|
None => return body.to_string(),
|
|
};
|
|
|
|
let mut out: Vec<String> = Vec::with_capacity(lines.len());
|
|
for (n, line) in lines.iter().enumerate() {
|
|
if n != target {
|
|
out.push((*line).to_string());
|
|
} else if let Some(new_line) = &replacement {
|
|
out.push(new_line.clone());
|
|
}
|
|
// None at the target line drops it, which is `remove_item`.
|
|
}
|
|
out.join("\n")
|
|
}
|
|
|
|
/// Tick or untick the `index`-th item.
|
|
pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String {
|
|
map_task_line(body, index, |t| {
|
|
Some(render_task_line(t.indent, t.bullet, checked, t.text))
|
|
})
|
|
}
|
|
|
|
/// Replace the text of the `index`-th item, keeping its state and its bullet.
|
|
pub fn set_item_text(body: &str, index: usize, text: &str) -> String {
|
|
map_task_line(body, index, |t| {
|
|
Some(render_task_line(t.indent, t.bullet, t.checked, text.trim()))
|
|
})
|
|
}
|
|
|
|
/// Delete the `index`-th item, line and all.
|
|
pub fn remove_item(body: &str, index: usize) -> String {
|
|
map_task_line(body, index, |_| None)
|
|
}
|
|
|
|
/// The body with the item on `line` toggled — or None when that line is not a task
|
|
/// line, or when `column` falls outside its `[ ]` marker.
|
|
///
|
|
/// Addressed by LINE and COLUMN rather than by a text offset, because the two sides of
|
|
/// the FFI do not count the same way: Compose measures an offset in UTF-16 units and
|
|
/// Rust in bytes, so the same number means different places in a note with an emoji in
|
|
/// it. A line number is identical in every encoding. So is a column inside the marker,
|
|
/// which is ASCII and sits at the start of its line — and that is the only range this
|
|
/// function looks at.
|
|
///
|
|
/// Only the marker toggles, not the whole line: the rest of it is text somebody needs
|
|
/// to be able to put a caret into.
|
|
pub fn toggle_at(body: &str, line: usize, column: usize) -> Option<String> {
|
|
let target = body.split('\n').nth(line)?;
|
|
let parsed = parse_task_line(target)?;
|
|
// One column of slack past the `]`, because a checkbox on a phone should forgive
|
|
// a near miss.
|
|
let marker_end = target.chars().position(|c| c == ']')? + 1;
|
|
if column > marker_end {
|
|
return None;
|
|
}
|
|
|
|
let mut index = 0;
|
|
for earlier in body.split('\n').take(line) {
|
|
if parse_task_line(earlier).is_some() {
|
|
index += 1;
|
|
}
|
|
}
|
|
Some(set_item_checked(body, index, !parsed.checked))
|
|
}
|
|
|
|
/// What pressing Enter at the end of `line` should leave behind.
|
|
///
|
|
/// * `None` — not a task line. Enter does what Enter always does.
|
|
/// * `Some("")` — an EMPTY item: clear the marker and end the list. Without this half
|
|
/// a list is impossible to get out of without deleting characters by hand.
|
|
/// * `Some(marker)` — start the next item. The indent and bullet are carried over
|
|
/// rather than normalised, because continuing someone's `*` list with a `-` is an
|
|
/// edit they did not ask for.
|
|
pub fn continuation(line: &str) -> Option<String> {
|
|
let parsed = parse_task_line(line)?;
|
|
if parsed.text.trim().is_empty() {
|
|
return Some(String::new());
|
|
}
|
|
Some(format!("{}{} [ ] ", parsed.indent, parsed.bullet))
|
|
}
|
|
|
|
/// Add an item at the end of the body.
|
|
///
|
|
/// Spaced exactly as `import_export.py:_note_markdown` writes a list — a blank line
|
|
/// between prose and the list, and nothing between consecutive items. That is not
|
|
/// cosmetic: the server migration folds existing rows into bodies using the same
|
|
/// layout, so an export taken before the migration and one taken after have to
|
|
/// agree byte for byte.
|
|
///
|
|
/// `checked` is a parameter rather than always false because the two migrations that
|
|
/// fold existing rows into bodies have to carry the state those rows were in. A new
|
|
/// item from the UI passes false.
|
|
pub fn append_item(body: &str, text: &str, checked: bool) -> String {
|
|
let line = render_task_line("", '-', checked, text.trim());
|
|
let trimmed = body.trim_end_matches('\n');
|
|
if trimmed.trim().is_empty() {
|
|
return line;
|
|
}
|
|
let follows_a_list = trimmed
|
|
.split('\n')
|
|
.next_back()
|
|
.is_some_and(|l| parse_task_line(l).is_some());
|
|
if follows_a_list {
|
|
format!("{trimmed}\n{line}")
|
|
} else {
|
|
format!("{trimmed}\n\n{line}")
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn tags_basic() {
|
|
assert_eq!(
|
|
extract_tags("a #todo and #Work-item_2 here"),
|
|
vec!["todo", "Work-item_2"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn tags_require_letter_start_and_boundary() {
|
|
// "#1" (digit) and an in-word "#" (email-ish) are not tags.
|
|
assert_eq!(extract_tags("#1 nope a#b no but #Yes"), vec!["Yes"]);
|
|
}
|
|
|
|
#[test]
|
|
fn tags_dedupe_case_insensitive() {
|
|
assert_eq!(extract_tags("#Home #home #HOME"), vec!["Home"]);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_body() {
|
|
assert!(extract_tags("").is_empty());
|
|
}
|
|
|
|
// ── checklist items ─────────────────────────────────────────────────────
|
|
|
|
fn item(text: &str, checked: bool, line: u32) -> DerivedItem {
|
|
DerivedItem {
|
|
text: text.to_string(),
|
|
checked,
|
|
line,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn items_basic() {
|
|
let body = "shopping\n\n- [ ] milk\n- [x] eggs";
|
|
assert_eq!(
|
|
extract_items(body),
|
|
vec![item("milk", false, 2), item("eggs", true, 3)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn items_may_sit_between_paragraphs() {
|
|
// The whole reason the body owns the list: a table of rows could only ever
|
|
// render after the prose.
|
|
let body = "before\n- [ ] middle\nafter";
|
|
assert_eq!(extract_items(body), vec![item("middle", false, 1)]);
|
|
}
|
|
|
|
#[test]
|
|
fn items_reject_near_misses() {
|
|
// Each of these is prose, and each has been someone's bug report somewhere.
|
|
for body in [
|
|
"-[ ] no space after the dash",
|
|
"- [] empty brackets",
|
|
"- [ ]no space after the brackets",
|
|
"- [y] not a mark",
|
|
"a [ ] mid sentence",
|
|
"[ ] no bullet at all",
|
|
] {
|
|
assert!(extract_items(body).is_empty(), "should be prose: {body}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn items_accept_star_bullets_and_indentation() {
|
|
// `*` because markdown.ts already takes it for a plain bullet.
|
|
let body = "* [ ] star\n - [x] indented";
|
|
assert_eq!(
|
|
extract_items(body),
|
|
vec![item("star", false, 0), item("indented", true, 1)]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_item_is_still_an_item() {
|
|
// What pressing Enter on a list leaves behind.
|
|
assert_eq!(extract_items("- [ ]"), vec![item("", false, 0)]);
|
|
assert_eq!(extract_items("- [ ] "), vec![item("", false, 0)]);
|
|
}
|
|
|
|
#[test]
|
|
fn uppercase_x_parses_and_normalises_on_rewrite() {
|
|
assert_eq!(extract_items("- [X] done"), vec![item("done", true, 0)]);
|
|
// Touching it once canonicalises it, and never again.
|
|
assert_eq!(set_item_checked("- [X] done", 0, true), "- [x] done");
|
|
}
|
|
|
|
#[test]
|
|
fn checking_preserves_indent_bullet_and_text() {
|
|
assert_eq!(set_item_checked(" * [ ] milk", 0, true), " * [x] milk");
|
|
assert_eq!(set_item_checked("- [x] milk", 0, false), "- [ ] milk");
|
|
}
|
|
|
|
#[test]
|
|
fn checking_addresses_items_not_lines() {
|
|
let body = "note\n- [ ] a\nprose\n- [ ] b";
|
|
assert_eq!(
|
|
set_item_checked(body, 1, true),
|
|
"note\n- [ ] a\nprose\n- [x] b"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn set_text_keeps_state() {
|
|
assert_eq!(set_item_text("- [x] old", 0, "new"), "- [x] new");
|
|
}
|
|
|
|
#[test]
|
|
fn remove_takes_the_whole_line() {
|
|
let body = "keep\n- [ ] drop\n- [ ] stay";
|
|
assert_eq!(remove_item(body, 0), "keep\n- [ ] stay");
|
|
}
|
|
|
|
#[test]
|
|
fn append_spaces_like_the_exporter() {
|
|
// Prose then a blank line then the list — byte-for-byte what
|
|
// import_export.py:_note_markdown writes, which is what the server
|
|
// migration will fold existing rows into.
|
|
assert_eq!(append_item("a note", "milk", false), "a note\n\n- [ ] milk");
|
|
// Nothing between consecutive items.
|
|
let one = "a note\n\n- [ ] milk";
|
|
assert_eq!(
|
|
append_item(one, "eggs", false),
|
|
format!("{one}\n- [ ] eggs")
|
|
);
|
|
// A list-only note starts at the first line.
|
|
assert_eq!(append_item("", "milk", false), "- [ ] milk");
|
|
assert_eq!(append_item("\n\n", "milk", false), "- [ ] milk");
|
|
// Carries state, which is what the two migrations need of it.
|
|
assert_eq!(append_item("", "done", true), "- [x] done");
|
|
}
|
|
|
|
#[test]
|
|
fn strip_marker_names_a_list_only_note() {
|
|
assert_eq!(strip_marker("- [x] milk"), "milk");
|
|
assert_eq!(strip_marker("just prose"), "just prose");
|
|
}
|
|
|
|
#[test]
|
|
fn render_item_is_what_extract_reads_back() {
|
|
assert_eq!(render_item("milk", false), "- [ ] milk");
|
|
assert_eq!(render_item("done", true), "- [x] done");
|
|
// An empty item has no trailing space, so a round trip does not grow it.
|
|
assert_eq!(render_item("", false), "- [ ]");
|
|
let line = render_item("milk", true);
|
|
assert_eq!(extract_items(&line), vec![item("milk", true, 0)]);
|
|
}
|
|
|
|
#[test]
|
|
fn items_carry_the_line_they_sit_on() {
|
|
let found = extract_items("a\n- [ ] x\nb\n- [x] y");
|
|
assert_eq!(found.iter().map(|i| i.line).collect::<Vec<_>>(), vec![1, 3]);
|
|
}
|
|
|
|
#[test]
|
|
fn toggle_at_only_fires_inside_the_marker() {
|
|
let body = "note\n- [ ] milk\n- [x] eggs";
|
|
// Column 0 is the bullet, 4 is the `]`, 5 the slack past it.
|
|
assert_eq!(
|
|
toggle_at(body, 1, 0).as_deref(),
|
|
Some("note\n- [x] milk\n- [x] eggs")
|
|
);
|
|
assert_eq!(
|
|
toggle_at(body, 2, 5).as_deref(),
|
|
Some("note\n- [ ] milk\n- [ ] eggs")
|
|
);
|
|
// Past the marker is text someone wants to put a caret in.
|
|
assert!(toggle_at(body, 1, 9).is_none());
|
|
// Not a task line, and off the end.
|
|
assert!(toggle_at(body, 0, 0).is_none());
|
|
assert!(toggle_at(body, 99, 0).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn continuation_starts_the_next_item_or_ends_the_list() {
|
|
assert_eq!(continuation("- [x] milk").as_deref(), Some("- [ ] "));
|
|
assert_eq!(continuation(" * [ ] milk").as_deref(), Some(" * [ ] "));
|
|
// An empty item ends the list rather than adding another.
|
|
assert_eq!(continuation("- [ ]").as_deref(), Some(""));
|
|
assert_eq!(continuation("- [ ] ").as_deref(), Some(""));
|
|
assert!(continuation("just prose").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn a_stale_index_does_nothing() {
|
|
// The index comes from a UI that may be a moment behind the store. A tap
|
|
// that arrives late should be inert, not fatal.
|
|
let body = "- [ ] only";
|
|
assert_eq!(set_item_checked(body, 7, true), body);
|
|
assert_eq!(remove_item(body, 7), body);
|
|
assert_eq!(set_item_text(body, 7, "x"), body);
|
|
}
|
|
|
|
#[test]
|
|
fn a_plain_body_is_returned_byte_identical() {
|
|
let body = "just prose\nwith two lines";
|
|
assert_eq!(set_item_checked(body, 0, true), body);
|
|
assert_eq!(set_item_text(body, 0, "x"), body);
|
|
assert_eq!(remove_item(body, 0), body);
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_is_stable() {
|
|
let body = "- [ ] a\n- [x] b\n- [ ] c";
|
|
let items = extract_items(body);
|
|
// Ticking and unticking returns the original bytes.
|
|
let touched = set_item_checked(&set_item_checked(body, 0, true), 0, false);
|
|
assert_eq!(touched, body);
|
|
assert_eq!(extract_items(&touched), items);
|
|
}
|
|
}
|