From 6e13b13eea5d3e697be66a87286e059191962fc3 Mon Sep 17 00:00:00 2001 From: CUBELinux-2 Date: Mon, 10 Aug 2026 18:03:04 -0400 Subject: [PATCH] =?UTF-8?q?feat(cubelinux-2):=20Package=201=20=E2=80=94=20?= =?UTF-8?q?cubecoords=20+=20cubestore=20from=20PDF=20spec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New code (no recycling from prior build). Implements: - cubecoords: Czyx 4-axis coordinate (pack/unpack u32), Null-class classification (Total / Cube 1-4 / User), TriWord tri-channel 64-bit codec (6 ASCII + 4 control bits), HeaderFlags + CubeHeader (derived flags). All coding decisions documented inline. - cubestore: CubeBackend trait, HashBackend (HashMap), CubeStore with dependency-free length-prefixed record codec (header TLV + body). - 8 unit tests, all passing; cargo test clean (0 warn/err). Per directive: separate git repo; current /home/CUBELinux kept as working tool; scoped to AI-OS-excluded PDF vision. --- .gitignore | 2 + Cargo.toml | 10 ++ cubecoords/Cargo.toml | 8 + cubecoords/src/lib.rs | 335 +++++++++++++++++++++++++++++++++++++++ cubestore/Cargo.toml | 9 ++ cubestore/src/lib.rs | 360 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 724 insertions(+) create mode 100644 .gitignore create mode 100644 Cargo.toml create mode 100644 cubecoords/Cargo.toml create mode 100644 cubecoords/src/lib.rs create mode 100644 cubestore/Cargo.toml create mode 100644 cubestore/src/lib.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96ef6c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/target +Cargo.lock diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..2c787ef --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,10 @@ +[workspace] +resolver = "2" +members = [ + "cubecoords", + "cubestore", +] + +[workspace.package] +edition = "2021" +license = "MIT OR Apache-2.0" diff --git a/cubecoords/Cargo.toml b/cubecoords/Cargo.toml new file mode 100644 index 0000000..0efef99 --- /dev/null +++ b/cubecoords/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "cubecoords" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "CUBELinux-2 coordinate layer: 4-axis CZYX + tri-channel word + rich record header, built new from the original PDF spec." + +[dependencies] diff --git a/cubecoords/src/lib.rs b/cubecoords/src/lib.rs new file mode 100644 index 0000000..6e6da7a --- /dev/null +++ b/cubecoords/src/lib.rs @@ -0,0 +1,335 @@ +//! CUBELinux-2 coordinate layer — built NEW from the original PDF spec. +//! +//! This is NOT recycled from the prior `/home/CUBELinux` build. The prior +//! build collapsed the PDF's four-axis CZYX model into three `u64` spatial +//! axes plus a 256-bit `SpaceId` capability. Here we restore the PDF model +//! faithfully: a 4×`u8` `CZYX` coordinate where the `C` axis (class/context) +//! is a real coordinate axis, a reserved "Null" control space for headers and +//! flags, and a 64-bit "tri-channel" word that packs six ASCII characters +//! plus four control bits. +//! +//! Coding decisions encountered while building to spec are documented inline +//! (see `Decision:` notes) so the divergence from a naive reading is visible. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +/// A coordinate in the four-axis CUBELinux space. +/// +/// Axes are each `u8` (0–255). The value `0` is reserved as "Null" on every +/// axis, following the PDF's design: `0` is not a normal data cell, it is the +/// control plane. +/// +/// User record space is `1..=255` on each axis, giving +/// `255^4 = 4,228,250,625` possible record coordinates — the figure quoted in +/// the 2006 notes and the PDF. +#[derive(Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash, Debug, Default)] +pub struct Czyx { + /// Class / context axis. `0` = Null (control space). + pub c: u8, + /// Z axis (depth). + pub z: u8, + /// Y axis (vertical). + pub y: u8, + /// X axis (horizontal). + pub x: u8, +} + +impl Czyx { + /// Construct a coordinate. + #[inline] + pub const fn new(c: u8, z: u8, y: u8, x: u8) -> Self { + Czyx { c, z, y, x } + } + + /// Total Null: `C=Z=Y=X=0`. Used as end-of-record / unused / deletion + /// marker per the PDF. + #[inline] + pub fn is_total_null(&self) -> bool { + self.c == 0 && self.z == 0 && self.y == 0 && self.x == 0 + } + + /// Pack into a single `u32` with `C` in the high byte, `X` in the low byte. + #[inline] + pub fn pack_u32(&self) -> u32 { + ((self.c as u32) << 24) + | ((self.z as u32) << 16) + | ((self.y as u32) << 8) + | (self.x as u32) + } + + /// Unpack a `u32` produced by [`pack_u32`]. + #[inline] + pub fn unpack_u32(v: u32) -> Self { + Czyx { + c: (v >> 24) as u8, + z: (v >> 16) as u8, + y: (v >> 8) as u8, + x: v as u8, + } + } + + // --- Null-class classification --------------------------------------- + // + // Decision: the PDF describes several Null special ranges ("Null cube 1–4, + // header layers, null rows") without a single canonical enumeration. We + // implement the two it defines precisely (Total Null, and the + // `C=0, Z/Y/X in 1..=255` Null-cube family) and expose a `NullClass` enum + // for the rest to be added as the store grows. This keeps the axis model + // exact while leaving a documented extension point. + + /// Returns true if this coordinate lives in the Null control space: + /// `C == 0` with at least one of Z/Y/X non-zero (the "Null cube" family). + #[inline] + pub fn is_null_cube(&self) -> bool { + self.c == 0 && (self.z != 0 || self.y != 0 || self.x != 0) + } + + /// Classify this coordinate. + #[inline] + pub fn null_class(&self) -> NullClass { + if self.is_total_null() { + NullClass::Total + } else if self.is_null_cube() { + // The PDF names "Null cube 1–4" by different Z/Y/X patterns. + // Decision: encode the cube number as a function of which + // non-zero pattern is present, deterministically, so it is + // stable and documented rather than ad hoc. + match (self.z != 0, self.y != 0, self.x != 0) { + (true, _, _) => NullClass::Cube(1), + (false, true, _) => NullClass::Cube(2), + (false, false, true) => NullClass::Cube(3), + (false, false, false) => NullClass::Cube(4), // unreachable after Total check, kept total + } + } else { + NullClass::User + } + } +} + +/// The classification of a coordinate with respect to the Null control plane. +#[derive(Copy, Clone, Eq, PartialEq, Debug)] +pub enum NullClass { + /// `C=Z=Y=X=0` — end-of-record / unused / deletion marker. + Total, + /// `C=0`, at least one of Z/Y/X non-zero — a Null control cube, numbered + /// `1..=4` by which axes are set (see [`Czyx::null_class`]). + Cube(u8), + /// Normal user data cell (`C >= 1`, or `C=0` only when used as plain data + /// outside the Null convention). + User, +} + +/// A 64-bit "tri-channel" word. +/// +/// Per the PDF, one word carries either three ASCII pairs plus 4 control bits +/// (6 ASCII chars), or alternate pair/triad/quad arrangements. We implement +/// the canonical `pack_6` / `unpack_6` form from the spec (6 ASCII bytes + +/// 4 control bits) and leave the pair/triad/quad specialization as a +/// documented extension point. +#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)] +pub struct TriWord(pub u64); + +/// Stateless encoder/decoder for [`TriWord`]. +pub struct TriEnc; + +impl TriEnc { + /// Pack 6 ASCII bytes (0–255) plus 4 control bits (0–15) into one 64-bit + /// word. + /// + /// Layout (high → low): `[4 control bits][48 ascii bits][12 unused]`. + /// The control bits occupy bits 60..=63; the six ASCII bytes occupy bits + /// 8..=59 (byte `i` at `8*(5-i)`), leaving the low 8 bits spare for future + /// use. + #[inline] + pub fn pack_6(control: u8, ascii: [u8; 6]) -> TriWord { + let mut v: u64 = 0; + v |= (control as u64 & 0x0F) << 60; + for (i, b) in ascii.iter().enumerate() { + let shift = 8 * (5 - i); + v |= (*b as u64) << shift; + } + TriWord(v) + } + + /// Unpack a word produced by [`pack_6`]. + #[inline] + pub fn unpack_6(word: TriWord) -> (u8, [u8; 6]) { + let v = word.0; + let control = ((v >> 60) & 0x0F) as u8; + let mut ascii = [0u8; 6]; + for i in 0..6 { + let shift = 8 * (5 - i); + ascii[i] = ((v >> shift) & 0xFF) as u8; + } + (control, ascii) + } +} + +/// Header flag bits, mirroring the PDF's title/type/date/size/permission +/// flag layout (flags 1–4 explicitly; 5–19 reserved for permissions and +/// associations). +#[derive(Copy, Clone, Eq, PartialEq, Debug, Default)] +pub struct HeaderFlags(pub u16); + +impl HeaderFlags { + /// Flag 1: title start present. + pub const TITLE: u16 = 1 << 0; + /// Flag 2: document type present. + pub const DOC_TYPE: u16 = 1 << 1; + /// Flag 3: creation date present. + pub const CREATED_AT: u16 = 1 << 2; + /// Flag 4: weight/size present. + pub const SIZE_BYTES: u16 = 1 << 3; + /// Flag 5: root-only permission. + pub const PERM_ROOT_ONLY: u16 = 1 << 4; + /// Flag 6: local-user owner. + pub const PERM_LOCAL_USER: u16 = 1 << 5; + /// Flag 7: remote-user owner. + pub const PERM_REMOTE_USER: u16 = 1 << 6; + /// Flag 8: has outgoing association links. + pub const HAS_ASSOCIATIONS: u16 = 1 << 7; + /// Flag 255 (conceptual end-of-header) is represented out-of-band by the + /// record serializer; there is no bit for it. + + /// Construct from a raw bitmask. + #[inline] + pub const fn from_bits(bits: u16) -> Self { + HeaderFlags(bits) + } + + /// The raw bitmask. + #[inline] + pub const fn bits(&self) -> u16 { + self.0 + } + + /// Set a flag bit. + #[inline] + pub fn set(&mut self, flag: u16) { + self.0 |= flag; + } + + /// Test a flag bit. + #[inline] + pub fn has(&self, flag: u16) -> bool { + self.0 & flag != 0 + } +} + +/// A record header, mirroring the PDF's title/type/date/perms/association +/// model. +/// +/// Decision: the PDF gives both a `bitflags`-style `HeaderFlags` and a +/// structured `CubeHeader` with `Option` fields. We keep the structured form +/// (it is what the store serializes) and derive the flag bits from which +/// fields are `Some`. This avoids storing redundant flag+field data. +#[derive(Clone, Debug, Default)] +pub struct CubeHeader { + /// Flag bits (derived; kept in sync by the accessors). + pub flags: HeaderFlags, + /// Flag 1: human title. + pub title: Option, + /// Flag 2: document type (like a file extension). + pub doc_type: Option, + /// Flag 3: creation time (epoch seconds). + pub created_at: Option, + /// Flag 4: payload size in bytes. + pub size_bytes: Option, + /// Owner: local user. + pub owner_local_user: Option, + /// Owner: remote user. + pub owner_remote_user: Option, + /// Association links to other records (flags 5–19). + pub linked_records: Vec, + /// Total local accesses (from the PDF's association/permission flags). + pub total_accesses: u64, + /// Total remote accesses. + pub total_remote_accesses: u64, + /// Timestamp of the last local access, if any. + pub last_access: Option, + /// Timestamp of the last remote access, if any. + pub last_remote_access: Option, +} + +impl CubeHeader { + /// Build a header, computing [`flags`] from the populated fields. + pub fn new() -> Self { + CubeHeader::default() + } + + /// Recompute the flag bits from which fields are present. Call after + /// mutating fields so `flags` stays consistent with the structure. + pub fn refresh_flags(&mut self) { + let mut f = 0u16; + if self.title.is_some() { + f |= HeaderFlags::TITLE; + } + if self.doc_type.is_some() { + f |= HeaderFlags::DOC_TYPE; + } + if self.created_at.is_some() { + f |= HeaderFlags::CREATED_AT; + } + if self.size_bytes.is_some() { + f |= HeaderFlags::SIZE_BYTES; + } + if self.owner_local_user.is_some() { + f |= HeaderFlags::PERM_LOCAL_USER; + } + if self.owner_remote_user.is_some() { + f |= HeaderFlags::PERM_REMOTE_USER; + } + if !self.linked_records.is_empty() { + f |= HeaderFlags::HAS_ASSOCIATIONS; + } + self.flags = HeaderFlags::from_bits(f); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn czyx_pack_roundtrips() { + let c = Czyx::new(12, 34, 56, 78); + assert_eq!(Czyx::unpack_u32(c.pack_u32()), c); + assert_eq!(c.pack_u32(), 0x0C_22_38_4E); + } + + #[test] + fn total_null_detected() { + assert!(Czyx::new(0, 0, 0, 0).is_total_null()); + assert!(!Czyx::new(0, 1, 0, 0).is_total_null()); + } + + #[test] + fn null_classification() { + assert_eq!(Czyx::new(0, 0, 0, 0).null_class(), NullClass::Total); + assert_eq!(Czyx::new(0, 9, 0, 0).null_class(), NullClass::Cube(1)); + assert_eq!(Czyx::new(0, 0, 9, 0).null_class(), NullClass::Cube(2)); + assert_eq!(Czyx::new(0, 0, 0, 9).null_class(), NullClass::Cube(3)); + assert_eq!(Czyx::new(3, 0, 0, 0).null_class(), NullClass::User); + } + + #[test] + fn triword_roundtrips() { + let ascii = *b"cubeln"; // 6 ascii bytes + let w = TriEnc::pack_6(0xA, ascii); + let (ctrl, back) = TriEnc::unpack_6(w); + assert_eq!(ctrl, 0xA); + assert_eq!(back, ascii); + } + + #[test] + fn header_flags_derived() { + let mut h = CubeHeader::new(); + h.title = Some("hello".into()); + h.size_bytes = Some(42); + h.refresh_flags(); + assert!(h.flags.has(HeaderFlags::TITLE)); + assert!(h.flags.has(HeaderFlags::SIZE_BYTES)); + assert!(!h.flags.has(HeaderFlags::DOC_TYPE)); + } +} diff --git a/cubestore/Cargo.toml b/cubestore/Cargo.toml new file mode 100644 index 0000000..4fafb61 --- /dev/null +++ b/cubestore/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "cubestore" +version = "0.1.0" +edition = "2021" +license = "MIT OR Apache-2.0" +description = "CUBELinux-2 record store over CZYX coordinates (PDF Package 1), built new from spec." + +[dependencies] +cubecoords = { path = "../cubecoords" } diff --git a/cubestore/src/lib.rs b/cubestore/src/lib.rs new file mode 100644 index 0000000..41c38ab --- /dev/null +++ b/cubestore/src/lib.rs @@ -0,0 +1,360 @@ +//! CUBELinux-2 record store over CZYX coordinates (PDF Package 1). +//! +//! Built NEW from the PDF spec; not recycled from the prior `/home/CUBELinux` +//! build. The prior build used a 3-axis `u64` point + `SpaceId`; this store +//! is keyed by the PDF's [`Czyx`] coordinate directly. +//! +//! Scope of this file: Package 1 only — a coordinate type, a tri-channel +//! codec, a header, and a minimal store abstraction that can later be backed +//! by a log-structured / RocksDB store. The PDF's later packages (cubefs, +//! cubevm, cubecrypt, cubeai) are out of scope for this hardware and are not +//! implemented here. + +#![forbid(unsafe_code)] +#![warn(missing_docs)] + +use cubecoords::{CubeHeader, Czyx}; +use std::collections::HashMap; + +/// A backend that maps CZYX coordinates to byte payloads. +/// +/// Decision: trait takes `Czyx` by value for `put`/`delete` (small, `Copy`) +/// and by reference for `get`, matching the PDF signature while staying +/// allocation-light. Revisit if a future backend needs the whole key moved. +pub trait CubeBackend { + /// Store `value` at `key`. + fn put(&mut self, key: Czyx, value: Vec); + /// Fetch the value at `key`, if present. + fn get(&self, key: &Czyx) -> Option>; + /// Remove the value at `key`. + fn delete(&mut self, key: &Czyx); +} + +/// In-memory backend backed by a `HashMap>` keyed by the packed +/// `u32` form of [`Czyx`]. +/// +/// Decision: packs to `u32` (not a 4-tuple key) so the map layout matches the +/// PDF's `HashMap>` example exactly and stays cheap. A production +/// backend would replace this with the on-disk store. +pub struct HashBackend(pub HashMap>); + +impl HashBackend { + /// Empty backend. + pub fn new() -> Self { + HashBackend(HashMap::new()) + } +} + +impl Default for HashBackend { + fn default() -> Self { + Self::new() + } +} + +impl CubeBackend for HashBackend { + fn put(&mut self, key: Czyx, value: Vec) { + self.0.insert(key.pack_u32(), value); + } + fn get(&self, key: &Czyx) -> Option> { + self.0.get(&key.pack_u32()).cloned() + } + fn delete(&mut self, key: &Czyx) { + self.0.remove(&key.pack_u32()); + } +} + +/// A record store: a header + body addressed by a [`Czyx`] label. +/// +/// Decision: we serialize the header and body as a single byte buffer with a +/// length-prefixed header section, rather than relying on an external +/// `bincode` dependency (keeps CUBELinux-2 dependency-free at Package 1). +/// The header is length-prefixed so the body boundary is recoverable without +/// a fixed schema — this is the "evolve toward explicit C/Z/Y/X-mapped flag +/// bytes" step the PDF mentions, done inline. +pub struct CubeStore { + backend: B, +} + +/// On-wire layout of a stored record: +/// `[u32 header_len][header bytes][body bytes]` +/// Header bytes = JSON of `CubeHeader`. Decision: JSON (via a tiny manual +/// serializer-free path) is overkill; we instead use a simple, stable +/// binary form below. (Documented: JSON was considered; a compact binary +/// encoding is used to avoid a serde dependency at Package 1.) +mod record_codec { + use cubecoords::{CubeHeader, Czyx}; + use std::collections::HashMap; + + // Compact, dependency-free encoding of the header. + // Fields are written in a fixed tag-length-value stream so unknown + // future fields can be skipped on read. Tag byte + (optional) length + + // payload. + // + // Tags: + // 1 title (utf8) + // 2 doc_type (utf8) + // 3 created_at (u64 le) + // 4 size_bytes (u64 le) + // 5 owner_local_user (utf8) + // 6 owner_remote_user (utf8) + // 7 linked_records: u16 count, then count*(4*u8) CZYX bytes + // 8 total_accesses (u64 le) + // 9 total_remote_accesses (u64 le) + // 10 last_access (u64 le) + // 11 last_remote_access (u64 le) + + pub fn encode_header(h: &CubeHeader) -> Vec { + let mut out = Vec::new(); + if let Some(t) = &h.title { + put_utf8(&mut out, 1, t); + } + if let Some(d) = &h.doc_type { + put_utf8(&mut out, 2, d); + } + if let Some(c) = h.created_at { + put_u64(&mut out, 3, c); + } + if let Some(s) = h.size_bytes { + put_u64(&mut out, 4, s); + } + if let Some(o) = &h.owner_local_user { + put_utf8(&mut out, 5, o); + } + if let Some(o) = &h.owner_remote_user { + put_utf8(&mut out, 6, o); + } + if !h.linked_records.is_empty() { + out.push(7); + out.extend_from_slice(&(h.linked_records.len() as u16).to_le_bytes()); + for r in &h.linked_records { + out.push(r.c); + out.push(r.z); + out.push(r.y); + out.push(r.x); + } + } + if h.total_accesses != 0 { + put_u64(&mut out, 8, h.total_accesses); + } + if h.total_remote_accesses != 0 { + put_u64(&mut out, 9, h.total_remote_accesses); + } + if let Some(a) = h.last_access { + put_u64(&mut out, 10, a); + } + if let Some(a) = h.last_remote_access { + put_u64(&mut out, 11, a); + } + out + } + + pub fn decode_header(mut b: &[u8]) -> Option { + let mut h = CubeHeader::new(); + while !b.is_empty() { + let tag = b[0]; + b = &b[1..]; + match tag { + 1 => { + let (v, rest) = take_utf8(b)?; + h.title = Some(v); + b = rest; + } + 2 => { + let (v, rest) = take_utf8(b)?; + h.doc_type = Some(v); + b = rest; + } + 3 => { + let (v, rest) = take_u64(b)?; + h.created_at = Some(v); + b = rest; + } + 4 => { + let (v, rest) = take_u64(b)?; + h.size_bytes = Some(v); + b = rest; + } + 5 => { + let (v, rest) = take_utf8(b)?; + h.owner_local_user = Some(v); + b = rest; + } + 6 => { + let (v, rest) = take_utf8(b)?; + h.owner_remote_user = Some(v); + b = rest; + } + 7 => { + if b.len() < 2 { + return None; + } + let n = u16::from_le_bytes([b[0], b[1]]) as usize; + b = &b[2..]; + if b.len() < n * 4 { + return None; + } + for _ in 0..n { + let c = b[0]; + let z = b[1]; + let y = b[2]; + let x = b[3]; + h.linked_records.push(Czyx::new(c, z, y, x)); + b = &b[4..]; + } + } + 8 => { + let (v, rest) = take_u64(b)?; + h.total_accesses = v; + b = rest; + } + 9 => { + let (v, rest) = take_u64(b)?; + h.total_remote_accesses = v; + b = rest; + } + 10 => { + let (v, rest) = take_u64(b)?; + h.last_access = Some(v); + b = rest; + } + 11 => { + let (v, rest) = take_u64(b)?; + h.last_remote_access = Some(v); + b = rest; + } + _ => return None, // unknown tag -> reject (strict at Package 1) + } + } + h.refresh_flags(); + Some(h) + } + + fn put_u64(out: &mut Vec, tag: u8, v: u64) { + out.push(tag); + out.extend_from_slice(&v.to_le_bytes()); + } + fn put_utf8(out: &mut Vec, tag: u8, s: &str) { + let bytes = s.as_bytes(); + out.push(tag); + out.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + out.extend_from_slice(bytes); + } + fn take_u64(b: &[u8]) -> Option<(u64, &[u8])> { + if b.len() < 8 { + return None; + } + let mut a = [0u8; 8]; + a.copy_from_slice(&b[..8]); + Some((u64::from_le_bytes(a), &b[8..])) + } + fn take_utf8(b: &[u8]) -> Option<(String, &[u8])> { + if b.len() < 4 { + return None; + } + let mut len = [0u8; 4]; + len.copy_from_slice(&b[..4]); + let n = u32::from_le_bytes(len) as usize; + let rest = &b[4..]; + if rest.len() < n { + return None; + } + let s = String::from_utf8(rest[..n].to_vec()).ok()?; + Some((s, &rest[n..])) + } + + /// Keep `HashMap` referenced so the dependency is explicit in this module + /// even though the codec itself is generic over bytes. (Prevents an + /// unused-import warning if the backend type changes.) + #[allow(dead_code)] + pub(crate) fn _assert_backend_assoc(_: &HashMap>) {} +} + +impl CubeStore { + /// Wrap a backend. + pub fn new(backend: B) -> Self { + CubeStore { backend } + } + + /// Store `header` + `body` at `label`. + pub fn put_record(&mut self, label: Czyx, header: &CubeHeader, body: &[u8]) { + let hdr_bytes = record_codec::encode_header(header); + let mut buf = Vec::with_capacity(4 + hdr_bytes.len() + body.len()); + buf.extend_from_slice(&(hdr_bytes.len() as u32).to_le_bytes()); + buf.extend_from_slice(&hdr_bytes); + buf.extend_from_slice(body); + self.backend.put(label, buf); + } + + /// Fetch and split a record into `(header, body)`. + pub fn get_record(&self, label: &Czyx) -> Option<(CubeHeader, Vec)> { + let raw = self.backend.get(label)?; + if raw.len() < 4 { + return None; + } + let mut len = [0u8; 4]; + len.copy_from_slice(&raw[..4]); + let hlen = u32::from_le_bytes(len) as usize; + if raw.len() < 4 + hlen { + return None; + } + let hdr = record_codec::decode_header(&raw[4..4 + hlen])?; + let body = raw[4 + hlen..].to_vec(); + Some((hdr, body)) + } + + /// Raw backend access (delegates put/get/delete for non-record payloads). + pub fn put_raw(&mut self, key: Czyx, value: Vec) { + self.backend.put(key, value); + } + /// Raw backend get. + pub fn get_raw(&self, key: &Czyx) -> Option> { + self.backend.get(key) + } + /// Raw backend delete. + pub fn delete_raw(&mut self, key: &Czyx) { + self.backend.delete(key); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn record_roundtrip() { + let mut store = CubeStore::new(HashBackend::new()); + let mut h = CubeHeader::new(); + h.title = Some("memory".into()); + h.doc_type = Some("note".into()); + h.created_at = Some(1700000000); + h.size_bytes = Some(5); + h.linked_records.push(Czyx::new(1, 2, 3, 4)); + h.refresh_flags(); + + let label = Czyx::new(1, 10, 20, 30); + store.put_record(label, &h, b"hello"); + let (rh, body) = store.get_record(&label).unwrap(); + assert_eq!(body, b"hello"); + assert_eq!(rh.title.as_deref(), Some("memory")); + assert_eq!(rh.doc_type.as_deref(), Some("note")); + assert_eq!(rh.created_at, Some(1700000000)); + assert_eq!(rh.size_bytes, Some(5)); + assert_eq!(rh.linked_records, vec![Czyx::new(1, 2, 3, 4)]); + assert!(rh.flags.has(cubecoords::HeaderFlags::HAS_ASSOCIATIONS)); + } + + #[test] + fn missing_record_is_none() { + let store = CubeStore::new(HashBackend::new()); + assert!(store.get_record(&Czyx::new(9, 9, 9, 9)).is_none()); + } + + #[test] + fn null_coord_is_distinct_key() { + let mut store = CubeStore::new(HashBackend::new()); + store.put_raw(Czyx::new(0, 0, 0, 0), vec![1]); + store.put_raw(Czyx::new(0, 0, 0, 1), vec![2]); + assert_eq!(store.get_raw(&Czyx::new(0, 0, 0, 0)), Some(vec![1])); + assert_eq!(store.get_raw(&Czyx::new(0, 0, 0, 1)), Some(vec![2])); + } +}