Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions crates/redmine-canon/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
189 changes: 34 additions & 155 deletions crates/redmine-canon/src/class_ids.rs
Original file line number Diff line number Diff line change
@@ -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(),
//! _ => {}
//! }
Expand All @@ -16,176 +15,56 @@
//! # 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 {
use super::*;
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);
}
}
Loading