Files
cubelinux-2/cubesys/src/notes.rs
T

755 lines
28 KiB
Rust

//! CUBELinux session-message / note log with **categories** and **projects**.
//!
//! The "message save path" helper. Each note is a CZYX record with a text-type
//! [`WordFlags`] stamp, a `created_at` day, and the session label as the owner.
//! Two extra, searchable dimensions layer on top:
//! * **category** — encoded in `doc_type` as `note:<category>` (so
//! [`scan_by_type`] / filtering slices notes by kind: design, impl, cube, ...).
//! * **project** — notes are *associated* (via `linked_records`) to a
//! `project` record (`doc_type = "project"`), so `cube project walk`
//! follows the links into a project's timeline/thread. This is the
//! CUBELinux first-class-association model (EDG) applied to project work.
//!
//! Cross-session: `created_at` (day) + session label + category + project make
//! any note recoverable as `note search <text> [--project p] [--cat c]`, and a
//! new harness session can `note list --project p` / `project walk` to see where
//! a project left off.
use std::time::{SystemTime, UNIX_EPOCH};
use cubecoords::{CubeHeader, Czyx, WordFlags};
use crate::store::ConcurrentStore;
/// Base `doc_type` for a note without a category.
pub const NOTE_DOC_TYPE: &str = "note";
/// `doc_type` for a project record.
pub const PROJECT_DOC_TYPE: &str = "project";
/// The default session label for CLI-created notes (today's calendar date).
pub fn default_session() -> String {
date_string(now())
}
/// The WordFlags stamped on a note: a complete single-record frame with the
/// text data type (type bits 00). Other metadata bits (encrypted, assoc, ...)
/// are left unset for the caller to raise as needed.
pub fn note_word_flags() -> WordFlags {
WordFlags::from_bits(
WordFlags::START_RECORD | WordFlags::END_RECORD | WordFlags::IS_HEADER,
)
}
/// Current unix time (seconds).
pub fn now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
/// A returned note (coordinate + header + body bytes).
#[derive(Clone, Debug)]
pub struct Note {
/// The note's coordinate (`C.Z.Y.X`).
pub coord: Czyx,
/// The note's record header (title, session label, WordFlags, category).
pub header: CubeHeader,
/// The note's body text as raw bytes.
pub body: Vec<u8>,
}
/// Query dimensions for listing/searching notes. `since`/`until` are
/// day-of-epoch (see [`date_to_day`]); the rest are exact-match labels.
#[derive(Clone, Default, Debug)]
pub struct NoteFilter {
/// Session label (owner_local_user).
pub session: Option<String>,
/// Project name (note must be associated to that project record).
pub project: Option<String>,
/// Category (doc_type `note:<category>`).
pub category: Option<String>,
/// Earliest day-of-epoch, inclusive.
pub since: Option<u64>,
/// Latest day-of-epoch, inclusive.
pub until: Option<u64>,
}
/// The category portion of a note's `doc_type`, if any (`note:<category>`).
pub fn category_of(doc_type: &str) -> Option<&str> {
doc_type.strip_prefix(&format!("{NOTE_DOC_TYPE}:"))
}
fn fnv1a(bytes: &[u8]) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for &b in bytes {
h ^= b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
fn non_zero(b: u8) -> u8 {
if b == 0 {
1
} else {
b
}
}
/// The single-byte notes class for a session label, so `scan_prefix(class,
/// None, None)` returns exactly that session's notes.
fn session_class(session: &str) -> u8 {
let h = fnv1a(session.as_bytes());
1 + ((h >> 24) as u8 & 0x7f) // 1..=128
}
/// Project records live in the upper class range (128..=255) so they never
/// collide with note session classes (1..=128).
fn project_class(name: &str) -> u8 {
let h = fnv1a(name.as_bytes());
128 + ((h >> 24) as u8 & 0x7f)
}
/// Derive a deterministic, collision-light coordinate for a note: the C axis
/// is the session class, Z/Y/X come from the note content + a salt.
fn note_coord(session: &str, created: u64, subject: &str, body: &str, salt: u64) -> Czyx {
let mut mix = fnv1a(session.as_bytes());
mix ^= created.wrapping_mul(0x9e37_79b9);
mix ^= fnv1a(subject.as_bytes());
mix ^= fnv1a(body.as_bytes());
mix ^= salt.wrapping_mul(0x85eb_ca6b);
Czyx::new(
session_class(session),
non_zero((mix >> 16) as u8),
non_zero((mix >> 8) as u8),
non_zero(mix as u8 ^ (salt as u8)),
)
}
/// Derive the deterministic coordinate of a project record.
fn project_coord(name: &str, salt: u64) -> Czyx {
let mut mix = fnv1a(name.as_bytes());
mix ^= salt.wrapping_mul(0x8549_b2a1);
Czyx::new(
project_class(name),
non_zero((mix >> 16) as u8),
non_zero((mix >> 8) as u8),
non_zero(mix as u8 ^ (salt as u8)),
)
}
/// Find an existing project record by name, without creating it.
pub fn find_project(store: &ConcurrentStore, name: &str) -> Option<Czyx> {
store
.read_snapshot()
.query_by_type(PROJECT_DOC_TYPE)
.into_iter()
.find(|(_, h, _)| {
h.title.as_deref() == Some(name)
|| h.doc_type.as_deref() == Some(PROJECT_DOC_TYPE) && h.path.as_deref() == Some(name)
})
.map(|(c, _, _)| c)
}
/// Find-or-create a project record named `name`, returning its coordinate.
/// Notes tagged with a project are associated to it via `linked_records`.
pub fn ensure_project(store: &ConcurrentStore, name: &str, session: &str) -> Result<Czyx, String> {
if let Some(c) = find_project(store, name) {
return Ok(c);
}
let mut salt = 0u64;
let mut coord = project_coord(name, salt);
while store.get_record(&coord).is_some() && salt < 4096 {
salt += 1;
coord = project_coord(name, salt);
}
if salt >= 4096 {
return Err("project store: could not find a free coordinate".into());
}
let mut h = CubeHeader::new();
h.title = Some(name.to_string());
h.path = Some(name.to_string());
h.doc_type = Some(PROJECT_DOC_TYPE.to_string());
h.created_at = Some(now());
h.owner_local_user = Some(session.to_string());
h.refresh_flags();
store.put_record(coord, &h, &[]);
Ok(coord)
}
/// Store a note record under `session`, with a `subject` title and `body`
/// text. `project` (if given) associates the note to a project record; `category`
/// (if given) sets the note's `doc_type` to `note:<category>`. Returns the note's
/// coordinate.
pub fn store_note(
store: &ConcurrentStore,
session: &str,
subject: &str,
body: &str,
project: Option<&str>,
category: Option<&str>,
) -> Result<Czyx, String> {
let created = now();
let proj_coord = match project {
Some(p) => Some(ensure_project(store, p, session)?),
None => None,
};
let mut salt = 0u64;
let mut coord = note_coord(session, created, subject, body, salt);
while store.get_record(&coord).is_some() && salt < 4096 {
salt += 1;
coord = note_coord(session, created, subject, body, salt);
}
if salt >= 4096 {
return Err("note store: could not find a free coordinate (4096 tries)".into());
}
let mut h = CubeHeader::new();
h.title = Some(subject.to_string());
h.doc_type = Some(match category {
Some(c) => format!("{NOTE_DOC_TYPE}:{c}"),
None => NOTE_DOC_TYPE.to_string(),
});
h.created_at = Some(created);
h.owner_local_user = Some(session.to_string());
if let Some(pc) = proj_coord {
h.linked_records = vec![pc];
}
h.word_flags = note_word_flags();
h.refresh_flags();
store.put_record(coord, &h, body.as_bytes());
Ok(coord)
}
/// All note records (`doc_type == "note"` or `"note:<cat>"`), regardless of
/// category, so a category filter is applied in Rust (the store's exact
/// `scan_by_type("note")` would miss categorized notes).
fn all_notes(snap: &cubestore::CubeStore<cubestore::HashBackend>) -> Vec<Note> {
snap.keys()
.into_iter()
.filter_map(|coord| {
let (header, body) = snap.get_record(&coord)?;
let dt = header.doc_type.as_deref().unwrap_or("");
if dt == NOTE_DOC_TYPE || dt.starts_with(&format!("{NOTE_DOC_TYPE}:")) {
Some(Note {
coord,
header,
body,
})
} else {
None
}
})
.collect()
}
/// List notes, newest first, filtered by [`NoteFilter`].
pub fn list_notes(store: &ConcurrentStore, filter: Option<&NoteFilter>) -> Vec<Note> {
let snap = store.read_snapshot();
let mut out: Vec<Note> = all_notes(&snap)
.into_iter()
.filter(|n| note_matches_filtered(store, n, filter))
.collect();
out.sort_by(|a, b| b.header.created_at.cmp(&a.header.created_at));
out
}
fn note_matches_filtered(store: &ConcurrentStore, n: &Note, filter: Option<&NoteFilter>) -> bool {
let Some(f) = filter else {
return true;
};
if let Some(s) = &f.session {
if n.header.owner_local_user.as_deref() != Some(s.as_str()) {
return false;
}
}
if let Some(cat) = &f.category {
let expected = format!("{NOTE_DOC_TYPE}:{cat}");
if n.header.doc_type.as_deref() != Some(expected.as_str()) {
return false;
}
}
if let Some(sd) = f.since {
if n.header.created_at.map(|c| c / 86_400).unwrap_or(0) < sd {
return false;
}
}
if let Some(ud) = f.until {
if n.header.created_at.map(|c| c / 86_400).unwrap_or(u64::MAX) > ud {
return false;
}
}
if let Some(p) = &f.project {
if let Some(pc) = find_project(store, p) {
if !n.header.linked_records.contains(&pc) && n.header.path.as_deref() != Some(p.as_str()) {
return false;
}
} else {
return false;
}
}
true
}
/// Search all notes (session + body) for `needle`, filtered, newest first.
pub fn search_notes(store: &ConcurrentStore, needle: &str, filter: Option<&NoteFilter>) -> Vec<Note> {
let needle = needle.to_lowercase();
let snap = store.read_snapshot();
let mut out: Vec<Note> = all_notes(&snap)
.into_iter()
.filter(|n| note_matches_filtered(store, n, filter))
.filter(|n| {
let subject = n.header.title.as_deref().unwrap_or("").to_lowercase();
let b = String::from_utf8_lossy(&n.body).to_lowercase();
subject.contains(&needle) || b.contains(&needle)
})
.collect();
out.sort_by(|a, b| b.header.created_at.cmp(&a.header.created_at));
out
}
/// Fetch a single note by coordinate.
pub fn show_note(store: &ConcurrentStore, coord: Czyx) -> Option<Note> {
store.get_record(&coord).map(|(header, body)| Note {
coord,
header,
body,
})
}
/// List all project records, newest first.
pub fn project_list(store: &ConcurrentStore) -> Vec<(Czyx, CubeHeader, Vec<u8>)> {
let mut v = store.read_snapshot().query_by_type(PROJECT_DOC_TYPE);
v.sort_by(|a, b| b.1.created_at.cmp(&a.1.created_at));
v
}
/// Return the notes associated to a project record (its thread/timeline),
/// newest first.
pub fn project_walk(store: &ConcurrentStore, coord: Czyx) -> Vec<Note> {
let snap = store.read_snapshot();
let mut out: Vec<Note> = all_notes(&snap)
.into_iter()
.filter(|n| n.header.linked_records.contains(&coord))
.collect();
out.sort_by(|a, b| b.header.created_at.cmp(&a.header.created_at));
out
}
/// Format a unix timestamp as `YYYY-MM-DD` (UTC).
pub fn date_string(ts: u64) -> String {
let days = (ts / 86_400) as i64;
let (y, m, d) = civil_from_days(days);
format!("{y:04}-{m:02}-{d:02}")
}
/// Parse `YYYY-MM-DD` into a day-of-epoch (days since 1970-01-01).
pub fn date_to_day(s: &str) -> Option<u64> {
let mut it = s.split('-');
let y: i64 = it.next()?.parse().ok()?;
let m: u32 = it.next()?.parse().ok()?;
let d: u32 = it.next()?.parse().ok()?;
if it.next().is_some() || !(1..=12).contains(&m) || !(1..=31).contains(&d) {
return None;
}
Some(days_from_civil(y, m, d))
}
/// Days since 1970-01-01 for a civil date (Howard Hinnant's algorithm).
fn days_from_civil(y: i64, m: u32, d: u32) -> u64 {
let yy = if m <= 2 { y - 1 } else { y };
let era = if yy >= 0 { yy } else { yy - 399 } / 400;
let yoe = yy - era * 400;
let mp = (m as i64 + 9) % 12;
let doy = (153 * mp + 2) / 5 + d as i64 - 1;
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
(era * 146_097 + doe - 719_468).max(0) as u64
}
fn civil_from_days(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = z - era * 146_097;
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
/// Render a coordinate as `C.Z.Y.X` (the [`crate::commands::parse_coord`]
/// form), so `note show <coord>` round-trips.
fn coord_str(c: Czyx) -> String {
format!("{}.{}.{}.{}", c.c, c.z, c.y, c.x)
}
/// Subject = the first line-ish portion of the note text (first 60 chars).
fn subject_of(text: &str) -> String {
let s = text.trim();
let s: String = s.chars().take(60).collect();
if s.len() < text.trim().len() {
format!("{s}…")
} else {
s
}
}
fn format_notes(notes: &[Note]) -> String {
if notes.is_empty() {
return "(no notes)".to_string();
}
let lines: Vec<String> = notes
.iter()
.map(|n| {
let subj = n.header.title.as_deref().unwrap_or("");
let when = n
.header
.created_at
.map(|t| date_string(t))
.unwrap_or_else(|| "?".into());
let cat = n
.header
.doc_type
.as_deref()
.and_then(category_of)
.map(|c| format!(" [{c}]"))
.unwrap_or_default();
format!(" {} [{}]{} {}", coord_str(n.coord), when, cat, subj)
})
.collect();
format!("{} note(s):\n{}", notes.len(), lines.join("\n"))
}
/// Parse `--key value` flags out of a command string; returns `(flags,
/// remainingPositionalJoined)`. A flag with no following token gets `"true"`.
fn split_flags(s: &str) -> (Vec<(String, String)>, String) {
let tokens: Vec<&str> = s.split_whitespace().collect();
let mut flags = Vec::new();
let mut pos: Vec<&str> = Vec::new();
let mut i = 0;
while i < tokens.len() {
let t = tokens[i];
if let Some(k) = t.strip_prefix("--") {
let (key, val) = if i + 1 < tokens.len() && !tokens[i + 1].starts_with("--") {
i += 1;
(k.to_string(), tokens[i].to_string())
} else {
(k.to_string(), "true".to_string())
};
flags.push((key, val));
} else {
pos.push(t);
}
i += 1;
}
(flags, pos.join(" "))
}
/// Process one `note`/`notes` command line against `store`.
///
/// Syntax (REPL/CLI):
/// `note <text>` quick note
/// `note add [--project P] [--cat C] <text>` add a note to a project/category
/// `note list [session] [--project P] [--cat C] [--since D] [--until D]`
/// `note search <text> [--project P] [--cat C]`
/// `note show <C.Z.Y.X>`
/// `project list` / `project show <C.Z.Y.X>` (see [`project_command`])
pub fn note_command(store: &ConcurrentStore, line: &str) -> Result<String, String> {
let body = line
.splitn(2, char::is_whitespace)
.nth(1)
.unwrap_or("")
.trim()
.to_string();
let Some((head, rest)) = split_head(&body) else {
let session = default_session();
let notes = list_notes(store, Some(&NoteFilter { session: Some(session), ..Default::default() }));
return Ok(format_notes(&notes));
};
match head {
"list" => {
let (flags, pos) = split_flags(rest);
let f = filter_from_flags(&flags);
let session = if pos.is_empty() { None } else { Some(pos.clone()) };
let f = NoteFilter { session, ..f };
let notes = list_notes(store, Some(&f));
Ok(format_notes(&notes))
}
"search" => {
let (flags, pos) = split_flags(rest);
let f = filter_from_flags(&flags);
if pos.is_empty() {
return Err("note search needs <text>".to_string());
}
let notes = search_notes(store, &pos, Some(&f));
Ok(format!("search '{pos}':\n{}", format_notes(&notes)))
}
"show" => {
let cs = rest.trim();
let coord = crate::commands::parse_coord(cs)
.ok_or_else(|| format!("bad coordinate '{cs}' (want C.Z.Y.X)"))?;
match show_note(store, coord) {
Some(n) => {
let subj = n.header.title.as_deref().unwrap_or("");
let when = n
.header
.created_at
.map(|t| date_string(t))
.unwrap_or_else(|| "?".into());
Ok(format!(
"note {cs} [{}] {}\n{}",
when,
subj,
String::from_utf8_lossy(&n.body)
))
}
None => Ok(format!("note {cs} -> (no note)")),
}
}
"add" | _ => {
let (flags, pos) = split_flags(rest);
let mut project = None;
let mut category = None;
for (k, v) in &flags {
match k.as_str() {
"project" | "p" => project = Some(v.clone()),
"cat" | "category" | "c" => category = Some(v.clone()),
_ => {}
}
}
let text = pos.trim();
if text.is_empty() {
return Err("note needs <text>".to_string());
}
let session = default_session();
let subject = subject_of(text);
let coord = store_note(store, &session, &subject, text, project.as_deref(), category.as_deref())?;
let proj = project.map(|p| format!(" (project {p})")).unwrap_or_default();
Ok(format!("note saved at {} (session {}){proj}", coord_str(coord), session))
}
}
}
fn filter_from_flags(flags: &[(String, String)]) -> NoteFilter {
let mut f = NoteFilter::default();
for (k, v) in flags {
match k.as_str() {
"project" | "p" => f.project = Some(v.clone()),
"cat" | "category" | "c" => f.category = Some(v.clone()),
"since" => f.since = date_to_day(v),
"until" => f.until = date_to_day(v),
_ => {}
}
}
f
}
/// Set a "resume" marker for a project. Stored as a note tagged
/// `category = "resume"` and associated to the project, so a new session can
/// ask "where did this project leave off?" and get it back.
pub fn project_resume(
store: &ConcurrentStore,
name: &str,
text: &str,
session: &str,
) -> Result<Czyx, String> {
let subject = format!("resume: {name}");
store_note(store, session, &subject, text, Some(name), Some("resume"))
}
/// Build a compact "project state" summary for context injection: the project
/// name, its note count, the resume marker (if any), and the latest notes with
/// date/category. A new session reads this to know where a project left off.
pub fn project_context(store: &ConcurrentStore, name: &str) -> String {
let Some(coord) = find_project(store, name) else {
return format!("(no project '{name}')");
};
let notes = project_walk(store, coord);
let mut out = String::new();
out.push_str(&format!("Project: {name}\n"));
out.push_str(&format!(" {} note(s); thread {}.\n", notes.len(), coord_str(coord)));
if let Some(r) = notes
.iter()
.find(|n| category_of(n.header.doc_type.as_deref().unwrap_or("")) == Some("resume"))
{
out.push_str(&format!(
" RESUME: {}\n",
String::from_utf8_lossy(&r.body).trim()
));
}
out.push_str(" Latest:\n");
for n in notes.iter().take(10) {
let subj = n.header.title.as_deref().unwrap_or("");
let when = n.header.created_at.map(|t| date_string(t)).unwrap_or_else(|| "?".into());
let cat = category_of(n.header.doc_type.as_deref().unwrap_or(""))
.map(|c| format!("[{c}] "))
.unwrap_or_default();
out.push_str(&format!(" {when} {cat}{subj}\n"));
}
out
}
/// Process a `project` command line: `project list`, `project show <C.Z.Y.X>`,
/// `project resume <name> <text>`, `project context <name>`.
pub fn project_command(store: &ConcurrentStore, line: &str) -> Result<String, String> {
let body = line
.splitn(2, char::is_whitespace)
.nth(1)
.unwrap_or("")
.trim()
.to_string();
let Some((head, rest)) = split_head(&body) else {
return Ok(format_projects(project_list(store)));
};
match head {
"list" => Ok(format_projects(project_list(store))),
"show" | "walk" => {
let cs = rest.trim();
let coord = crate::commands::parse_coord(cs)
.ok_or_else(|| format!("bad coordinate '{cs}' (want C.Z.Y.X)"))?;
match show_note(store, coord) {
Some(p) => {
let name = p.header.title.as_deref().unwrap_or("?");
let notes = project_walk(store, coord);
Ok(format!(
"project {cs} \"{name}\" — {} note(s):\n{}",
notes.len(),
format_notes(&notes)
))
}
None => Ok(format!("project {cs} -> (no project record)")),
}
}
"resume" => {
let (_, pos) = split_flags(rest);
let mut words = pos.splitn(2, char::is_whitespace);
let name = words.next().unwrap_or("").to_string();
let text = words.next().unwrap_or("").trim().to_string();
if name.is_empty() || text.is_empty() {
return Err("project resume needs <name> <text>".to_string());
}
let session = default_session();
let coord = project_resume(store, &name, &text, &session)?;
Ok(format!("resume set for '{name}' at {} (session {})", coord_str(coord), session))
}
"context" => {
let name = rest.trim();
if name.is_empty() {
return Err("project context needs <name>".to_string());
}
Ok(project_context(store, name))
}
_ => Err(format!("project: unknown subcommand '{head}' (want list|show|resume|context)")),
}
}
fn format_projects(projects: Vec<(Czyx, CubeHeader, Vec<u8>)>) -> String {
if projects.is_empty() {
return "(no projects)".to_string();
}
let lines: Vec<String> = projects
.iter()
.map(|(c, h, _)| {
let name = h.title.as_deref().unwrap_or("");
let when = h.created_at.map(|t| date_string(t)).unwrap_or_else(|| "?".into());
format!(" {} [{}] {}", coord_str(*c), when, name)
})
.collect();
format!("{} project(s):\n{}", projects.len(), lines.join("\n"))
}
/// Split the first whitespace-delimited token from the rest of a string.
fn split_head(s: &str) -> Option<(&str, &str)> {
let idx = s.find(char::is_whitespace)?;
Some((&s[..idx], &s[idx..].trim()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn store_and_list_a_note() {
let store = ConcurrentStore::memory();
let coord = store_note(&store, "test-session", "hello", "world note", None, None).unwrap();
assert!(store.get_record(&coord).is_some());
let all = list_notes(&store, None);
assert_eq!(all.len(), 1);
assert_eq!(all[0].header.doc_type.as_deref(), Some(NOTE_DOC_TYPE));
assert_eq!(all[0].header.owner_local_user.as_deref(), Some("test-session"));
assert_eq!(all[0].body, b"world note");
assert!(all[0].header.word_flags.has(WordFlags::START_RECORD));
assert!(!all[0].header.word_flags.has(WordFlags::ENCRYPTED));
}
#[test]
fn session_and_day_filters() {
let store = ConcurrentStore::memory();
store_note(&store, "a", "one", "first", None, None).unwrap();
store_note(&store, "b", "two", "second", None, None).unwrap();
assert_eq!(
list_notes(&store, Some(&NoteFilter { session: Some("a".into()), ..Default::default() })).len(),
1
);
assert_eq!(list_notes(&store, None).len(), 2);
let today = now() / 86_400;
assert_eq!(
list_notes(&store, Some(&NoteFilter { since: Some(today), ..Default::default() })).len(),
2
);
}
#[test]
fn category_and_project_filters() {
let store = ConcurrentStore::memory();
let a = store_note(&store, "s", "design", "widget design", Some("cubelinux"), Some("design")).unwrap();
let b = store_note(&store, "s", "impl", "widget impl", None, Some("impl")).unwrap();
let c = store_note(&store, "s", "cube", "cube notes", None, None).unwrap();
// category filter
assert_eq!(
list_notes(&store, Some(&NoteFilter { category: Some("design".into()), ..Default::default() })).len(),
1
);
// all categorized + base notes are still found by an unfiltered list
assert_eq!(
list_notes(&store, Some(&NoteFilter { session: Some("s".into()), ..Default::default() })).len(),
3
);
// project filter (a is linked to project cubelinux)
let pc = ensure_project(&store, "cubelinux", "s").unwrap();
assert_eq!(store.get_record(&a).unwrap().0.linked_records, vec![pc]);
assert_eq!(store.get_record(&b).unwrap().0.linked_records, vec![]);
assert_eq!(store.get_record(&c).unwrap().0.linked_records, vec![]);
let proj_notes = list_notes(&store, Some(&NoteFilter { project: Some("cubelinux".into()), ..Default::default() }));
assert_eq!(proj_notes.len(), 1);
assert_eq!(proj_notes[0].header.title.as_deref(), Some("design"));
// project walk returns the thread
assert_eq!(project_walk(&store, pc).len(), 1);
}
#[test]
fn full_text_search_and_dates() {
let store = ConcurrentStore::memory();
store_note(&store, "s", "kernel", "the ipu4 camera driver linked", None, None).unwrap();
store_note(&store, "s", "mail", "opendkim signing works", None, None).unwrap();
assert_eq!(search_notes(&store, "ipu4", None).len(), 1);
assert_eq!(search_notes(&store, "opendkim", None).len(), 1);
assert_eq!(search_notes(&store, "nomatchxyz", None).len(), 0);
assert_eq!(date_to_day("2026-09-08"), Some(20_704));
assert_eq!(date_string(20_704 * 86_400), "2026-09-08");
}
#[test]
fn project_resume_and_context() {
let store = ConcurrentStore::memory();
store_note(&store, "s", "design", "widget design", Some("p"), Some("design")).unwrap();
project_resume(&store, "p", "at the design review", "s").unwrap();
let ctx = project_context(&store, "p");
assert!(ctx.contains("Project: p"));
assert!(ctx.contains("RESUME: at the design review"));
assert!(ctx.contains("[design]"));
}
}