use anyhow::{Result, anyhow, bail};
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PendingState {
Open,
Gated,
Done,
}
impl PendingState {
pub fn box_char(self) -> char {
match self {
PendingState::Open => ' ',
PendingState::Gated => '/',
PendingState::Done => 'x',
}
}
#[allow(dead_code)]
pub fn from_box_char(c: char) -> Option<PendingState> {
match c {
' ' => Some(PendingState::Open),
'/' => Some(PendingState::Gated),
'x' | 'X' => Some(PendingState::Done),
_ => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)]
pub enum PendingOp {
Gate,
Ungate,
MarkDone,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransitionResult {
Transition(PendingState),
NoOp,
}
pub fn validate_transition(from: PendingState, op: PendingOp) -> Result<TransitionResult> {
use PendingOp::*;
use PendingState as S;
use TransitionResult::*;
match (from, op) {
(S::Open, Gate) => Ok(Transition(S::Gated)),
(S::Open, Ungate) => bail!("cannot ungate Open item: source must be `[/]`"),
(S::Open, MarkDone) => Ok(Transition(S::Done)),
(S::Gated, Gate) => Ok(NoOp),
(S::Gated, Ungate) => Ok(Transition(S::Open)),
(S::Gated, MarkDone) => Ok(Transition(S::Done)),
(S::Done, Gate) => bail!("cannot gate Done item: add a new pending item for the follow-up gate"),
(S::Done, Ungate) => bail!("cannot ungate Done item: source must be `[/]`"),
(S::Done, MarkDone) => Ok(NoOp),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PendingItem {
pub id: String,
pub state: PendingState,
pub gate_type: Option<String>,
pub text: String,
}
impl PendingItem {
pub fn render(&self) -> String {
let checkbox = match (&self.state, &self.gate_type) {
(PendingState::Gated, Some(gt)) => format!("[/{}]", gt),
_ => format!("[{}]", self.state.box_char()),
};
format!("- {} [#{}] {}", checkbox, self.id, self.text)
}
pub fn is_done(&self) -> bool {
matches!(self.state, PendingState::Done)
}
}
pub fn parse_items(body: &str) -> (String, Vec<PendingItem>, String) {
let lines: Vec<&str> = body.lines().collect();
let first_item = lines.iter().position(|l| is_item_line(l));
let first_item = match first_item {
Some(i) => i,
None => return (body.to_string(), Vec::new(), String::new()),
};
let last_item = lines
.iter()
.rposition(|l| is_item_line(l))
.unwrap_or(first_item);
let prelude = join_lines(&lines[..first_item], has_trailing_newline(body) || first_item > 0);
let postlude = if last_item + 1 < lines.len() {
join_lines(&lines[last_item + 1..], has_trailing_newline(body))
} else {
String::new()
};
let mut items = Vec::new();
for line in &lines[first_item..=last_item] {
if let Some(item) = parse_item_line(line) {
items.push(item);
}
}
(prelude, items, postlude)
}
fn is_item_line(line: &str) -> bool {
let t = line.trim_start();
t.starts_with("- ") || t == "-"
}
fn has_trailing_newline(body: &str) -> bool {
body.ends_with('\n')
}
fn join_lines(lines: &[&str], with_trailing: bool) -> String {
if lines.is_empty() {
return String::new();
}
let mut s = lines.join("\n");
if with_trailing {
s.push('\n');
}
s
}
fn parse_item_line(line: &str) -> Option<PendingItem> {
let trimmed = line.trim_start();
let rest = trimmed.strip_prefix("- ")?;
let rest = rest.trim_start();
let (state, gate_type, after_box) = if let Some(r) = rest.strip_prefix("[ ]") {
(PendingState::Open, None, r.trim_start())
} else if let Some(r) = rest.strip_prefix("[/]") {
(PendingState::Gated, None, r.trim_start())
} else if let Some(inner) = rest.strip_prefix("[/") {
if let Some(close) = inner.find(']') {
let gt = &inner[..close];
if !gt.is_empty() && gt.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
let r = &inner[close + 1..];
(PendingState::Gated, Some(gt.to_lowercase()), r.trim_start())
} else {
(PendingState::Open, None, rest)
}
} else {
(PendingState::Open, None, rest)
}
} else if let Some(r) = rest.strip_prefix("[x]") {
(PendingState::Done, None, r.trim_start())
} else if let Some(r) = rest.strip_prefix("[X]") {
(PendingState::Done, None, r.trim_start())
} else {
(PendingState::Open, None, rest)
};
let (id, text) = if let Some(after_hash) = after_box.strip_prefix("[#") {
if let Some(close) = after_hash.find(']') {
let id_raw = &after_hash[..close];
let tail = after_hash[close + 1..].trim_start();
if is_valid_hash_id(id_raw) {
(id_raw.to_lowercase(), tail.to_string())
} else {
(String::new(), after_box.to_string())
}
} else {
(String::new(), after_box.to_string())
}
} else {
(String::new(), after_box.to_string())
};
Some(PendingItem {
id,
state,
gate_type,
text: text.trim_end().to_string(),
})
}
fn is_valid_hash_id(s: &str) -> bool {
!s.is_empty()
&& s.len() <= 8
&& s.chars().all(|c| c.is_ascii_alphanumeric())
}
pub fn render_items(prelude: &str, items: &[PendingItem], postlude: &str) -> String {
let mut out = String::new();
out.push_str(prelude);
if !prelude.is_empty() && !prelude.ends_with('\n') {
out.push('\n');
}
for item in items {
out.push_str(&item.render());
out.push('\n');
}
if !postlude.is_empty() {
out.push_str(postlude);
if !postlude.ends_with('\n') {
out.push('\n');
}
}
out
}
#[allow(dead_code)]
pub fn generate_hash(text: &str, doc_id: &str, counter: u64) -> String {
generate_hash_n(text, doc_id, counter, 4)
}
pub fn generate_hash_n(text: &str, doc_id: &str, counter: u64, width: usize) -> String {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(text.as_bytes());
hasher.update(b":");
hasher.update(doc_id.as_bytes());
hasher.update(b":");
hasher.update(counter.to_le_bytes());
let digest = hasher.finalize();
const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz";
let width = width.clamp(4, 8);
let mut out = String::with_capacity(width);
let b0 = digest[0] as u32;
let b1 = digest[1] as u32;
let b2 = digest[2] as u32;
let v: u32 = (b0 << 16) | (b1 << 8) | b2;
out.push(ALPHABET[((v >> 15) & 0x1f) as usize] as char);
out.push(ALPHABET[((v >> 10) & 0x1f) as usize] as char);
out.push(ALPHABET[((v >> 5) & 0x1f) as usize] as char);
out.push(ALPHABET[(v & 0x1f) as usize] as char);
if width > 4 {
let e0 = digest[3] as u32;
let e1 = digest[4] as u32;
let e2 = digest[5] as u32;
let extra: u32 = (e0 << 16) | (e1 << 8) | e2;
for i in 0..(width - 4) {
let shift = 15 - (i as u32) * 5;
out.push(ALPHABET[((extra >> shift) & 0x1f) as usize] as char);
}
}
out
}
fn assign_unique_hash(text: &str, doc_id: &str, taken: &HashSet<String>) -> String {
const RETRIES_PER_WIDTH: u64 = 4;
let mut counter: u64 = 0;
loop {
let width = std::cmp::min(4 + (counter / RETRIES_PER_WIDTH) as usize, 8);
let id = generate_hash_n(text, doc_id, counter, width);
if !taken.contains(&id) {
return id;
}
counter = counter.saturating_add(1);
}
}
pub fn backfill(body: &str, doc_id: &str, existing_ids: &HashSet<String>) -> (String, bool) {
let (prelude, items, postlude) = parse_items(body);
let mut taken: HashSet<String> = existing_ids.clone();
for item in &items {
if !item.id.is_empty() {
taken.insert(item.id.clone());
}
}
let mut changed = false;
let mut new_items = Vec::with_capacity(items.len());
for item in items {
if item.id.is_empty() {
let id = assign_unique_hash(&item.text, doc_id, &taken);
taken.insert(id.clone());
changed = true;
new_items.push(PendingItem { id, ..item });
} else {
new_items.push(item);
}
}
let new_body = render_items(&prelude, &new_items, &postlude);
if new_body != body {
changed = true;
}
(new_body, changed)
}
pub fn reap(body: &str) -> (String, Vec<String>) {
let (new_body, removed) = reap_with_items(body);
let ids = removed.iter().map(|i| i.id.clone()).collect();
(new_body, ids)
}
pub fn reap_with_items(body: &str) -> (String, Vec<PendingItem>) {
let (prelude, items, postlude) = parse_items(body);
let mut removed = Vec::new();
let mut kept = Vec::new();
for item in items {
if item.is_done() {
if !item.id.is_empty() {
removed.push(item);
}
} else {
kept.push(item);
}
}
if removed.is_empty() {
return (body.to_string(), removed);
}
let new_body = render_items(&prelude, &kept, &postlude);
(new_body, removed)
}
pub fn detect_reorder(snapshot_body: &str, current_body: &str) -> Option<Vec<String>> {
let (_, snap_items, _) = parse_items(snapshot_body);
let (_, cur_items, _) = parse_items(current_body);
let snap_ids: Vec<String> = snap_items
.iter()
.filter(|i| !i.id.is_empty())
.map(|i| i.id.clone())
.collect();
let cur_ids: Vec<String> = cur_items
.iter()
.filter(|i| !i.id.is_empty())
.map(|i| i.id.clone())
.collect();
if snap_ids.len() != cur_ids.len() {
return None;
}
let snap_set: HashSet<&String> = snap_ids.iter().collect();
let cur_set: HashSet<&String> = cur_ids.iter().collect();
if snap_set != cur_set {
return None;
}
if snap_ids == cur_ids {
return None;
}
Some(cur_ids)
}
pub fn op_add(body: &str, text: &str, doc_id: &str, gated: bool) -> Result<(String, String)> {
let text = text.trim();
if text.is_empty() {
bail!("pending add: text must be non-empty");
}
if text.starts_with("[ ]") || text.starts_with("[/]") || text.starts_with("[x]") || text.starts_with("[X]") {
bail!("pending add: text must not start with a state marker ([ ], [/], [x]); use --pending-add-gated for gated items");
}
let (prelude, mut items, postlude) = parse_items(body);
let mut taken: HashSet<String> = items
.iter()
.filter(|i| !i.id.is_empty())
.map(|i| i.id.clone())
.collect();
let id = assign_unique_hash(text, doc_id, &taken);
taken.insert(id.clone());
items.push(PendingItem {
id: id.clone(),
state: if gated { PendingState::Gated } else { PendingState::Open },
gate_type: None,
text: text.to_string(),
});
Ok((render_items(&prelude, &items, &postlude), id))
}
pub fn op_done(body: &str, id: &str) -> Result<String> {
let id = id.trim().to_lowercase();
let (prelude, mut items, postlude) = parse_items(body);
let item = items
.iter_mut()
.find(|i| i.id == id)
.ok_or_else(|| anyhow!("pending done: no item with id [#{}]", id))?;
item.state = PendingState::Done;
Ok(render_items(&prelude, &items, &postlude))
}
pub fn op_gate(body: &str, id: &str) -> Result<String> {
let id = id.trim().to_lowercase();
let (prelude, mut items, postlude) = parse_items(body);
let item = items
.iter_mut()
.find(|i| i.id == id)
.ok_or_else(|| anyhow!("pending gate: no item with id [#{}]", id))?;
match validate_transition(item.state, PendingOp::Gate)? {
TransitionResult::Transition(next) => {
item.state = next;
Ok(render_items(&prelude, &items, &postlude))
}
TransitionResult::NoOp => Ok(body.to_string()),
}
}
pub fn op_ungate(body: &str, id: &str) -> Result<String> {
let id = id.trim().to_lowercase();
let (prelude, mut items, postlude) = parse_items(body);
let item = items
.iter_mut()
.find(|i| i.id == id)
.ok_or_else(|| anyhow!("pending ungate: no item with id [#{}]", id))?;
match validate_transition(item.state, PendingOp::Ungate)? {
TransitionResult::Transition(next) => {
item.state = next;
Ok(render_items(&prelude, &items, &postlude))
}
TransitionResult::NoOp => Ok(body.to_string()),
}
}
pub fn op_edit(body: &str, id: &str, new_text: &str) -> Result<String> {
let new_text = new_text.trim();
if new_text.is_empty() {
bail!("pending edit: text must be non-empty");
}
let id = id.trim().to_lowercase();
let (prelude, mut items, postlude) = parse_items(body);
let item = items
.iter_mut()
.find(|i| i.id == id)
.ok_or_else(|| anyhow!("pending edit: no item with id [#{}]", id))?;
item.text = new_text.to_string();
Ok(render_items(&prelude, &items, &postlude))
}
pub fn op_clear(body: &str) -> Result<String> {
let (prelude, _items, postlude) = parse_items(body);
Ok(render_items(&prelude, &[], &postlude))
}
pub fn op_reorder(body: &str, ids: &[String]) -> Result<String> {
let (prelude, items, postlude) = parse_items(body);
let requested: Vec<String> = ids.iter().map(|s| s.trim().to_lowercase()).collect();
for id in &requested {
if !items.iter().any(|i| i.id == *id) {
bail!("pending reorder: no item with id [#{}]", id);
}
}
let mut remaining: Vec<PendingItem> = items.clone();
let mut ordered: Vec<PendingItem> = Vec::new();
for id in &requested {
if let Some(pos) = remaining.iter().position(|i| i.id == *id) {
ordered.push(remaining.remove(pos));
}
}
ordered.extend(remaining);
Ok(render_items(&prelude, &ordered, &postlude))
}
pub fn op_resolve_gate(body: &str, gate_type: &str) -> (String, Vec<String>) {
let gt = gate_type.trim().to_lowercase();
let (prelude, mut items, postlude) = parse_items(body);
let mut resolved = Vec::new();
for item in &mut items {
if item.state == PendingState::Gated && item.gate_type.as_deref() == Some(gt.as_str()) {
item.state = PendingState::Done;
item.gate_type = None;
resolved.push(item.id.clone());
}
}
(render_items(&prelude, &items, &postlude), resolved)
}
pub fn op_set_gate_type(body: &str, id: &str, gate_type: &str) -> Result<String> {
let id = id.trim().to_lowercase();
let gt = gate_type.trim().to_lowercase();
if gt.is_empty() || !gt.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') {
bail!("invalid gate type: must be alphanumeric/dash/underscore");
}
let (prelude, mut items, postlude) = parse_items(body);
let item = items
.iter_mut()
.find(|i| i.id == id)
.ok_or_else(|| anyhow!("pending set-gate-type: no item with id [#{}]", id))?;
if item.state != PendingState::Gated {
bail!("pending set-gate-type: item [#{}] must be gated ([/]) to set a typed gate, current state: [{}]", id, item.state.box_char());
}
item.gate_type = Some(gt);
Ok(render_items(&prelude, &items, &postlude))
}
#[cfg(test)]
mod tests {
use super::*;
const DOC_ID: &str = "test-doc";
fn ids() -> HashSet<String> {
HashSet::new()
}
#[test]
fn parse_empty_body() {
let (p, items, post) = parse_items("");
assert_eq!(p, "");
assert!(items.is_empty());
assert_eq!(post, "");
}
#[test]
fn parse_fully_migrated() {
let body = "- [ ] [#a3f2] first\n- [x] [#b1c4] second\n";
let (_, items, _) = parse_items(body);
assert_eq!(items.len(), 2);
assert_eq!(items[0].id, "a3f2");
assert_eq!(items[0].state, PendingState::Open);
assert_eq!(items[0].text, "first");
assert_eq!(items[1].id, "b1c4");
assert_eq!(items[1].state, PendingState::Done);
}
#[test]
fn parse_gated_state() {
let body = "- [/] [#eg0w] CommitLock — gate: v0.32.5\n";
let (_, items, _) = parse_items(body);
assert_eq!(items.len(), 1);
assert_eq!(items[0].state, PendingState::Gated);
assert_eq!(items[0].id, "eg0w");
assert_eq!(items[0].text, "CommitLock — gate: v0.32.5");
}
#[test]
fn parse_all_three_states() {
let body = "- [ ] [#a3f2] open\n- [/] [#b1c4] gated\n- [x] [#c9e0] done\n";
let (_, items, _) = parse_items(body);
assert_eq!(items[0].state, PendingState::Open);
assert_eq!(items[1].state, PendingState::Gated);
assert_eq!(items[2].state, PendingState::Done);
}
#[test]
fn parse_checkbox_only_no_id() {
let body = "- [ ] just text\n- [x] done item\n";
let (_, items, _) = parse_items(body);
assert_eq!(items.len(), 2);
assert_eq!(items[0].id, "");
assert_eq!(items[0].text, "just text");
assert_eq!(items[1].state, PendingState::Done);
}
#[test]
fn parse_legacy_no_checkbox() {
let body = "- legacy one\n- legacy two\n";
let (_, items, _) = parse_items(body);
assert_eq!(items.len(), 2);
assert_eq!(items[0].text, "legacy one");
assert_eq!(items[0].state, PendingState::Open);
assert_eq!(items[0].id, "");
}
#[test]
fn parse_mixed() {
let body = "- [ ] [#a3f2] migrated\n- [ ] partial\n- legacy\n";
let (_, items, _) = parse_items(body);
assert_eq!(items.len(), 3);
assert_eq!(items[0].id, "a3f2");
assert_eq!(items[1].id, "");
assert_eq!(items[1].text, "partial");
assert_eq!(items[2].text, "legacy");
}
#[test]
fn render_roundtrip_canonical() {
let body = "- [ ] [#a3f2] first\n- [x] [#b1c4] second\n";
let (p, items, post) = parse_items(body);
let out = render_items(&p, &items, &post);
assert_eq!(out, body);
}
#[test]
fn render_roundtrip_all_three_states() {
let body = "- [ ] [#a3f2] open\n- [/] [#b1c4] gated — gate: v0.32.5\n- [x] [#c9e0] done\n";
let (p, items, post) = parse_items(body);
let out = render_items(&p, &items, &post);
assert_eq!(out, body);
}
#[test]
fn render_emits_slash_for_gated() {
let item = PendingItem {
id: "eg0w".to_string(),
state: PendingState::Gated,
gate_type: None,
text: "CommitLock".to_string(),
};
assert_eq!(item.render(), "- [/] [#eg0w] CommitLock");
}
#[test]
fn backfill_adds_hashes() {
let body = "- legacy one\n- legacy two\n";
let (new_body, changed) = backfill(body, DOC_ID, &ids());
assert!(changed);
let (_, items, _) = parse_items(&new_body);
assert_eq!(items.len(), 2);
assert!(!items[0].id.is_empty());
assert!(!items[1].id.is_empty());
assert_ne!(items[0].id, items[1].id);
assert!(new_body.contains("- [ ] [#"));
}
#[test]
fn backfill_idempotent() {
let body = "- [ ] [#a3f2] first\n";
let (new_body, changed) = backfill(body, DOC_ID, &ids());
assert!(!changed, "fully-migrated body should not change");
assert_eq!(new_body, body);
}
#[test]
fn backfill_normalizes_checkbox_only() {
let body = "- [ ] no id here\n";
let (new_body, changed) = backfill(body, DOC_ID, &ids());
assert!(changed);
assert!(new_body.contains("[#"));
}
#[test]
fn backfill_never_inserts_gated() {
let body = "- legacy item awaiting v0.32.5\n";
let (new_body, _) = backfill(body, DOC_ID, &ids());
assert!(new_body.contains("- [ ] "));
assert!(!new_body.contains("- [/] "));
}
#[test]
fn backfill_preserves_existing_gated() {
let body = "- [/] [#eg0w] CommitLock — gate: v0.32.5\n";
let (new_body, changed) = backfill(body, DOC_ID, &ids());
assert!(!changed);
assert_eq!(new_body, body);
}
#[test]
fn reap_skips_gated() {
let body = "- [/] [#eg0w] gated\n- [x] [#c9e0] done\n";
let (new_body, removed) = reap(body);
assert_eq!(removed, vec!["c9e0"]);
assert!(new_body.contains("[#eg0w]"));
assert!(!new_body.contains("[#c9e0]"));
}
#[test]
fn reap_removes_checked() {
let body = "- [ ] [#a3f2] keep\n- [x] [#b1c4] drop\n- [ ] [#c5d6] keep2\n";
let (new_body, removed) = reap(body);
assert_eq!(removed, vec!["b1c4"]);
assert!(new_body.contains("a3f2"));
assert!(!new_body.contains("b1c4"));
assert!(new_body.contains("c5d6"));
}
#[test]
fn reap_noop_when_none_checked() {
let body = "- [ ] [#a3f2] keep\n";
let (new_body, removed) = reap(body);
assert!(removed.is_empty());
assert_eq!(new_body, body);
}
#[test]
fn detect_reorder_same_set_different_order() {
let snap = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
let cur = "- [ ] [#c3d4] two\n- [ ] [#a1b2] one\n";
let result = detect_reorder(snap, cur);
assert_eq!(result, Some(vec!["c3d4".to_string(), "a1b2".to_string()]));
}
#[test]
fn detect_reorder_none_when_sets_differ() {
let snap = "- [ ] [#a1b2] one\n";
let cur = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
assert_eq!(detect_reorder(snap, cur), None);
}
#[test]
fn detect_reorder_none_when_same_order() {
let snap = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
assert_eq!(detect_reorder(snap, snap), None);
}
#[test]
fn op_add_appends_new_item_with_hash() {
let body = "";
let (new_body, id) = op_add(body, "first task", DOC_ID, false).unwrap();
assert!(new_body.contains("- [ ] [#"));
assert!(new_body.contains("first task"));
assert!(!id.is_empty());
}
#[test]
fn op_add_rejects_empty() {
assert!(op_add("", " ", DOC_ID, false).is_err());
}
#[test]
fn op_add_rejects_state_marker_prefix() {
for marker in &["[ ] task", "[/] task", "[x] task", "[X] task"] {
let err = op_add("", marker, DOC_ID, false).unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("state marker"), "expected state marker error for '{}', got: {}", marker, msg);
}
}
#[test]
fn op_add_gated_produces_gated_item() {
let (new_body, id) = op_add("", "gated task", DOC_ID, true).unwrap();
assert!(new_body.contains("[/]"), "expected [/] in: {}", new_body);
assert!(new_body.contains(&format!("[#{}]", id)));
assert!(new_body.contains("gated task"));
}
#[test]
fn op_add_returns_assigned_id() {
let (body, id1) = op_add("", "task one", DOC_ID, false).unwrap();
assert!(!id1.is_empty());
assert!(body.contains(&format!("[#{}]", id1)));
let (body2, id2) = op_add(&body, "task two", DOC_ID, false).unwrap();
assert_ne!(id1, id2);
assert!(body2.contains(&format!("[#{}]", id2)));
}
#[test]
fn op_done_marks_checked() {
let body = "- [ ] [#a1b2] task\n";
let new_body = op_done(body, "a1b2").unwrap();
assert!(new_body.contains("[x]"));
}
#[test]
fn op_done_unknown_id_errors() {
let body = "- [ ] [#a1b2] task\n";
assert!(op_done(body, "zzzz").is_err());
}
#[test]
fn op_edit_preserves_hash() {
let body = "- [ ] [#a1b2] original\n";
let new_body = op_edit(body, "a1b2", "updated").unwrap();
assert!(new_body.contains("[#a1b2]"));
assert!(new_body.contains("updated"));
assert!(!new_body.contains("original"));
}
#[test]
fn op_clear_empties_items() {
let body = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
let new_body = op_clear(body).unwrap();
assert!(!new_body.contains("[#"));
}
#[test]
fn op_reorder_reorders_by_id() {
let body = "- [ ] [#a1b2] first\n- [ ] [#c3d4] second\n- [ ] [#e5f6] third\n";
let new_body =
op_reorder(body, &["e5f6".to_string(), "a1b2".to_string()]).unwrap();
let (_, items, _) = parse_items(&new_body);
assert_eq!(items[0].id, "e5f6");
assert_eq!(items[1].id, "a1b2");
assert_eq!(items[2].id, "c3d4");
}
#[test]
fn op_reorder_unknown_id_errors() {
let body = "- [ ] [#a1b2] one\n";
assert!(op_reorder(body, &["zzzz".to_string()]).is_err());
}
#[test]
fn validate_transition_full_matrix() {
use PendingOp::*;
use PendingState::*;
use TransitionResult::*;
assert_eq!(validate_transition(Open, Gate).unwrap(), Transition(Gated));
assert!(validate_transition(Open, Ungate).is_err());
assert_eq!(validate_transition(Open, MarkDone).unwrap(), Transition(Done));
assert_eq!(validate_transition(Gated, Gate).unwrap(), NoOp);
assert_eq!(validate_transition(Gated, Ungate).unwrap(), Transition(Open));
assert_eq!(validate_transition(Gated, MarkDone).unwrap(), Transition(Done));
assert!(validate_transition(Done, Gate).is_err());
assert!(validate_transition(Done, Ungate).is_err());
assert_eq!(validate_transition(Done, MarkDone).unwrap(), NoOp);
}
#[test]
fn op_gate_open_to_gated() {
let body = "- [ ] [#a1b2] task\n";
let new_body = op_gate(body, "a1b2").unwrap();
assert!(new_body.contains("- [/] [#a1b2]"));
}
#[test]
fn op_gate_gated_is_noop() {
let body = "- [/] [#a1b2] task\n";
let new_body = op_gate(body, "a1b2").unwrap();
assert_eq!(new_body, body);
}
#[test]
fn op_gate_done_errors() {
let body = "- [x] [#a1b2] task\n";
let err = op_gate(body, "a1b2").unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("cannot gate Done item"), "got: {}", msg);
}
#[test]
fn op_gate_unknown_id_errors() {
let body = "- [ ] [#a1b2] task\n";
assert!(op_gate(body, "zzzz").is_err());
}
#[test]
fn op_ungate_gated_to_open() {
let body = "- [/] [#a1b2] task\n";
let new_body = op_ungate(body, "a1b2").unwrap();
assert!(new_body.contains("- [ ] [#a1b2]"));
}
#[test]
fn op_ungate_open_errors() {
let body = "- [ ] [#a1b2] task\n";
let err = op_ungate(body, "a1b2").unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("cannot ungate Open"), "got: {}", msg);
}
#[test]
fn op_ungate_done_errors() {
let body = "- [x] [#a1b2] task\n";
let err = op_ungate(body, "a1b2").unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("cannot ungate Done"), "got: {}", msg);
}
#[test]
fn op_ungate_unknown_id_errors() {
let body = "- [/] [#a1b2] task\n";
assert!(op_ungate(body, "zzzz").is_err());
}
#[test]
fn op_gate_preserves_other_items_and_text() {
let body = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two — gate: v0.32.6\n- [x] [#e5f6] three\n";
let new_body = op_gate(body, "c3d4").unwrap();
let (_, items, _) = parse_items(&new_body);
assert_eq!(items[0].state, PendingState::Open);
assert_eq!(items[1].state, PendingState::Gated);
assert_eq!(items[1].text, "two — gate: v0.32.6");
assert_eq!(items[2].state, PendingState::Done);
}
#[test]
fn generate_hash_deterministic_and_short() {
let h = generate_hash("text", "doc", 0);
assert_eq!(h.len(), 4);
assert_eq!(h, generate_hash("text", "doc", 0));
assert_ne!(h, generate_hash("text", "doc", 1));
}
#[test]
fn generate_hash_n_width4_matches_generate_hash() {
let cases = [
("text", "doc", 0u64),
("refactor preflight", "abc123", 7),
("", "", 42),
("long text with spaces", "doc_id_long", 99),
];
for (t, d, c) in cases {
assert_eq!(generate_hash(t, d, c), generate_hash_n(t, d, c, 4));
}
}
#[test]
fn generate_hash_n_widths_have_correct_length() {
for w in 4..=8 {
let h = generate_hash_n("text", "doc", 0, w);
assert_eq!(h.len(), w, "width {} produced len {}", w, h.len());
}
assert_eq!(generate_hash_n("x", "y", 0, 1).len(), 4);
assert_eq!(generate_hash_n("x", "y", 0, 20).len(), 8);
}
#[test]
fn generate_hash_n_wider_extends_shorter() {
let h4 = generate_hash_n("text", "doc", 0, 4);
let h5 = generate_hash_n("text", "doc", 0, 5);
let h8 = generate_hash_n("text", "doc", 0, 8);
assert!(h5.starts_with(&h4), "h5={} h4={}", h5, h4);
assert!(h8.starts_with(&h4), "h8={} h4={}", h8, h4);
assert!(h8.starts_with(&h5), "h8={} h5={}", h8, h5);
}
#[test]
fn assign_unique_hash_extends_on_collision() {
let h4 = generate_hash_n("item", "doc", 0, 4);
let mut taken = HashSet::new();
taken.insert(h4.clone());
let id = assign_unique_hash("item", "doc", &taken);
assert_ne!(id, h4);
assert!((4..=8).contains(&id.len()));
}
#[test]
fn assign_unique_hash_widens_when_counter_exhausted_at_width4() {
let mut taken = HashSet::new();
for c in 0..=3u64 {
taken.insert(generate_hash_n("x", "d", c, 4));
}
let id = assign_unique_hash("x", "d", &taken);
assert!(!taken.contains(&id));
assert!((4..=8).contains(&id.len()));
}
#[test]
fn backfill_assigns_collision_free_ids_under_pressure() {
let mut body = String::new();
for i in 0..50 {
body.push_str(&format!("- item {}\n", i));
}
let (out, changed) = backfill(&body, "doc", &HashSet::new());
assert!(changed);
let (_, items, _) = parse_items(&out);
assert_eq!(items.len(), 50);
let ids: HashSet<String> = items.iter().map(|i| i.id.clone()).collect();
assert_eq!(ids.len(), 50, "ids must be unique");
for id in &ids {
assert!((4..=8).contains(&id.len()), "id {} has width {}", id, id.len());
}
}
#[test]
fn parse_typed_gate_release() {
let body = "- [/release] [#a1b2] Release v0.32.4\n";
let (_, items, _) = parse_items(body);
assert_eq!(items.len(), 1);
assert_eq!(items[0].state, PendingState::Gated);
assert_eq!(items[0].gate_type, Some("release".to_string()));
assert_eq!(items[0].text, "Release v0.32.4");
}
#[test]
fn parse_typed_gate_deploy() {
let body = "- [/deploy] [#c3d4] Push CDN config\n";
let (_, items, _) = parse_items(body);
assert_eq!(items[0].state, PendingState::Gated);
assert_eq!(items[0].gate_type, Some("deploy".to_string()));
}
#[test]
fn parse_untyped_gate_has_no_gate_type() {
let body = "- [/] [#a1b2] waiting\n";
let (_, items, _) = parse_items(body);
assert_eq!(items[0].state, PendingState::Gated);
assert_eq!(items[0].gate_type, None);
}
#[test]
fn parse_open_has_no_gate_type() {
let body = "- [ ] [#a1b2] task\n";
let (_, items, _) = parse_items(body);
assert_eq!(items[0].gate_type, None);
}
#[test]
fn render_typed_gate() {
let item = PendingItem {
id: "a1b2".to_string(),
state: PendingState::Gated,
gate_type: Some("release".to_string()),
text: "Release v0.32.4".to_string(),
};
assert_eq!(item.render(), "- [/release] [#a1b2] Release v0.32.4");
}
#[test]
fn render_roundtrip_typed_gate() {
let body = "- [/release] [#a1b2] Release v0.32.4\n- [/deploy] [#c3d4] Push\n- [/] [#e5f6] Generic\n";
let (p, items, post) = parse_items(body);
let out = render_items(&p, &items, &post);
assert_eq!(out, body);
}
#[test]
fn op_resolve_gate_flips_matching() {
let body = "- [/release] [#a1b2] Release v0.32.4\n- [/deploy] [#c3d4] Deploy\n- [/] [#e5f6] Generic gate\n";
let (new_body, resolved) = op_resolve_gate(body, "release");
assert_eq!(resolved, vec!["a1b2"]);
let (_, items, _) = parse_items(&new_body);
assert_eq!(items[0].state, PendingState::Done); assert_eq!(items[0].gate_type, None); assert_eq!(items[1].state, PendingState::Gated); assert_eq!(items[1].gate_type, Some("deploy".to_string()));
assert_eq!(items[2].state, PendingState::Gated); assert_eq!(items[2].gate_type, None);
}
#[test]
fn op_resolve_gate_no_match() {
let body = "- [/release] [#a1b2] Release\n- [/] [#c3d4] Generic\n";
let (new_body, resolved) = op_resolve_gate(body, "deploy");
assert!(resolved.is_empty());
assert_eq!(new_body, body);
}
#[test]
fn op_resolve_gate_ignores_untyped() {
let body = "- [/] [#a1b2] Generic gate\n";
let (_, resolved) = op_resolve_gate(body, "release");
assert!(resolved.is_empty());
}
#[test]
fn op_resolve_gate_multiple_same_type() {
let body = "- [/release] [#a1b2] First\n- [/release] [#c3d4] Second\n";
let (_, resolved) = op_resolve_gate(body, "release");
assert_eq!(resolved, vec!["a1b2", "c3d4"]);
}
#[test]
fn op_set_gate_type_on_gated() {
let body = "- [/] [#a1b2] Release v0.32.4\n";
let new_body = op_set_gate_type(body, "a1b2", "release").unwrap();
assert!(new_body.contains("[/release]"));
let (_, items, _) = parse_items(&new_body);
assert_eq!(items[0].gate_type, Some("release".to_string()));
}
#[test]
fn op_set_gate_type_errors_on_open() {
let body = "- [ ] [#a1b2] task\n";
assert!(op_set_gate_type(body, "a1b2", "release").is_err());
}
#[test]
fn op_set_gate_type_errors_on_done() {
let body = "- [x] [#a1b2] task\n";
assert!(op_set_gate_type(body, "a1b2", "release").is_err());
}
#[test]
fn op_set_gate_type_replaces_existing() {
let body = "- [/release] [#a1b2] task\n";
let new_body = op_set_gate_type(body, "a1b2", "deploy").unwrap();
assert!(new_body.contains("[/deploy]"));
assert!(!new_body.contains("[/release]"));
}
#[test]
fn parse_typed_gate_case_insensitive() {
let body = "- [/Release] [#a1b2] task\n";
let (_, items, _) = parse_items(body);
assert_eq!(items[0].gate_type, Some("release".to_string()));
}
#[test]
fn parse_typed_gate_with_hyphens_underscores() {
let body = "- [/code-review] [#a1b2] Review PR\n- [/pre_release] [#c3d4] Pre-release check\n";
let (_, items, _) = parse_items(body);
assert_eq!(items[0].gate_type, Some("code-review".to_string()));
assert_eq!(items[1].gate_type, Some("pre_release".to_string()));
}
}