diff --git a/Cargo.lock b/Cargo.lock index 3c2381c..82b6e04 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14,6 +14,11 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "ogar-vocab" +version = "0.1.0" +source = "git+https://github.com/AdaWorldAPI/OGAR?branch=main#6d2aa579aec02bb29860112172100b560c85bc75" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -36,6 +41,7 @@ dependencies = [ name = "redmine-canon" version = "0.1.0" dependencies = [ + "ogar-vocab", "serde", "serde_json", ] diff --git a/crates/redmine-canon/Cargo.toml b/crates/redmine-canon/Cargo.toml index e66ab83..71335b9 100644 --- a/crates/redmine-canon/Cargo.toml +++ b/crates/redmine-canon/Cargo.toml @@ -9,3 +9,7 @@ description = "Canonical contract for the Redmine → Rust port: the OGAR codebo [dependencies] serde = { workspace = true } serde_json = { workspace = true } +# Canonical home of the class-id constants (OGAR codebook). Re-exported by +# `class_ids` so this port shares the single source of truth with +# `op-canon` — the constants cannot drift across ports. +ogar-vocab = { git = "https://github.com/AdaWorldAPI/OGAR", branch = "main" } diff --git a/crates/redmine-canon/src/class_ids.rs b/crates/redmine-canon/src/class_ids.rs index 9cebe1a..bb7aa7e 100644 --- a/crates/redmine-canon/src/class_ids.rs +++ b/crates/redmine-canon/src/class_ids.rs @@ -1,13 +1,12 @@ -//! Compile-time **canonical class-id constants** — the Redmine view of the -//! OGAR codebook, exposed as named `u16`s so downstream `rm-*` crates can -//! dispatch on identity without parsing the snapshot JSON at runtime. +//! **Canonical class-id constants** — re-exported from +//! [`ogar_vocab::class_ids`], the single source of truth. //! //! ``` //! use redmine_canon::class_ids; //! //! fn dispatch(incoming_id: u16) { //! match incoming_id { -//! class_ids::PROJECT_WORK_ITEM => handle_issue(), +//! class_ids::PROJECT_WORK_ITEM => handle_issue(), //! class_ids::BILLABLE_WORK_ENTRY => handle_time_entry(), //! _ => {} //! } @@ -16,116 +15,18 @@ //! # fn handle_time_entry() {} //! ``` //! -//! **These are the same values `op-canon::class_ids` exposes** — the -//! codebook is minted once in [`AdaWorldAPI/OGAR`](https://github.com/AdaWorldAPI/OGAR) -//! and shared across both `-rs` ports. A node typed `PROJECT_WORK_ITEM` -//! (`0x0102`) is the same identity whether it came from Redmine's `Issue` -//! or OpenProject's `WorkPackage`. That is the whole point: the consumer -//! holding both curators dispatches on one arm. +//! **These are the same constants `op-canon::class_ids` exposes.** Both +//! `-rs` ports re-export from `ogar_vocab` so the values cannot drift +//! across ports: the codebook is minted once in +//! [`AdaWorldAPI/OGAR`](https://github.com/AdaWorldAPI/OGAR) and the typed +//! constants come from there. //! -//! Constants are **kept in sync with the vendored snapshot mechanically**: -//! [`tests::constants_match_the_snapshot`] asserts the vendored -//! [`crate::Snapshot`] reports the same `(canonical_concept, id)` pair for -//! every constant. Drift is impossible without a failing test. -//! -//! Ids are stable forever (per the OGAR codebook contract). They only -//! arrive — never move, never get re-assigned. - -/// `project` (`0x0101`) — the root project container. Redmine `Project`. -pub const PROJECT: u16 = 0x0101; -/// `project_work_item` (`0x0102`) — project-scoped work item. Redmine -/// `Issue` / OpenProject `WorkPackage` collapse here. -pub const PROJECT_WORK_ITEM: u16 = 0x0102; -/// `billable_work_entry` (`0x0103`) — booked work / time / cost. The -/// **first cross-domain bridge**: Redmine `TimeEntry`, OpenProject -/// `TimeEntry`, Odoo `account.analytic.line` all converge here. -pub const BILLABLE_WORK_ENTRY: u16 = 0x0103; -/// `project_actor` (`0x0104`) — the actor identity (Principal + User + -/// Group STI chain collapsed). -pub const PROJECT_ACTOR: u16 = 0x0104; -/// `project_status` (`0x0105`) — workflow status. Redmine `IssueStatus`, -/// OpenProject `Status`. -pub const PROJECT_STATUS: u16 = 0x0105; -/// `project_type` (`0x0106`) — work-item type. Redmine `Tracker`, -/// OpenProject `Type`. -pub const PROJECT_TYPE: u16 = 0x0106; -/// `priority` (`0x0107`) — priority enumeration. Both ship `IssuePriority`. -pub const PRIORITY: u16 = 0x0107; -/// `project_membership` (`0x0108`) — actor↔project join. Both ship `Member`. -pub const PROJECT_MEMBERSHIP: u16 = 0x0108; -/// `project_journal` (`0x0109`) — change journal entry. -pub const PROJECT_JOURNAL: u16 = 0x0109; -/// `project_repository` (`0x010A`) — VCS repository. -pub const PROJECT_REPOSITORY: u16 = 0x010A; -/// `project_version` (`0x010B`) — release / milestone. -pub const PROJECT_VERSION: u16 = 0x010B; -/// `project_wiki_page` (`0x010C`). -pub const PROJECT_WIKI_PAGE: u16 = 0x010C; -/// `project_query` (`0x010D`) — saved query. -pub const PROJECT_QUERY: u16 = 0x010D; -/// `project_attachment` (`0x010E`). -pub const PROJECT_ATTACHMENT: u16 = 0x010E; -/// `project_comment` (`0x010F`). -pub const PROJECT_COMMENT: u16 = 0x010F; -/// `project_custom_field` (`0x0110`). -pub const PROJECT_CUSTOM_FIELD: u16 = 0x0110; -/// `project_relation` (`0x0111`) — work-item↔work-item link. Redmine -/// `IssueRelation`, OpenProject `Relation`. -pub const PROJECT_RELATION: u16 = 0x0111; -/// `project_changeset` (`0x0112`) — VCS commit metadata. -pub const PROJECT_CHANGESET: u16 = 0x0112; -/// `project_watcher` (`0x0113`). -pub const PROJECT_WATCHER: u16 = 0x0113; -/// `project_news` (`0x0114`) — project news / blog post. -pub const PROJECT_NEWS: u16 = 0x0114; -/// `project_message` (`0x0115`) — forum / board message. -pub const PROJECT_MESSAGE: u16 = 0x0115; -/// `project_forum` (`0x0116`) — message container. Redmine `Board`, -/// OpenProject `Forum`. -pub const PROJECT_FORUM: u16 = 0x0116; -/// `project_role` (`0x0117`) — RBAC permission-set bundle. Redmine `Role` -/// (OpenProject additionally ships a `ProjectRole` subclass that collapses -/// here too). -pub const PROJECT_ROLE: u16 = 0x0117; -/// `project_member_role` (`0x0118`) — RBAC join (membership ↔ role). -pub const PROJECT_MEMBER_ROLE: u16 = 0x0118; -/// `project_custom_value` (`0x0119`) — value of a [`PROJECT_CUSTOM_FIELD`] -/// on a record. -pub const PROJECT_CUSTOM_VALUE: u16 = 0x0119; -/// `project_enabled_module` (`0x011A`) — per-project module enablement. -pub const PROJECT_ENABLED_MODULE: u16 = 0x011A; +//! OGAR carries the forward+reverse drift guards (constants ↔ codebook). +//! This module carries one port-local guard: every concept the **vendored +//! snapshot** promotes must agree with the re-exported constant at the +//! same id — so a regen that drifts from the published codebook fails CI. -/// Every `(canonical_concept_name, id)` pair the Redmine snapshot promotes. -/// Walked by the drift-guard test below; consumers reaching for a specific -/// id should use the named constant above, not this slice. -pub const ALL: &[(&str, u16)] = &[ - ("project", PROJECT), - ("project_work_item", PROJECT_WORK_ITEM), - ("billable_work_entry", BILLABLE_WORK_ENTRY), - ("project_actor", PROJECT_ACTOR), - ("project_status", PROJECT_STATUS), - ("project_type", PROJECT_TYPE), - ("priority", PRIORITY), - ("project_membership", PROJECT_MEMBERSHIP), - ("project_journal", PROJECT_JOURNAL), - ("project_repository", PROJECT_REPOSITORY), - ("project_version", PROJECT_VERSION), - ("project_wiki_page", PROJECT_WIKI_PAGE), - ("project_query", PROJECT_QUERY), - ("project_attachment", PROJECT_ATTACHMENT), - ("project_comment", PROJECT_COMMENT), - ("project_custom_field", PROJECT_CUSTOM_FIELD), - ("project_relation", PROJECT_RELATION), - ("project_changeset", PROJECT_CHANGESET), - ("project_watcher", PROJECT_WATCHER), - ("project_news", PROJECT_NEWS), - ("project_message", PROJECT_MESSAGE), - ("project_forum", PROJECT_FORUM), - ("project_role", PROJECT_ROLE), - ("project_member_role", PROJECT_MEMBER_ROLE), - ("project_custom_value", PROJECT_CUSTOM_VALUE), - ("project_enabled_module", PROJECT_ENABLED_MODULE), -]; +pub use ogar_vocab::class_ids::*; #[cfg(test)] mod tests { @@ -133,59 +34,37 @@ mod tests { use crate::Snapshot; #[test] - fn constants_match_the_snapshot() { - // The drift guard. Every (name, id) pair in ALL must be exactly - // what the vendored snapshot reports — a regen that changes an id - // or drops a concept fails THIS test before any consumer breaks. + fn snapshot_concepts_match_re_exported_constants() { + // Port-local drift guard: every concept the vendored snapshot + // promotes must resolve to the same id via OGAR's codebook. If a + // regen produces an id that disagrees with the published codebook + // (which by contract never re-assigns), this fires. let s = Snapshot::load(); - for (name, id) in ALL { - let c = s - .concept(name) - .unwrap_or_else(|| panic!("{name} promoted in ALL but missing from snapshot")); + for c in &s.concepts { + let id = ogar_vocab::canonical_concept_id(&c.canonical_concept).unwrap_or_else(|| { + panic!( + "{} promoted in snapshot but absent from OGAR codebook", + c.canonical_concept + ) + }); assert_eq!( c.class_id_u16(), - *id, - "{name}: constant 0x{id:04X} disagrees with snapshot 0x{:04X}", - c.class_id_u16(), - ); - } - } - - #[test] - fn every_snapshot_concept_has_a_constant() { - // The reverse drift guard: if the snapshot promotes a new concept, - // ALL (and the named const block above) must learn it. Catches the - // "regen forgot to update class_ids.rs" case. - let s = Snapshot::load(); - let known: std::collections::HashSet<&str> = ALL.iter().map(|(n, _)| *n).collect(); - for c in &s.concepts { - assert!( - known.contains(c.canonical_concept.as_str()), - "{} promoted in snapshot but missing from class_ids::ALL", + id, + "{}: snapshot 0x{:04X} disagrees with OGAR codebook 0x{id:04X}", c.canonical_concept, + c.class_id_u16(), ); } } #[test] - fn constants_are_unique() { - use std::collections::HashSet; - let mut seen = HashSet::new(); - for (name, id) in ALL { - assert!(seen.insert(*id), "duplicate id 0x{id:04X} (saw at {name})"); - } - } - - #[test] - fn divergent_curator_names_share_one_constant() { - // The whole point of the codebook, in code: a Redmine Issue and an - // OpenProject WorkPackage both route on PROJECT_WORK_ITEM. A - // consumer dispatching on incoming codebook ids needs the SAME arm - // for both. These values are identical to op-canon::class_ids. + fn re_export_brings_in_the_headline_constants() { + // Sanity: the `pub use ogar_vocab::class_ids::*` actually pulled + // the constants this port cares about into scope, at the codebook + // ids ogar-vocab vouches for. assert_eq!(PROJECT_WORK_ITEM, 0x0102); - assert_eq!(PROJECT_STATUS, 0x0105); - assert_eq!(PROJECT_TYPE, 0x0106); - assert_eq!(PROJECT_FORUM, 0x0116); assert_eq!(BILLABLE_WORK_ENTRY, 0x0103); + assert_eq!(PROJECT_FORUM, 0x0116); + assert_eq!(PROJECT_ROLE, 0x0117); } }