//! 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:` (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 [--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, } /// 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, /// Project name (note must be associated to that project record). pub project: Option, /// Category (doc_type `note:`). pub category: Option, /// Earliest day-of-epoch, inclusive. pub since: Option, /// Latest day-of-epoch, inclusive. pub until: Option, } /// The category portion of a note's `doc_type`, if any (`note:`). 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 { 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 { 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:`. Returns the note's /// coordinate. pub fn store_note( store: &ConcurrentStore, session: &str, subject: &str, body: &str, project: Option<&str>, category: Option<&str>, ) -> Result { 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:"`), 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) -> Vec { 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 { let snap = store.read_snapshot(); let mut out: Vec = 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 { let needle = needle.to_lowercase(); let snap = store.read_snapshot(); let mut out: Vec = 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 { 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)> { 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 { let snap = store.read_snapshot(); let mut out: Vec = 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 { 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 ` 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 = 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 ` quick note /// `note add [--project P] [--cat C] ` add a note to a project/category /// `note list [session] [--project P] [--cat C] [--since D] [--until D]` /// `note search [--project P] [--cat C]` /// `note show ` /// `project list` / `project show ` (see [`project_command`]) pub fn note_command(store: &ConcurrentStore, line: &str) -> Result { 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(¬es)); }; 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(¬es)) } "search" => { let (flags, pos) = split_flags(rest); let f = filter_from_flags(&flags); if pos.is_empty() { return Err("note search needs ".to_string()); } let notes = search_notes(store, &pos, Some(&f)); Ok(format!("search '{pos}':\n{}", format_notes(¬es))) } "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 ".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 { 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 `, /// `project resume `, `project context `. pub fn project_command(store: &ConcurrentStore, line: &str) -> Result { 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(¬es) )) } 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 ".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 ".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)>) -> String { if projects.is_empty() { return "(no projects)".to_string(); } let lines: Vec = 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]")); } }