diff --git a/Cargo.lock b/Cargo.lock index 62780c9..5772df3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,24 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -63,6 +81,47 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" +dependencies = [ + "chrono", + "chrono-tz-build", + "phf", +] + +[[package]] +name = "chrono-tz-build" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" +dependencies = [ + "parse-zoneinfo", + "phf", + "phf_codegen", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "errno" version = "0.3.14" @@ -161,13 +220,39 @@ dependencies = [ name = "heph-core" version = "0.0.0" dependencies = [ + "chrono", "proptest", "pulldown-cmark", + "rrule", "rusqlite", - "thiserror", + "thiserror 2.0.18", "ulid", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "js-sys" version = "0.3.99" @@ -180,6 +265,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.186" @@ -203,6 +294,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "log" +version = "0.4.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" + [[package]] name = "memchr" version = "2.8.1" @@ -224,6 +321,53 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parse-zoneinfo" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f2a05b18d44e2957b88f96ba460715e295bc1d7510468a2f3d3b44535d26c24" +dependencies = [ + "regex", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -264,7 +408,7 @@ dependencies = [ "bit-vec", "bitflags", "num-traits", - "rand", + "rand 0.9.4", "rand_chacha", "rand_xorshift", "regex-syntax", @@ -305,6 +449,15 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "rand" version = "0.9.4" @@ -312,7 +465,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" dependencies = [ "rand_chacha", - "rand_core", + "rand_core 0.9.5", ] [[package]] @@ -322,9 +475,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core", + "rand_core 0.9.5", ] +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + [[package]] name = "rand_core" version = "0.9.5" @@ -340,7 +499,30 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" dependencies = [ - "rand_core", + "rand_core 0.9.5", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", ] [[package]] @@ -349,6 +531,20 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "rrule" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cff1ca93145ff07cdc878b5f6bb90391a299cc8712538af0ad73ebf37613e46a" +dependencies = [ + "chrono", + "chrono-tz", + "lazy_static", + "log", + "regex", + "thiserror 1.0.69", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -400,6 +596,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + [[package]] name = "slab" version = "0.4.12" @@ -436,13 +638,33 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + [[package]] name = "thiserror" version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "thiserror-impl", + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", ] [[package]] @@ -462,7 +684,7 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "470dbf6591da1b39d43c14523b2b469c86879a53e8b758c8e090a470fe7b1fbe" dependencies = [ - "rand", + "rand 0.9.4", "web-time", ] @@ -569,12 +791,65 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.61.2" diff --git a/Cargo.toml b/Cargo.toml index 38ddb1b..4cb4bd6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,8 @@ ulid = "1" thiserror = "2" anyhow = "1" pulldown-cmark = { version = "0.13", default-features = false } +rrule = "0.13" +chrono = { version = "0.4", default-features = false, features = ["clock"] } [profile.release] lto = "thin" diff --git a/crates/heph-core/Cargo.toml b/crates/heph-core/Cargo.toml index c4aa4e6..9633ecb 100644 --- a/crates/heph-core/Cargo.toml +++ b/crates/heph-core/Cargo.toml @@ -13,6 +13,8 @@ rusqlite.workspace = true ulid.workspace = true thiserror.workspace = true pulldown-cmark.workspace = true +rrule.workspace = true +chrono.workspace = true [dev-dependencies] proptest = "1" diff --git a/crates/heph-core/src/lib.rs b/crates/heph-core/src/lib.rs index 7892cf7..40d431a 100644 --- a/crates/heph-core/src/lib.rs +++ b/crates/heph-core/src/lib.rs @@ -13,6 +13,7 @@ pub mod error; pub mod extract; pub mod model; pub mod ranking; +pub mod recurrence; pub mod sqlite; pub mod store; @@ -21,5 +22,6 @@ pub use error::{Error, Result}; pub use extract::{extract, ContextItem, Extraction}; pub use model::{Attention, Link, LinkType, NewNode, NewTask, Node, NodeKind, Task, TaskState}; pub use ranking::{rank, Dimension, RankedTask, RANKING}; +pub use recurrence::{next_occurrence, reset_checkboxes}; pub use sqlite::LocalStore; pub use store::Store; diff --git a/crates/heph-core/src/recurrence.rs b/crates/heph-core/src/recurrence.rs new file mode 100644 index 0000000..9664c53 --- /dev/null +++ b/crates/heph-core/src/recurrence.rs @@ -0,0 +1,185 @@ +//! Recurrence — roll-forward in place (tech-spec §4.4, [[design]] §3.3). +//! +//! A recurring task is a **single node** carrying an RFC-5545 RRULE. On +//! completing an occurrence it (1) resets its checklist to all-unchecked, +//! (2) logs the occurrence, and (3) advances its do-date to the **next RRULE +//! instance strictly after now**, *skipping* missed occurrences. So a missed +//! daily routine is one gently-overdue item, never a pile, and **completion +//! never carries forward**. +//! +//! This module holds the two pure pieces: [`next_occurrence`] (lazy RRULE +//! expansion — only the next instance is ever computed) and +//! [`reset_checkboxes`] (the fresh-checklist transform). + +use chrono::TimeZone; +use rrule::{RRule, Tz, Unvalidated}; + +use crate::error::{Error, Result}; + +/// Hard cap on how many series instances we'll skip past while searching for +/// the next one after `now`. Protects against pathological iteration; a normal +/// missed-routine gap is a handful of instances. +const MAX_SKIP: usize = 100_000; + +/// The next RRULE instance **strictly after** `after_ms`, in epoch ms, anchored +/// at `anchor_ms` (the series DTSTART — in practice the task's current do-date, +/// which is itself a series instance). `None` if the series is exhausted +/// (a finite rule with `COUNT`/`UNTIL`). +/// +/// Lazy: it walks instances from the anchor and returns the first past `after`, +/// never materializing the series. +pub fn next_occurrence(rrule: &str, anchor_ms: i64, after_ms: i64) -> Result> { + let anchor = to_dt(anchor_ms)?; + let after = to_dt(after_ms)?; + + let rule: RRule = rrule + .parse() + .map_err(|e| Error::Integrity(format!("invalid RRULE {rrule:?}: {e}")))?; + let set = rule + .build(anchor) + .map_err(|e| Error::Integrity(format!("invalid RRULE {rrule:?}: {e}")))?; + + for (i, occ) in set.into_iter().enumerate() { + if i >= MAX_SKIP { + return Err(Error::Integrity(format!( + "recurrence search for {rrule:?} exceeded {MAX_SKIP} instances" + ))); + } + if occ.timestamp_millis() > after_ms && occ > after { + return Ok(Some(occ.timestamp_millis())); + } + } + Ok(None) +} + +fn to_dt(ms: i64) -> Result> { + Tz::UTC + .timestamp_millis_opt(ms) + .single() + .ok_or_else(|| Error::Integrity(format!("invalid timestamp {ms}"))) +} + +/// Return `body` with every checked GFM task marker (`- [x]` / `- [X]`) reset to +/// unchecked (`- [ ]`). Idempotent; preserves indentation, bullet style +/// (`-`/`*`/`+`), and line endings. Unchecked items are left untouched. +pub fn reset_checkboxes(body: &str) -> String { + body.split_inclusive('\n').map(uncheck_line).collect() +} + +fn uncheck_line(line: &str) -> String { + let indent_len = line.len() - line.trim_start().len(); + let (indent, rest) = line.split_at(indent_len); + let b = rest.as_bytes(); + let is_checked_item = b.len() >= 5 + && matches!(b[0], b'-' | b'*' | b'+') + && b[1] == b' ' + && b[2] == b'[' + && matches!(b[3], b'x' | b'X') + && b[4] == b']'; + if is_checked_item { + let bullet = &rest[0..1]; + let tail = &rest[5..]; // everything after "]" (incl. any trailing '\n') + format!("{indent}{bullet} [ ]{tail}") + } else { + line.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::prelude::*; + + // 2024-01-01T00:00:00Z and friends, in epoch ms. + const JAN1: i64 = 1_704_067_200_000; + const ONE_DAY: i64 = 86_400_000; + + #[test] + fn daily_advances_one_day_when_completed_on_time() { + // anchor = Jan 1; completing at Jan 1 noon → next instance Jan 2. + let next = next_occurrence("FREQ=DAILY", JAN1, JAN1 + ONE_DAY / 2) + .unwrap() + .unwrap(); + assert_eq!(next, JAN1 + ONE_DAY); + } + + #[test] + fn daily_skips_missed_occurrences() { + // anchor Jan 1, but we don't complete until 3.5 days later → next is + // Jan 5 (one item, not a pile of four). + let now = JAN1 + 3 * ONE_DAY + ONE_DAY / 2; + let next = next_occurrence("FREQ=DAILY", JAN1, now).unwrap().unwrap(); + assert_eq!(next, JAN1 + 4 * ONE_DAY); + } + + #[test] + fn weekly_advances_a_week() { + let next = next_occurrence("FREQ=WEEKLY", JAN1, JAN1 + ONE_DAY) + .unwrap() + .unwrap(); + assert_eq!(next, JAN1 + 7 * ONE_DAY); + } + + #[test] + fn finite_rule_can_be_exhausted() { + // COUNT=2 from the anchor → instances at Jan 1 and Jan 2; after Jan 2 + // there is no next. + let after_last = JAN1 + 5 * ONE_DAY; + assert_eq!( + next_occurrence("FREQ=DAILY;COUNT=2", JAN1, after_last).unwrap(), + None + ); + } + + #[test] + fn invalid_rrule_is_an_error() { + assert!(next_occurrence("FREQ=NONSENSE", JAN1, JAN1).is_err()); + } + + #[test] + fn reset_unchecks_all_checked_items() { + let body = "- [x] a\n- [ ] b\n* [X] c\n + [x] nested\n"; + let expected = "- [ ] a\n- [ ] b\n* [ ] c\n + [ ] nested\n"; + assert_eq!(reset_checkboxes(body), expected); + } + + #[test] + fn reset_is_idempotent() { + let body = "- [x] a\n- [ ] b\n"; + let once = reset_checkboxes(body); + assert_eq!(reset_checkboxes(&once), once); + } + + #[test] + fn reset_leaves_non_checkbox_text_alone() { + let body = "# Heading\n\nSome [x] not a checkbox\n- regular item\n"; + assert_eq!(reset_checkboxes(body), body); + } + + proptest! { + /// The fresh-checklist invariant: after a reset, no checked task marker + /// remains — completion can never carry forward (tech-spec §9). + #[test] + fn reset_leaves_no_checked_markers(body in ".{0,200}") { + let out = reset_checkboxes(&body); + for line in out.lines() { + let t = line.trim_start(); + let bytes = t.as_bytes(); + let checked = bytes.len() >= 5 + && matches!(bytes[0], b'-' | b'*' | b'+') + && bytes[1] == b' ' + && bytes[2] == b'[' + && matches!(bytes[3], b'x' | b'X') + && bytes[4] == b']'; + prop_assert!(!checked, "checked marker survived reset: {line:?}"); + } + } + + /// Reset is idempotent for any input. + #[test] + fn reset_idempotent_for_any_body(body in ".{0,200}") { + let once = reset_checkboxes(&body); + prop_assert_eq!(reset_checkboxes(&once), once); + } + } +} diff --git a/crates/heph-core/src/sqlite/links.rs b/crates/heph-core/src/sqlite/links.rs index 87bb5f9..7e5c46a 100644 --- a/crates/heph-core/src/sqlite/links.rs +++ b/crates/heph-core/src/sqlite/links.rs @@ -53,6 +53,25 @@ pub(super) fn add( Ok(link) } +/// The destination of the first non-tombstoned link of `link_type` out of +/// `src_id`, if any (e.g. a task's canonical-context doc or its log doc). +pub(super) fn first_dst( + conn: &Connection, + src_id: &str, + link_type: LinkType, +) -> Result> { + let dst = conn + .query_row( + "SELECT dst_id FROM links + WHERE src_id = ?1 AND type = ?2 AND tombstoned = 0 + ORDER BY created_at, id LIMIT 1", + (src_id, link_type.as_str()), + |r| r.get(0), + ) + .optional()?; + Ok(dst) +} + /// All non-tombstoned links originating at `id`. pub(super) fn outgoing(conn: &Connection, id: &str) -> Result> { query(conn, "src_id", id) diff --git a/crates/heph-core/src/sqlite/log.rs b/crates/heph-core/src/sqlite/log.rs new file mode 100644 index 0000000..847b7f6 --- /dev/null +++ b/crates/heph-core/src/sqlite/log.rs @@ -0,0 +1,83 @@ +//! Per-task append-only log ([[design]] §6.4). +//! +//! A task's log is a `doc` node linked from the task by `log-of`. Entries are +//! appended as lines; the log is the resumption breadcrumb store ([[design]] +//! §6.1) and the narrative history of recurring completions (§4.4). +//! +//! These take `&Connection` so they compose inside a caller's transaction +//! (the recurrence roll-forward appends a completion entry within its tx). + +use rusqlite::Connection; + +use super::{hlc_for, links, nodes}; +use crate::error::{Error, Result}; +use crate::model::{LinkType, NodeKind}; + +/// The task's log doc id, creating (and linking) it on first use. +pub(super) fn ensure_doc( + conn: &Connection, + owner: &str, + now: i64, + task_id: &str, +) -> Result { + if let Some(id) = links::first_dst(conn, task_id, LinkType::LogOf)? { + return Ok(id); + } + let task = + nodes::get(conn, task_id)?.ok_or_else(|| Error::NodeNotFound(task_id.to_string()))?; + let doc = nodes::build( + owner, + now, + NodeKind::Doc, + format!("{} — log", task.title), + Some(String::new()), + ); + nodes::insert(conn, &doc)?; + links::add(conn, now, task_id, &doc.id, LinkType::LogOf)?; + Ok(doc.id) +} + +/// Append `text` as a new entry (one line) to the task's log. +pub(super) fn append( + conn: &Connection, + owner: &str, + now: i64, + task_id: &str, + text: &str, +) -> Result<()> { + let doc_id = ensure_doc(conn, owner, now, task_id)?; + let doc = nodes::get(conn, &doc_id)?.ok_or_else(|| Error::NodeNotFound(doc_id.clone()))?; + let new_body = append_line(doc.body.as_deref().unwrap_or(""), text); + conn.execute( + "UPDATE nodes SET body = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4", + (&new_body, now, hlc_for(now), &doc_id), + )?; + Ok(()) +} + +/// The task's latest `n` log entries (oldest→newest), empty if it has no log. +pub(super) fn tail(conn: &Connection, task_id: &str, n: usize) -> Result> { + let Some(doc_id) = links::first_dst(conn, task_id, LinkType::LogOf)? else { + return Ok(Vec::new()); + }; + let doc = nodes::get(conn, &doc_id)?.ok_or_else(|| Error::NodeNotFound(doc_id.clone()))?; + let body = doc.body.unwrap_or_default(); + let entries: Vec = body + .lines() + .filter(|l| !l.trim().is_empty()) + .map(str::to_string) + .collect(); + let start = entries.len().saturating_sub(n); + Ok(entries[start..].to_vec()) +} + +/// Append a single entry line to a log body, ensuring separation. +fn append_line(body: &str, text: &str) -> String { + let mut s = body.to_string(); + if !s.is_empty() && !s.ends_with('\n') { + s.push('\n'); + } + s.push_str(text); + s.push('\n'); + s +} diff --git a/crates/heph-core/src/sqlite/mod.rs b/crates/heph-core/src/sqlite/mod.rs index e6e2caf..c91ad84 100644 --- a/crates/heph-core/src/sqlite/mod.rs +++ b/crates/heph-core/src/sqlite/mod.rs @@ -10,6 +10,7 @@ //! delegating layer so a transaction can span several of them. mod links; +mod log; mod migrations; mod nodes; mod tasks; @@ -129,7 +130,12 @@ impl Store for LocalStore { fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result { let now = self.clock.now_ms(); - tasks::set_state(&self.conn, now, node_id, state) + tasks::set_state(&mut self.conn, &self.owner_id, now, node_id, state) + } + + fn skip_recurrence(&mut self, node_id: &str) -> Result { + let now = self.clock.now_ms(); + tasks::skip(&self.conn, now, node_id) } fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result { @@ -154,6 +160,18 @@ impl Store for LocalStore { fn backlinks(&self, id: &str) -> Result> { links::backlinks(&self.conn, id) } + + fn log_append(&mut self, task_id: &str, text: &str) -> Result<()> { + let now = self.clock.now_ms(); + let tx = self.conn.transaction()?; + log::append(&tx, &self.owner_id, now, task_id, text)?; + tx.commit()?; + Ok(()) + } + + fn log_tail(&self, task_id: &str, n: usize) -> Result> { + log::tail(&self.conn, task_id, n) + } } #[cfg(test)] diff --git a/crates/heph-core/src/sqlite/tasks.rs b/crates/heph-core/src/sqlite/tasks.rs index 91bc532..be836bd 100644 --- a/crates/heph-core/src/sqlite/tasks.rs +++ b/crates/heph-core/src/sqlite/tasks.rs @@ -5,10 +5,11 @@ use rusqlite::{Connection, OptionalExtension, Row}; -use super::{links, nodes}; +use super::{hlc_for, links, log, nodes}; use crate::error::{Error, Result}; use crate::model::{Attention, LinkType, NewTask, NodeKind, Task, TaskState}; use crate::ranking::{self, RankedTask}; +use crate::recurrence; fn from_row(row: &Row) -> rusqlite::Result { let attention = match row.get::<_, Option>("attention")? { @@ -100,20 +101,104 @@ fn require(conn: &Connection, node_id: &str) -> Result { get(conn, node_id)?.ok_or_else(|| Error::NodeNotFound(node_id.to_string())) } -/// Set a task's lifecycle state. +/// Set a task's lifecycle state. Completing a **recurring** task rolls it +/// forward in place (tech-spec §4.4) rather than marking it done. pub(super) fn set_state( - conn: &Connection, + conn: &mut Connection, + owner: &str, now: i64, node_id: &str, state: TaskState, ) -> Result { - let updated = conn.execute( + let task = require(conn, node_id)?; + if state == TaskState::Done && task.recurrence.is_some() { + return roll_forward(conn, owner, now, &task); + } + conn.execute( "UPDATE tasks SET state = ?1 WHERE node_id = ?2", (state.as_str(), node_id), )?; - if updated == 0 { - return Err(Error::NodeNotFound(node_id.to_string())); + nodes::touch(conn, now, node_id)?; + require(conn, node_id) +} + +/// Roll a recurring task forward on completion (tech-spec §4.4): reset its +/// checklist to all-unchecked, log the occurrence, and advance the do-date to +/// the next RRULE instance strictly after `now` (skipping misses) — all in one +/// transaction. If the series is exhausted, the task is finally marked done. +fn roll_forward(conn: &mut Connection, owner: &str, now: i64, task: &Task) -> Result { + let rrule = task + .recurrence + .as_deref() + .expect("roll_forward called on a recurring task"); + let tx = conn.transaction()?; + + // 1. Fresh checklist — reset the canonical context doc's checkboxes. + if let Some(doc_id) = links::first_dst(&tx, &task.node_id, LinkType::CanonicalContext)? { + if let Some(doc) = nodes::get(&tx, &doc_id)? { + let body = doc.body.unwrap_or_default(); + let reset = recurrence::reset_checkboxes(&body); + if reset != body { + tx.execute( + "UPDATE nodes SET body = ?1, modified_at = ?2, hlc = ?3 WHERE id = ?4", + (&reset, now, hlc_for(now), &doc_id), + )?; + links::sync_wiki_links(&tx, owner, &doc_id, &reset, now)?; + } + } } + + // 2. Narrative history — append the completed occurrence to the log. + let entry = match task.do_date { + Some(d) => format!("- completed occurrence (do-date {d})"), + None => "- completed occurrence".to_string(), + }; + log::append(&tx, owner, now, &task.node_id, &entry)?; + + // 3. Advance the do-date (or finally finish a finite series). + advance(&tx, now, &task.node_id, rrule, task.do_date)?; + nodes::touch(&tx, now, &task.node_id)?; + + tx.commit()?; + require(conn, &task.node_id) +} + +/// Advance a recurring task to its next instance after `now`, or mark it `done` +/// if the series is exhausted. Shared by completion roll-forward and `skip`. +fn advance( + conn: &Connection, + now: i64, + node_id: &str, + rrule: &str, + do_date: Option, +) -> Result<()> { + let anchor = do_date.unwrap_or(now); + match recurrence::next_occurrence(rrule, anchor, now)? { + Some(next) => { + conn.execute( + "UPDATE tasks SET do_date = ?1, state = 'outstanding' WHERE node_id = ?2", + (next, node_id), + )?; + } + None => { + conn.execute( + "UPDATE tasks SET state = 'done' WHERE node_id = ?1", + [node_id], + )?; + } + } + Ok(()) +} + +/// Skip the current occurrence of a recurring task: advance the do-date the same +/// way as completion but **without** logging a completion (tech-spec §4.4). +pub(super) fn skip(conn: &Connection, now: i64, node_id: &str) -> Result { + let task = require(conn, node_id)?; + let rrule = task + .recurrence + .as_deref() + .ok_or_else(|| Error::Integrity(format!("skip on non-recurring task {node_id}")))?; + advance(conn, now, node_id, rrule, task.do_date)?; nodes::touch(conn, now, node_id)?; require(conn, node_id) } diff --git a/crates/heph-core/src/store.rs b/crates/heph-core/src/store.rs index 48638a5..854d39e 100644 --- a/crates/heph-core/src/store.rs +++ b/crates/heph-core/src/store.rs @@ -42,10 +42,16 @@ pub trait Store { /// Fetch a task by its node id. fn get_task(&self, node_id: &str) -> Result>; - /// Set a task's lifecycle state. (Recurrence roll-forward is layered on in - /// a later slice — tech-spec §4.4.) + /// Set a task's lifecycle state. Completing a **recurring** task rolls it + /// forward in place — fresh checklist, logged occurrence, advanced do-date + /// (tech-spec §4.4) — rather than marking it done. fn set_task_state(&mut self, node_id: &str, state: TaskState) -> Result; + /// Skip the current occurrence of a recurring task: advance its do-date + /// without logging a completion (tech-spec §4.4). Errors on a non-recurring + /// task. + fn skip_recurrence(&mut self, node_id: &str) -> Result; + /// Set a task's attention-state. fn set_task_attention(&mut self, node_id: &str, attention: Attention) -> Result; @@ -64,4 +70,13 @@ pub trait Store { /// All non-tombstoned links pointing at `id` (backlinks). fn backlinks(&self, id: &str) -> Result>; + + // --- per-task log ([[design]] §6.4) --- + + /// Append a line to a task's append-only log (creating the log on first + /// use). The log is the resumption breadcrumb store. + fn log_append(&mut self, task_id: &str, text: &str) -> Result<()>; + + /// The task's latest `n` log entries (oldest→newest); empty if it has none. + fn log_tail(&self, task_id: &str, n: usize) -> Result>; } diff --git a/crates/heph-core/tests/recurrence.rs b/crates/heph-core/tests/recurrence.rs new file mode 100644 index 0000000..72844e7 --- /dev/null +++ b/crates/heph-core/tests/recurrence.rs @@ -0,0 +1,175 @@ +//! End-to-end recurrence: completing a recurring task rolls it forward in +//! place — fresh checklist, logged occurrence, advanced do-date, never +//! carrying completion forward (tech-spec §4.4, slice 5). + +use std::sync::atomic::{AtomicI64, Ordering}; +use std::sync::Arc; + +use heph_core::{Clock, LinkType, LocalStore, NewTask, Store, TaskState}; + +const JAN1: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z +const ONE_DAY: i64 = 86_400_000; + +/// A clock the test can advance between completions. Cloneable so the test +/// keeps a handle while the store owns its own `Box`. +#[derive(Clone)] +struct StepClock(Arc); + +impl StepClock { + fn new(ms: i64) -> Self { + StepClock(Arc::new(AtomicI64::new(ms))) + } + fn set(&self, ms: i64) { + self.0.store(ms, Ordering::SeqCst); + } +} + +impl Clock for StepClock { + fn now_ms(&self) -> i64 { + self.0.load(Ordering::SeqCst) + } +} + +fn store_at(ms: i64) -> (LocalStore, StepClock) { + let clock = StepClock::new(ms); + let store = LocalStore::open_in_memory(Box::new(clock.clone())).unwrap(); + (store, clock) +} + +fn canonical_doc_id(s: &LocalStore, task_id: &str) -> String { + s.outgoing_links(task_id) + .unwrap() + .into_iter() + .find(|l| l.link_type == LinkType::CanonicalContext) + .unwrap() + .dst_id +} + +#[test] +fn completing_a_recurring_task_rolls_forward_with_a_fresh_checklist() { + let (mut s, _clock) = store_at(JAN1 + ONE_DAY / 2); + + let task = s + .create_task(NewTask { + title: "Morning routine".into(), + do_date: Some(JAN1), + recurrence: Some("FREQ=DAILY".into()), + ..Default::default() + }) + .unwrap(); + let doc_id = canonical_doc_id(&s, &task.node_id); + + // Put a checklist in the context doc and check items off. + s.update_node( + &doc_id, + None, + Some("- [x] brush teeth\n- [x] feed birds\n- [ ] coffee\n".into()), + ) + .unwrap(); + + let rolled = s.set_task_state(&task.node_id, TaskState::Done).unwrap(); + + // It stays outstanding and the do-date advanced to the next day. + assert_eq!(rolled.state, TaskState::Outstanding); + assert_eq!(rolled.do_date, Some(JAN1 + ONE_DAY)); + + // The checklist is fresh — every item unchecked. Completion did NOT carry. + let doc = s.get_node(&doc_id).unwrap().unwrap(); + assert_eq!( + doc.body.as_deref(), + Some("- [ ] brush teeth\n- [ ] feed birds\n- [ ] coffee\n") + ); + + // The completion was logged (the narrative breadcrumb). + let log = s.log_tail(&task.node_id, 10).unwrap(); + assert_eq!(log.len(), 1); + assert!(log[0].contains("completed occurrence")); +} + +#[test] +fn missed_occurrences_collapse_to_one_not_a_pile() { + // Complete 3.5 days late → next instance is day 4, not a backlog of 4. + let (mut s, _clock) = store_at(JAN1 + 3 * ONE_DAY + ONE_DAY / 2); + + let task = s + .create_task(NewTask { + title: "Water plants".into(), + do_date: Some(JAN1), + recurrence: Some("FREQ=DAILY".into()), + ..Default::default() + }) + .unwrap(); + + let rolled = s.set_task_state(&task.node_id, TaskState::Done).unwrap(); + assert_eq!(rolled.do_date, Some(JAN1 + 4 * ONE_DAY)); + assert_eq!(rolled.state, TaskState::Outstanding); +} + +#[test] +fn completion_never_carries_forward_across_many_occurrences() { + let (mut s, clock) = store_at(JAN1 + ONE_DAY / 2); + + let task = s + .create_task(NewTask { + title: "Daily standup notes".into(), + do_date: Some(JAN1), + recurrence: Some("FREQ=DAILY".into()), + ..Default::default() + }) + .unwrap(); + let doc_id = canonical_doc_id(&s, &task.node_id); + + for occurrence in 0..5 { + // Check everything off this occurrence. + s.update_node(&doc_id, None, Some("- [x] note A\n- [x] note B\n".into())) + .unwrap(); + let rolled = s.set_task_state(&task.node_id, TaskState::Done).unwrap(); + assert_eq!(rolled.state, TaskState::Outstanding); + + // Fresh checklist after every single completion. + let doc = s.get_node(&doc_id).unwrap().unwrap(); + assert_eq!( + doc.body.as_deref(), + Some("- [ ] note A\n- [ ] note B\n"), + "occurrence {occurrence} did not present a fresh checklist" + ); + + // Advance "now" past the new do-date for the next loop. + clock.set(rolled.do_date.unwrap() + ONE_DAY / 2); + } + + // Five completions → five log entries. + assert_eq!(s.log_tail(&task.node_id, 100).unwrap().len(), 5); +} + +#[test] +fn skip_advances_without_logging() { + let (mut s, _clock) = store_at(JAN1 + ONE_DAY / 2); + + let task = s + .create_task(NewTask { + title: "Optional chore".into(), + do_date: Some(JAN1), + recurrence: Some("FREQ=DAILY".into()), + ..Default::default() + }) + .unwrap(); + + let skipped = s.skip_recurrence(&task.node_id).unwrap(); + assert_eq!(skipped.do_date, Some(JAN1 + ONE_DAY)); + assert_eq!(skipped.state, TaskState::Outstanding); + assert!(s.log_tail(&task.node_id, 10).unwrap().is_empty()); +} + +#[test] +fn non_recurring_task_done_is_just_done() { + let (mut s, _clock) = store_at(JAN1); + let task = s + .create_task(NewTask { + title: "One-off".into(), + ..Default::default() + }) + .unwrap(); + let t = s.set_task_state(&task.node_id, TaskState::Done).unwrap(); + assert_eq!(t.state, TaskState::Done); +} diff --git a/docs/changelog.d/v1-prototype.feature.md b/docs/changelog.d/v1-prototype.feature.md index 8ec8d72..2ac879e 100644 --- a/docs/changelog.d/v1-prototype.feature.md +++ b/docs/changelog.d/v1-prototype.feature.md @@ -4,4 +4,5 @@ Begin the v1 prototype (Phase 1, tech-spec §11.1), built in TDD slices: - Markdown extraction (§5): `[[wiki-links]]` and GFM `- [ ]` checkbox context-items derived purely and idempotently from a body, skipping code blocks. - Committed tasks (§4.3, §6): `task.create` auto-creates the canonical context `doc` + `canonical-context` link; attention/do-date/late-on/state/recurrence columns; set-state/set-attention. Links CRUD (outgoing/backlinks). A body update reconciles `wiki` links (diff-based, resolved by alias/title, idempotent). - "What is next?" ranking (§7): pure, clock-injected, two-stage engine — candidacy filter (do-date as a boolean gate only) then a reorderable list of named dimensions (past-late-on → overdue-amount → attention band → FIFO). `late_on` is the sole urgency signal; blue hidden; red always shown. Proptest-checked total order. `Store::next` surfaces it over SQLite. +- Recurrence — roll-forward in place (§4.4): completing a recurring task resets its checklist to all-unchecked, logs the occurrence, and advances the do-date to the next RRULE instance after now (skipping misses) — completion never carries forward (proptest-checked). Per-task append-only logs (`log-of`) with `log.append`/`log.tail`; `skip` advances without logging. - CI runs the Rust suite (fmt/clippy/test) via the project build hook.