Skip to content

Commit 47275d2

Browse files
committed
feat(ogar-from-ruff): prove emit_rust on a Rails-lifted CompiledClass
Step 2 of the gating order in the OP+Redmine convergence handover (openproject-nexgen-rs .claude/handovers/2026-06-30-1200-...md §4): before this commit, emit_rust/csharp/python had only ever run on an Odoo-lifted CompiledClass (compile_graph_python, in emit.rs's own tests). This adds a Rails fixture (compile_graph_ruby::<OpenProjectPort> on a WorkPackage graph with a typed attribute, an untyped attribute, a belongs_to with a class_name override, and a has_many) and asserts emit_rust renders it correctly — closing the "pull-back codegen leg unproven on Rails" gap named in the handover's risk list. Fully offline, OGAR-only — no openproject-nexgen-rs change. Found and fixed a real (if narrow) bug along the way: screaming_snake() only split on '.'/'_', so it silently produced WORKPACKAGE_CLASSID instead of WORK_PACKAGE_CLASSID for any already-PascalCase Rails class name (Rails class names carry no separator at all — Odoo's underscored names happened to mask this). Fixed to also split on a lower->upper case transition; added a direct unit test (screaming_snake_splits_bare_pascal_case_rails_names) covering WorkPackage/TimeEntry/Project plus the original dotted-Odoo-name case. Verification: standalone probe workspace (same as the rest of this PR). 46/46 tests pass (2 new). clippy -D warnings clean, both --no-deps and full workspace, at the pinned 1.95.0 toolchain. cargo fmt --check clean for the lines this commit touches (the crate carries pre-existing, unrelated fmt drift from never being cargo-fmt-gated — same root cause as the clippy debt fixed earlier in this PR; left untouched, out of scope here).
1 parent 8508a49 commit 47275d2

2 files changed

Lines changed: 117 additions & 13 deletions

File tree

crates/ogar-from-ruff/src/emit.rs

Lines changed: 115 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -323,21 +323,43 @@ fn pascal_case(name: &str) -> String {
323323
.collect()
324324
}
325325

326-
/// `account_move` → `ACCOUNT_MOVE` (for the `*_CLASSID` const name).
326+
/// `account_move` → `ACCOUNT_MOVE`; `WorkPackage` → `WORK_PACKAGE` (for the
327+
/// `*_CLASSID` const name). Splits on `.`/`_` (Odoo's dotted/underscored
328+
/// names) AND on a lower→upper case transition (Rails' bare PascalCase class
329+
/// names carry no separator at all — `screaming_snake` must still find the
330+
/// word boundary, or every Rails-sourced const collapses to one run-on word
331+
/// like `WORKPACKAGE_CLASSID`). Does not split consecutive uppercase runs
332+
/// (acronyms): `HTTPServer` → `HTTPSERVER` — no Rails/Odoo class name in the
333+
/// corpus is acronym-prefixed, so this is a deliberately narrow rule, not a
334+
/// general camelCase tokenizer.
327335
fn screaming_snake(name: &str) -> String {
328-
name.split(['.', '_'])
329-
.filter(|seg| !seg.is_empty())
330-
.map(str::to_uppercase)
331-
.collect::<Vec<_>>()
332-
.join("_")
336+
let mut out = String::new();
337+
let mut prev_lower = false;
338+
for ch in name.chars() {
339+
if ch == '.' || ch == '_' {
340+
if !out.is_empty() && !out.ends_with('_') {
341+
out.push('_');
342+
}
343+
prev_lower = false;
344+
continue;
345+
}
346+
if ch.is_uppercase() && prev_lower {
347+
out.push('_');
348+
}
349+
out.extend(ch.to_uppercase());
350+
prev_lower = ch.is_lowercase();
351+
}
352+
out
333353
}
334354

335355
#[cfg(test)]
336356
mod tests {
337357
use super::*;
338358
use crate::mint::compile_graph_python;
339359
use ogar_vocab::ports::OdooPort;
340-
use ruff_spo_triplet::{Field, Function, Model, ModelGraph};
360+
use ruff_spo_triplet::{
361+
AssocDecl, AssocKind, AttrDecl, AttrKind, Field, Function, Model, ModelGraph,
362+
};
341363

342364
fn account_move_graph() -> ModelGraph {
343365
let mut m = Model::new("account_move");
@@ -420,6 +442,79 @@ mod tests {
420442
assert!(rust.contains("// computed: amount_total <- _compute_amount(line_ids.balance)"));
421443
}
422444

445+
// ───── Rails (compile_graph_ruby) — the convergence proof ─────
446+
//
447+
// The pull-back codegen leg (emit_rust/csharp/python) was, before this
448+
// session, only ever exercised on an Odoo-lifted `CompiledClass`. This
449+
// fixture proves the SAME emitters run unmodified on a Rails-lifted one
450+
// (compile_graph_ruby, ruff#38 + this crate's `mint::compile_graph_ruby`),
451+
// closing the "unproven on Rails" gap named in the OP+Redmine convergence
452+
// handover (openproject-nexgen-rs .claude/handovers/
453+
// 2026-06-30-1200-op-redmine-ogar-convergence-assessment.md §4 step 2).
454+
455+
fn work_package_rail_graph() -> ModelGraph {
456+
let mut m = Model::new("WorkPackage");
457+
m.attributes.push(AttrDecl {
458+
kind: AttrKind::Attribute,
459+
name: "estimated_hours".to_string(),
460+
options: vec![("type".to_string(), "integer".to_string())],
461+
});
462+
// No "type" option → the OgScalar fallback path, same as Odoo's
463+
// `narration` case.
464+
m.attributes.push(AttrDecl {
465+
kind: AttrKind::Attribute,
466+
name: "subject".to_string(),
467+
options: vec![],
468+
});
469+
m.associations.push(AssocDecl {
470+
kind: AssocKind::BelongsTo,
471+
name: "project".to_string(),
472+
options: vec![("class_name".to_string(), "\"Project\"".to_string())],
473+
});
474+
m.associations.push(AssocDecl {
475+
kind: AssocKind::HasMany,
476+
name: "time_entries".to_string(),
477+
options: vec![],
478+
});
479+
let mut g = ModelGraph::new("openproject");
480+
g.models.push(m);
481+
g
482+
}
483+
484+
#[test]
485+
fn emits_rust_struct_for_rails_lifted_class() {
486+
use crate::mint::compile_graph_ruby;
487+
use ogar_vocab::ports::OpenProjectPort;
488+
489+
let cc = &compile_graph_ruby::<OpenProjectPort>(&work_package_rail_graph())[0];
490+
let rust = emit_rust(cc);
491+
492+
assert!(
493+
rust.contains("pub const WORK_PACKAGE_CLASSID: u32 = 0x00010102;"),
494+
"got:\n{rust}"
495+
); // exercises the screaming_snake PascalCase fix below
496+
assert!(rust.contains("pub struct WorkPackage {"));
497+
// Rails `attribute :estimated_hours, :integer` -> OgInt (the same
498+
// og_scalar_type table Odoo's `integer` constructor maps through —
499+
// shared vocabulary across producers, per §1.6).
500+
assert!(rust.contains("pub estimated_hours: OgInt,"), "got:\n{rust}");
501+
// Untyped attribute -> OgScalar fallback.
502+
assert!(rust.contains("pub subject: OgScalar,"), "got:\n{rust}");
503+
// belongs_to with class_name override -> ToOne<Project>, not
504+
// ToOne<Project> from the (singular) relation name by coincidence —
505+
// assert the class_name path specifically by using a relation name
506+
// that would pascal_case differently if class_name were ignored.
507+
assert!(
508+
rust.contains("pub project: ToOne<Project>,"),
509+
"got:\n{rust}"
510+
);
511+
// has_many, no class_name -> pascal_case(time_entries) = TimeEntries.
512+
assert!(
513+
rust.contains("pub time_entries: ToMany<TimeEntries>,"),
514+
"got:\n{rust}"
515+
);
516+
}
517+
423518
#[test]
424519
fn emits_csharp_record_with_wrapper_contract_types() {
425520
let cc = &compile_graph_python::<OdooPort>(&account_move_graph())[0];
@@ -536,6 +631,19 @@ mod tests {
536631
assert_eq!(screaming_snake("account_move"), "ACCOUNT_MOVE");
537632
}
538633

634+
#[test]
635+
fn screaming_snake_splits_bare_pascal_case_rails_names() {
636+
// Rails class names carry no separator at all (no dots, no
637+
// underscores) — screaming_snake must find the word boundary from
638+
// case alone, or every Rails const collapses to one run-on word.
639+
assert_eq!(screaming_snake("WorkPackage"), "WORK_PACKAGE");
640+
assert_eq!(screaming_snake("TimeEntry"), "TIME_ENTRY");
641+
// Already-snake input is unaffected (the original behaviour).
642+
assert_eq!(screaming_snake("account.move.line"), "ACCOUNT_MOVE_LINE");
643+
// A single PascalCase word with no internal boundary stays whole.
644+
assert_eq!(screaming_snake("Project"), "PROJECT");
645+
}
646+
539647
#[test]
540648
fn og_scalar_type_maps_odoo_constructors() {
541649
assert_eq!(og_scalar_type(Some("char")), "OgStr");

crates/ogar-from-ruff/src/mint.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,8 @@ fn model_of(node: &str) -> &str {
136136
#[cfg(test)]
137137
mod tests {
138138
use super::*;
139-
use ogar_vocab::ports::{OdooPort, OpenProjectPort, RedminePort};
140139
use ogar_vocab::Language;
140+
use ogar_vocab::ports::{OdooPort, OpenProjectPort, RedminePort};
141141
use ruff_spo_triplet::{AssocDecl, AssocKind, Field, Function, Model};
142142

143143
// A representative `account.move` `ModelGraph`, constructed directly (the
@@ -301,11 +301,7 @@ mod tests {
301301

302302
// OpenProject prefix 0x0001 | project_work_item concept 0x0102.
303303
assert_eq!(cc.facet.facet_classid(), 0x0001_0102);
304-
assert_eq!(
305-
cc.facet.facet_classid() & 0xFFFF,
306-
0x0102,
307-
"shared concept"
308-
);
304+
assert_eq!(cc.facet.facet_classid() & 0xFFFF, 0x0102, "shared concept");
309305
assert_eq!(
310306
(cc.facet.facet_classid() >> 16) as u16,
311307
OpenProjectPort::APP_PREFIX,

0 commit comments

Comments
 (0)