From 2448f7bd29338d5e872df903387428e75ac087e5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 01:28:08 +0000 Subject: [PATCH] feat(op-canon): canonical_concept_name reverse map re-export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-export `ogar_vocab::canonical_concept_name` (OGAR PR #98) on `op_canon::app` — the reverse codebook map (shared concept id -> canonical name), completing the round-trip against class_id_of: name --class_id_of--> concept id --canonical_concept_name--> name Same one-source-of-truth discipline as the forward resolvers and #61's app_of/concept_of: the canonical layer owns the map, op-canon re-exports. Returns the SHARED canonical name (WorkPackage and Redmine's Issue both reverse to project_work_item), not the OpenProject surface name. - 1 doctest (round-trip via class_id_of + None for unknown ids). - 2 unit tests: reverses class_id_of on headline concepts; agrees with the snapshot's canonical_concept for every id it resolves (>=20 covered). cargo +1.95 fmt / clippy -D warnings / test -p op-canon: 23 unit + 8 doctests green. --- crates/op-canon/src/app.rs | 68 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/crates/op-canon/src/app.rs b/crates/op-canon/src/app.rs index eed407c..688996b 100644 --- a/crates/op-canon/src/app.rs +++ b/crates/op-canon/src/app.rs @@ -49,6 +49,9 @@ //! — the inverse decomposition (`0x0001_DDCC` → prefix + concept), so a render //! classid round-trips entirely on this crate's surface without reaching into //! `ogar_vocab::app`. +//! - [`canonical_concept_name`] re-exports `ogar_vocab::canonical_concept_name` +//! — the reverse codebook map (shared concept id → canonical name), the +//! inverse of [`class_id_of`] at the concept level. //! //! Same discipline as [`crate::class_ids`] (which re-exports //! `ogar_vocab::class_ids::*`): the canonical layer mints; this crate @@ -150,6 +153,33 @@ pub fn concept_of(classid: u32) -> u16 { ogar_vocab::app::concept_of(classid) } +/// Reverse of [`class_id_of`] at the concept level: a shared canonical +/// **concept id** (the low u16 / port-pull currency) → its canonical +/// concept **name** (e.g. `project_work_item`). `None` for an id the +/// codebook does not carry. +/// +/// Re-export of `ogar_vocab::canonical_concept_name` (OGAR PR #98) — the +/// central reverse map, same one-source-of-truth discipline as the forward +/// resolvers. It returns the **shared** canonical name, not the OpenProject +/// surface name: `class_id_of("WorkPackage")` and Redmine's `Issue` both map +/// back to `project_work_item`. +/// +/// ``` +/// use op_canon::{app, class_ids}; +/// assert_eq!( +/// app::canonical_concept_name(class_ids::PROJECT_WORK_ITEM), +/// Some("project_work_item"), +/// ); +/// // Round-trip with class_id_of through the shared concept id: +/// let id = app::class_id_of("WorkPackage").unwrap(); +/// assert_eq!(app::canonical_concept_name(id), Some("project_work_item")); +/// assert_eq!(app::canonical_concept_name(0xFFFF), None); +/// ``` +#[must_use] +pub fn canonical_concept_name(concept: u16) -> Option<&'static str> { + ogar_vocab::canonical_concept_name(concept) +} + #[cfg(test)] mod tests { use super::*; @@ -269,6 +299,44 @@ mod tests { assert_eq!(((app_of(cid) as u32) << 16) | (concept_of(cid) as u32), cid,); } + #[test] + fn canonical_concept_name_reverses_class_id_of() { + // Forward: surface name -> shared concept id. Reverse: shared concept + // id -> canonical concept name. The reverse yields the SHARED name, not + // the OpenProject surface name (WorkPackage and Redmine's Issue both + // land on `project_work_item`). + let id = class_id_of("WorkPackage").expect("WorkPackage resolves"); + assert_eq!(canonical_concept_name(id), Some("project_work_item")); + assert_eq!(canonical_concept_name(class_ids::PROJECT), Some("project")); + // Unknown ids resolve to None, not a panic. + assert_eq!(canonical_concept_name(0xFFFF), None); + } + + #[test] + fn canonical_concept_name_agrees_with_the_snapshot() { + // The re-export is OGAR's central reverse map; every snapshot concept + // whose id the map resolves must round-trip to the snapshot's own + // canonical_concept name (the checked-mirror discipline). Coverage is + // asserted too, so it isn't a vacuous pass. + let s = Snapshot::load(); + let mut checked = 0u32; + for concept in &s.concepts { + if let Some(name) = canonical_concept_name(concept.class_id_u16()) { + assert_eq!( + name, + concept.canonical_concept.as_str(), + "reverse map disagrees with snapshot for 0x{:04X}", + concept.class_id_u16(), + ); + checked += 1; + } + } + assert!( + checked >= 20, + "expected the reverse map to cover most snapshot concepts, only matched {checked}", + ); + } + #[test] fn port_pull_agrees_with_the_snapshot() { // The drift guard that lets the PORT be the resolver and the snapshot