diff --git a/crates/ogar-from-rails/src/lib.rs b/crates/ogar-from-rails/src/lib.rs index e1866e4..dc6b576 100644 --- a/crates/ogar-from-rails/src/lib.rs +++ b/crates/ogar-from-rails/src/lib.rs @@ -700,6 +700,46 @@ mod tests { } } + /// **Board/Forum convergence** on real source. Cross-curator name + /// divergence (Redmine `Board` ↔ OP `Forum`) unified onto + /// `project_forum`. Closes the parent-container reference from + /// `project_message`. + #[test] + #[ignore = "requires Redmine + OpenProject checkouts"] + fn redmine_board_and_openproject_forum_converge_as_project_forum() { + let Ok(redmine_src) = std::env::var("REDMINE_SRC") else { + eprintln!("skipping: REDMINE_SRC not set"); + return; + }; + let op_src = std::env::var("OPENPROJECT_SRC") + .unwrap_or_else(|_| "/home/user/openproject".to_string()); + let op_path = PathBuf::from(&op_src); + if !op_path.exists() { + eprintln!("skipping: OpenProject not present at {op_src}"); + return; + } + + let redmine = extract(&PathBuf::from(redmine_src)); + let openproject = extract(&op_path); + let id = ogar_vocab::canonical_concept_id("project_forum"); + assert!(id.is_some()); + + let redmine_board = redmine + .iter() + .find(|c| c.name == "Board") + .expect("Redmine ships a Board model"); + let op_forum = openproject + .iter() + .find(|c| c.name == "Forum") + .expect("OpenProject ships a Forum model"); + + for c in [redmine_board, op_forum] { + assert_eq!(c.canonical_concept.as_deref(), Some("project_forum")); + assert_eq!(c.source_domain.as_deref(), Some("project")); + assert_eq!(c.canonical_id(), id); + } + } + /// Exactly-one-Model invariant post AdaWorldAPI/ruff#26: OP's /// `app/models/work_package/` sub-files reopen `class WorkPackage` /// without adding ontology declarations; before the fix this produced diff --git a/crates/ogar-vocab/src/lib.rs b/crates/ogar-vocab/src/lib.rs index a4107ae..fd6e18f 100644 --- a/crates/ogar-vocab/src/lib.rs +++ b/crates/ogar-vocab/src/lib.rs @@ -1088,6 +1088,7 @@ const CODEBOOK: &[(&str, u16)] = &[ ("project_watcher", 0x0113), // Redmine Watcher ↔ OP Watcher ("project_news", 0x0114), // Redmine News ↔ OP News ("project_message", 0x0115), // Redmine Message ↔ OP Message (board/forum divergence) + ("project_forum", 0x0116), // Redmine Board ↔ OP Forum (name divergence; parent of project_message) // ── 0x02XX — commerce / billing / ERP domain (OSB ↔ Odoo) ── // Promoted from the parallel session's `lance-graph-ontology::ar_shape` @@ -1600,6 +1601,16 @@ pub fn canonical_concept(name: &str) -> String { ) { return "project_message".to_string(); } + // ProjectForum — the parent container for [`project_message`]. + // Cross-curator name divergence: Redmine `Board`, OpenProject `Forum` + // (same shape — project + last_message + name/description, with + // has_many :messages). + if matches!(lower.as_str(), + "board" | "boards" | "forum" | "forums" + | "project_forum" | "projectforum" + ) { + return "project_forum".to_string(); + } // ── Commerce / billing / ERP domain (OSB ↔ Odoo) ── // CommercialLineItem — line on a commercial document. OSB // `InvoiceLineItem`, Odoo `account_move_line` (ruff normalises @@ -2224,6 +2235,30 @@ pub fn project_message() -> Class { c } +/// `Board`/`Forum` — the parent container for [`project_message`]. +/// Cross-curator name divergence: Redmine `Board`, OpenProject `Forum`. +/// Universal shape: `belongs_to :project` + `has_many :messages` + +/// `belongs_to :last_message` + `name` / `description` / `position`. +#[must_use] +pub fn project_forum() -> Class { + let mut c = Class::new("ProjectForum"); + c.language = Language::Unknown; + c.canonical_concept = Some("project_forum".to_string()); + c.associations = vec![ + family_edge("project", "Project"), + family_has_many("messages", "ProjectMessage"), + family_edge("last_message", "ProjectMessage"), + ]; + let mut name = Attribute::new("name"); + name.type_name = Some("string".to_string()); + let mut description = Attribute::new("description"); + description.type_name = Some("text".to_string()); + let mut position = Attribute::new("position"); + position.type_name = Some("integer".to_string()); + c.attributes = vec![name, description, position]; + c +} + // ───────────────────────────────────────────────────────────────────── // Commerce / billing / ERP domain canonical classes (OSB ↔ Odoo). // @@ -2849,7 +2884,7 @@ mod tests { "project_version", "project_wiki_page", "project_query", "project_attachment", "project_comment", "project_custom_field", "project_relation", "project_changeset", "project_watcher", - "project_news", "project_message", + "project_news", "project_message", "project_forum", ] { let id = canonical_concept_id(project_concept) .unwrap_or_else(|| panic!("{project_concept} missing from codebook")); @@ -2900,6 +2935,7 @@ mod tests { (project_watcher(), "ProjectWatcher", 0x0113), (project_news(), "ProjectNews", 0x0114), (project_message(), "ProjectMessage", 0x0115), + (project_forum(), "ProjectForum", 0x0116), ] { assert_eq!(canonical.name, name); assert_eq!(canonical.language, Language::Unknown); @@ -2980,6 +3016,8 @@ mod tests { ("project_watcher", &["Watcher", "watchers", "ProjectWatcher"]), ("project_news", &["News", "news", "ProjectNews"]), ("project_message", &["Message", "messages", "ProjectMessage"]), + // Cross-curator name divergence: Redmine `Board` ↔ OP `Forum`. + ("project_forum", &["Board", "boards", "Forum", "forums", "ProjectForum"]), ]; for (concept, aliases) in cases { let id = canonical_concept_id(concept).unwrap();