diff --git a/crates/redmine-canon/src/app.rs b/crates/redmine-canon/src/app.rs new file mode 100644 index 0000000..6a551ff --- /dev/null +++ b/crates/redmine-canon/src/app.rs @@ -0,0 +1,173 @@ +//! **APP‖class — pull OGAR via class, no bridge.** +//! +//! The landing shape of [lance-graph#589][589] for the Redmine consumer: +//! resolve a Redmine surface name to its canonical class-id by **pulling +//! the OGAR port** ([`ogar_vocab::ports::RedminePort::class_id`]), never a +//! bridge, never a hand-rolled registry. The vendored snapshot +//! ([`crate::Snapshot::concept_of_class`]) stays as a **checked mirror** — +//! the [`tests::port_pull_agrees_with_the_snapshot`] drift guard proves the +//! port and the snapshot agree on every surface name the port resolves, so +//! the port can be the resolver while the snapshot remains the corpus +//! evidence. +//! +//! [589]: https://github.com/AdaWorldAPI/lance-graph/pull/589 +//! +//! # The two halves of a classid (`APP-CLASS-CODEBOOK-LAYOUT.md`) +//! +//! ```text +//! classid : u32 = [ hi u16 : APP / render prefix ] [ lo u16 : concept ] +//! 0x0007 (Redmine) 0xDDCC (shared) +//! ``` +//! +//! - **low u16 — the shared canonical concept.** What the object *is*: the +//! RBAC + ontology + cross-app identity key. This is what the port pull +//! returns, and it is identical to the id OpenProject's `WorkPackage` pulls +//! (`project_work_item = 0x0102`). The shared currency. +//! - **high u16 — Redmine's render prefix [`APP_PREFIX`] (`0x0007`).** *Whose* +//! rendering: Redmine's `ClassView` / template lens. Reserved for Redmine +//! in `APP-CLASS-CODEBOOK-LAYOUT.md` §2; OpenProject's twin is `0x0001`. +//! A full render classid is `0x0007_DDCC` — same concept as OpenProject's +//! `0x0001_DDCC`, different lens (the W0 "two renders, one concept" +//! showcase). +//! +//! Composing the high half here is the consumer **stamping its own reserved +//! prefix** (`APP-CLASS-CODEBOOK-LAYOUT.md` §1, §3d), not minting an OGAR +//! codebook class — that mint is gated on OGAR's 5+3 pass. The pull (low +//! half) is the part that is canonical and available today. + +use ogar_vocab::ports::{PortSpec, RedminePort}; + +/// Redmine's reserved **APP / render prefix** — the high u16 of a full +/// `classid` (`APP-CLASS-CODEBOOK-LAYOUT.md` §2 allocation table). Pairs with +/// OpenProject's `0x0001`: same low-half concept, different render lens. +pub const APP_PREFIX: u16 = 0x0007; + +/// Pull the canonical class-id for a Redmine surface name **via the OGAR +/// port** — the #589 "pull OGAR via class" path (no bridge, no registry). +/// `None` for a name the codebook does not carry. +/// +/// ``` +/// use redmine_canon::{app, class_ids}; +/// assert_eq!(app::class_id_of("Issue"), Some(class_ids::PROJECT_WORK_ITEM)); +/// assert_eq!(app::class_id_of("TimeEntry"), Some(class_ids::BILLABLE_WORK_ENTRY)); +/// assert_eq!(app::class_id_of("NotARedmineClass"), None); +/// ``` +#[must_use] +pub fn class_id_of(surface_name: &str) -> Option { + RedminePort::class_id(surface_name) +} + +/// Compose the full 32-bit **render** classid for a shared `concept` under +/// Redmine's prefix: `(APP_PREFIX << 16) | concept` → `0x0007_DDCC`. +/// +/// ``` +/// use redmine_canon::{app, class_ids}; +/// assert_eq!(app::render_classid(class_ids::PROJECT_WORK_ITEM), 0x0007_0102); +/// ``` +#[must_use] +pub const fn render_classid(concept: u16) -> u32 { + ((APP_PREFIX as u32) << 16) | (concept as u32) +} + +/// Pull + compose in one step: a Redmine surface name → its full render +/// classid `0x0007_DDCC`, via the OGAR port. `None` if the port does not +/// carry the name. +/// +/// ``` +/// use redmine_canon::app; +/// assert_eq!(app::render_classid_of("Issue"), Some(0x0007_0102)); +/// ``` +#[must_use] +pub fn render_classid_of(surface_name: &str) -> Option { + class_id_of(surface_name).map(render_classid) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{class_ids, Snapshot}; + + #[test] + fn port_pull_resolves_headline_surface_names() { + // The #589 AFTER pattern: surface name → class_id via the port. + assert_eq!(class_id_of("Issue"), Some(class_ids::PROJECT_WORK_ITEM)); + assert_eq!(class_id_of("Project"), Some(class_ids::PROJECT)); + assert_eq!( + class_id_of("TimeEntry"), + Some(class_ids::BILLABLE_WORK_ENTRY) + ); + assert_eq!(class_id_of("Tracker"), Some(class_ids::PROJECT_TYPE)); + assert_eq!(class_id_of("IssueStatus"), Some(class_ids::PROJECT_STATUS)); + assert_eq!(class_id_of("Role"), Some(class_ids::PROJECT_ROLE)); + // STI fold: User / Principal / Group all pull project_actor. + assert_eq!(class_id_of("User"), Some(class_ids::PROJECT_ACTOR)); + assert_eq!(class_id_of("Principal"), Some(class_ids::PROJECT_ACTOR)); + assert_eq!(class_id_of("Group"), Some(class_ids::PROJECT_ACTOR)); + // Unknown names resolve to None (not a panic, not a bridge fallthrough). + assert_eq!(class_id_of("NotARedmineClass"), None); + assert_eq!(class_id_of(""), None); + } + + #[test] + fn render_classid_composes_redmine_prefix() { + assert_eq!(APP_PREFIX, 0x0007); + // Full render classid = 0x0007_DDCC (W0 worked table). + assert_eq!(render_classid(class_ids::PROJECT_WORK_ITEM), 0x0007_0102); + assert_eq!(render_classid(class_ids::BILLABLE_WORK_ENTRY), 0x0007_0103); + assert_eq!(render_classid(class_ids::PROJECT_ROLE), 0x0007_0117); + // Pull + compose. + assert_eq!(render_classid_of("Issue"), Some(0x0007_0102)); + assert_eq!(render_classid_of("TimeEntry"), Some(0x0007_0103)); + } + + #[test] + fn render_classid_keeps_concept_in_the_low_half() { + // The low half is the shared concept (== the port pull); the high + // half is Redmine's render lens. OpenProject's twin carries the + // SAME low half under prefix 0x0001 — "two renders, one concept". + for &concept in &[ + class_ids::PROJECT_WORK_ITEM, + class_ids::PROJECT, + class_ids::BILLABLE_WORK_ENTRY, + class_ids::PROJECT_ROLE, + ] { + let cid = render_classid(concept); + assert_eq!((cid >> 16) as u16, APP_PREFIX, "high half = Redmine lens"); + assert_eq!(cid as u16, concept, "low half = shared concept"); + } + } + + #[test] + fn port_pull_agrees_with_the_snapshot() { + // The drift guard that lets the PORT be the resolver and the snapshot + // be a checked mirror: every Redmine curator class the snapshot carries + // that the port also knows must resolve to the SAME id. (The port covers + // the public surface names; snapshot-only subclasses the port doesn't + // alias are skipped — `None` is not a disagreement.) + let s = Snapshot::load(); + let mut checked = 0u32; + for concept in &s.concepts { + for curator_class in &concept.curator_classes { + if let Some(pulled) = class_id_of(curator_class) { + assert_eq!( + pulled, + concept.class_id_u16(), + "port `{curator_class}` -> 0x{pulled:04X} disagrees with snapshot \ + `{}` 0x{:04X}", + concept.canonical_concept, + concept.class_id_u16(), + ); + checked += 1; + } + } + } + // Sanity: the guard actually exercised the port (not a vacuous pass). + // The Redmine port carries 28 aliases; all 28 appear in the snapshot + // curator_classes, so the floor is 28 (not a vacuous pass). + assert!( + checked >= 20, + "expected the port to cover most snapshot surface names, only matched {checked}", + ); + let _ = checked; + } +} diff --git a/crates/redmine-canon/src/lib.rs b/crates/redmine-canon/src/lib.rs index ef6cfba..b96034a 100644 --- a/crates/redmine-canon/src/lib.rs +++ b/crates/redmine-canon/src/lib.rs @@ -45,6 +45,7 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +pub mod app; pub mod class_ids; pub mod class_view; pub mod convergence;